feat(FN-3370): strengthen research store lifecycle and coverage testing

The merge lands FN-3370's research store hardening work, updating the preflight documentation with a refined coverage scope and adding regression tests for the research store lifecycle alongside the corresponding implementation adjustments in `packages/core/src/research-store.ts`.

Fusion-Task-Id: FN-3370
This commit is contained in:
Fusion
2026-05-04 21:27:23 -07:00
committed by gsxdsm
parent 18e29342b3
commit 5de69cd0ad
3 changed files with 84 additions and 17 deletions

View File

@@ -51,6 +51,31 @@ describe("ResearchStore", () => {
expect(store.getRun(cancelled.id)?.cancelledAt).toBeTruthy();
});
it("persists terminal lifecycle metadata and default error codes", () => {
const timedOut = store.createRun({ query: "timeout" });
store.updateStatus(timedOut.id, "running");
store.updateStatus(timedOut.id, "timed_out");
const timedOutSaved = store.getRun(timedOut.id)!;
expect(timedOutSaved.lifecycle?.terminalReason).toBe("timed_out");
expect(timedOutSaved.lifecycle?.retryable).toBe(true);
expect(timedOutSaved.lifecycle?.errorCode).toBe("PROVIDER_TIMEOUT");
expect(timedOutSaved.lifecycle?.failureClass).toBe("timed_out");
const failed = store.createRun({ query: "non-retryable" });
store.updateStatus(failed.id, "running");
store.updateStatus(failed.id, "failed", { lifecycle: { failureClass: "non_retryable" } });
const failedSaved = store.getRun(failed.id)!;
expect(failedSaved.lifecycle?.terminalReason).toBe("failed");
expect(failedSaved.lifecycle?.retryable).toBe(false);
expect(failedSaved.lifecycle?.errorCode).toBe("NON_RETRYABLE_PROVIDER_ERROR");
const done = store.createRun({ query: "done" });
store.updateStatus(done.id, "running");
store.updateStatus(done.id, "completed");
expect(store.getRun(done.id)?.lifecycle?.terminalReason).toBe("completed");
expect(store.getRun(done.id)?.lifecycle?.retryable).toBe(false);
});
it("enforces terminal immutability and valid transitions", () => {
const run = store.createRun({ query: "guarded" });
store.updateStatus(run.id, "running");
@@ -62,14 +87,18 @@ describe("ResearchStore", () => {
expect(() => store.updateStatus(queued.id, "completed")).toThrow(/Invalid run status transition/i);
});
it("persists lifecycle events to research_run_events", () => {
it("persists lifecycle events in sequence", () => {
const run = store.createRun({ query: "events" });
store.updateStatus(run.id, "running");
store.appendLifecycleEvent(run.id, { type: "info", message: "custom event" });
store.appendEvent(run.id, { type: "progress", message: "snapshot event" });
const events = store.listRunEvents(run.id);
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events.at(-1)?.message).toBe("custom event");
expect(events.length).toBe(3);
expect(events.map((event) => event.seq)).toEqual([1, 2, 3]);
expect(events[0]?.status).toBe("running");
expect(events[1]?.message).toBe("custom event");
expect(events[2]?.message).toBe("snapshot event");
});
it("guards against duplicate active runs per project and trigger", () => {
@@ -107,10 +136,13 @@ describe("ResearchStore", () => {
it("supports filtering, search, ordering, exports and stats", () => {
const r1 = store.createRun({ query: "alpha", topic: "first", tags: ["core"] });
const r2 = store.createRun({ query: "beta", topic: "second", tags: ["edge"] });
const r3 = store.createRun({ query: "gamma", topic: "third", tags: ["core", "edge"] });
store.setResults(r2.id, { summary: "beta summary", findings: [] });
expect(store.listRuns({ tag: "core" }).map((r) => r.id)).toEqual([r1.id]);
expect(store.listRuns({ tag: "core" }).map((r) => r.id).sort()).toEqual([r1.id, r3.id].sort());
expect(store.listRuns({ search: "third" }).map((r) => r.id)).toEqual([r3.id]);
expect(store.searchRuns("beta").map((r) => r.id)).toContain(r2.id);
expect(store.listRuns({ limit: 1, offset: 1 })).toHaveLength(1);
const all = store.listRuns();
expect(all[0].createdAt <= all[1].createdAt).toBe(true);
@@ -124,7 +156,7 @@ describe("ResearchStore", () => {
store.updateStatus(r2.id, "running");
store.updateStatus(r2.id, "completed");
const stats = store.getStats();
expect(stats.total).toBeGreaterThanOrEqual(2);
expect(stats.total).toBeGreaterThanOrEqual(3);
expect(stats.byStatus.completed).toBeGreaterThanOrEqual(1);
store.deleteRun(r1.id);
@@ -135,16 +167,21 @@ describe("ResearchStore", () => {
const run = store.createRun({ query: "cancel me" });
const first = store.requestCancellation(run.id);
expect(first.status).toBe("cancelling");
expect(first.lifecycle?.errorCode).toBe("RUN_CANCELLED");
const second = store.requestCancellation(run.id);
expect(second.status).toBe("cancelling");
const events = store.listRunEvents(run.id).filter((event) => event.type === "cancel_requested");
expect(events).toHaveLength(1);
store.updateStatus(run.id, "cancelled");
const terminal = store.requestCancellation(run.id);
expect(terminal.status).toBe("cancelled");
});
it("marks retry exhaustion when max attempts reached", () => {
const run = store.createRun({ query: "retry", lifecycle: { attempt: 3, maxAttempts: 3 } });
it("creates retry_waiting run and marks exhaustion", () => {
const run = store.createRun({ query: "retry", lifecycle: { attempt: 1, maxAttempts: 2 } });
store.updateStatus(run.id, "running");
store.updateStatus(run.id, "failed", {
lifecycle: {
...(run.lifecycle ?? {}),
@@ -153,8 +190,24 @@ describe("ResearchStore", () => {
},
});
expect(() => store.createRetryRun(run.id)).toThrow(/non-retryable|exhausted retries/i);
expect(store.getRun(run.id)?.status).toBe("retry_exhausted");
const retry = store.createRetryRun(run.id);
expect(retry.lifecycle?.retryOfRunId).toBe(run.id);
expect(store.getRun(retry.id)?.status).toBe("retry_waiting");
store.updateStatus(retry.id, "queued");
store.updateStatus(retry.id, "running");
store.updateStatus(retry.id, "failed", {
lifecycle: {
...(retry.lifecycle ?? {}),
retryable: true,
failureClass: "retryable_transient",
},
});
expect(() => store.createRetryRun(retry.id)).toThrow(/non-retryable|exhausted retries/i);
const exhausted = store.getRun(retry.id)!;
expect(exhausted.status).toBe("retry_exhausted");
expect(exhausted.lifecycle?.errorCode).toBe("RETRY_EXHAUSTED");
});
it("emits status events and throws for missing run mutations", () => {

View File

@@ -158,12 +158,11 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
: undefined;
const nonMutableKeys = Object.keys(input).filter((key) => key !== "events" && key !== "metadata");
if (
TERMINAL_STATUSES.has(normalizedExistingStatus)
&& nonMutableKeys.length > 0
&& !(nonMutableKeys.length === 1 && nonMutableKeys[0] === "status")
) {
throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
if (TERMINAL_STATUSES.has(normalizedExistingStatus) && nonMutableKeys.length > 0) {
const allowedTerminalMutation = nonMutableKeys.every((key) => key === "status" || key === "lifecycle");
if (!allowedTerminalMutation) {
throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
}
}
if (normalizedInputStatus && normalizedInputStatus !== normalizedExistingStatus) {
@@ -386,6 +385,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
status: normalizedStatus,
lifecycle: {
...(run.lifecycle ?? {}),
...(extra?.lifecycle ?? {}),
},
};
@@ -577,7 +577,16 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const configuredMaxAttempts = maxAttempts ?? run.lifecycle?.maxAttempts ?? 3;
const nextAttempt = currentAttempt + 1;
if (nextAttempt > configuredMaxAttempts) {
this.updateRun(runId, { status: "retry_exhausted" });
this.updateRun(runId, {
status: "retry_exhausted",
lifecycle: {
...(run.lifecycle ?? {}),
terminalReason: "retry_exhausted",
retryable: false,
failureClass: run.lifecycle?.failureClass ?? "non_retryable",
errorCode: "RETRY_EXHAUSTED",
},
});
throw new ResearchLifecycleError(`Run ${runId} exhausted retries`, "not_retryable");
}