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:
parent
d65912bc63
commit
f0ae1131de
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user