feat(FN-3013): add durable insight lifecycle with bounded run executor

Adds durable insight lifecycle management to the Fusion system, including a bounded insight run executor, storage contracts for insight and research data, and API integration through the insights routes. Legacy schema compatibility is stabilized, and lifecycle safeguards are documented in the archit

Fusion-Task-Id: FN-3013
This commit is contained in:
Fusion
2026-05-03 02:24:15 -07:00
committed by gsxdsm
parent 6984bcde07
commit f2c4b77b34
14 changed files with 1478 additions and 165 deletions

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase } from "../db.js";
import { InsightLifecycleError, InsightStore } from "../insight-store.js";
import { classifyInsightRunError, executeInsightRunLifecycle, retryInsightRunLifecycle } from "../insight-run-executor.js";
function createStore(): InsightStore {
const fusionDir = mkdtempSync(join(tmpdir(), "fn-insight-executor-"));
const db = createDatabase(fusionDir, { inMemory: true });
db.init();
return new InsightStore(db);
}
describe("classifyInsightRunError", () => {
it("classifies cancellation", () => {
const result = classifyInsightRunError(new DOMException("Aborted", "AbortError"));
expect(result.failureClass).toBe("cancelled");
});
it("classifies timeout", () => {
const result = classifyInsightRunError(new Error("timed out while calling provider"));
expect(result.failureClass).toBe("timed_out");
expect(result.retryable).toBe(true);
});
it("classifies transient provider errors as retryable", () => {
const result = classifyInsightRunError(new Error("HTTP 503 from provider"));
expect(result.failureClass).toBe("retryable_transient");
expect(result.retryable).toBe(true);
});
it("classifies deterministic failures as non-retryable", () => {
const result = classifyInsightRunError(new Error("invalid JSON contract response"));
expect(result.failureClass).toBe("non_retryable");
expect(result.retryable).toBe(false);
});
});
describe("executeInsightRunLifecycle", () => {
it("completes and persists events", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
executeAttempt: async () => ({
summary: "done",
insightsCreated: 2,
insightsUpdated: 1,
}),
});
expect(run.status).toBe("completed");
const events = store.listRunEvents(run.id);
expect(events.map((event) => event.type)).toEqual(["status_changed", "status_changed", "info", "status_changed"]);
});
it("retries transient failures with bounded attempts", async () => {
const store = createStore();
let calls = 0;
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 2,
retryDelayMs: 0,
executeAttempt: async () => {
calls += 1;
if (calls === 1) {
throw new Error("HTTP 503");
}
return {
summary: "recovered",
insightsCreated: 1,
insightsUpdated: 0,
};
},
});
expect(calls).toBe(2);
expect(run.status).toBe("completed");
const events = store.listRunEvents(run.id);
expect(events.some((event) => event.type === "retry_scheduled")).toBe(true);
});
it("fails non-retryable errors without retry", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 3,
executeAttempt: async () => {
throw new Error("validation failed");
},
});
expect(run.status).toBe("failed");
expect(run.lifecycle.failureClass).toBe("non_retryable");
expect(run.lifecycle.retryable).toBe(false);
});
it("blocks duplicate active runs for same project+trigger", async () => {
const store = createStore();
store.createRun("proj", { trigger: "manual" });
await expect(() => executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
executeAttempt: async () => ({ insightsCreated: 0, insightsUpdated: 0 }),
})).rejects.toMatchObject({ code: "active_run_conflict" } satisfies Partial<InsightLifecycleError>);
});
it("marks timeout as terminal failure classification", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
timeoutMs: 10,
maxAttempts: 1,
executeAttempt: async ({ signal }) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 50);
signal.addEventListener("abort", () => {
clearTimeout(timeout);
reject(signal.reason ?? new Error("aborted"));
});
});
return { insightsCreated: 0, insightsUpdated: 0 };
},
});
expect(run.status).toBe("failed");
expect(run.lifecycle.failureClass).toBe("timed_out");
});
});
describe("retryInsightRunLifecycle", () => {
it("creates a new run from retryable failed run", async () => {
const store = createStore();
const failed = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 1,
executeAttempt: async () => {
throw new Error("HTTP 503");
},
});
const retried = await retryInsightRunLifecycle({
store,
runId: failed.id,
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
});
expect(retried.run.id).not.toBe(failed.id);
expect(retried.run.lifecycle.retryOfRunId).toBe(failed.id);
expect(retried.run.status).toBe("completed");
});
it("rejects retry for non-retryable failures", async () => {
const store = createStore();
const failed = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 1,
executeAttempt: async () => {
throw new Error("invalid input");
},
});
await expect(retryInsightRunLifecycle({
store,
runId: failed.id,
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
})).rejects.toMatchObject({ code: "not_retryable" } satisfies Partial<InsightLifecycleError>);
});
});

View File

@@ -749,16 +749,14 @@ describe("InsightStore Run CRUD", () => {
expect(fromDb).toEqual(updated);
});
it("preserves existing completedAt on later updates", () => {
it("rejects updates after terminal completion", () => {
const run = store.createRun("proj", { trigger: "manual" });
const completed = store.updateRun(run.id, { status: "failed", error: "boom" });
const firstCompletedAt = completed?.completedAt;
expect(completed?.completedAt).toBeTruthy();
const patched = store.updateRun(run.id, { summary: "postmortem" });
expect(firstCompletedAt).toBeTruthy();
expect(patched?.completedAt).toBe(firstCompletedAt);
expect(patched?.summary).toBe("postmortem");
expect(() => store.updateRun(run.id, { summary: "postmortem" })).toThrow(
/terminal and immutable/i,
);
});
it("does not override completedAt if already provided", () => {

View File

@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDatabase, type Database } from "../db.js";
import { ResearchStore } from "../research-store.js";
import { ResearchLifecycleError, ResearchStore } from "../research-store.js";
describe("ResearchStore", () => {
let db: Database;
@@ -51,6 +51,36 @@ describe("ResearchStore", () => {
expect(store.getRun(cancelled.id)?.cancelledAt).toBeTruthy();
});
it("enforces terminal immutability and valid transitions", () => {
const run = store.createRun({ query: "guarded" });
store.updateStatus(run.id, "running");
store.updateStatus(run.id, "completed");
expect(() => store.updateRun(run.id, { topic: "changed" })).toThrow(ResearchLifecycleError);
const pending = store.createRun({ query: "pending" });
expect(() => store.updateStatus(pending.id, "completed")).toThrow(/Invalid run status transition/i);
});
it("persists lifecycle events to research_run_events", () => {
const run = store.createRun({ query: "events" });
store.updateStatus(run.id, "running");
store.appendLifecycleEvent(run.id, { type: "info", message: "custom event" });
const events = store.listRunEvents(run.id);
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events.at(-1)?.message).toBe("custom event");
});
it("guards against duplicate active runs per project and trigger", () => {
const run = store.createRun({ query: "r1", projectId: "p1", trigger: "manual" });
expect(store.getActiveRun("p1", "manual")?.id).toBe(run.id);
expect(() => store.assertNoActiveRun("p1", "manual")).toThrow(ResearchLifecycleError);
store.updateStatus(run.id, "cancelled");
expect(() => store.assertNoActiveRun("p1", "manual")).not.toThrow();
});
it("appends events, manages sources, and sets results", () => {
const run = store.createRun({ query: "events" });
const event = store.appendEvent(run.id, { type: "info", message: "started" });
@@ -91,6 +121,7 @@ describe("ResearchStore", () => {
expect(store.getExport("REXP-missing")).toBeUndefined();
store.updateStatus(r1.id, "running");
store.updateStatus(r2.id, "running");
store.updateStatus(r2.id, "completed");
const stats = store.getStats();
expect(stats.total).toBeGreaterThanOrEqual(2);
@@ -107,6 +138,7 @@ describe("ResearchStore", () => {
store.on("run:completed", onCompleted);
const run = store.createRun({ query: "events" });
store.updateStatus(run.id, "running");
store.updateStatus(run.id, "completed");
expect(onStatus).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();