feat(FN-5140): merge fusion/fn-5140

This commit is contained in:
gsxdsm
2026-05-19 10:29:42 -07:00
parent ba1604835b
commit abfbbcda1e
4 changed files with 153 additions and 31 deletions

View File

@@ -30,6 +30,14 @@
- `cleanupArchivedTasks` intentionally tolerates dangling lineage pointers in historical/archive cleanup flows; it does not run lineage rewrites.
- For forensic reads, soft-deleted parents remain accessible through `readTaskFromDb(id, { includeDeleted: true })`.
### Documents under soft-deleted tasks (FN-5140)
- Soft-deleting a task preserves its `task_documents` and `task_document_revisions` rows; document storage is not hard-deleted as part of `TaskStore.deleteTask`.
- Normal live-reader APIs must hide those rows by enforcing the parent-task active filter through `ACTIVE_TASKS_WHERE`: `getAllDocuments`, `getTaskDocuments`, `getTaskDocument`, and `getTaskDocumentRevisions` all treat a soft-deleted parent as out of scope for ordinary reads.
- The HTTP surface inherits the same contract: `GET /api/documents` excludes documents whose parent task is soft-deleted, while per-task document GET routes behave like "task not found" (`[]` for list/revisions and `404 Document not found` for the single-document read).
- No public forensic flag is exposed on document read methods or routes. Forensic access remains an internal/operator concern via `readTaskFromDb(id, { includeDeleted: true })` plus direct SQL against the preserved document tables.
- Write semantics stay intentionally asymmetric: `upsertTaskDocument` still refuses soft-deleted parents, while `deleteTaskDocument` remains allowed so forensic cleanup can scrub preserved document rows when needed.
### Task-ID integrity detection
Fusion runs a read-only task-ID integrity detector at startup and on demand to surface allocator regressions before operators lose track of overwritten cards. The detector checks for:

View File

@@ -280,18 +280,115 @@ describe("TaskStore task documents", () => {
);
});
it("preserves task documents when a task is soft-deleted", async () => {
const task = await store.createTask({ description: "Soft delete docs task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
describe("FN-5140: soft-deleted task document visibility", () => {
it("excludes documents for soft-deleted parents from getAllDocuments with and without search", async () => {
const liveTask = await store.createTask({
title: "Live task for FN-5140",
description: "Live task for getAllDocuments coverage",
});
const deletedTask = await store.createTask({
title: "Deleted task for FN-5140",
description: "Deleted task for getAllDocuments coverage",
});
await store.deleteTask(task.id);
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "shared visibility token" });
await store.upsertTaskDocument(deletedTask.id, { key: "notes", content: "shared visibility token" });
await store.deleteTask(deletedTask.id);
const documents = await store.getTaskDocuments(task.id);
expect(documents).toHaveLength(1);
for (const options of [undefined, { searchQuery: "shared visibility token" }]) {
const documents = await store.getAllDocuments(options);
expect(documents).toHaveLength(1);
expect(documents[0]?.taskId).toBe(liveTask.id);
expect(documents[0]?.key).toBe("plan");
}
});
const document = await store.getTaskDocument(task.id, "plan");
expect(document?.content).toBe("v1");
}, 15_000);
it("keeps task_documents rows stored after the parent task is soft-deleted", async () => {
const task = await store.createTask({ description: "Stored but hidden doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
const row = db
.prepare("SELECT COUNT(*) as count FROM task_documents WHERE taskId = ?")
.get(task.id) as { count: number };
expect(row.count).toBe(1);
});
it("returns [] from getTaskDocuments for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted list doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
await expect(store.getTaskDocuments(task.id)).resolves.toEqual([]);
});
it("returns null from getTaskDocument for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted get doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
await expect(store.getTaskDocument(task.id, "plan")).resolves.toBeNull();
});
it("returns [] from getTaskDocumentRevisions for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted revision doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.deleteTask(task.id);
await expect(store.getTaskDocumentRevisions(task.id, "plan")).resolves.toEqual([]);
});
it("still refuses upsertTaskDocument for a soft-deleted parent", async () => {
const task = await store.createTask({ description: "Soft-deleted upsert doc task" });
await store.deleteTask(task.id);
await expect(
store.upsertTaskDocument(task.id, { key: "plan", content: "v1" }),
).rejects.toThrow(`Task ${task.id} not found`);
});
it("still allows deleteTaskDocument for a soft-deleted parent forensic cleanup", async () => {
const task = await store.createTask({ description: "Soft-deleted delete doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.deleteTask(task.id);
await expect(store.deleteTaskDocument(task.id, "plan")).resolves.toBeUndefined();
const row = db
.prepare("SELECT COUNT(*) as count FROM task_documents WHERE taskId = ?")
.get(task.id) as { count: number };
expect(row.count).toBe(0);
});
it("leaves live task document reads unaffected when another task is soft-deleted", async () => {
const liveTask = await store.createTask({ description: "Live control doc task" });
const deletedTask = await store.createTask({ description: "Deleted sibling doc task" });
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(liveTask.id, { key: "plan", content: "v2" });
await store.upsertTaskDocument(deletedTask.id, { key: "notes", content: "hidden" });
await store.deleteTask(deletedTask.id);
const allDocuments = await store.getAllDocuments();
expect(allDocuments.map((document) => document.taskId)).toEqual([liveTask.id]);
const taskDocuments = await store.getTaskDocuments(liveTask.id);
expect(taskDocuments).toHaveLength(1);
expect(taskDocuments[0]?.key).toBe("plan");
const taskDocument = await store.getTaskDocument(liveTask.id, "plan");
expect(taskDocument?.content).toBe("v2");
const revisions = await store.getTaskDocumentRevisions(liveTask.id, "plan");
expect(revisions.map((revision) => revision.revision)).toEqual([1]);
});
});
it("accepts valid key edge cases and rejects invalid ones", async () => {
const task = await store.createTask({ description: "Key edge case task" });

View File

@@ -7352,10 +7352,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return task;
}
private hasActiveTask(taskId: string): boolean {
const row = this.db.prepare(`SELECT id FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(taskId) as
| { id: string }
| undefined;
return Boolean(row);
}
/**
* List all current task documents for a task, ordered by key.
*/
async getTaskDocuments(taskId: string): Promise<TaskDocument[]> {
if (!this.hasActiveTask(taskId)) {
return [];
}
const rows = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? ORDER BY key")
.all(taskId) as unknown as TaskDocumentRow[];
@@ -7378,12 +7389,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
SELECT td.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn
FROM task_documents td
JOIN tasks t ON td.taskId = t.id
WHERE t.${TaskStore.ACTIVE_TASKS_WHERE}
`;
const params: (string | number)[] = [];
if (options?.searchQuery && options.searchQuery.trim() !== "") {
const query = `%${options.searchQuery.trim()}%`;
sql += ` WHERE td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?`;
sql += ` AND (td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?)`;
params.push(query, query, query);
}
@@ -7406,6 +7418,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Get the current revision of a specific task document.
*/
async getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null> {
if (!this.hasActiveTask(taskId)) {
return null;
}
const row = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, key) as unknown as TaskDocumentRow | undefined;
@@ -7517,6 +7533,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
key: string,
options?: { limit?: number },
): Promise<TaskDocumentRevision[]> {
if (!this.hasActiveTask(taskId)) {
return [];
}
const hasLimit = options?.limit !== undefined;
const rows = hasLimit
? (this.db
@@ -7535,6 +7555,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/**
* Delete a task document and all archived revisions for its key.
* Read paths gate on the parent task's active state, but deletes remain allowed
* for forensic cleanup against soft-deleted parents.
*/
async deleteTaskDocument(taskId: string, key: string): Promise<void> {
const existing = this.db
@@ -7560,8 +7582,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
this.db.bumpLastModified();
const task = await this.getTask(taskId);
this.emit("task:updated", task);
const task = this.readTaskFromDb(taskId, { includeDeleted: true });
if (task && task.deletedAt == null) {
this.emit("task:updated", task);
}
}
private getTaskPrInfos(task: Task): import("./types.js").PrInfo[] {

View File

@@ -2494,7 +2494,7 @@ describe("Pause/Unpause endpoints", () => {
}
describe("GET /tasks/:id/documents", () => {
it("returns empty array when no documents", async () => {
it("returns empty array when the store hides documents for a soft-deleted parent", async () => {
(store.getTaskDocuments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await GET(buildApp(), "/api/tasks/KB-001/documents");
expect(res.status).toBe(200);
@@ -2521,7 +2521,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body).toEqual(doc);
});
it("returns 404 when document not found", async () => {
it("returns 404 when the store hides a document for a soft-deleted parent", async () => {
(store.getTaskDocument as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const res = await GET(buildApp(), "/api/tasks/KB-001/documents/missing");
expect(res.status).toBe(404);
@@ -2541,7 +2541,14 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body).toEqual(revisions);
});
it("returns empty array for nonexistent document", async () => {
it("returns empty array when the store hides revisions for a soft-deleted parent", async () => {
(store.getTaskDocumentRevisions as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await GET(buildApp(), "/api/tasks/KB-001/documents/missing/revisions");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array for nonexistent document errors", async () => {
(store.getTaskDocumentRevisions as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
const res = await GET(buildApp(), "/api/tasks/KB-001/documents/missing/revisions");
// Per spec: "Return empty array if document doesn't exist (not an error)"
@@ -2665,7 +2672,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body).toEqual([]);
});
it("returns documents across multiple tasks", async () => {
it("returns only the live-parent documents surfaced by the store", async () => {
const mockDocs = [
{
id: "doc-1",
@@ -2679,25 +2686,11 @@ describe("Pause/Unpause endpoints", () => {
taskTitle: "Task One",
taskColumn: "triage",
},
{
id: "doc-2",
taskId: "KB-002",
key: "notes",
content: "Notes content",
revision: 1,
author: "agent",
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
taskTitle: "Task Two",
taskColumn: "in-progress",
},
];
(store.getAllDocuments as ReturnType<typeof vi.fn>).mockResolvedValue(mockDocs);
const res = await GET(buildApp(), "/api/documents");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].taskTitle).toBe("Task One");
expect(res.body[1].taskTitle).toBe("Task Two");
expect(res.body).toEqual(mockDocs);
});
it("filters by search query", async () => {