feat(team-mode): add team tasklist update with tests
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { claimTask } from "./claim"
|
||||||
|
import { getTask } from "./get"
|
||||||
|
import { createTask } from "./store"
|
||||||
|
import { createTaskInput, createTasklistFixture } from "./test-support"
|
||||||
|
import { CrossOwnerUpdateError, InvalidTaskTransitionError, updateTaskStatus } from "./update"
|
||||||
|
|
||||||
|
test("updateTaskStatus supports the one-way claim to complete flow", async () => {
|
||||||
|
// given
|
||||||
|
const fixture = await createTasklistFixture()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await createTask(fixture.teamRunId, createTaskInput(), fixture.config)
|
||||||
|
await claimTask(fixture.teamRunId, task.id, "member-a", fixture.config)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await updateTaskStatus(fixture.teamRunId, task.id, "in_progress", "member-a", fixture.config)
|
||||||
|
const completedTask = await updateTaskStatus(fixture.teamRunId, task.id, "completed", "member-a", fixture.config)
|
||||||
|
const loadedTask = await getTask(fixture.teamRunId, task.id, fixture.config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(completedTask.status).toBe("completed")
|
||||||
|
expect(loadedTask.status).toBe("completed")
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updateTaskStatus auto-claims when a member starts a pending task directly", async () => {
|
||||||
|
// given
|
||||||
|
const fixture = await createTasklistFixture()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await createTask(fixture.teamRunId, createTaskInput(), fixture.config)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const inProgressTask = await updateTaskStatus(fixture.teamRunId, task.id, "in_progress", "member-a", fixture.config)
|
||||||
|
const loadedTask = await getTask(fixture.teamRunId, task.id, fixture.config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(inProgressTask.status).toBe("in_progress")
|
||||||
|
expect(inProgressTask.owner).toBe("member-a")
|
||||||
|
expect(typeof inProgressTask.claimedAt).toBe("number")
|
||||||
|
expect(loadedTask.status).toBe("in_progress")
|
||||||
|
expect(loadedTask.owner).toBe("member-a")
|
||||||
|
expect(typeof loadedTask.claimedAt).toBe("number")
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updateTaskStatus rejects reverse transitions", async () => {
|
||||||
|
// given
|
||||||
|
const fixture = await createTasklistFixture()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await createTask(
|
||||||
|
fixture.teamRunId,
|
||||||
|
createTaskInput({ status: "completed", owner: "member-a", claimedAt: Date.now() }),
|
||||||
|
fixture.config,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
let thrownError: unknown = null
|
||||||
|
try {
|
||||||
|
await updateTaskStatus(fixture.teamRunId, task.id, "claimed", "member-a", fixture.config)
|
||||||
|
} catch (error) {
|
||||||
|
thrownError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(thrownError).toBeInstanceOf(InvalidTaskTransitionError)
|
||||||
|
expect(thrownError).toHaveProperty("message", "no reverse transitions from completed to claimed")
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updateTaskStatus rejects non-owner updates except deletion", async () => {
|
||||||
|
// given
|
||||||
|
const fixture = await createTasklistFixture()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await createTask(
|
||||||
|
fixture.teamRunId,
|
||||||
|
createTaskInput({ status: "claimed", owner: "member-a", claimedAt: Date.now() }),
|
||||||
|
fixture.config,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
let crossOwnerError: unknown = null
|
||||||
|
try {
|
||||||
|
await updateTaskStatus(fixture.teamRunId, task.id, "in_progress", "member-b", fixture.config)
|
||||||
|
} catch (error) {
|
||||||
|
crossOwnerError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(crossOwnerError).toBeInstanceOf(CrossOwnerUpdateError)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const deletedTask = await updateTaskStatus(fixture.teamRunId, task.id, "deleted", "lead-member", fixture.config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(deletedTask.status).toBe("deleted")
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||||
|
import { getTasksDir, resolveBaseDir } from "../team-registry"
|
||||||
|
import { atomicWrite } from "../team-state-store/locks"
|
||||||
|
import { TaskSchema } from "../types"
|
||||||
|
import type { Task } from "../types"
|
||||||
|
import { claimTask } from "./claim"
|
||||||
|
import { getTask } from "./get"
|
||||||
|
|
||||||
|
const ALLOWED_TRANSITIONS: Readonly<Record<Task["status"], ReadonlyArray<Task["status"]>>> = {
|
||||||
|
pending: ["claimed", "deleted"],
|
||||||
|
claimed: ["in_progress", "deleted"],
|
||||||
|
in_progress: ["completed", "deleted"],
|
||||||
|
completed: ["deleted"],
|
||||||
|
deleted: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTransition(currentStatus: Task["status"], nextStatus: Task["status"]): boolean {
|
||||||
|
if (currentStatus === nextStatus) return true
|
||||||
|
return ALLOWED_TRANSITIONS[currentStatus].includes(nextStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidTaskTransitionError extends Error {
|
||||||
|
constructor(currentStatus: Task["status"], nextStatus: Task["status"]) {
|
||||||
|
super(`no reverse transitions from ${currentStatus} to ${nextStatus}`)
|
||||||
|
this.name = "InvalidTaskTransitionError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CrossOwnerUpdateError extends Error {
|
||||||
|
constructor(message = "cross-owner updates are not allowed") {
|
||||||
|
super(message)
|
||||||
|
this.name = "CrossOwnerUpdateError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatus(
|
||||||
|
teamRunId: string,
|
||||||
|
taskId: string,
|
||||||
|
newStatus: Task["status"],
|
||||||
|
memberName: string,
|
||||||
|
config: TeamModeConfig,
|
||||||
|
): Promise<Task> {
|
||||||
|
const task = await getTask(teamRunId, taskId, config)
|
||||||
|
|
||||||
|
if (task.status === newStatus) return task
|
||||||
|
|
||||||
|
if (task.status === "pending" && newStatus === "in_progress") {
|
||||||
|
await claimTask(teamRunId, taskId, memberName, config)
|
||||||
|
return updateTaskStatus(teamRunId, taskId, newStatus, memberName, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTransition(task.status, newStatus)) {
|
||||||
|
throw new InvalidTaskTransitionError(task.status, newStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus !== "deleted" && task.owner !== memberName) {
|
||||||
|
throw new CrossOwnerUpdateError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = TaskSchema.parse({
|
||||||
|
...task,
|
||||||
|
status: newStatus,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasksDirectory = getTasksDir(resolveBaseDir(config), teamRunId)
|
||||||
|
await atomicWrite(
|
||||||
|
path.join(tasksDirectory, `${taskId}.json`),
|
||||||
|
`${JSON.stringify(updatedTask, null, 2)}\n`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedTask
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user