From 377eee64affe1df69f4fdf6404a6496283fa5d48 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 00:36:23 -0700 Subject: [PATCH] FN-7410: allow archived task deletion Allow archived task snapshots to be deleted while preserving soft-delete tombstones. - Restore cold archived snapshots into the tasks table before applying delete tombstones. - Remove archive snapshots after deletion and preserve allowResurrection semantics. - Cover archived deletion through store, API, UI menu, docs, and changeset updates. Files changed: .changeset/fn-7410-delete-archived-tasks.md | 7 ++ docs/task-management.md | 3 +- .../core/src/__tests__/soft-delete-tasks.test.ts | 129 +++++++++++++++++++++ packages/core/src/store.ts | 25 +++- .../components/__tests__/TaskContextMenu.test.tsx | 19 +++ .../src/__tests__/routes-tasks-ops.test.ts | 24 ++++ 6 files changed, 204 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7410 Fusion-Task-Lineage: fa7d9983-e62c-4849-89d8-1ae5f6134815 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7410-delete-archived-tasks.md | 7 + docs/task-management.md | 3 +- .../src/__tests__/soft-delete-tasks.test.ts | 129 ++++++++++++++++++ packages/core/src/store.ts | 25 +++- .../__tests__/TaskContextMenu.test.tsx | 19 +++ .../src/__tests__/routes-tasks-ops.test.ts | 24 ++++ 6 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7410-delete-archived-tasks.md diff --git a/.changeset/fn-7410-delete-archived-tasks.md b/.changeset/fn-7410-delete-archived-tasks.md new file mode 100644 index 0000000000..7b380d8b64 --- /dev/null +++ b/.changeset/fn-7410-delete-archived-tasks.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow operators to delete archived tasks. +category: fix +dev: Extends task deletion to archive-db snapshots while preserving soft-delete tombstones and ID reservation. diff --git a/docs/task-management.md b/docs/task-management.md index 49dea483a6..2f21cdc386 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -617,6 +617,7 @@ Behavior: - `fn task archive ` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected. - Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`. - Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task. +- Archived tasks can also be deleted from the dashboard/API/CLI. Deleting an archived task removes the archived snapshot from lists and search, but first materializes the normal soft-delete tombstone so the task ID remains reserved unless the operator explicitly chooses allow-resurrection behavior. - Cleanup mode can persist compact metadata and remove the task directory - Archived tasks are read-only for task log/document writes: - `logEntry()` throws `Task is archived — logging is read-only` @@ -625,7 +626,7 @@ Behavior: ### Cleanup behavior -- Archived entries are persisted as compact archive snapshots (current runtime stores these in SQLite `archivedTasks`; legacy docs may refer to `.fusion/archive.jsonl`) +- Archived entries are persisted as compact archive snapshots in `archive.db`; legacy in-main-DB `archivedTasks` rows and older `.fusion/archive.jsonl` references may still appear in historical data/docs. - Task directory (`task.json`, `PROMPT.md`, `agent.log`, attachments) can be removed ### Compact archive entry format diff --git a/packages/core/src/__tests__/soft-delete-tasks.test.ts b/packages/core/src/__tests__/soft-delete-tasks.test.ts index 4f458b5cf6..717ae12359 100644 --- a/packages/core/src/__tests__/soft-delete-tasks.test.ts +++ b/packages/core/src/__tests__/soft-delete-tasks.test.ts @@ -118,6 +118,135 @@ describe("TaskStore soft delete", () => { expect((store as any).archiveDb.get(doneTask.id)?.id).toBe(doneTask.id); }); + it("deletes cold archive snapshots through soft-delete tombstones", async () => { + const store = harness.store(); + const task = await store.createTask({ + column: "todo", + title: "Cold Archived Delete", + description: "cold-archive-delete-needle description", + }); + await store.addComment(task.id, "cold-archive-comment-needle", "operator"); + const taskDir = join(harness.rootDir(), ".fusion", "tasks", task.id); + + await store.archiveTask(task.id, true); + + expect(existsSync(taskDir)).toBe(false); + expect((store as any).archiveDb.get(task.id)?.id).toBe(task.id); + expect((await store.searchTasks("cold-archive-delete-needle", { includeArchived: true })).map((entry) => entry.id)).toContain(task.id); + expect((await store.searchTasks("cold-archive-comment-needle", { includeArchived: true })).map((entry) => entry.id)).toContain(task.id); + + const deletedEvents: string[] = []; + store.on("task:deleted", (event) => deletedEvents.push(event.id)); + + const deleted = await store.deleteTask(task.id); + + expect(deleted).toMatchObject({ id: task.id, column: "archived", title: "Cold Archived Delete" }); + expect((store as any).archiveDb.get(task.id)).toBeUndefined(); + expect((await store.listTasks({ column: "archived", includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + expect((await store.listTasks({ includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + expect((await store.searchTasks("cold-archive-delete-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + expect((await store.searchTasks("cold-archive-comment-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + expect((await store.searchTasks(task.id, { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + await expect(store.getTask(task.id)).rejects.toThrow(`Task ${task.id} not found`); + + const row = (store as any).db + .prepare("SELECT id, deletedAt, allowResurrection, \"column\" FROM tasks WHERE id = ?") + .get(task.id) as { id: string; deletedAt: string | null; allowResurrection: number; column: string }; + expect(row).toMatchObject({ id: task.id, allowResurrection: 0, column: "archived" }); + expect(row.deletedAt).toBeTruthy(); + expect(() => (store as any).assertTaskIdAvailable(task.id)).toThrow(); + expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true); + expect((store as any).isTaskArchived(task.id)).toBe(false); + expect(deletedEvents).toEqual([task.id]); + }); + + it("honors allowResurrection when deleting cold archived snapshots", async () => { + const store = harness.store(); + const task = await store.createTask({ column: "todo", description: "resurrectable cold archive" }); + + await store.archiveTask(task.id, true); + await store.deleteTask(task.id, { allowResurrection: true }); + + const row = (store as any).db + .prepare("SELECT deletedAt, allowResurrection FROM tasks WHERE id = ?") + .get(task.id) as { deletedAt: string | null; allowResurrection: number }; + expect(row.deletedAt).toBeTruthy(); + expect(row.allowResurrection).toBe(1); + expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true); + expect(() => (store as any).assertTaskIdAvailable(task.id)).toThrow(); + expect(() => (store as any).maybeResolveTombstonedTaskId(task.id, { forceResurrect: false }, "createTask")).not.toThrow(); + }); + + it("applies dependency and lineage guards when deleting cold archives", async () => { + const store = harness.store(); + const parent = await store.createTask({ column: "todo", description: "cold parent" }); + const dependent = await store.createTask({ column: "todo", description: "live dependent" }); + await store.updateTask(dependent.id, { dependencies: [parent.id] }); + + await store.archiveTask(parent.id, true); + + await expect(store.deleteTask(parent.id)).rejects.toThrow("still referenced as a dependency"); + await expect(store.deleteTask(parent.id, { removeDependencyReferences: true })).resolves.toMatchObject({ id: parent.id }); + expect((await store.getTask(dependent.id)).dependencies).toEqual([]); + + const lineageParent = await store.createTask({ column: "todo", description: "cold lineage parent" }); + const lineageChild = await store.createTask({ column: "todo", description: "live lineage child" }); + await store.archiveTask(lineageParent.id, true); + (store as any).db.prepare("UPDATE tasks SET sourceParentTaskId = ?, sourceType = ?, updatedAt = ? WHERE id = ?").run( + lineageParent.id, + "duplicate", + new Date().toISOString(), + lineageChild.id, + ); + + await expect(store.deleteTask(lineageParent.id)).rejects.toThrow("still referenced as a lineage parent"); + await expect(store.deleteTask(lineageParent.id, { removeLineageReferences: true })).resolves.toMatchObject({ id: lineageParent.id }); + expect((await store.getTask(lineageChild.id)).sourceParentTaskId).toBeUndefined(); + }); + + it("removes duplicate archive snapshots when deleting the authoritative active row", async () => { + const store = harness.store(); + const task = await store.createTask({ column: "todo", title: "Authoritative Active", description: "stale-archive-duplicate-needle" }); + const staleEntry = await (store as any).taskToArchiveEntry({ ...task, column: "archived" }, new Date().toISOString()); + (store as any).archiveDb.upsert(staleEntry); + + expect((store as any).archiveDb.get(task.id)?.id).toBe(task.id); + + await store.deleteTask(task.id); + + expect((store as any).archiveDb.get(task.id)).toBeUndefined(); + expect((await store.listTasks({ includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + expect((await store.searchTasks("stale-archive-duplicate-needle", { includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id); + }); + + it("deletes hot archived rows idempotently without duplicate delete events", async () => { + const store = harness.store(); + const task = await store.createTask({ column: "todo", description: "hot archive delete target" }); + await store.archiveTask(task.id, false); + + const deletedEvents: string[] = []; + store.on("task:deleted", (event) => deletedEvents.push(event.id)); + + const firstResult = await store.deleteTask(task.id); + const firstRow = (store as any).db + .prepare("SELECT deletedAt, updatedAt, \"column\" FROM tasks WHERE id = ?") + .get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null }; + + const secondResult = await store.deleteTask(task.id); + const secondRow = (store as any).db + .prepare("SELECT deletedAt, updatedAt, \"column\" FROM tasks WHERE id = ?") + .get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null }; + + expect(firstResult).toMatchObject({ id: task.id, column: "archived" }); + expect(firstRow.deletedAt).toBeTruthy(); + expect(firstRow.column).toBe("archived"); + expect(secondResult.deletedAt).toBe(firstRow.deletedAt); + expect(deletedEvents).toEqual([task.id]); + expect(secondRow.deletedAt).toBe(firstRow.deletedAt); + expect(secondRow.updatedAt).toBe(firstRow.updatedAt); + expect(secondRow.column).toBe("archived"); + }); + it("is idempotent on re-delete and does not re-emit task:deleted", async () => { const store = harness.store(); const task = await store.createTask({ column: "todo", description: "idempotent re-delete target" }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 3828c92ea7..d5e93d8a63 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -11391,9 +11391,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Flush buffered agent logs inside the lock so no new appends for this // task can sneak in between flush and soft-delete mutation. this.flushAgentLogBuffer(); - const task = this.readTaskFromDb(id, { includeDeleted: true }); + let task = this.readTaskFromDb(id, { includeDeleted: true }); + let restoredColdArchive = false; if (!task) { - throw new Error(`Task ${id} not found`); + const archivedEntry = await this.findInArchive(id); + if (!archivedEntry) { + throw new Error(`Task ${id} not found`); + } + /* + FNXC:TaskDeletion 2026-07-01-23:58: + Cold archived tasks exist only as archive.db snapshots, but deleting them must still leave a tasks-table soft-delete tombstone. Restore the snapshot into the normal delete path so task IDs stay reserved and allowResurrection keeps using the existing tombstone contract instead of hard-dropping history. + + FNXC:TaskDeletion 2026-07-02-00:00: + The restore helper only materializes task files; cold archived deletes must also insert the restored task row inside the delete transaction before applying the tombstone update, otherwise the archive snapshot can be removed with no DB row preserving the ID reservation. + */ + task = await this.restoreFromArchive(archivedEntry); + restoredColdArchive = true; } if (task.deletedAt) { @@ -11431,9 +11444,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds); rewrittenBlockedByResidueDependents = this.rewriteBlockedByResidueDependentsForRemoval(id, new Set(dependentIds)); rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds); + if (restoredColdArchive) { + this.upsertTaskWithFtsRecovery(task); + } const deletedAt = new Date().toISOString(); const allowResurrection = options?.allowResurrection === true ? 1 : 0; this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, allowResurrection = ?, updatedAt = ? WHERE id = ?").run(deletedAt, allowResurrection, deletedAt, id); + task.deletedAt = deletedAt; + task.allowResurrection = options?.allowResurrection === true ? true : undefined; + task.updatedAt = deletedAt; this.recordRunAuditEvent({ domain: "database", mutationType: "task:deleted", @@ -11469,6 +11488,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} this.agentLogBuffer = this.agentLogBuffer.filter((entry) => entry.taskId !== id); } + this.archiveDb.delete(id); + // Remove from cache if watcher is active if (this.isWatching) this.taskCache.delete(id); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index 137cae6d21..55a86d1267 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -210,6 +210,25 @@ describe("TaskContextMenu shared task action model", () => { }).reviewAction).toMatchObject({ id: "pr-automation", label: "Merging PR…", disabled: true }); }); + it("keeps archived delete available without live-only destructive shells", () => { + const onDelete = vi.fn(); + const archivedModel = buildTaskActionMenuModel({ + task: makeTask({ column: "archived" }), + t, + columnLabel: columnLabel as any, + hasResetHandler: true, + onReset: vi.fn(), + onTogglePause: vi.fn(), + onDelete, + }); + + expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]); + expect(archivedModel.actions.map((action) => action.id)).not.toContain("pause"); + expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset"); + archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.(); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + it("renders descriptors and delegates selection to injected host handlers", () => { const onDelete = vi.fn(); const onActionSelect = vi.fn(); diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 93c76052a0..3707bbda28 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -1898,6 +1898,30 @@ describe("DELETE /tasks/:id", () => { })); }); + it("returns 200 for archived tasks and forwards every delete option unchanged", async () => { + const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-ARCHIVED", column: "archived" }; + (store.deleteTask as ReturnType).mockResolvedValue(deletedTask); + + const res = await REQUEST( + buildApp(), + "DELETE", + "/api/tasks/KB-ARCHIVED?allowResurrection=1&removeDependencyReferences=1&removeLineageReferences=true&githubIssueAction=leave", + ); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "KB-ARCHIVED", column: "archived" }); + expect(store.deleteTask).toHaveBeenCalledWith("KB-ARCHIVED", expect.objectContaining({ + allowResurrection: true, + removeDependencyReferences: true, + removeLineageReferences: true, + githubIssueAction: "leave", + auditContext: expect.objectContaining({ + agentId: "system", + runId: expect.stringMatching(/^synthetic-dashboard-delete-KB-ARCHIVED-/), + }), + })); + }); + it.each(["close", "delete", "leave", "auto"] as const)("forwards githubIssueAction=%s", async (githubIssueAction) => { const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" }; (store.deleteTask as ReturnType).mockResolvedValue(deletedTask);