feat(FN-5140): merge fusion/fn-5140
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user