test(dashboard): align github-tracking-documentation phrases with current docs

The doc was rewritten to cover "every task-creation path" (with an
explicit enumeration of surfaces) instead of the older "task creation
flows (including ...)" wording, and uses "best-effort and non-blocking"
instead of "Creation is best-effort and non-blocking". Update the
documentation contract test to match the current phrasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fusion-Task-Id: FN-5208
This commit is contained in:
gsxdsm
2026-05-19 19:17:49 -07:00
parent 9b1dde15ab
commit 87da1cb757
8 changed files with 470 additions and 34 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskDeletedError } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("soft-delete QA boundary audit (FN-5124)", () => {
@@ -50,20 +51,20 @@ describe("soft-delete QA boundary audit (FN-5124)", () => {
expect((store as any).findLiveDependents(parent.id)).toEqual([]);
});
it("archiving a soft-deleted task succeeds and hard-removes the row consistently", async () => {
it("refuses archiving a soft-deleted task and preserves the deleted row", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", title: "done task", description: "done task description" });
await store.deleteTask(doneTask.id);
const archived = await store.archiveTask(doneTask.id);
await expect(store.archiveTask(doneTask.id)).rejects.toBeInstanceOf(TaskDeletedError);
const liveRow = (store as any).db.prepare("SELECT id, deletedAt FROM tasks WHERE id = ?").get(doneTask.id) as
| { id: string; deletedAt: string | null }
| undefined;
expect(archived.column).toBe("archived");
expect(liveRow).toBeUndefined();
expect((store as any).archiveDb.get(doneTask.id)?.id).toBe(doneTask.id);
expect(liveRow).toMatchObject({ id: doneTask.id });
expect(typeof liveRow?.deletedAt).toBe("string");
expect((store as any).archiveDb.get(doneTask.id)).toBeUndefined();
});
it.each(["todo", "in-progress", "in-review", "done", "triage"])(

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { TaskDeletedError } from "../store.js";
import type { Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("FN-5208 soft-delete resurrection guards", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
it("blocks readTaskJson file fallback when the DB row is soft-deleted", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "resurrection target", description: "keep disk copy" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const diskTask = JSON.parse(await readFile(join(dir, "task.json"), "utf-8")) as Task;
expect(diskTask.deletedAt).toBeUndefined();
await store.deleteTask(task.id);
await expect((store as any).readTaskJson(dir)).rejects.toBeInstanceOf(TaskDeletedError);
});
it("preserves legacy file-only fallback when no DB row exists", async () => {
const store = harness.store();
const taskId = "FN-9999";
const dir = join(harness.rootDir(), ".fusion", "tasks", taskId);
const now = new Date().toISOString();
const fileTask: Task = {
id: taskId,
title: "legacy fallback",
description: "file-only task",
column: "todo",
priority: "normal",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
};
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "task.json"), JSON.stringify(fileTask));
await expect((store as any).readTaskJson(dir)).resolves.toMatchObject({
id: taskId,
title: "legacy fallback",
description: "file-only task",
});
});
it("refuses updateTask resurrection attempts and preserves deletedAt", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "before delete", description: "original description" });
await store.deleteTask(task.id);
await expect(store.updateTask(task.id, { title: "after delete" })).rejects.toBeInstanceOf(TaskDeletedError);
const row = (store as any).db
.prepare("SELECT title, description, deletedAt FROM tasks WHERE id = ?")
.get(task.id) as { title: string; description: string; deletedAt: string | null };
expect(row.title).toBe("before delete");
expect(row.description).toBe("original description");
expect(typeof row.deletedAt).toBe("string");
});
it("refuses stale atomicWriteTaskJson upserts after delete", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "pre-delete title", description: "pre-delete description" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const staleTask: Task = {
...task,
title: "stale title",
description: "stale description",
};
await store.deleteTask(task.id);
await expect((store as any).atomicWriteTaskJson(dir, staleTask)).rejects.toBeInstanceOf(TaskDeletedError);
const row = (store as any).db
.prepare("SELECT title, description, deletedAt FROM tasks WHERE id = ?")
.get(task.id) as { title: string; description: string; deletedAt: string | null };
expect(row.title).toBe("pre-delete title");
expect(row.description).toBe("pre-delete description");
expect(typeof row.deletedAt).toBe("string");
});
it("does not emit task:created when stale create/write attempts target a deleted id", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "created once", description: "do not recreate" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const createdEvents: string[] = [];
store.on("task:created", (event) => createdEvents.push(event.id));
await store.deleteTask(task.id);
await expect((store as any).atomicCreateTaskJson(dir, { ...task, title: "stale recreate" }, "createTask")).rejects.toBeInstanceOf(TaskDeletedError);
expect(createdEvents).toEqual([]);
});
it("keeps deleteTask idempotent and avoids task:updated on refused resurrection writes", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "idempotent delete", description: "delete twice" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const deletedEvents: string[] = [];
const updatedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
store.on("task:updated", (event) => updatedEvents.push(event.id));
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 };
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 };
await expect((store as any).atomicWriteTaskJson(dir, { ...task, title: "resurrect me" })).rejects.toBeInstanceOf(TaskDeletedError);
expect(firstRow.deletedAt).toBeTruthy();
expect(secondRow.deletedAt).toBe(firstRow.deletedAt);
expect(secondRow.updatedAt).toBe(firstRow.updatedAt);
expect(deletedEvents).toEqual([task.id]);
expect(updatedEvents).toEqual([]);
});
it("allows explicit deletedAt-carrying writes for legitimate soft-delete maintenance paths", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "restore me", description: "restore path" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
await store.deleteTask(task.id);
const deletedRow = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as Task;
await expect((store as any).atomicWriteTaskJson(dir, {
...deletedRow,
log: [...(deletedRow.log ?? []), { timestamp: new Date().toISOString(), action: "maintenance write" }],
})).resolves.toBeUndefined();
const persisted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as Task;
expect(persisted.deletedAt).toBe(deletedRow.deletedAt);
});
it("records a task:resurrection-blocked audit event when a stale write is refused", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", title: "audit me", description: "audit trail" });
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
await store.deleteTask(task.id);
await expect((store as any).atomicWriteTaskJson(dir, { ...task, title: "blocked write" })).rejects.toBeInstanceOf(TaskDeletedError);
const events = (store as any).db.prepare(
"SELECT mutationType, domain, target, metadata FROM runAuditEvents WHERE taskId = ? AND mutationType = ? ORDER BY timestamp ASC"
).all(task.id, "task:resurrection-blocked") as Array<{
mutationType: string;
domain: string;
target: string;
metadata: string | null;
}>;
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
mutationType: "task:resurrection-blocked",
domain: "database",
target: task.id,
});
expect(events[0].metadata ?? "").toContain("atomicWriteTaskJson");
});
});

View File

@@ -129,6 +129,7 @@ export {
SELF_DEFEATING_OPERATION_VERBS,
detectSelfDefeatingDependency,
SelfDefeatingDependencyError,
TaskDeletedError,
} from "./store.js";
export {
STOPWORDS,

View File

@@ -599,6 +599,16 @@ export class TaskHasDependentsError extends Error {
}
}
export class TaskDeletedError extends Error {
constructor(
public readonly taskId: string,
public readonly deletedAt: string,
) {
super(`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be read or mutated`);
this.name = "TaskDeletedError";
}
}
export class TaskHasLineageChildrenError extends Error {
readonly taskId: string;
readonly childIds: string[];
@@ -2254,20 +2264,89 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
private getTaskIdFromDir(dir: string): string {
const parts = dir.replace(/\\/g, "/").split("/");
return parts[parts.length - 1];
}
private insertRunAuditEventRow(input: Omit<RunAuditEventInput, "agentId" | "runId"> & { agentId?: string; runId?: string }): void {
const eventId = randomUUID();
const timestamp = input.timestamp ?? new Date().toISOString();
const agentId = input.agentId ?? "store";
const runId = input.runId ?? `store:${input.mutationType}:${input.taskId ?? input.target}:${eventId}`;
this.db.prepare(`
INSERT INTO runAuditEvents (
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
eventId,
timestamp,
input.taskId ?? null,
agentId,
runId,
input.domain,
input.mutationType,
input.target,
toJsonNullable(input.metadata),
);
}
private getSoftDeletedWriteConflict(id: string, task: Task): string | undefined {
const existing = this.readTaskFromDb(id, { includeDeleted: true });
if (!existing?.deletedAt || task.deletedAt !== undefined) {
return undefined;
}
return existing.deletedAt;
}
private throwSoftDeletedWriteBlocked(
id: string,
deletedAt: string,
operation: string,
auditInput?: {
agentId?: string;
runId?: string;
timestamp?: string;
},
): never {
storeLog.warn(`[soft-delete-resurrection-blocked] refusing ${operation} for ${id}`, {
id,
deletedAt,
operation,
});
this.insertRunAuditEventRow({
taskId: id,
agentId: auditInput?.agentId,
runId: auditInput?.runId,
timestamp: auditInput?.timestamp,
domain: "database",
mutationType: "task:resurrection-blocked",
target: id,
metadata: {
id,
deletedAt,
operation,
},
});
throw new TaskDeletedError(id, deletedAt);
}
/**
* Read a task from SQLite by ID (extracted from dir path for backward compat).
* Falls back to file-based reading if not in DB.
* Falls back to file-based reading only when no DB row exists at all.
*/
private async readTaskJson(dir: string): Promise<Task> {
// Extract task ID from directory path (handles both / and \ separators)
const parts = dir.replace(/\\/g, "/").split("/");
const id = parts[parts.length - 1];
// Try SQLite first
const id = this.getTaskIdFromDir(dir);
const task = this.readTaskFromDb(id);
if (task) return task;
// Fallback to file-based reading (for legacy compatibility)
const deletedTask = this.readTaskFromDb(id, { includeDeleted: true });
if (deletedTask?.deletedAt) {
throw new TaskDeletedError(id, deletedTask.deletedAt);
}
// Fallback to file-based reading (for legacy compatibility when no DB row exists).
const filePath = join(dir, "task.json");
const raw = await readFile(filePath, "utf-8");
try {
@@ -2313,7 +2392,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* so duplicate IDs fail safely instead of overwriting existing rows.
*/
private async atomicCreateTaskJson(dir: string, task: Task, operation: string): Promise<void> {
this.insertTaskWithFtsRecovery(task, operation);
const id = this.getTaskIdFromDir(dir);
let deletedAt: string | undefined;
this.db.transactionImmediate(() => {
deletedAt = this.getSoftDeletedWriteConflict(id, task);
if (deletedAt) return;
this.insertTaskWithFtsRecovery(task, operation);
});
if (deletedAt) {
this.throwSoftDeletedWriteBlocked(id, deletedAt, operation);
}
await this.writeTaskJsonFile(dir, task);
}
@@ -2322,7 +2410,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* for backward compatibility and debugging.
*/
private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
this.upsertTaskWithFtsRecovery(task);
const id = this.getTaskIdFromDir(dir);
let deletedAt: string | undefined;
this.db.transactionImmediate(() => {
// Soft-delete/restore state is written via direct SQL paths (deleteTask and
// future restore flows), so stale task.json upserts must never clear deletedAt.
deletedAt = this.getSoftDeletedWriteConflict(id, task);
if (deletedAt) return;
this.upsertTaskWithFtsRecovery(task);
});
if (deletedAt) {
this.throwSoftDeletedWriteBlocked(id, deletedAt, "atomicWriteTaskJson");
}
// Also write to disk for backward compatibility
await this.writeTaskJsonFile(dir, task);
}
@@ -2340,31 +2439,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task: Task,
auditInput?: RunAuditEventInput,
): Promise<void> {
const id = this.getTaskIdFromDir(dir);
let deletedAt: string | undefined;
this.db.transactionImmediate(() => {
deletedAt = this.getSoftDeletedWriteConflict(id, task);
if (deletedAt) return;
// Upsert the task
this.upsertTaskWithFtsRecovery(task);
// Optionally record the audit event in the same transaction
if (auditInput) {
const eventId = randomUUID();
const timestamp = auditInput.timestamp ?? new Date().toISOString();
this.db.prepare(`
INSERT INTO runAuditEvents (
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
eventId,
timestamp,
auditInput.taskId ?? null,
auditInput.agentId,
auditInput.runId,
auditInput.domain,
auditInput.mutationType,
auditInput.target,
toJsonNullable(auditInput.metadata),
);
this.insertRunAuditEventRow(auditInput);
}
});
if (deletedAt) {
this.throwSoftDeletedWriteBlocked(id, deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", {
agentId: auditInput?.agentId,
runId: auditInput?.runId,
timestamp: auditInput?.timestamp,
});
}
// File writes are not part of the SQLite transaction
await this.writeTaskJsonFile(dir, task);
@@ -4392,6 +4487,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} catch (error) {
const archived = this.archiveDb.get(id);
if (!archived) {
// Public API: propagate TaskDeletedError (and other typed failures)
// instead of silently treating soft-deleted live rows as missing.
throw error;
}
task = this.archiveEntryToTask(archived, false);