feat(FN-5233): add tombstone recreate guard and allow-resurrection delete f
Implements the FN-5233 tombstone system for soft-delete resurrection: a configurable `tombstoneWindowSeconds` deduplicates recreation of recently deleted tasks, with an `allowResurrection` flag that permits explicit resurrect-on-recreate, tombstone recreate guards in the store layer, and cleanup of Fusion-Task-Id: FN-5233
This commit is contained in:
committed by
gsxdsm
parent
916047c2ae
commit
2d2e5b809f
@@ -650,6 +650,16 @@ describe("bin command routing and fallbacks", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: fn pr create <task-id>"));
|
||||
});
|
||||
|
||||
it("routes task delete with allow-resurrection flag", async () => {
|
||||
await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]);
|
||||
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined);
|
||||
});
|
||||
|
||||
it("routes task delete default allow-resurrection=false", async () => {
|
||||
await runBin(["task", "delete", "FN-1", "--force"]);
|
||||
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, false, undefined);
|
||||
});
|
||||
|
||||
it("routes desktop flags to runDesktop", async () => {
|
||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import kbExtension from "../extension.js";
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string }) => Promise<any>;
|
||||
};
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
tools,
|
||||
registerTool(tool: RegisteredTool) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {
|
||||
// no-op for tests
|
||||
},
|
||||
on() {
|
||||
// no-op for tests
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("task delete allowResurrection plumbing", () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await mkdtemp(join(tmpdir(), "fn-task-delete-allow-"));
|
||||
await mkdir(join(rootDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fn_task_delete forwards allowResurrection=true", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
await tool.execute("call-1", { id: task.id, allowResurrection: true }, undefined, undefined, { cwd: rootDir });
|
||||
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(deleted.allowResurrection).toBe(true);
|
||||
});
|
||||
|
||||
it("fn_task_delete defaults allowResurrection=false", async () => {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
const task = await store.createTask({ title: "x", description: "y", column: "todo" });
|
||||
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||
await tool.execute("call-2", { id: task.id }, undefined, undefined, { cwd: rootDir });
|
||||
|
||||
const deleted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { allowResurrection?: boolean; deletedAt?: string };
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(deleted.allowResurrection).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -275,7 +275,8 @@ Usage:
|
||||
fn task refine <id> [opts] Create a refinement task from done/in-review
|
||||
fn task archive <id> Archive a done task
|
||||
fn task unarchive <id> Unarchive an archived task
|
||||
fn task delete <id> [--force] Delete a task (use --force to skip confirmation)
|
||||
fn task delete <id> [--force] [--allow-resurrection]
|
||||
Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation)
|
||||
fn task attach <id> <file> Attach a file to a task
|
||||
fn task pause <id> Pause a task (stops all automation)
|
||||
fn task unpause <id> Unpause a task (resumes automation)
|
||||
@@ -1138,9 +1139,10 @@ async function main() {
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task delete <id> [--force]"); process.exit(1); }
|
||||
if (!id) { console.error("Usage: fn task delete <id> [--force] [--allow-resurrection]"); process.exit(1); }
|
||||
const force = args.includes("--force");
|
||||
await runTaskDelete(id, force, projectName);
|
||||
const allowResurrection = args.includes("--allow-resurrection");
|
||||
await runTaskDelete(id, force, allowResurrection, projectName);
|
||||
break;
|
||||
}
|
||||
case "attach": {
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
|
||||
export async function runTaskDelete(id: string, force?: boolean, allowResurrection?: boolean, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Check if task exists first
|
||||
@@ -1051,6 +1051,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
|
||||
|
||||
try {
|
||||
await store.deleteTask(id, {
|
||||
allowResurrection: allowResurrection === true,
|
||||
auditContext: {
|
||||
agentId: "cli",
|
||||
runId: `synthetic-cli-delete-${id}-${Date.now()}`,
|
||||
|
||||
@@ -1177,22 +1177,24 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
label: "fn: Delete Task",
|
||||
description:
|
||||
"Soft-delete a task from active Fusion board views. " +
|
||||
"The task row and artifacts are preserved, and the task ID remains reserved for potential operator recovery.",
|
||||
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.",
|
||||
promptSnippet: "Soft-delete a Fusion task",
|
||||
promptGuidelines: [
|
||||
"Use for cleaning up test tasks or tasks created in error when you want the task hidden from active board views",
|
||||
"This tool performs a soft delete: task data is preserved and the ID stays reserved",
|
||||
"Tasks cannot be undeleted through the current pi/CLI tool surface",
|
||||
"Use allowResurrection:true when operators want the deleted task ID to be intentionally reusable on future createTask calls",
|
||||
"Use fn_task_archive for completed work you want to keep referenceable in the board",
|
||||
"True hard removal is handled by archive cleanup paths (archiveTaskAndCleanup / cleanupArchivedTasks), not fn_task_delete",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
||||
allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.deleteTask(params.id, {
|
||||
allowResurrection: params.allowResurrection === true,
|
||||
auditContext: {
|
||||
agentId: "pi-extension",
|
||||
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
||||
|
||||
Reference in New Issue
Block a user