feat(FN-5256): complete Step 2 — enforce cycle checks on writes
This commit is contained in:
committed by
gsxdsm
parent
ad8fa7be0d
commit
e12adeb3fa
86
packages/core/src/__tests__/store-dependency-cycle.test.ts
Normal file
86
packages/core/src/__tests__/store-dependency-cycle.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DependencyCycleError,
|
||||||
|
detectDependencyCycle,
|
||||||
|
} from "../store.js";
|
||||||
|
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("detectDependencyCycle", () => {
|
||||||
|
const lookup = (graph: Record<string, string[]>) => (taskId: string) => graph[taskId];
|
||||||
|
|
||||||
|
it("detects direct self-edge", () => {
|
||||||
|
expect(detectDependencyCycle("A", ["A"], lookup({}))).toEqual(["A", "A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects 2-node cycle", () => {
|
||||||
|
expect(detectDependencyCycle("A", ["B"], lookup({ B: ["A"] }))).toEqual(["A", "B", "A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects 3-node cycle", () => {
|
||||||
|
expect(detectDependencyCycle("FN-5240", ["FN-5241"], lookup({
|
||||||
|
"FN-5241": ["FN-5242"],
|
||||||
|
"FN-5242": ["FN-5240"],
|
||||||
|
}))).toEqual(["FN-5240", "FN-5241", "FN-5242", "FN-5240"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for diamond non-cycle", () => {
|
||||||
|
expect(detectDependencyCycle("A", ["B", "C"], lookup({ B: ["D"], C: ["D"], D: [] }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores missing dependencies", () => {
|
||||||
|
expect(detectDependencyCycle("A", ["MISSING"], lookup({}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports candidate not yet persisted", () => {
|
||||||
|
expect(detectDependencyCycle("A", ["B"], lookup({ B: ["C"], C: [] }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TaskStore dependency cycle guard", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cycle-forming update and preserves persisted dependencies", async () => {
|
||||||
|
const store = harness.store();
|
||||||
|
const a = await store.createTask({ title: "A", description: "A" });
|
||||||
|
const b = await store.createTask({ title: "B", description: "B", dependencies: [a.id] });
|
||||||
|
|
||||||
|
await expect(store.updateTask(a.id, { dependencies: [b.id] })).rejects.toBeInstanceOf(DependencyCycleError);
|
||||||
|
|
||||||
|
const refreshedA = await store.getTask(a.id);
|
||||||
|
expect(refreshedA.dependencies).toEqual([]);
|
||||||
|
|
||||||
|
const rows = (store as any).db.prepare(`SELECT mutationType FROM runAuditEvents WHERE taskId = ? AND mutationType = ?`).all(a.id, "task:dependency-cycle-rejected");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts umbrella parent depending on children with no back-edge", async () => {
|
||||||
|
const store = harness.store();
|
||||||
|
const childA = await store.createTask({ title: "child-a", description: "a" });
|
||||||
|
const childB = await store.createTask({ title: "child-b", description: "b" });
|
||||||
|
|
||||||
|
const parent = await store.createTask({
|
||||||
|
title: "umbrella",
|
||||||
|
description: "parent",
|
||||||
|
dependencies: [childA.id, childB.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parent.dependencies).toEqual([childA.id, childB.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts non-cyclic updates", async () => {
|
||||||
|
const store = harness.store();
|
||||||
|
const a = await store.createTask({ title: "A", description: "A" });
|
||||||
|
const b = await store.createTask({ title: "B", description: "B" });
|
||||||
|
|
||||||
|
const updated = await store.updateTask(b.id, { dependencies: [a.id] });
|
||||||
|
expect(updated.dependencies).toEqual([a.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,7 +130,9 @@ export {
|
|||||||
TaskStore,
|
TaskStore,
|
||||||
SELF_DEFEATING_OPERATION_VERBS,
|
SELF_DEFEATING_OPERATION_VERBS,
|
||||||
detectSelfDefeatingDependency,
|
detectSelfDefeatingDependency,
|
||||||
|
detectDependencyCycle,
|
||||||
SelfDefeatingDependencyError,
|
SelfDefeatingDependencyError,
|
||||||
|
DependencyCycleError,
|
||||||
TaskDeletedError,
|
TaskDeletedError,
|
||||||
MergeQueueTaskNotFoundError,
|
MergeQueueTaskNotFoundError,
|
||||||
MergeQueueLeaseOwnershipError,
|
MergeQueueLeaseOwnershipError,
|
||||||
|
|||||||
@@ -837,6 +837,70 @@ export function detectSelfDefeatingDependency(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DependencyCycleError extends Error {
|
||||||
|
readonly code = "DEPENDENCY_CYCLE" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly taskId: string,
|
||||||
|
readonly cyclePath: readonly string[],
|
||||||
|
) {
|
||||||
|
super(`Dependency cycle detected for ${taskId}: ${cyclePath.join(" → ")}`);
|
||||||
|
this.name = "DependencyCycleError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectDependencyCycle(
|
||||||
|
candidateTaskId: string,
|
||||||
|
candidateDependencies: readonly string[],
|
||||||
|
lookupDependencies: (taskId: string) => readonly string[] | undefined,
|
||||||
|
): string[] | null {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
for (const dep of candidateDependencies) {
|
||||||
|
if (dep === candidateTaskId) {
|
||||||
|
return [candidateTaskId, candidateTaskId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialDeps = lookupDependencies(dep);
|
||||||
|
if (!initialDeps) continue;
|
||||||
|
|
||||||
|
const stack: Array<{ taskId: string; deps: readonly string[]; index: number }> = [
|
||||||
|
{ taskId: dep, deps: initialDeps, index: 0 },
|
||||||
|
];
|
||||||
|
const path = [candidateTaskId, dep];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack[stack.length - 1]!;
|
||||||
|
if (top.index >= top.deps.length) {
|
||||||
|
stack.pop();
|
||||||
|
path.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = top.deps[top.index++]!;
|
||||||
|
if (next === candidateTaskId) {
|
||||||
|
return [...path, candidateTaskId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDeps = lookupDependencies(next);
|
||||||
|
if (!nextDeps) {
|
||||||
|
visited.add(next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(next);
|
||||||
|
stack.push({ taskId: next, deps: nextDeps, index: 0 });
|
||||||
|
path.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class MergeQueueTaskNotFoundError extends Error {
|
export class MergeQueueTaskNotFoundError extends Error {
|
||||||
constructor(public readonly taskId: string) {
|
constructor(public readonly taskId: string) {
|
||||||
super(`Cannot enqueue merge queue entry for missing task ${taskId}`);
|
super(`Cannot enqueue merge queue entry for missing task ${taskId}`);
|
||||||
@@ -3213,6 +3277,50 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return resolved.length > 0 ? resolved : undefined;
|
return resolved.length > 0 ? resolved : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildActiveTaskDependencyLookup(overrides?: Map<string, readonly string[]>): Promise<Map<string, readonly string[]>> {
|
||||||
|
const tasks = await this.listTasks({ includeArchived: false });
|
||||||
|
const lookup = new Map<string, readonly string[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
lookup.set(task.id, task.dependencies ?? []);
|
||||||
|
}
|
||||||
|
if (overrides) {
|
||||||
|
for (const [taskId, deps] of overrides.entries()) {
|
||||||
|
lookup.set(taskId, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordDependencyCycleRejectedAudit(
|
||||||
|
taskId: string,
|
||||||
|
cyclePath: readonly string[],
|
||||||
|
source: "createTask" | "createTaskWithReservedId" | "updateTask" | "replication",
|
||||||
|
): void {
|
||||||
|
this.insertRunAuditEventRow({
|
||||||
|
taskId,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: source === "replication" ? "task:dependency-cycle-rejected-replication" : "task:dependency-cycle-rejected",
|
||||||
|
target: taskId,
|
||||||
|
metadata: { taskId, cyclePath, source },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNoDependencyCycle(
|
||||||
|
taskId: string,
|
||||||
|
dependencies: readonly string[],
|
||||||
|
source: "createTask" | "createTaskWithReservedId" | "updateTask" | "replication",
|
||||||
|
overrides?: Map<string, readonly string[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
const lookup = await this.buildActiveTaskDependencyLookup(overrides);
|
||||||
|
const cyclePath = detectDependencyCycle(taskId, dependencies, (candidateId) => lookup.get(candidateId));
|
||||||
|
if (!cyclePath) return;
|
||||||
|
this.recordDependencyCycleRejectedAudit(taskId, cyclePath, source);
|
||||||
|
if (source === "replication") {
|
||||||
|
storeLog.warn("Skipping replicated task create due to dependency cycle", { taskId, cyclePath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new DependencyCycleError(taskId, cyclePath);
|
||||||
|
}
|
||||||
async createTask(
|
async createTask(
|
||||||
input: TaskCreateInput,
|
input: TaskCreateInput,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -3302,6 +3410,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
if (input.dependencies?.includes(taskId)) {
|
if (input.dependencies?.includes(taskId)) {
|
||||||
throw new Error(`Task ${taskId} cannot depend on itself`);
|
throw new Error(`Task ${taskId} cannot depend on itself`);
|
||||||
}
|
}
|
||||||
|
await this.assertNoDependencyCycle(taskId, input.dependencies ?? [], "createTask");
|
||||||
return this._createTaskInternal(
|
return this._createTaskInternal(
|
||||||
input,
|
input,
|
||||||
title,
|
title,
|
||||||
@@ -3405,6 +3514,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
throw new Error(`Task ${id} cannot depend on itself`);
|
throw new Error(`Task ${id} cannot depend on itself`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertNoDependencyCycle(id, input.dependencies ?? [], "createTaskWithReservedId");
|
||||||
|
|
||||||
this.assertTaskIdAvailable(id);
|
this.assertTaskIdAvailable(id);
|
||||||
|
|
||||||
const title = input.title?.trim() || undefined;
|
const title = input.title?.trim() || undefined;
|
||||||
@@ -3456,6 +3567,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
throw replicationCollisionError(payload.taskId);
|
throw replicationCollisionError(payload.taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.input.dependencies?.includes(payload.taskId)) {
|
||||||
|
this.recordDependencyCycleRejectedAudit(payload.taskId, [payload.taskId, payload.taskId], "replication");
|
||||||
|
storeLog.warn("Skipping replicated task create due to self dependency", { taskId: payload.taskId });
|
||||||
|
return { task: payload.input as Task, applied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = await this.buildActiveTaskDependencyLookup(new Map([[payload.taskId, payload.input.dependencies ?? []]]));
|
||||||
|
const replicationCycle = detectDependencyCycle(payload.taskId, payload.input.dependencies ?? [], (candidateId) => lookup.get(candidateId));
|
||||||
|
if (replicationCycle) {
|
||||||
|
this.recordDependencyCycleRejectedAudit(payload.taskId, replicationCycle, "replication");
|
||||||
|
storeLog.warn("Skipping replicated task create due to dependency cycle", { taskId: payload.taskId, cyclePath: replicationCycle });
|
||||||
|
return { task: payload.input as Task, applied: false };
|
||||||
|
}
|
||||||
|
|
||||||
const task = await this.createTaskWithReservedId(payload.input, {
|
const task = await this.createTaskWithReservedId(payload.input, {
|
||||||
taskId: payload.taskId,
|
taskId: payload.taskId,
|
||||||
createdAt: payload.createdAt,
|
createdAt: payload.createdAt,
|
||||||
@@ -4944,6 +5069,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
if (updates.dependencies?.includes(id)) {
|
if (updates.dependencies?.includes(id)) {
|
||||||
throw new Error(`Task ${id} cannot depend on itself`);
|
throw new Error(`Task ${id} cannot depend on itself`);
|
||||||
}
|
}
|
||||||
|
if (updates.dependencies !== undefined) {
|
||||||
|
await this.assertNoDependencyCycle(
|
||||||
|
id,
|
||||||
|
updates.dependencies,
|
||||||
|
"updateTask",
|
||||||
|
new Map([[id, updates.dependencies]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
const task = await this.readTaskJson(dir);
|
const task = await this.readTaskJson(dir);
|
||||||
|
|||||||
Reference in New Issue
Block a user