feat(agent-teams): add team config store with atomic writes

- Implement CRUD operations for team config.json
- Use atomic writes with temp+rename pattern
- Reuse acquireLock for concurrent access safety
- Team config lives at ~/.sisyphus/teams/{teamName}/config.json
- deleteTeamDir removes team + inbox + task dirs recursively
- Fix timestamp: use ISO string instead of number
- 4 comprehensive tests with locking verification

Task 4/25 complete
This commit is contained in:
YeonGyu-Kim 2026-02-11 21:53:09 +09:00
parent d65912bc63
commit f0ae1131de
3 changed files with 93 additions and 30 deletions

View File

@ -8,6 +8,8 @@ import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
import { import {
createTeamConfig, createTeamConfig,
deleteTeamData, deleteTeamData,
deleteTeamDir,
listTeams,
readTeamConfigOrThrow, readTeamConfigOrThrow,
teamExists, teamExists,
upsertTeammate, upsertTeammate,
@ -17,17 +19,27 @@ import {
describe("agent-teams team config store", () => { describe("agent-teams team config store", () => {
let originalCwd: string let originalCwd: string
let tempProjectDir: string let tempProjectDir: string
let createdTeams: string[]
beforeEach(() => { beforeEach(() => {
originalCwd = process.cwd() originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-")) tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-"))
process.chdir(tempProjectDir) process.chdir(tempProjectDir)
createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") createdTeams = []
const timestamp = Date.now()
createTeamConfig(`core-${timestamp}`, "Core team", `ses-main-${timestamp}`, tempProjectDir, "sisyphus")
createdTeams.push(`core-${timestamp}`)
}) })
afterEach(() => { afterEach(() => {
if (teamExists("core")) { for (const teamName of createdTeams) {
deleteTeamData("core") if (teamExists(teamName)) {
try {
deleteTeamData(teamName)
} catch {
// Ignore cleanup errors
}
}
} }
process.chdir(originalCwd) process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true }) rmSync(tempProjectDir, { recursive: true, force: true })
@ -35,54 +47,57 @@ describe("agent-teams team config store", () => {
test("deleteTeamData waits for team lock before removing team files", () => { test("deleteTeamData waits for team lock before removing team files", () => {
//#given //#given
const lock = acquireLock(getTeamDir("core")) const teamName = createdTeams[0]
const lock = acquireLock(getTeamDir(teamName))
expect(lock.acquired).toBe(true) expect(lock.acquired).toBe(true)
try { try {
//#when //#when
const deleteWhileLocked = () => deleteTeamData("core") const deleteWhileLocked = () => deleteTeamData(teamName)
//#then //#then
expect(deleteWhileLocked).toThrow("team_lock_unavailable") expect(deleteWhileLocked).toThrow("team_lock_unavailable")
expect(teamExists("core")).toBe(true) expect(teamExists(teamName)).toBe(true)
} finally { } finally {
//#when //#when
lock.release() lock.release()
} }
deleteTeamData("core") deleteTeamData(teamName)
//#then //#then
expect(teamExists("core")).toBe(false) expect(teamExists(teamName)).toBe(false)
}) })
test("deleteTeamData waits for task lock before removing task files", () => { test("deleteTeamData waits for task lock before removing task files", () => {
//#given //#given
const lock = acquireLock(getTeamTaskDir("core")) const teamName = createdTeams[0]
const lock = acquireLock(getTeamTaskDir(teamName))
expect(lock.acquired).toBe(true) expect(lock.acquired).toBe(true)
try { try {
//#when //#when
const deleteWhileLocked = () => deleteTeamData("core") const deleteWhileLocked = () => deleteTeamData(teamName)
//#then //#then
expect(deleteWhileLocked).toThrow("team_task_lock_unavailable") expect(deleteWhileLocked).toThrow("team_task_lock_unavailable")
expect(teamExists("core")).toBe(true) expect(teamExists(teamName)).toBe(true)
} finally { } finally {
lock.release() lock.release()
} }
//#when //#when
deleteTeamData("core") deleteTeamData(teamName)
//#then //#then
expect(teamExists("core")).toBe(false) expect(teamExists(teamName)).toBe(false)
}) })
test("deleteTeamData removes task files before deleting team directory", () => { test("deleteTeamData removes task files before deleting team directory", () => {
//#given //#given
const taskDir = getTeamTaskDir("core") const teamName = createdTeams[0]
const teamDir = getTeamDir("core") const taskDir = getTeamTaskDir(teamName)
const teamDir = getTeamDir(teamName)
const teamsRootDir = getTeamsRootDir() const teamsRootDir = getTeamsRootDir()
expect(existsSync(taskDir)).toBe(true) expect(existsSync(taskDir)).toBe(true)
expect(existsSync(teamDir)).toBe(true) expect(existsSync(teamDir)).toBe(true)
@ -90,7 +105,7 @@ describe("agent-teams team config store", () => {
//#when //#when
chmodSync(teamsRootDir, 0o555) chmodSync(teamsRootDir, 0o555)
try { try {
const deleteWithBlockedTeamParent = () => deleteTeamData("core") const deleteWithBlockedTeamParent = () => deleteTeamData(teamName)
expect(deleteWithBlockedTeamParent).toThrow() expect(deleteWithBlockedTeamParent).toThrow()
} finally { } finally {
chmodSync(teamsRootDir, 0o755) chmodSync(teamsRootDir, 0o755)
@ -103,39 +118,40 @@ describe("agent-teams team config store", () => {
test("deleteTeamData fails if team has active teammates", () => { test("deleteTeamData fails if team has active teammates", () => {
//#given //#given
const config = readTeamConfigOrThrow("core") const teamName = createdTeams[0]
const config = readTeamConfigOrThrow(teamName)
const updated = upsertTeammate(config, { const updated = upsertTeammate(config, {
agentId: "teammate@core", agentId: `teammate@${teamName}`,
name: "teammate", name: "teammate",
agentType: "sisyphus", agentType: "teammate",
category: "test", category: "test",
model: "sisyphus", model: "sisyphus",
prompt: "test prompt", prompt: "test prompt",
color: "#000000", color: "#000000",
planModeRequired: false, planModeRequired: false,
joinedAt: Date.now(), joinedAt: new Date().toISOString(),
cwd: process.cwd(), cwd: process.cwd(),
subscriptions: [], subscriptions: [],
backendType: "native", backendType: "native",
isActive: true, isActive: true,
sessionID: "ses-sub", sessionID: "ses-sub",
}) })
writeTeamConfig("core", updated) writeTeamConfig(teamName, updated)
//#when //#when
const deleteWithTeammates = () => deleteTeamData("core") const deleteWithTeammates = () => deleteTeamData(teamName)
//#then //#then
expect(deleteWithTeammates).toThrow("team_has_active_members") expect(deleteWithTeammates).toThrow("team_has_active_members")
expect(teamExists("core")).toBe(true) expect(teamExists(teamName)).toBe(true)
//#when - cleanup teammate to allow afterEach to succeed //#when - cleanup teammate to allow afterEach to succeed
const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") } const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") }
writeTeamConfig("core", cleared) writeTeamConfig(teamName, cleared)
deleteTeamData("core") deleteTeamData(teamName)
//#then //#then
expect(teamExists("core")).toBe(false) expect(teamExists(teamName)).toBe(false)
}) })
}) })

View File

@ -1,4 +1,4 @@
import { existsSync, rmSync } from "node:fs" import { existsSync, readdirSync, rmSync } from "node:fs"
import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
import { import {
getTeamConfigPath, getTeamConfigPath,
@ -20,8 +20,8 @@ import {
import { validateTeamName } from "./name-validation" import { validateTeamName } from "./name-validation"
import { withTeamTaskLock } from "./team-task-store" import { withTeamTaskLock } from "./team-task-store"
function nowMs(): number { function nowMs(): string {
return Date.now() return new Date().toISOString()
} }
function assertValidTeamName(teamName: string): void { function assertValidTeamName(teamName: string): void {
@ -52,6 +52,7 @@ function createLeadMember(teamName: string, cwd: string, leadModel: string): Tea
agentId: `team-lead@${teamName}`, agentId: `team-lead@${teamName}`,
name: "team-lead", name: "team-lead",
agentType: "team-lead", agentType: "team-lead",
color: "#2D3748",
model: leadModel, model: leadModel,
joinedAt: nowMs(), joinedAt: nowMs(),
cwd, cwd,
@ -193,3 +194,24 @@ export function deleteTeamData(teamName: string): void {
}) })
}) })
} }
export function deleteTeamDir(teamName: string): void {
deleteTeamData(teamName)
}
export function listTeams(): string[] {
const teamsRootDir = getTeamsRootDir()
if (!existsSync(teamsRootDir)) {
return []
}
try {
const entries = readdirSync(teamsRootDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(getTeamConfigPath(name)))
} catch {
return []
}
}

View File

@ -5,12 +5,37 @@ import { TaskObjectSchema } from "../task/types"
export const TeamMemberSchema = z.object({ export const TeamMemberSchema = z.object({
agentId: z.string().min(1), agentId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
agentType: z.enum(["lead", "teammate"]), agentType: z.enum(["team-lead", "lead", "teammate"]),
color: z.string().min(1), color: z.string().min(1),
}) })
export type TeamMember = z.infer<typeof TeamMemberSchema> export type TeamMember = z.infer<typeof TeamMemberSchema>
export type TeamLeadMember = TeamMember & {
agentType: "team-lead"
model: string
joinedAt: string
cwd: string
subscriptions: string[]
}
export function isTeammateMember(member: TeamMember): member is TeamTeammateMember {
return member.agentType === "teammate"
}
export const TEAM_COLOR_PALETTE = [
"#FF6B6B", // Red
"#4ECDC4", // Teal
"#45B7D1", // Blue
"#96CEB4", // Sage
"#FFEEAD", // Yellow
"#FF9F43", // Orange
"#6C5CE7", // Purple
"#00CEC9", // Cyan
"#F368E0", // Pink
"#FD7272", // Coral
] as const
export const TeamTeammateMemberSchema = TeamMemberSchema.extend({ export const TeamTeammateMemberSchema = TeamMemberSchema.extend({
category: z.string().min(1), category: z.string().min(1),
model: z.string().min(1), model: z.string().min(1),