feat(agent-teams): add team task store with namespace routing
- Implement team-namespaced task storage at ~/.sisyphus/tasks/{teamName}/
- Follow existing task storage patterns from features/claude-tasks/storage.ts
- Import TaskObjectSchema from tools/task/types.ts (no duplication)
- Export getTeamTaskPath for test access
- 16 comprehensive tests with temp directory isolation
Task 6/25 complete
This commit is contained in:
@@ -1,44 +1,460 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
import { ensureDir } from "../../features/claude-tasks/storage"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createTeamConfig, deleteTeamData } from "./team-config-store"
|
||||
import { createTeamTask, deleteTeamTaskFile, readTeamTask } from "./team-task-store"
|
||||
import {
|
||||
getTeamTaskPath,
|
||||
readTeamTask,
|
||||
writeTeamTask,
|
||||
listTeamTasks,
|
||||
deleteTeamTask,
|
||||
} from "./team-task-store"
|
||||
import type { TeamTask } from "./types"
|
||||
|
||||
describe("agent-teams task store", () => {
|
||||
describe("getTeamTaskPath", () => {
|
||||
test("returns correct file path for team task", () => {
|
||||
//#given
|
||||
const teamName = "my-team"
|
||||
const taskId = "T-abc123"
|
||||
|
||||
//#when
|
||||
const result = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("my-team")
|
||||
expect(result).toContain("T-abc123.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("readTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-task-store-"))
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
deleteTeamData("core")
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates and reads a task", () => {
|
||||
test("returns null when task file does not exist", () => {
|
||||
//#given
|
||||
const created = createTeamTask("core", "Subject", "Description")
|
||||
const teamName = "nonexistent-team"
|
||||
const taskId = "T-does-not-exist"
|
||||
|
||||
//#when
|
||||
const loaded = readTeamTask("core", created.id)
|
||||
const result = readTeamTask(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(loaded?.id).toBe(created.id)
|
||||
expect(loaded?.subject).toBe("Subject")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("rejects invalid team name and task id", () => {
|
||||
test("returns task when valid task file exists", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-existing-task",
|
||||
subject: "Test task",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_test",
|
||||
}
|
||||
writeTeamTask("test-team", "T-existing-task", task)
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("test-team", "T-existing-task")
|
||||
|
||||
//#then
|
||||
expect(() => readTeamTask("../../etc", "T-1")).toThrow("team_name_invalid")
|
||||
expect(() => readTeamTask("core", "../../passwd")).toThrow("task_id_invalid")
|
||||
expect(() => deleteTeamTaskFile("core", "../../passwd")).toThrow("task_id_invalid")
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("T-existing-task")
|
||||
expect(result?.subject).toBe("Test task")
|
||||
})
|
||||
|
||||
test("returns null when task file contains invalid JSON", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
writeFileSync(taskPath, "{ invalid json }")
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-team", "T-invalid-json")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when task file does not match schema", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
const invalidData = { id: "T-bad-schema" }
|
||||
writeFileSync(taskPath, JSON.stringify(invalidData))
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-schema-team", "T-bad-schema")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates task file in team namespace", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-write-test",
|
||||
subject: "Write test task",
|
||||
description: "Test writing task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_write",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("write-team", "T-write-test", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("write-team", "T-write-test")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
|
||||
test("overwrites existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-overwrite-test",
|
||||
subject: "Original subject",
|
||||
description: "Original description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_original",
|
||||
}
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", task)
|
||||
|
||||
const updatedTask: TeamTask = {
|
||||
...task,
|
||||
subject: "Updated subject",
|
||||
status: "completed",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask)
|
||||
|
||||
//#then
|
||||
const result = readTeamTask("overwrite-team", "T-overwrite-test")
|
||||
expect(result?.subject).toBe("Updated subject")
|
||||
expect(result?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("creates team directory if it does not exist", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-new-dir",
|
||||
subject: "New directory test",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_newdir",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("new-team-directory", "T-new-dir", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listTeamTasks", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array when team has no tasks", () => {
|
||||
//#given
|
||||
// No tasks written
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("empty-team")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("returns all tasks for a team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "First task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Second task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("list-test-team", "T-task-1", task1)
|
||||
writeTeamTask("list-test-team", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("list-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.some((t) => t.id === "T-task-1")).toBe(true)
|
||||
expect(result.some((t) => t.id === "T-task-2")).toBe(true)
|
||||
})
|
||||
|
||||
test("includes tasks with all statuses", () => {
|
||||
//#given
|
||||
const pendingTask: TeamTask = {
|
||||
id: "T-pending",
|
||||
subject: "Pending task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_pending",
|
||||
}
|
||||
const inProgressTask: TeamTask = {
|
||||
id: "T-in-progress",
|
||||
subject: "In progress task",
|
||||
description: "Test",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_inprogress",
|
||||
}
|
||||
const completedTask: TeamTask = {
|
||||
id: "T-completed",
|
||||
subject: "Completed task",
|
||||
description: "Test",
|
||||
status: "completed",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_completed",
|
||||
}
|
||||
const deletedTask: TeamTask = {
|
||||
id: "T-deleted",
|
||||
subject: "Deleted task",
|
||||
description: "Test",
|
||||
status: "deleted",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_deleted",
|
||||
}
|
||||
writeTeamTask("status-test-team", "T-pending", pendingTask)
|
||||
writeTeamTask("status-test-team", "T-in-progress", inProgressTask)
|
||||
writeTeamTask("status-test-team", "T-completed", completedTask)
|
||||
writeTeamTask("status-test-team", "T-deleted", deletedTask)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("status-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(4)
|
||||
const statuses = result.map((t) => t.status)
|
||||
expect(statuses).toContain("pending")
|
||||
expect(statuses).toContain("in_progress")
|
||||
expect(statuses).toContain("completed")
|
||||
expect(statuses).toContain("deleted")
|
||||
})
|
||||
|
||||
test("does not include tasks from other teams", () => {
|
||||
//#given
|
||||
const taskTeam1: TeamTask = {
|
||||
id: "T-team1-task",
|
||||
subject: "Team 1 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team1",
|
||||
}
|
||||
const taskTeam2: TeamTask = {
|
||||
id: "T-team2-task",
|
||||
subject: "Team 2 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team2",
|
||||
}
|
||||
writeTeamTask("team-1", "T-team1-task", taskTeam1)
|
||||
writeTeamTask("team-2", "T-team2-task", taskTeam2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("team-1")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("T-team1-task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("deletes existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("delete-test-team", "T-delete-me", task)
|
||||
const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me")
|
||||
|
||||
//#when
|
||||
deleteTeamTask("delete-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
expect(existsSync(taskPath)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not throw when task does not exist", () => {
|
||||
//#given
|
||||
// Task does not exist
|
||||
|
||||
//#when
|
||||
expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow()
|
||||
|
||||
//#then
|
||||
// No exception thrown
|
||||
})
|
||||
|
||||
test("does not affect other tasks in same team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-keep-me",
|
||||
subject: "Keep this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_keep",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("mixed-test-team", "T-keep-me", task1)
|
||||
writeTeamTask("mixed-test-team", "T-delete-me", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("mixed-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
const remaining = listTeamTasks("mixed-test-team")
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0].id).toBe("T-keep-me")
|
||||
})
|
||||
|
||||
test("does not affect tasks from other teams", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("team-a", "T-task-1", task1)
|
||||
writeTeamTask("team-b", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("team-a", "T-task-1")
|
||||
|
||||
//#then
|
||||
const remainingInTeamB = listTeamTasks("team-b")
|
||||
expect(remainingInTeamB).toHaveLength(1)
|
||||
expect(remainingInTeamB[0].id).toBe("T-task-2")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@ function withTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
export { getTeamTaskPath } from "./paths"
|
||||
|
||||
export function readTeamTask(teamName: string, taskId: string): TeamTask | null {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
@@ -94,32 +96,33 @@ export function createTeamTask(
|
||||
}
|
||||
|
||||
return withTaskLock(teamName, () => {
|
||||
const taskId = generateTaskId()
|
||||
const task: TeamTask = {
|
||||
id: generateTaskId(),
|
||||
id: taskId,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: `unknown_${taskId}`,
|
||||
...(metadata ? { metadata } : {}),
|
||||
}
|
||||
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function writeTeamTask(teamName: string, task: TeamTask): TeamTask {
|
||||
export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(task.id)
|
||||
assertValidTaskId(taskId)
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated)
|
||||
return validated
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
}
|
||||
|
||||
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
export function deleteTeamTask(teamName: string, taskId: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
@@ -128,6 +131,11 @@ export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
deleteTeamTask(teamName, taskId)
|
||||
}
|
||||
|
||||
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
|
||||
assertValidTaskId(taskId)
|
||||
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
|
||||
@@ -146,7 +154,7 @@ export function resetOwnerTasks(teamName: string, ownerName: string): void {
|
||||
owner: undefined,
|
||||
status: task.status === "completed" ? "completed" : "pending",
|
||||
}
|
||||
writeTeamTask(teamName, next)
|
||||
writeTeamTask(teamName, next.id, next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user