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:
@@ -80,6 +80,7 @@ describe("kb pi extension", () => {
|
||||
"kb_task_attach",
|
||||
"kb_task_pause",
|
||||
"kb_task_unpause",
|
||||
"kb_task_duplicate",
|
||||
"kb_task_import_github",
|
||||
"kb_task_import_github_issue",
|
||||
"kb_task_browse_github_issues",
|
||||
|
||||
@@ -39,7 +39,7 @@ if (isBunBinary) {
|
||||
|
||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate } = await import("./commands/task.js");
|
||||
|
||||
const HELP = `
|
||||
kb — AI-orchestrated task board
|
||||
@@ -54,6 +54,7 @@ Usage:
|
||||
kb task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
||||
kb task log <id> <message> Add a log entry
|
||||
kb task merge <id> Merge an in-review task and close it
|
||||
kb task duplicate <id> Duplicate a task (creates copy in triage)
|
||||
kb task attach <id> <file> Attach a file to a task
|
||||
kb task pause <id> Pause a task (stops all automation)
|
||||
kb task unpause <id> Unpause a task (resumes automation)
|
||||
@@ -164,6 +165,12 @@ async function main() {
|
||||
await runTaskMerge(id);
|
||||
break;
|
||||
}
|
||||
case "duplicate": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task duplicate <id>"); process.exit(1); }
|
||||
await runTaskDuplicate(id);
|
||||
break;
|
||||
}
|
||||
case "attach": {
|
||||
const id = args[2], file = args[3];
|
||||
if (!id || !file) {
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { runTaskShow, runTaskCreate } from "./task.js";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate } from "./task.js";
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -838,3 +838,57 @@ describe("runTaskImportFromGitHub", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Duplicate Tests ---
|
||||
|
||||
describe("runTaskDuplicate", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockDuplicateTask: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockDuplicateTask = vi.fn().mockResolvedValue({
|
||||
id: "KB-002",
|
||||
description: "Duplicated task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
duplicateTask: mockDuplicateTask,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("duplicates task and prints success", async () => {
|
||||
await runTaskDuplicate("KB-001");
|
||||
|
||||
expect(mockDuplicateTask).toHaveBeenCalledOnce();
|
||||
expect(mockDuplicateTask).toHaveBeenCalledWith("KB-001");
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Duplicated"),
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
expect(successLine![0]).toContain("KB-001");
|
||||
expect(successLine![0]).toContain("KB-002");
|
||||
});
|
||||
|
||||
it("throws when task not found", async () => {
|
||||
mockDuplicateTask.mockRejectedValueOnce(new Error("Task KB-999 not found"));
|
||||
|
||||
await expect(runTaskDuplicate("KB-999")).rejects.toThrow("Task KB-999 not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,6 +286,16 @@ export async function runTaskMove(id: string, column: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDuplicate(id: string) {
|
||||
const store = await getStore();
|
||||
const newTask = await store.duplicateTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Duplicated ${id} → ${newTask.id}`);
|
||||
console.log(` Path: .kb/tasks/${newTask.id}/`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskImportGitHubInteractive(
|
||||
ownerRepo: string,
|
||||
options: TaskImportOptions = {}
|
||||
|
||||
@@ -341,6 +341,36 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_duplicate ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_task_duplicate",
|
||||
label: "KB: Duplicate Task",
|
||||
description:
|
||||
"Duplicate an existing task, creating a fresh copy in triage. " +
|
||||
"Copies the title and description but resets all execution state. " +
|
||||
"The AI triage agent will re-specify the new task.",
|
||||
promptSnippet: "Duplicate a kb task (creates copy in triage)",
|
||||
promptGuidelines: [
|
||||
"Use when a task needs to be re-done, split, or used as a template",
|
||||
"The duplicated task will be placed in triage for re-specification",
|
||||
"Dependencies, attachments, and execution state are NOT copied",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Source task ID to duplicate (e.g. KB-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const newTask = await store.duplicateTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Duplicated ${params.id} → ${newTask.id}` }],
|
||||
details: { sourceId: params.id, newTaskId: newTask.id },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_import_github ─────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
@@ -1297,4 +1297,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.
|
||||
*
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user