feat(KB-034): add task archive/unarchive functionality
- Add 'archived' column to task store with archiveTask and unarchiveTask methods - Add CLI commands: kb task archive <id> and kb task unarchive <id> - Add pi extension tools for archive and unarchive operations - Add dashboard API endpoints POST /api/tasks/:id/archive and /unarchive - Add Archived column to board UI with archive/unarchive buttons - Prevent drag-drop into archived column, add visual distinction - Include duplicateTask from concurrent branch in merge resolution
This commit is contained in:
@@ -1563,4 +1563,230 @@ describe("TaskStore", () => {
|
||||
expect(duplicated.baseBranch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Archive/Unarchive Tests ──────────────────────────────────────
|
||||
|
||||
describe("archiveTask", () => {
|
||||
it("archives a done task (moves done → archived)", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("adds log entry 'Task archived'", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.log.some((l) => l.action === "Task archived")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits task:moved event with correct from/to columns", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:moved", (data) => events.push(data));
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].from).toBe("done");
|
||||
expect(events[0].to).toBe("archived");
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
const fetched = await store.getTask(task.id);
|
||||
|
||||
expect(fetched.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("throws error when task is not in 'done' column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage, not done
|
||||
|
||||
await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'");
|
||||
});
|
||||
|
||||
it("updates columnMovedAt timestamp", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
const beforeArchive = (await store.getTask(task.id)).columnMovedAt;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.columnMovedAt).not.toBe(beforeArchive);
|
||||
expect(new Date(archived.columnMovedAt!).getTime()).toBeGreaterThan(new Date(beforeArchive!).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveTask", () => {
|
||||
it("unarchives an archived task (moves archived → done)", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
|
||||
expect(unarchived.column).toBe("done");
|
||||
});
|
||||
|
||||
it("adds log entry 'Task unarchived'", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
|
||||
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits task:moved event with correct from/to columns", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:moved", (data) => events.push(data));
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].from).toBe("archived");
|
||||
expect(events[0].to).toBe("done");
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
const fetched = await store.getTask(task.id);
|
||||
|
||||
expect(fetched.column).toBe("done");
|
||||
});
|
||||
|
||||
it("throws error when task is not in 'archived' column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage, not archived
|
||||
|
||||
await expect(store.unarchiveTask(task.id)).rejects.toThrow("must be in 'archived'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
|
||||
it("moveTask from archived → in-progress should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "in-progress")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → triage should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "triage")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → todo should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "todo")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → in-review should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "in-review")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from triage → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from todo → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from in-progress → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from in-review → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -742,6 +742,72 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a done task (move from done → archived).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
*/
|
||||
async archiveTask(id: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (task.column !== "done") {
|
||||
throw new Error(
|
||||
`Cannot archive ${id}: task is in '${task.column}', must be in 'done'`,
|
||||
);
|
||||
}
|
||||
|
||||
task.column = "archived";
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
task.log.push({
|
||||
timestamp: task.columnMovedAt,
|
||||
action: "Task archived",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive an archived task (move from archived → done).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
*/
|
||||
async unarchiveTask(id: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (task.column !== "archived") {
|
||||
throw new Error(
|
||||
`Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`,
|
||||
);
|
||||
}
|
||||
|
||||
task.column = "done";
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
task.log.push({
|
||||
timestamp: task.columnMovedAt,
|
||||
action: "Task unarchived",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
private async moveToDone(task: Task, dir: string): Promise<void> {
|
||||
task.column = "done";
|
||||
task.worktree = undefined;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
|
||||
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
||||
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
|
||||
export type Column = (typeof COLUMNS)[number];
|
||||
|
||||
export type PrStatus = "open" | "closed" | "merged";
|
||||
@@ -267,6 +267,7 @@ export const COLUMN_LABELS: Record<Column, string> = {
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
|
||||
@@ -275,6 +276,7 @@ export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
|
||||
"in-progress": "AI is working on this in a worktree",
|
||||
"in-review": "Complete — ready to merge",
|
||||
done: "Merged and closed",
|
||||
archived: "Completed and archived",
|
||||
};
|
||||
|
||||
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
@@ -282,5 +284,6 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
todo: ["in-progress", "triage"],
|
||||
"in-progress": ["in-review", "todo", "triage"],
|
||||
"in-review": ["done", "in-progress"],
|
||||
done: [],
|
||||
done: ["archived"],
|
||||
archived: ["done"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user