feat(FN-5175): thread soft-delete audit context and add reconciliation scri

Adds a reconcile script to recover leaked soft-deleted tasks, threads delete audit context through all callers, and records soft-delete audit events with archive column tracking across core/engine/cli/dashboard, with reliability backstop tests covering caller alignment.

Fusion-Task-Id: FN-5175
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 02:38:58 -07:00
committed by gsxdsm
parent 6ecef44ab3
commit c9fd41ef26
17 changed files with 656 additions and 21 deletions

View File

@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("soft-delete audit + archived column (FN-5175)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("writes exactly one task:deleted run-audit row with explicit auditContext", async () => {
const store = harness.store();
const task = await store.createTask({ column: "in-review", description: "audit me" });
await store.deleteTask(task.id, {
auditContext: {
agentId: "agent-explicit",
runId: "run-explicit",
sessionId: "session-explicit",
},
});
const events = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
domain: "database",
mutationType: "task:deleted",
target: task.id,
taskId: task.id,
agentId: "agent-explicit",
runId: "run-explicit",
metadata: {
previousColumn: "in-review",
previousStatus: null,
githubIssueAction: "auto",
removeDependencyReferences: false,
removeLineageReferences: false,
sessionId: "session-explicit",
},
});
});
it("falls back to a synthetic delete runId and remains idempotent on re-delete", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "synthetic delete" });
await store.deleteTask(task.id);
await store.deleteTask(task.id);
const events = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(events).toHaveLength(1);
expect(events[0]?.agentId).toBe("system");
expect(events[0]?.runId).toMatch(/^synthetic-task-delete-/);
});
it("marks the tasks row archived without moving it into archivedTasks", async () => {
const store = harness.store();
const task = await store.createTask({ column: "in-progress", description: "archive the soft-deleted row" });
await store.deleteTask(task.id);
const row = (store as any).db.prepare('SELECT "column", deletedAt FROM tasks WHERE id = ?').get(task.id) as {
column: string;
deletedAt: string | null;
};
expect(row.column).toBe("archived");
expect(typeof row.deletedAt).toBe("string");
expect((store as any).archiveDb.get(task.id)).toBeUndefined();
});
it("keeps soft-deleted rows out of listTasks even when includeArchived is true", async () => {
const store = harness.store();
const task = await store.createTask({ column: "done", description: "hidden from listTasks" });
await store.deleteTask(task.id);
expect((await store.listTasks({ includeArchived: false })).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.listTasks({ column: "archived", includeArchived: true })).map((entry) => entry.id)).not.toContain(task.id);
});
it("records githubIssueAction and option flags in audit metadata", async () => {
const store = harness.store();
const task = await store.createTask({ column: "triage", description: "metadata flags" });
await store.deleteTask(task.id, {
githubIssueAction: "delete",
removeDependencyReferences: true,
removeLineageReferences: true,
});
const [event] = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" });
expect(event?.metadata).toMatchObject({
previousColumn: "triage",
githubIssueAction: "delete",
removeDependencyReferences: true,
removeLineageReferences: true,
});
});
});

View File

@@ -84,7 +84,7 @@ describe("TaskStore soft delete", () => {
expect(deletedEvents.length).toBe(afterFirstPoll);
});
it("keeps soft-deleted ids reserved and non-archived", async () => {
it("keeps soft-deleted ids reserved while leaving them out of archivedTasks", async () => {
const store = harness.store();
const task = await store.createTask({ description: "reserved id task" });
@@ -94,6 +94,9 @@ describe("TaskStore soft delete", () => {
expect((store as any).taskIdExistsAnywhere(task.id)).toBe(true);
expect((store as any).isTaskArchived(task.id)).toBe(false);
const row = (store as any).db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(task.id) as { column: string };
expect(row.column).toBe("archived");
const prefix = task.id.split("-")[0];
reconcileTaskIdState((store as any).db);
const allocator = createDistributedTaskIdAllocator((store as any).db);
@@ -124,22 +127,24 @@ describe("TaskStore soft delete", () => {
const firstResult = await store.deleteTask(task.id);
const firstRow = (store as any).db
.prepare("SELECT deletedAt, updatedAt FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
.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 FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
.prepare("SELECT deletedAt, updatedAt, \"column\" FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null; updatedAt: string | null; column: string | null };
const thirdResult = await store.deleteTask(task.id);
expect(firstRow.deletedAt).toBeTruthy();
expect(firstRow.column).toBe("archived");
expect(secondResult.deletedAt).toBe(firstRow.deletedAt);
expect(thirdResult.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");
await expect(store.deleteTask("FN-DOES-NOT-EXIST")).rejects.toThrow("Task FN-DOES-NOT-EXIST not found");
});

View File

@@ -6202,6 +6202,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return paths.filter((path) => isValidFileScopeEntry(path));
}
private makeSyntheticDeleteRunId(taskId: string): string {
return `synthetic-task-delete-${taskId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
}
/**
* Soft-delete a live task by setting tasks.deletedAt/updatedAt while leaving
* the row and on-disk task artifacts in place for potential recovery.
@@ -6215,6 +6219,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
auditContext?: { agentId: string; runId: string; sessionId?: string };
},
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -6260,7 +6265,23 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
rewrittenDependents = this.rewriteDependentsForRemoval(id, dependentIds);
rewrittenLineageChildren = this.rewriteLineageChildrenForRemoval(id, lineageChildIds);
const deletedAt = new Date().toISOString();
this.db.prepare("UPDATE tasks SET deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
this.db.prepare("UPDATE tasks SET \"column\" = 'archived', deletedAt = ?, updatedAt = ? WHERE id = ?").run(deletedAt, deletedAt, id);
this.recordRunAuditEvent({
domain: "database",
mutationType: "task:deleted",
target: task.id,
taskId: task.id,
agentId: options?.auditContext?.agentId ?? "system",
runId: options?.auditContext?.runId ?? this.makeSyntheticDeleteRunId(task.id),
metadata: {
previousColumn: task.column,
previousStatus: task.status ?? null,
githubIssueAction: options?.githubIssueAction ?? "auto",
removeDependencyReferences: !!options?.removeDependencyReferences,
removeLineageReferences: !!options?.removeLineageReferences,
sessionId: options?.auditContext?.sessionId,
},
});
this.clearLinkedAgentTaskIds(id, deletedAt);
this.db.bumpLastModified();
});
@@ -7116,6 +7137,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// TaskStore instance wrote the row in-process.
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" });
} else {
// Polling replicas only mirror the originating delete signal.
// Do not record run-audit here; the writer already owns that row.
this.emit("task:deleted", cached);
}
} finally {
@@ -7145,6 +7168,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (task.deletedAt) {
if (cached) {
this.taskCache.delete(task.id);
// Polling replicas only re-emit task:deleted for subscribers.
// They must not insert duplicate run-audit rows cross-instance.
this.emit("task:deleted", cached);
}
continue;