feat(KB-016): add task duplicate command

- Add duplicateTask() method to core store with fresh state reset
- Add CLI command handler and register duplicate subcommand
- Add POST /tasks/:id/duplicate API endpoint to dashboard
- Add comprehensive tests for store, CLI, and API routes
- Add pi extension tool for task duplication
- Add changeset for minor release bump
This commit is contained in:
gsxdsm
2026-03-29 18:22:02 -07:00
parent 64fcb5d1b1
commit f3097c7023
11 changed files with 419 additions and 2 deletions

View File

@@ -63,6 +63,10 @@ export function retryTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/retry`, { method: "POST" });
}
export function duplicateTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/duplicate`, { method: "POST" });
}
export function pauseTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/pause`, { method: "POST" });
}

View File

@@ -206,6 +206,61 @@ describe("POST /tasks/:id/retry", () => {
});
});
describe("POST /tasks/:id/duplicate", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
duplicateTask: vi.fn(),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("duplicates a task and returns 201 with new task", async () => {
const newTask = { ...FAKE_TASK_DETAIL, id: "KB-002", column: "triage" };
(store.duplicateTask as ReturnType<typeof vi.fn>).mockResolvedValue(newTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.id).toBe("KB-002");
expect(res.body.column).toBe("triage");
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
});
it("returns 404 when source task not found", async () => {
const error = new Error("Task not found") as NodeJS.ErrnoException;
error.code = "ENOENT";
(store.duplicateTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-999/duplicate", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 500 on unexpected errors", async () => {
(store.duplicateTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Database error");
});
});
describe("PATCH /tasks/:id", () => {
let store: TaskStore;

View File

@@ -276,6 +276,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Duplicate task
router.post("/tasks/:id/duplicate", async (req, res) => {
try {
const newTask = await store.duplicateTask(req.params.id);
res.status(201).json(newTask);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
// Upload attachment
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
try {