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

@@ -250,6 +250,20 @@ describe("Insights routes", () => {
expect((res.body as { error: string }).error).toContain("Invalid trigger");
});
it("POST /api/insights/run returns 409 when an active run exists for trigger", async () => {
storeA.getInsightStore().createRun("", { trigger: "manual" });
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
});
it("POST /api/insights/run persists completed run metadata", async () => {
const res = await request(
app,
@@ -283,7 +297,8 @@ describe("Insights routes", () => {
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.status).toBe(201);
expect((res.body as { status: string }).status).toBe("failed");
const runs = storeA.getInsightStore().listRuns({});
expect(runs[0].status).toBe("failed");
@@ -291,6 +306,76 @@ describe("Insights routes", () => {
expect(runs[0].completedAt).toBeTruthy();
});
it("GET /api/insights/runs/:id/events returns durable event trail", async () => {
const runRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
const run = runRes.body as { id: string };
const eventsRes = await request(app, "GET", `/api/insights/runs/${run.id}/events`);
expect(eventsRes.status).toBe(200);
const events = (eventsRes.body as { events: Array<{ type: string }> }).events;
expect(events.length).toBeGreaterThan(0);
expect(events.some((event) => event.type === "status_changed")).toBe(true);
});
it("POST /api/insights/runs/:id/cancel returns 409 for terminal run", async () => {
const runRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
const run = runRes.body as { id: string };
const cancelRes = await request(app, "POST", `/api/insights/runs/${run.id}/cancel`, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(cancelRes.status).toBe(409);
});
it("POST /api/insights/runs/:id/retry only allows retryable failures", async () => {
piMocks.promptWithFallback.mockRejectedValue(new Error("validation failed"));
const failedRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
const failedRun = failedRes.body as { id: string };
const retryRes = await request(app, "POST", `/api/insights/runs/${failedRun.id}/retry`, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(retryRes.status).toBe(409);
piMocks.promptWithFallback.mockRejectedValue(new Error("HTTP 503"));
const retryableRunRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
const retryableRun = retryableRunRes.body as { id: string };
piMocks.promptWithFallback.mockResolvedValue(undefined);
const retriedRes = await request(app, "POST", `/api/insights/runs/${retryableRun.id}/retry`, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(retriedRes.status).toBe(201);
const retried = retriedRes.body as { lifecycle: { retryOfRunId: string } };
expect(retried.lifecycle.retryOfRunId).toBe(retryableRun.id);
});
it("POST /api/insights/:id/create-task returns task-conversion payload", async () => {
const insight = storeA.getInsightStore().createInsight("", {
title: "Refactor parser",