feat(KB-048): add collapsible list sections to dashboard
- Add section expansion state management with localStorage persistence - Update section headers with chevron toggle controls - Implement conditional task row rendering based on section state - Add Expand All / Collapse All toolbar controls - Add CSS styles for chevron rotation animation and section headers - Add comprehensive tests for collapsible section behavior
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
|
||||
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel } from "./types.js";
|
||||
export type { Column, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -345,6 +345,19 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoResolveConflicts setting", () => {
|
||||
it("persists autoResolveConflicts and returns it via getSettings", async () => {
|
||||
await store.updateSettings({ autoResolveConflicts: false });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoResolveConflicts).toBe(false);
|
||||
});
|
||||
|
||||
it("default settings have autoResolveConflicts set to true", async () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoResolveConflicts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent stress test ───────────────────────────────────────
|
||||
|
||||
describe("concurrent stress", () => {
|
||||
@@ -602,6 +615,64 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask — model overrides", () => {
|
||||
it("sets executor model provider and id via updateTask", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("sets validator model provider and id via updateTask", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("clears executor model fields via null", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
const updated = await store.updateTask(task.id, { modelProvider: null, modelId: null });
|
||||
expect(updated.modelProvider).toBeUndefined();
|
||||
expect(updated.modelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears validator model fields via null", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
const updated = await store.updateTask(task.id, { validatorModelProvider: null, validatorModelId: null });
|
||||
expect(updated.validatorModelProvider).toBeUndefined();
|
||||
expect(updated.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets only executor model without affecting validator model", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("preserves model fields when updating unrelated fields", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const updated = await store.updateTask(task.id, { title: "Updated title" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
expect(updated.title).toBe("Updated title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent log persistence", () => {
|
||||
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
|
||||
const task = await createTestTask();
|
||||
@@ -718,6 +789,281 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSteeringComment", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.addSteeringComment(task.id, "Please handle the edge case");
|
||||
|
||||
expect(updated.steeringComments).toHaveLength(1);
|
||||
expect(updated.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(updated.steeringComments![0].author).toBe("user");
|
||||
expect(updated.steeringComments![0].id).toBeDefined();
|
||||
expect(updated.steeringComments![0].createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts agent as author", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.addSteeringComment(task.id, "Note from agent", "agent");
|
||||
|
||||
expect(updated.steeringComments).toHaveLength(1);
|
||||
expect(updated.steeringComments![0].author).toBe("agent");
|
||||
});
|
||||
|
||||
it("initializes steeringComments array if undefined", async () => {
|
||||
const task = await createTestTask();
|
||||
expect(task.steeringComments).toBeUndefined();
|
||||
|
||||
const updated = await store.addSteeringComment(task.id, "First comment");
|
||||
expect(updated.steeringComments).toBeDefined();
|
||||
expect(updated.steeringComments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appends multiple comments in order", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.addSteeringComment(task.id, "First comment");
|
||||
await store.addSteeringComment(task.id, "Second comment");
|
||||
await store.addSteeringComment(task.id, "Third comment");
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.steeringComments).toHaveLength(3);
|
||||
expect(fetched.steeringComments![0].text).toBe("First comment");
|
||||
expect(fetched.steeringComments![1].text).toBe("Second comment");
|
||||
expect(fetched.steeringComments![2].text).toBe("Third comment");
|
||||
});
|
||||
|
||||
it("generates unique IDs for each comment", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated1 = await store.addSteeringComment(task.id, "Comment 1");
|
||||
const updated2 = await store.addSteeringComment(task.id, "Comment 2");
|
||||
|
||||
const id1 = updated1.steeringComments![0].id;
|
||||
const id2 = updated2.steeringComments![1].id;
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it("emits task:updated event", async () => {
|
||||
const task = await createTestTask();
|
||||
const events: any[] = [];
|
||||
store.on("task:updated", (t) => events.push(t));
|
||||
|
||||
await store.addSteeringComment(task.id, "Test comment");
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].steeringComments).toHaveLength(1);
|
||||
expect(events[0].steeringComments![0].text).toBe("Test comment");
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.addSteeringComment(task.id, "Persisted comment");
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.steeringComments).toHaveLength(1);
|
||||
expect(fetched.steeringComments![0].text).toBe("Persisted comment");
|
||||
expect(fetched.steeringComments![0].author).toBe("user");
|
||||
});
|
||||
|
||||
it("adds log entry for the action", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.addSteeringComment(task.id, "Comment with log");
|
||||
|
||||
expect(updated.log.some((l) => l.action === "Steering comment added")).toBe(true);
|
||||
expect(updated.log.some((l) => l.outcome === "by user")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates updatedAt timestamp", async () => {
|
||||
const task = await createTestTask();
|
||||
const before = task.updatedAt;
|
||||
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
|
||||
|
||||
const updated = await store.addSteeringComment(task.id, "Timestamp test");
|
||||
expect(updated.updatedAt).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePrInfo", () => {
|
||||
it("adds PR info to a task without existing PR", async () => {
|
||||
const task = await createTestTask();
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
|
||||
const updated = await store.updatePrInfo(task.id, prInfo);
|
||||
|
||||
expect(updated.prInfo).toEqual(prInfo);
|
||||
expect(updated.log.some((l) => l.action === "PR linked" && l.outcome?.includes("#42"))).toBe(true);
|
||||
});
|
||||
|
||||
it("updates existing PR info with new values", async () => {
|
||||
const task = await createTestTask();
|
||||
const prInfo1 = {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open" as const,
|
||||
title: "Initial PR",
|
||||
headBranch: "branch-1",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
await store.updatePrInfo(task.id, prInfo1);
|
||||
|
||||
const prInfo2 = {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "merged" as const,
|
||||
title: "Initial PR (updated)",
|
||||
headBranch: "branch-1",
|
||||
baseBranch: "main",
|
||||
commentCount: 3,
|
||||
lastCommentAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const updated = await store.updatePrInfo(task.id, prInfo2);
|
||||
|
||||
expect(updated.prInfo?.status).toBe("merged");
|
||||
expect(updated.prInfo?.commentCount).toBe(3);
|
||||
expect(updated.prInfo?.lastCommentAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("clears PR info when passed null", async () => {
|
||||
const task = await createTestTask();
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
|
||||
const updated = await store.updatePrInfo(task.id, null);
|
||||
|
||||
expect(updated.prInfo).toBeUndefined();
|
||||
expect(updated.log.some((l) => l.action === "PR unlinked")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits task:updated event when PR info changes", async () => {
|
||||
const task = await createTestTask();
|
||||
const events: any[] = [];
|
||||
store.on("task:updated", (t) => events.push(t));
|
||||
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].prInfo?.number).toBe(42);
|
||||
});
|
||||
|
||||
it("does NOT emit task:updated when PR info is unchanged", async () => {
|
||||
const task = await createTestTask();
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:updated", (t) => events.push(t));
|
||||
|
||||
// Update with same values (status and number unchanged)
|
||||
await store.updatePrInfo(task.id, { ...prInfo });
|
||||
|
||||
// Should not emit because number and status are the same
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await createTestTask();
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 5,
|
||||
lastCommentAt: "2026-03-30T12:00:00.000Z",
|
||||
};
|
||||
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
const fetched = await store.getTask(task.id);
|
||||
|
||||
expect(fetched.prInfo).toEqual(prInfo);
|
||||
});
|
||||
|
||||
it("updates updatedAt timestamp", async () => {
|
||||
const task = await createTestTask();
|
||||
const before = task.updatedAt;
|
||||
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
|
||||
|
||||
const prInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb-001-fix-bug",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
const updated = await store.updatePrInfo(task.id, prInfo);
|
||||
|
||||
expect(updated.updatedAt).not.toBe(before);
|
||||
});
|
||||
|
||||
it("serializes concurrent updates correctly", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
// Fire 5 concurrent updates
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
store.updatePrInfo(task.id, {
|
||||
url: `https://github.com/owner/repo/pull/${i + 1}`,
|
||||
number: i + 1,
|
||||
status: "open" as const,
|
||||
title: `PR ${i + 1}`,
|
||||
headBranch: `branch-${i + 1}`,
|
||||
baseBranch: "main",
|
||||
commentCount: i,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Read back and verify valid JSON
|
||||
const taskJsonPath = join(rootDir, ".kb", "tasks", task.id, "task.json");
|
||||
const raw = await readFile(taskJsonPath, "utf-8");
|
||||
const result = JSON.parse(raw) as Task;
|
||||
|
||||
// Should have exactly one of the PRs set (last one wins)
|
||||
expect(result.prInfo).toBeDefined();
|
||||
expect(result.prInfo!.number).toBeGreaterThanOrEqual(1);
|
||||
expect(result.prInfo!.number).toBeLessThanOrEqual(5);
|
||||
|
||||
// Should have all the PR linked log entries
|
||||
const prLogs = result.log.filter((l) => l.action === "PR linked");
|
||||
expect(prLogs).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDependenciesFromPrompt", () => {
|
||||
it("returns single dependency from PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Task with dep" });
|
||||
@@ -1022,4 +1368,199 @@ describe("TaskStore", () => {
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate Task Tests ─────────────────────────────────────────
|
||||
|
||||
describe("duplicateTask", () => {
|
||||
it("duplicates from triage column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.id).not.toBe(task.id);
|
||||
expect(duplicated.id).toMatch(/^KB-\d+$/);
|
||||
expect(duplicated.column).toBe("triage");
|
||||
expect(duplicated.description).toContain(task.description);
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("duplicates from todo column", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.column).toBe("triage");
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("duplicates from in-progress column", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.column).toBe("triage");
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("duplicates from in-review column", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.column).toBe("triage");
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("duplicates from done column", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.column).toBe("triage");
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("new task is always in triage regardless of source column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
expect(duplicated.column).toBe("triage");
|
||||
});
|
||||
|
||||
it("description includes source reference", async () => {
|
||||
const task = await store.createTask({ description: "Original description" });
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.description).toBe(`Original description\n\n(Duplicated from ${task.id})`);
|
||||
});
|
||||
|
||||
it("resets execution state (no steps, no worktree, etc.)", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
// Add some execution state
|
||||
await store.updateTask(task.id, { worktree: "/some/path", status: "executing" });
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.steps).toEqual([]);
|
||||
expect(duplicated.currentStep).toBe(0);
|
||||
expect(duplicated.worktree).toBeUndefined();
|
||||
expect(duplicated.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT copy dependencies", async () => {
|
||||
const dep = await store.createTask({ description: "Dependency" });
|
||||
const task = await store.createTask({ description: "Test task", dependencies: [dep.id] });
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT copy attachments", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Add an attachment
|
||||
await store.addAttachment(task.id, "test.png", Buffer.from("fake"), "image/png");
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT copy steering comments", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.addSteeringComment(task.id, "Test comment");
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.steeringComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits task:created event", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
const events: any[] = [];
|
||||
store.on("task:created", (t) => events.push(t));
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].id).toBe(duplicated.id);
|
||||
});
|
||||
|
||||
it("adds log entry for duplicate action", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.log).toHaveLength(1);
|
||||
expect(duplicated.log[0].action).toContain(`Duplicated from ${task.id}`);
|
||||
});
|
||||
|
||||
it("copies source PROMPT.md content", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
const sourceDetail = await store.getTask(task.id);
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
const dupDetail = await store.getTask(duplicated.id);
|
||||
|
||||
expect(dupDetail.prompt).toBe(sourceDetail.prompt);
|
||||
});
|
||||
|
||||
it("throws ENOENT when source task does not exist", async () => {
|
||||
await expect(store.duplicateTask("KB-999")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("copies title if present", async () => {
|
||||
const task = await store.createTask({ title: "My Task", description: "Test" });
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.title).toBe("My Task");
|
||||
});
|
||||
|
||||
it("does NOT copy prInfo", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.updatePrInfo(task.id, {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "Test PR",
|
||||
headBranch: "kb/kb-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
});
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.prInfo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT copy paused state", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.pauseTask(task.id, true);
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.paused).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT copy blockedBy", async () => {
|
||||
const blocker = await store.createTask({ description: "Blocker" });
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.updateTask(task.id, { blockedBy: blocker.id });
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.blockedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT copy baseBranch", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.updateTask(task.id, { baseBranch: "some-branch" });
|
||||
|
||||
const duplicated = await store.duplicateTask(task.id);
|
||||
|
||||
expect(duplicated.baseBranch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,6 +213,51 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing task, creating a fresh copy in triage.
|
||||
* Copies title and description with source reference, but resets all
|
||||
* execution state. The new task will be re-specified by the AI.
|
||||
*/
|
||||
async duplicateTask(id: string): Promise<Task> {
|
||||
// Read the source task with its prompt
|
||||
const sourceTask = await this.getTask(id);
|
||||
|
||||
// Allocate a new ID
|
||||
const newId = await this.allocateId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Create new task with copied title/description, but fresh state
|
||||
const newTask: Task = {
|
||||
id: newId,
|
||||
title: sourceTask.title,
|
||||
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
||||
column: "triage",
|
||||
dependencies: [], // Fresh task should have no dependencies
|
||||
steps: [], // Reset execution state
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: now, action: `Duplicated from ${id}` }],
|
||||
columnMovedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// Explicitly NOT copied: worktree, status, blockedBy, paused, baseBranch,
|
||||
// attachments, steeringComments, prInfo, agent logs, size, reviewLevel
|
||||
};
|
||||
|
||||
const newDir = this.taskDir(newId);
|
||||
await mkdir(newDir, { recursive: true });
|
||||
await this.atomicWriteTaskJson(newDir, newTask);
|
||||
|
||||
// Copy source PROMPT.md content (the AI will re-specify it in triage)
|
||||
const sourcePrompt = sourceTask.prompt;
|
||||
await writeFile(join(newDir, "PROMPT.md"), sourcePrompt);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(newId, { ...newTask });
|
||||
|
||||
this.emit("task:created", newTask);
|
||||
return newTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a task's JSON and prompt content.
|
||||
*
|
||||
@@ -295,7 +340,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
@@ -337,6 +382,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
|
||||
if (updates.size !== undefined) task.size = updates.size;
|
||||
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
|
||||
if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries;
|
||||
if (updates.modelProvider === null) {
|
||||
task.modelProvider = undefined;
|
||||
} else if (updates.modelProvider !== undefined) {
|
||||
task.modelProvider = updates.modelProvider;
|
||||
}
|
||||
if (updates.modelId === null) {
|
||||
task.modelId = undefined;
|
||||
} else if (updates.modelId !== undefined) {
|
||||
task.modelId = updates.modelId;
|
||||
}
|
||||
if (updates.validatorModelProvider === null) {
|
||||
task.validatorModelProvider = undefined;
|
||||
} else if (updates.validatorModelProvider !== undefined) {
|
||||
task.validatorModelProvider = updates.validatorModelProvider;
|
||||
}
|
||||
if (updates.validatorModelId === null) {
|
||||
task.validatorModelId = undefined;
|
||||
} else if (updates.validatorModelId !== undefined) {
|
||||
task.validatorModelId = updates.validatorModelId;
|
||||
}
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
@@ -981,6 +1047,99 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.emit("agent:log", entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a steering comment to a task.
|
||||
* Steering comments are user-provided feedback injected into the AI execution context.
|
||||
*/
|
||||
async addSteeringComment(
|
||||
id: string,
|
||||
text: string,
|
||||
author: "user" | "agent" = "user",
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
// Generate unique ID: timestamp + random suffix for collision resistance
|
||||
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const comment: import("./types.js").SteeringComment = {
|
||||
id: commentId,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
author,
|
||||
};
|
||||
|
||||
if (!task.steeringComments) {
|
||||
task.steeringComments = [];
|
||||
}
|
||||
task.steeringComments.push(comment);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: "Steering comment added",
|
||||
outcome: `by ${author}`,
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or clear PR information for a task.
|
||||
* Updates task.json atomically and emits `task:updated` event.
|
||||
*
|
||||
* @param id - The task ID
|
||||
* @param prInfo - The PR info to set, or null to clear
|
||||
* @returns The updated task
|
||||
*/
|
||||
async updatePrInfo(
|
||||
id: string,
|
||||
prInfo: import("./types.js").PrInfo | null,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
const prevPrNumber = task.prInfo?.number;
|
||||
const prevPrStatus = task.prInfo?.status;
|
||||
|
||||
if (prInfo) {
|
||||
task.prInfo = prInfo;
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "PR linked",
|
||||
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
|
||||
});
|
||||
} else {
|
||||
task.prInfo = undefined;
|
||||
if (prevPrNumber) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "PR unlinked",
|
||||
outcome: `PR #${prevPrNumber} removed`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
// Only emit if PR info actually changed
|
||||
if (prevPrNumber !== prInfo?.number || prevPrStatus !== prInfo?.status) {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all historical agent log entries for a task from its agent log file.
|
||||
* Returns entries in chronological order (oldest first).
|
||||
|
||||
@@ -5,6 +5,20 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
export type Column = (typeof COLUMNS)[number];
|
||||
|
||||
export type PrStatus = "open" | "closed" | "merged";
|
||||
|
||||
export interface PrInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
status: PrStatus;
|
||||
title: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
commentCount: number;
|
||||
lastCommentAt?: string;
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
|
||||
|
||||
export interface TaskStep {
|
||||
@@ -49,6 +63,13 @@ export interface TaskAttachment {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SteeringComment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
author: "user" | "agent";
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -73,9 +94,30 @@ export interface Task {
|
||||
* dependency's branch instead of HEAD. Cleared after worktree creation. */
|
||||
baseBranch?: string;
|
||||
attachments?: TaskAttachment[];
|
||||
steeringComments?: SteeringComment[];
|
||||
/** PR information for tasks linked to GitHub pull requests */
|
||||
prInfo?: PrInfo;
|
||||
log: TaskLogEntry[];
|
||||
size?: "S" | "M" | "L";
|
||||
reviewLevel?: number;
|
||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||
* Must be set together with `modelId`. When both model fields are undefined,
|
||||
* the executor uses global settings defaults. */
|
||||
modelProvider?: string;
|
||||
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
|
||||
* Must be set together with `modelProvider`. When both model fields are undefined,
|
||||
* the executor uses global settings defaults. */
|
||||
modelId?: string;
|
||||
/** AI model provider override for the validator/reviewer agent.
|
||||
* Must be set together with `validatorModelId`. When both validator model fields
|
||||
* are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelProvider?: string;
|
||||
/** AI model ID override for the validator/reviewer agent.
|
||||
* Must be set together with `validatorModelProvider`. When both validator model
|
||||
* fields are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelId?: string;
|
||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||
mergeRetries?: number;
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
@@ -133,6 +175,9 @@ export interface Settings {
|
||||
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||
* their original IDs. */
|
||||
taskPrefix?: string;
|
||||
/** Whether GitHub token is configured for PR operations (read-only, set by server).
|
||||
* When false, PR creation features are disabled in the UI. */
|
||||
githubTokenConfigured?: boolean;
|
||||
/** When true, merge commit messages include the task ID as the conventional
|
||||
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
|
||||
* omitted (e.g. `feat: ...`). Default: true. */
|
||||
@@ -150,6 +195,20 @@ export interface Settings {
|
||||
* produce better results but cost more. When undefined, the engine
|
||||
* uses the model's default thinking level. */
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
/** When true, auto-merge will automatically resolve common conflict patterns
|
||||
* (lock files, generated files, trivial conflicts) without requiring AI
|
||||
* intervention. When AI resolution fails, the system will retry with escalating
|
||||
* strategies. Default: true. */
|
||||
autoResolveConflicts?: boolean;
|
||||
/** Alias for autoResolveConflicts. When true, enables automatic resolution of
|
||||
* lock files (ours), generated files (theirs), and trivial whitespace conflicts
|
||||
* without spawning an AI agent. Default: true. */
|
||||
smartConflictResolution?: boolean;
|
||||
/** When enabled, AI-generated task specifications require manual approval
|
||||
* before the task can move from triage to todo. Tasks with approved specs
|
||||
* remain in triage with status "awaiting-approval" until a user approves
|
||||
* or rejects the plan. Default: false. */
|
||||
requirePlanApproval?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -167,6 +226,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
@@ -181,6 +243,14 @@ export interface MergeResult {
|
||||
worktreeRemoved: boolean;
|
||||
branchDeleted: boolean;
|
||||
error?: string;
|
||||
/** Strategy that successfully resolved the merge, if any */
|
||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
|
||||
/** Alias for resolutionStrategy — how conflicts were resolved (for metrics/debugging) */
|
||||
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
|
||||
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
|
||||
attemptsMade?: 1 | 2 | 3;
|
||||
/** Number of files auto-resolved (for tracking mixed resolution scenarios) */
|
||||
autoResolvedCount?: number;
|
||||
}
|
||||
|
||||
export const COLUMN_LABELS: Record<Column, string> = {
|
||||
|
||||
@@ -3,5 +3,12 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["**/*.test.ts", "**/*.d.ts", "dist/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user