This commit is contained in:
gsxdsm
2026-05-22 04:09:22 -07:00
parent 5a9095e547
commit 3a0d179ba2
9 changed files with 57 additions and 451 deletions

View File

@@ -4,7 +4,6 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import { createDistributedTaskIdAllocator, reconcileTaskIdState } from "../distributed-task-id.js";
import { TaskAlreadyHardArchivedError } from "../store.js";
describe("TaskStore soft delete", () => {
const harness = createTaskStoreTestHarness();
@@ -150,99 +149,6 @@ describe("TaskStore soft delete", () => {
await expect(store.deleteTask("FN-DOES-NOT-EXIST")).rejects.toThrow("Task FN-DOES-NOT-EXIST not found");
});
it("throws TaskAlreadyHardArchivedError when deleting a hard-archived task", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive delete target" });
await store.archiveTask(doneTask.id);
const row = (store as any).db
.prepare('SELECT id FROM tasks WHERE id = ?')
.get(doneTask.id) as { id: string } | undefined;
expect(row).toBeUndefined();
const archived = (store as any).archiveDb.get(doneTask.id);
expect(archived?.id).toBe(doneTask.id);
let thrown: unknown;
try {
await store.deleteTask(doneTask.id);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TaskAlreadyHardArchivedError);
expect((thrown as TaskAlreadyHardArchivedError).taskId).toBe(doneTask.id);
expect((thrown as TaskAlreadyHardArchivedError).archivedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("preserves task id reservation after archive+delete attempt", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive reservation target" });
await store.archiveTask(doneTask.id);
await expect(store.deleteTask(doneTask.id)).rejects.toBeInstanceOf(TaskAlreadyHardArchivedError);
expect((store as any).taskIdExistsAnywhere(doneTask.id)).toBe(true);
expect(() => (store as any).assertTaskIdAvailable(doneTask.id)).toThrow();
});
it("does not re-emit task:deleted when delete fails against a hard-archived task", async () => {
const store = harness.store();
const doneTask = await store.createTask({ column: "done", description: "hard archive event target" });
const deletedEvents: string[] = [];
store.on("task:deleted", (event) => deletedEvents.push(event.id));
await store.archiveTask(doneTask.id);
await expect(store.deleteTask(doneTask.id)).rejects.toBeInstanceOf(TaskAlreadyHardArchivedError);
expect(deletedEvents).toEqual([]);
});
it("still throws generic Error when target id has never existed", async () => {
const store = harness.store();
await expect(store.deleteTask("FN-999999")).rejects.toMatchObject({
name: "Error",
message: expect.stringContaining("not found"),
});
});
it("throws TaskAlreadyHardArchivedError for a legacy in-db archivedTasks row", async () => {
const store = harness.store();
const archivedAt = new Date().toISOString();
(store as any).db.prepare("INSERT INTO archivedTasks (id, data, archivedAt) VALUES (?, ?, ?)").run(
"FN-888888",
JSON.stringify({ id: "FN-888888", description: "legacy row" }),
archivedAt,
);
await expect(store.deleteTask("FN-888888")).rejects.toMatchObject({
name: "TaskAlreadyHardArchivedError",
taskId: "FN-888888",
archivedAt: null,
});
});
it("keeps FN-5127 idempotent re-delete short-circuit ahead of any archive probe", async () => {
const store = harness.store();
const task = await store.createTask({ column: "todo", description: "idempotent archive probe guard" });
const firstResult = await store.deleteTask(task.id);
const firstRow = (store as any).db
.prepare("SELECT deletedAt FROM tasks WHERE id = ?")
.get(task.id) as { deletedAt: string | null };
expect(firstRow.deletedAt).toBeTruthy();
await store.findInArchive(task.id);
const getSpy = vi.spyOn((store as any).archiveDb, "get");
const legacySpy = vi.spyOn(store as any, "isTaskIdPresentInArchivedTasksTable");
const secondResult = await store.deleteTask(task.id);
expect(secondResult.deletedAt).toBe(firstRow.deletedAt); expect(getSpy).not.toHaveBeenCalled();
expect(legacySpy).not.toHaveBeenCalled();
});
it("unlinks mission feature task references when task is soft-deleted", async () => {
const store = harness.store();
const unlinkFeatureFromTask = vi.fn();

View File

@@ -134,7 +134,6 @@ export {
SelfDefeatingDependencyError,
DependencyCycleError,
TaskDeletedError,
TaskAlreadyHardArchivedError,
MergeQueueTaskNotFoundError,
MergeQueueInvalidColumnError,
MergeQueueLeaseOwnershipError,

View File

@@ -621,21 +621,6 @@ export class TaskDeletedError extends Error {
}
}
export class TaskAlreadyHardArchivedError extends Error {
readonly taskId: string;
readonly archivedAt: string | null;
constructor(taskId: string, archivedAt: string | null) {
super(
`Task ${taskId} is already hard-archived (moved to archive.db) and cannot be soft-deleted. `
+ "Use unarchiveTask first, or call the archive-cleanup APIs for forensic removal.",
);
this.name = "TaskAlreadyHardArchivedError";
this.taskId = taskId;
this.archivedAt = archivedAt;
}
}
export class TaskHasLineageChildrenError extends Error {
readonly taskId: string;
readonly childIds: string[];
@@ -6647,15 +6632,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.flushAgentLogBuffer();
const task = this.readTaskFromDb(id, { includeDeleted: true });
if (!task) {
// FN-5196: keep legacy archivedTasks probe first because archiveDb.get()
// only sees modern archive.db rows and does not include legacy entries.
if (this.isTaskIdPresentInArchivedTasksTable(id)) {
throw new TaskAlreadyHardArchivedError(id, null);
}
const archived = this.archiveDb.get(id);
if (archived) {
throw new TaskAlreadyHardArchivedError(id, archived.archivedAt);
}
throw new Error(`Task ${id} not found`);
}