import { readFileSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { TaskStore } from "../src/task-store.js"; // Helper: create a subtask, which can be ticked off directly. function createSubtask(store: TaskStore, subject: string) { const parent = store.create(`${subject} parent`, "Desc", "done criterion"); return store.create(subject, "Desc", "done criterion", undefined, undefined, parent.id); } describe("TaskStore (in-memory)", () => { let store: TaskStore; beforeEach(() => { store = new TaskStore(); // no listId = in-memory }); it("creates tasks with auto-incrementing IDs", () => { const t1 = store.create("First task", "Description 1", "criterion 1"); const t2 = store.create("Second task", "Description 2", "criterion 2"); expect(t1.id).toBe("1"); expect(t2.id).toBe("2"); expect(t1.status).toBe("pending"); expect(t1.subject).toBe("First task"); expect(t1.description).toBe("Description 1"); expect(t1.done_criterion).toBe("criterion 1"); }); it("creates tasks with optional fields", () => { const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" }); expect(t.progress_label).toBe("Running task"); expect(t.metadata).toEqual({ key: "value" }); }); it("gets a task by ID", () => { store.create("Test", "Desc", "done"); const task = store.get("1"); expect(task).toBeDefined(); expect(task!.subject).toBe("Test"); }); it("returns undefined for non-existent task", () => { expect(store.get("999")).toBeUndefined(); }); it("lists all tasks sorted by ID", () => { store.create("Task 3", "Desc", "done"); store.create("Task 1", "Desc", "done"); store.create("Task 2", "Desc", "done"); const tasks = store.list(); expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]); }); it("updates task status", () => { store.create("Test", "Desc", "done"); const { task, changedFields } = store.update("1", { status: "in_progress" }); expect(task!.status).toBe("in_progress"); expect(changedFields).toEqual(["status"]); }); it("updates multiple fields at once", () => { store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { subject: "Updated subject", description: "Updated desc", metadata: { owner: "agent-1" }, }); expect(changedFields).toContain("subject"); expect(changedFields).toContain("description"); expect(changedFields).toContain("metadata"); const task = store.get("1")!; expect(task.subject).toBe("Updated subject"); expect(task.metadata.owner).toBe("agent-1"); }); it("deletes a task with status: deleted", () => { store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { status: "deleted" }); expect(changedFields).toEqual(["deleted"]); expect(store.get("1")).toBeUndefined(); expect(store.list()).toHaveLength(0); }); it("preserves ID counter after deletion", () => { store.create("Task 1", "Desc", "done"); store.create("Task 2", "Desc", "done"); store.update("1", { status: "deleted" }); const t3 = store.create("Task 3", "Desc", "done"); expect(t3.id).toBe("3"); // Not "1" — counter continues }); it("merges metadata with null key deletion", () => { store.create("Test", "Desc", "done", undefined, { a: 1, b: 2, c: 3 }); store.update("1", { metadata: { b: null, d: 4 } }); const task = store.get("1")!; expect(task.metadata).toEqual({ a: 1, c: 3, d: 4 }); }); it("sets up bidirectional blocks via add_blocks", () => { store.create("Blocker", "Desc", "done"); store.create("Blocked", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); const t1 = store.get("1")!; const t2 = store.get("2")!; expect(t1.blocks).toContain("2"); expect(t2.blockedBy).toContain("1"); }); it("sets up bidirectional blocks via add_blocked_by", () => { store.create("Blocker", "Desc", "done"); store.create("Blocked", "Desc", "done"); store.update("2", { add_blocked_by: ["1"] }); const t1 = store.get("1")!; const t2 = store.get("2")!; expect(t1.blocks).toContain("2"); expect(t2.blockedBy).toContain("1"); }); it("does not duplicate dependency edges", () => { store.create("A", "Desc", "done"); store.create("B", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); store.update("1", { add_blocks: ["2"] }); // duplicate const t1 = store.get("1")!; expect(t1.blocks.filter(id => id === "2")).toHaveLength(1); }); it("cleans up dependency edges on deletion", () => { store.create("A", "Desc", "done"); store.create("B", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); store.update("1", { status: "deleted" }); const t2 = store.get("2")!; expect(t2.blockedBy).toEqual([]); }); it("clears completed tasks", () => { store.create("Completed", "Desc", "done"); store.create("Pending", "Desc", "done"); store.complete("1"); const count = store.clearCompleted(); expect(count).toBe(1); expect(store.list()).toHaveLength(1); expect(store.list()[0].id).toBe("2"); }); it("allows TaskUpdate(status=completed) for subtasks", () => { createSubtask(store, "Checklist item"); const { task, changedFields } = store.update("2", { status: "completed" }); expect(task!.status).toBe("completed"); expect(changedFields).toContain("status"); }); it("blocks TaskUpdate(status=completed) for top-level tasks", () => { store.create("Goal", "Desc", "done"); expect(() => store.update("1", { status: "completed" })).toThrow("Top-level task #1 requires proof"); }); it("keeps top-level completion gated even after proof evidence exists", () => { store.create("Escalated", "Desc", "done"); store.update("1", { metadata: { lgtm_evidence: "literal output" } }); expect(() => store.update("1", { status: "completed" })).toThrow("TaskClaimDone"); }); it("rejects changing parentId after creation", () => { store.create("Parent", "Desc", "done"); store.create("Child", "Desc", "done"); expect(() => store.update("2", { parentId: "1" })).toThrow("parentId is creation-only"); }); it("returns not found for update on non-existent task", () => { const { task, changedFields } = store.update("999", { status: "in_progress" }); expect(task).toBeUndefined(); expect(changedFields).toEqual([]); }); it("complete() is the internal proof-review completion path", () => { store.create("Test", "Desc", "done"); const task = store.complete("1"); expect(task.status).toBe("completed"); }); it("complete() also works for subtasks", () => { createSubtask(store, "Test"); const task = store.complete("2"); expect(task.status).toBe("completed"); }); it("complete() throws on non-existent task", () => { expect(() => store.complete("999")).toThrow("not found"); }); it("delete method works", () => { store.create("Test", "Desc", "done"); expect(store.delete("1")).toBe(true); expect(store.delete("1")).toBe(false); // already deleted expect(store.list()).toHaveLength(0); }); it("creates tasks with metadata via TaskCreate", () => { const t = store.create("With meta", "Desc", "done", undefined, { pr: "123", reviewer: "alice" }); expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" }); const retrieved = store.get("1")!; expect(retrieved.metadata).toEqual({ pr: "123", reviewer: "alice" }); }); it("allows circular dependencies with warning", () => { store.create("A", "Desc", "done"); store.create("B", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); const { warnings } = store.update("2", { add_blocks: ["1"] }); expect(store.get("1")!.blocks).toContain("2"); expect(store.get("2")!.blocks).toContain("1"); expect(warnings).toContain("cycle: #2 and #1 block each other"); }); it("allows self-dependency with warning", () => { store.create("Self", "Desc", "done"); const { warnings } = store.update("1", { add_blocks: ["1"] }); expect(store.get("1")!.blocks).toContain("1"); expect(warnings).toContain("#1 blocks itself"); }); it("stores dangling edge IDs with warning", () => { store.create("Real", "Desc", "done"); const { warnings } = store.update("1", { add_blocks: ["9999"] }); expect(store.get("1")!.blocks).toContain("9999"); expect(warnings).toContain("#9999 does not exist"); }); it("returns no warnings for valid dependencies", () => { store.create("A", "Desc", "done"); store.create("B", "Desc", "done"); const { warnings } = store.update("1", { add_blocks: ["2"] }); expect(warnings).toEqual([]); }); it("accepts whitespace-only subjects (matches Claude Code)", () => { const t = store.create(" ", "Desc", "done"); expect(t.subject).toBe(" "); }); it("updates progress_label field", () => { store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { progress_label: "Running tests" }); expect(changedFields).toContain("progress_label"); expect(store.get("1")!.progress_label).toBe("Running tests"); }); it("updates description field", () => { store.create("Test", "Original desc", "done"); const { changedFields } = store.update("1", { description: "Updated desc" }); expect(changedFields).toContain("description"); expect(store.get("1")!.description).toBe("Updated desc"); }); it("updates done_criterion field", () => { store.create("Test", "Desc", "original criterion"); const { changedFields } = store.update("1", { done_criterion: "updated criterion" }); expect(changedFields).toContain("done_criterion"); expect(store.get("1")!.done_criterion).toBe("updated criterion"); }); it("returns empty changedFields when updating non-existent task", () => { const { task, changedFields, warnings } = store.update("999", { status: "in_progress" }); expect(task).toBeUndefined(); expect(changedFields).toEqual([]); expect(warnings).toEqual([]); }); it("clearCompleted cleans up dependency edges", () => { store.create("Blocker", "Desc", "done"); store.create("Blocked", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); // complete() is the internal proof-review completion path. store.complete("1"); store.clearCompleted(); const t2 = store.get("2")!; expect(t2.blockedBy).toEqual([]); }); it("handles multiple add_blocks in one call", () => { store.create("Blocker", "Desc", "done"); store.create("B1", "Desc", "done"); store.create("B2", "Desc", "done"); store.update("1", { add_blocks: ["2", "3"] }); expect(store.get("1")!.blocks).toEqual(["2", "3"]); expect(store.get("2")!.blockedBy).toContain("1"); expect(store.get("3")!.blockedBy).toContain("1"); }); it("add_blocked_by warns on self-dependency", () => { store.create("Self", "Desc", "done"); const { warnings } = store.update("1", { add_blocked_by: ["1"] }); expect(store.get("1")!.blockedBy).toContain("1"); expect(warnings).toContain("#1 blocks itself"); }); it("add_blocked_by warns on dangling ref", () => { store.create("Real", "Desc", "done"); const { warnings } = store.update("1", { add_blocked_by: ["9999"] }); expect(store.get("1")!.blockedBy).toContain("9999"); expect(warnings).toContain("#9999 does not exist"); }); it("add_blocked_by warns on cycle", () => { store.create("A", "Desc", "done"); store.create("B", "Desc", "done"); store.update("1", { add_blocks: ["2"] }); const { warnings } = store.update("1", { add_blocked_by: ["2"] }); expect(warnings).toContain("cycle: #1 and #2 block each other"); }); it("clearCompleted returns 0 when no completed tasks", () => { store.create("Pending", "Desc", "done"); expect(store.clearCompleted()).toBe(0); }); it("list sorts pending → in_progress → completed with all three present", () => { store.create("Pending task", "Desc", "done"); store.create("Completed task", "Desc", "done"); store.create("In-progress task", "Desc", "done"); store.create("Another pending", "Desc", "done"); store.complete("2"); store.update("3", { status: "in_progress" }); const tasks = store.list(); const statusOrder: Record = { pending: 0, in_progress: 1, completed: 2 }; const sorted = [...tasks].sort((a, b) => { const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0); if (so !== 0) return so; return Number(a.id) - Number(b.id); }); expect(sorted.map(t => t.id)).toEqual(["1", "4", "3", "2"]); expect(sorted.map(t => t.status)).toEqual(["pending", "pending", "in_progress", "completed"]); }); }); describe("TaskStore (file-backed)", () => { const testListId = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`; const tasksDir = join(homedir(), ".pi", "tasks"); const filePath = join(tasksDir, `${testListId}.json`); afterEach(() => { try { rmSync(filePath); } catch { /* */ } try { rmSync(filePath + ".lock"); } catch { /* */ } try { rmSync(filePath + ".tmp"); } catch { /* */ } }); it("persists tasks to disk", () => { const store1 = new TaskStore(testListId); store1.create("Persistent task", "Should survive reload", "done"); const store2 = new TaskStore(testListId); const tasks = store2.list(); expect(tasks).toHaveLength(1); expect(tasks[0].subject).toBe("Persistent task"); }); it("persists in_progress updates to disk", () => { const store1 = new TaskStore(testListId); store1.create("Task", "Desc", "done"); store1.update("1", { status: "in_progress" }); const store2 = new TaskStore(testListId); expect(store2.get("1")!.status).toBe("in_progress"); }); it("persists completed tasks to disk", () => { const store1 = new TaskStore(testListId); store1.create("Done task", "Desc", "done"); store1.create("Pending task", "Desc", "done"); store1.complete("1"); const store2 = new TaskStore(testListId); expect(store2.get("1")).toBeDefined(); expect(store2.get("1")!.status).toBe("completed"); expect(store2.get("2")).toBeDefined(); expect(store2.list()).toHaveLength(2); }); it("restores all tasks across instances", () => { const store1 = new TaskStore(testListId); store1.create("Pending", "Desc", "done"); store1.create("In progress", "Desc", "done"); store1.create("Done", "Desc", "done"); store1.update("2", { status: "in_progress" }); store1.complete("3"); const store2 = new TaskStore(testListId); const tasks = store2.list(); expect(tasks).toHaveLength(3); expect(tasks.map(t => t.id)).toContain("1"); expect(tasks.map(t => t.id)).toContain("2"); expect(tasks.map(t => t.id)).toContain("3"); }); it("persists ID counter across instances", () => { const store1 = new TaskStore(testListId); store1.create("Task 1", "Desc", "done"); store1.create("Task 2", "Desc", "done"); const store2 = new TaskStore(testListId); const t3 = store2.create("Task 3", "Desc", "done"); expect(t3.id).toBe("3"); }); }); describe("TaskStore (absolute path)", () => { const absFilePath = join(tmpdir(), `pi-tasks-test-${Date.now()}.json`); afterEach(() => { try { rmSync(absFilePath); } catch { /* */ } try { rmSync(absFilePath + ".lock"); } catch { /* */ } try { rmSync(absFilePath + ".tmp"); } catch { /* */ } }); it("accepts absolute path and persists tasks", () => { const store1 = new TaskStore(absFilePath); store1.create("Abs path task", "Desc", "done"); const store2 = new TaskStore(absFilePath); expect(store2.list()).toHaveLength(1); expect(store2.list()[0].subject).toBe("Abs path task"); }); it("persists completed tasks when using absolute path", () => { const store1 = new TaskStore(absFilePath); store1.create("Pending", "Desc", "done"); store1.create("Completed", "Desc", "done"); store1.complete("2"); const raw = JSON.parse(readFileSync(absFilePath, "utf-8")); expect(raw.tasks).toHaveLength(2); }); });