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:
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal file
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal 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>);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -419,6 +419,8 @@ CREATE TABLE IF NOT EXISTS research_runs (
|
||||
query TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
status TEXT NOT NULL,
|
||||
projectId TEXT,
|
||||
trigger TEXT,
|
||||
providerConfig TEXT,
|
||||
sources TEXT NOT NULL DEFAULT '[]',
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
@@ -427,6 +429,7 @@ CREATE TABLE IF NOT EXISTS research_runs (
|
||||
tokenUsage TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
metadata TEXT,
|
||||
lifecycle TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
startedAt TEXT,
|
||||
@@ -448,6 +451,20 @@ CREATE TABLE IF NOT EXISTS research_exports (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxResearchExportsRunId ON research_exports(runId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_run_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT,
|
||||
classification TEXT,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxResearchRunEventsRunIdSeq ON research_run_events(runId, seq);
|
||||
|
||||
-- Schema version tracking
|
||||
CREATE TABLE IF NOT EXISTS __meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -643,9 +660,11 @@ CREATE TABLE IF NOT EXISTS project_insight_runs (
|
||||
insightsUpdated INTEGER NOT NULL DEFAULT 0,
|
||||
inputMetadata TEXT,
|
||||
outputMetadata TEXT,
|
||||
lifecycle TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
startedAt TEXT,
|
||||
completedAt TEXT
|
||||
completedAt TEXT,
|
||||
cancelledAt TEXT
|
||||
);
|
||||
|
||||
-- Index for filtering insights by projectId
|
||||
@@ -663,6 +682,23 @@ CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
|
||||
-- Index for filtering runs by projectId
|
||||
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
|
||||
ON project_insight_runs(projectId);
|
||||
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus
|
||||
ON project_insight_runs(projectId, trigger, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_insight_run_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT,
|
||||
classification TEXT,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (runId) REFERENCES project_insight_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxInsightRunEventsRunIdSeq
|
||||
ON project_insight_run_events(runId, seq);
|
||||
|
||||
-- Todo list persistence tables (FN-2575)
|
||||
-- Project-scoped todo lists and ordered checklist items
|
||||
@@ -1789,9 +1825,11 @@ export class Database {
|
||||
insightsUpdated INTEGER NOT NULL DEFAULT 0,
|
||||
inputMetadata TEXT,
|
||||
outputMetadata TEXT,
|
||||
lifecycle TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
startedAt TEXT,
|
||||
completedAt TEXT
|
||||
completedAt TEXT,
|
||||
cancelledAt TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -2155,6 +2193,8 @@ export class Database {
|
||||
query TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
status TEXT NOT NULL,
|
||||
projectId TEXT,
|
||||
trigger TEXT,
|
||||
providerConfig TEXT,
|
||||
sources TEXT NOT NULL DEFAULT '[]',
|
||||
events TEXT NOT NULL DEFAULT '[]',
|
||||
@@ -2163,6 +2203,7 @@ export class Database {
|
||||
tokenUsage TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
metadata TEXT,
|
||||
lifecycle TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
startedAt TEXT,
|
||||
@@ -2174,6 +2215,7 @@ export class Database {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsStatus ON research_runs(status)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsCreatedAt ON research_runs(createdAt)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsUpdatedAt ON research_runs(updatedAt)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsProjectTriggerStatus ON research_runs(projectId, trigger, status)`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS research_exports (
|
||||
@@ -2254,6 +2296,57 @@ export class Database {
|
||||
this.applyMigration(59, () => {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksColumn ON tasks("column")`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksUpdatedAt ON tasks(updatedAt DESC)`);
|
||||
|
||||
if (this.hasTable("research_runs")) {
|
||||
this.addColumnIfMissing("research_runs", "projectId", "TEXT");
|
||||
this.addColumnIfMissing("research_runs", "trigger", "TEXT");
|
||||
this.addColumnIfMissing("research_runs", "lifecycle", "TEXT");
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsProjectTriggerStatus ON research_runs(projectId, trigger, status)`);
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS research_run_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT,
|
||||
classification TEXT,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
if (this.hasTable("research_run_events")) {
|
||||
this.addColumnIfMissing("research_run_events", "seq", "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunEventsRunIdSeq ON research_run_events(runId, seq)`);
|
||||
|
||||
if (this.hasTable("project_insight_runs")) {
|
||||
this.addColumnIfMissing("project_insight_runs", "lifecycle", "TEXT");
|
||||
this.addColumnIfMissing("project_insight_runs", "cancelledAt", "TEXT");
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus ON project_insight_runs(projectId, trigger, status)`);
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS project_insight_run_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT,
|
||||
classification TEXT,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (runId) REFERENCES project_insight_runs(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
if (this.hasTable("project_insight_run_events")) {
|
||||
this.addColumnIfMissing("project_insight_run_events", "seq", "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunEventsRunIdSeq ON project_insight_run_events(runId, seq)`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -586,7 +586,12 @@ export type { AgentDreamProcessorResult, DreamProcessorResult, DreamPromptExecut
|
||||
|
||||
// ── Project Insights ──────────────────────────────────────────────────────
|
||||
|
||||
export { InsightStore, computeInsightFingerprint } from "./insight-store.js";
|
||||
export { InsightLifecycleError, InsightStore, computeInsightFingerprint } from "./insight-store.js";
|
||||
export {
|
||||
classifyInsightRunError,
|
||||
executeInsightRunLifecycle,
|
||||
retryInsightRunLifecycle,
|
||||
} from "./insight-run-executor.js";
|
||||
export type {
|
||||
InsightCategory,
|
||||
InsightStatus,
|
||||
@@ -599,6 +604,10 @@ export type {
|
||||
InsightRun,
|
||||
InsightRunStatus,
|
||||
InsightRunTrigger,
|
||||
InsightRunFailureClass,
|
||||
InsightRunLifecycle,
|
||||
InsightRunEventType,
|
||||
InsightRunEvent,
|
||||
InsightRunInputMetadata,
|
||||
InsightRunOutputMetadata,
|
||||
InsightRunCreateInput,
|
||||
@@ -606,10 +615,16 @@ export type {
|
||||
InsightRunListOptions,
|
||||
InsightStoreEvents,
|
||||
} from "./insight-types.js";
|
||||
export type {
|
||||
InsightRunAttemptContext,
|
||||
InsightRunAttemptResult,
|
||||
InsightRunExecutorErrorClassification,
|
||||
InsightRunExecutorOptions,
|
||||
} from "./insight-run-executor.js";
|
||||
|
||||
// ── Research System ───────────────────────────────────────────────────────
|
||||
|
||||
export { ResearchStore } from "./research-store.js";
|
||||
export { ResearchLifecycleError, ResearchStore } from "./research-store.js";
|
||||
export {
|
||||
RESEARCH_RUN_STATUSES,
|
||||
RESEARCH_SOURCE_STATUSES,
|
||||
@@ -618,6 +633,7 @@ export {
|
||||
RESEARCH_EVENT_TYPES,
|
||||
RESEARCH_ORCHESTRATION_PHASES,
|
||||
RESEARCH_ORCHESTRATION_STEP_STATUSES,
|
||||
RESEARCH_RUN_FAILURE_CLASSES,
|
||||
} from "./research-types.js";
|
||||
export type {
|
||||
ResearchRunStatus,
|
||||
@@ -631,6 +647,9 @@ export type {
|
||||
ResearchResult,
|
||||
ResearchTokenUsage,
|
||||
ResearchRun,
|
||||
ResearchRunLifecycle,
|
||||
ResearchRunFailureClass,
|
||||
ResearchRunEvent,
|
||||
ResearchExport,
|
||||
ResearchRunCreateInput,
|
||||
ResearchRunUpdateInput,
|
||||
|
||||
303
packages/core/src/insight-run-executor.ts
Normal file
303
packages/core/src/insight-run-executor.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type {
|
||||
InsightRun,
|
||||
InsightRunCreateInput,
|
||||
InsightRunFailureClass,
|
||||
InsightRunOutputMetadata,
|
||||
InsightRunTrigger,
|
||||
InsightRunUpdateInput,
|
||||
} from "./insight-types.js";
|
||||
import { InsightLifecycleError, InsightStore } from "./insight-store.js";
|
||||
|
||||
export interface InsightRunAttemptResult {
|
||||
summary?: string | null;
|
||||
insightsCreated: number;
|
||||
insightsUpdated: number;
|
||||
outputMetadata?: InsightRunOutputMetadata;
|
||||
}
|
||||
|
||||
export interface InsightRunAttemptContext {
|
||||
run: InsightRun;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface InsightRunExecutorOptions {
|
||||
store: InsightStore;
|
||||
projectId: string;
|
||||
input: InsightRunCreateInput;
|
||||
executeAttempt: (ctx: InsightRunAttemptContext) => Promise<InsightRunAttemptResult>;
|
||||
timeoutMs?: number;
|
||||
maxAttempts?: number;
|
||||
retryDelayMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface InsightRunExecutorErrorClassification {
|
||||
failureClass: InsightRunFailureClass;
|
||||
retryable: boolean;
|
||||
terminalReason: "cancelled" | "failed" | "timed_out";
|
||||
terminalCause: string;
|
||||
}
|
||||
|
||||
function isAbortLike(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function asErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function classifyInsightRunError(error: unknown): InsightRunExecutorErrorClassification {
|
||||
if (isAbortLike(error)) {
|
||||
return {
|
||||
failureClass: "cancelled",
|
||||
retryable: false,
|
||||
terminalReason: "cancelled",
|
||||
terminalCause: asErrorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
const message = asErrorMessage(error);
|
||||
if (/timeout|timed out|deadline/i.test(message)) {
|
||||
return {
|
||||
failureClass: "timed_out",
|
||||
retryable: true,
|
||||
terminalReason: "timed_out",
|
||||
terminalCause: message,
|
||||
};
|
||||
}
|
||||
|
||||
if (/ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|429|5\d\d/i.test(message)) {
|
||||
return {
|
||||
failureClass: "retryable_transient",
|
||||
retryable: true,
|
||||
terminalReason: "failed",
|
||||
terminalCause: message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
failureClass: "non_retryable",
|
||||
retryable: false,
|
||||
terminalReason: "failed",
|
||||
terminalCause: message,
|
||||
};
|
||||
}
|
||||
|
||||
function composeSignal(timeoutMs: number | undefined, parent?: AbortSignal): { signal: AbortSignal; clear: () => void } {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = timeoutMs && timeoutMs > 0
|
||||
? setTimeout(() => controller.abort(new Error(`Insight run timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||
: undefined;
|
||||
|
||||
const onAbort = () => {
|
||||
controller.abort(parent?.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
|
||||
if (parent) {
|
||||
if (parent.aborted) onAbort();
|
||||
else parent.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
clear: () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (parent) parent.removeEventListener("abort", onAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchForStatus(status: "completed" | "failed" | "cancelled", patch: InsightRunUpdateInput): InsightRunUpdateInput {
|
||||
if (status === "cancelled") {
|
||||
return {
|
||||
...patch,
|
||||
cancelledAt: patch.cancelledAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function executeExistingRun(
|
||||
store: InsightStore,
|
||||
run: InsightRun,
|
||||
options: Omit<InsightRunExecutorOptions, "input" | "projectId"> & { maxAttempts: number; retryDelayMs: number },
|
||||
): Promise<InsightRun> {
|
||||
const started = store.updateRun(run.id, {
|
||||
status: "running",
|
||||
startedAt: run.startedAt ?? new Date().toISOString(),
|
||||
lifecycle: {
|
||||
...run.lifecycle,
|
||||
maxAttempts: options.maxAttempts,
|
||||
attempt: run.lifecycle.attempt ?? 1,
|
||||
},
|
||||
});
|
||||
let active = started ?? run;
|
||||
store.appendRunEvent(active.id, { type: "status_changed", status: "running", message: "Run started" });
|
||||
|
||||
for (let attempt = active.lifecycle.attempt ?? 1; attempt <= options.maxAttempts; attempt += 1) {
|
||||
const { signal, clear } = composeSignal(options.timeoutMs, options.signal);
|
||||
try {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
store.appendRunEvent(active.id, {
|
||||
type: "info",
|
||||
message: `Attempt ${attempt}/${options.maxAttempts}`,
|
||||
metadata: { attempt, maxAttempts: options.maxAttempts },
|
||||
});
|
||||
|
||||
const result = await options.executeAttempt({ run: active, attempt, maxAttempts: options.maxAttempts, signal });
|
||||
const completed = store.updateRun(active.id, {
|
||||
status: "completed",
|
||||
summary: result.summary ?? null,
|
||||
insightsCreated: result.insightsCreated,
|
||||
insightsUpdated: result.insightsUpdated,
|
||||
outputMetadata: result.outputMetadata,
|
||||
lifecycle: {
|
||||
...active.lifecycle,
|
||||
attempt,
|
||||
maxAttempts: options.maxAttempts,
|
||||
terminalReason: "completed",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
if (!completed) throw new Error(`Run disappeared while completing: ${active.id}`);
|
||||
store.appendRunEvent(completed.id, { type: "status_changed", status: "completed", message: "Run completed" });
|
||||
return completed;
|
||||
} catch (error) {
|
||||
const classification = classifyInsightRunError(error);
|
||||
const canRetry = classification.retryable && attempt < options.maxAttempts;
|
||||
store.appendRunEvent(active.id, {
|
||||
type: canRetry ? "retry_scheduled" : "error",
|
||||
status: canRetry ? "running" : classification.terminalReason === "cancelled" ? "cancelled" : "failed",
|
||||
classification: classification.failureClass,
|
||||
message: canRetry
|
||||
? `Attempt ${attempt} failed (${classification.failureClass}); retrying`
|
||||
: `Run failed (${classification.failureClass})`,
|
||||
metadata: { attempt, maxAttempts: options.maxAttempts, error: asErrorMessage(error) },
|
||||
});
|
||||
|
||||
if (canRetry) {
|
||||
active = store.updateRun(active.id, {
|
||||
lifecycle: {
|
||||
...active.lifecycle,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: options.maxAttempts,
|
||||
failureClass: classification.failureClass,
|
||||
retryable: true,
|
||||
},
|
||||
}) ?? active;
|
||||
if (options.retryDelayMs > 0) {
|
||||
await delay(options.retryDelayMs, undefined, { signal: options.signal });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const terminalStatus = classification.terminalReason === "cancelled" ? "cancelled" : "failed";
|
||||
const terminal = store.updateRun(active.id, patchForStatus(terminalStatus, {
|
||||
status: terminalStatus,
|
||||
error: asErrorMessage(error),
|
||||
lifecycle: {
|
||||
...active.lifecycle,
|
||||
attempt,
|
||||
maxAttempts: options.maxAttempts,
|
||||
terminalReason: classification.terminalReason,
|
||||
terminalCause: classification.terminalCause,
|
||||
failureClass: classification.failureClass,
|
||||
retryable: classification.failureClass === "retryable_transient",
|
||||
timeoutAt: classification.failureClass === "timed_out" ? new Date().toISOString() : active.lifecycle.timeoutAt,
|
||||
},
|
||||
}));
|
||||
if (!terminal) throw new Error(`Run disappeared while failing: ${active.id}`);
|
||||
return terminal;
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
const failed = store.updateRun(active.id, {
|
||||
status: "failed",
|
||||
error: "Run exhausted attempts",
|
||||
lifecycle: {
|
||||
...active.lifecycle,
|
||||
terminalReason: "failed",
|
||||
terminalCause: "Run exhausted attempts",
|
||||
failureClass: "non_retryable",
|
||||
retryable: false,
|
||||
attempt: options.maxAttempts,
|
||||
maxAttempts: options.maxAttempts,
|
||||
},
|
||||
});
|
||||
if (!failed) throw new Error(`Run disappeared after attempts exhausted: ${active.id}`);
|
||||
return failed;
|
||||
}
|
||||
|
||||
export async function executeInsightRunLifecycle(options: InsightRunExecutorOptions): Promise<InsightRun> {
|
||||
const maxAttempts = Math.max(1, options.maxAttempts ?? 2);
|
||||
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 250);
|
||||
|
||||
let run: InsightRun;
|
||||
try {
|
||||
run = options.store.createRunOrThrowConflict(options.projectId, {
|
||||
...options.input,
|
||||
lifecycle: {
|
||||
...options.input.lifecycle,
|
||||
attempt: options.input.lifecycle?.attempt ?? 1,
|
||||
maxAttempts,
|
||||
rootRunId: options.input.lifecycle?.rootRunId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InsightLifecycleError && error.code === "active_run_conflict") {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
options.store.appendRunEvent(run.id, {
|
||||
type: "status_changed",
|
||||
status: "pending",
|
||||
message: "Run created",
|
||||
});
|
||||
|
||||
return executeExistingRun(options.store, run, {
|
||||
...options,
|
||||
maxAttempts,
|
||||
retryDelayMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryInsightRunLifecycle(
|
||||
options: Omit<InsightRunExecutorOptions, "input" | "projectId"> & { runId: string; trigger?: InsightRunTrigger; inputMetadata?: InsightRunCreateInput["inputMetadata"] },
|
||||
): Promise<{ run: InsightRun; retryOf: InsightRun }> {
|
||||
const original = options.store.getRun(options.runId);
|
||||
if (!original) {
|
||||
throw new Error(`Insight run not found: ${options.runId}`);
|
||||
}
|
||||
if (original.status !== "failed") {
|
||||
throw new InsightLifecycleError(`Run ${original.id} must be failed to retry`, "not_retryable");
|
||||
}
|
||||
if (!original.lifecycle.retryable || original.lifecycle.failureClass !== "retryable_transient") {
|
||||
throw new InsightLifecycleError(`Run ${original.id} is non-retryable`, "not_retryable");
|
||||
}
|
||||
|
||||
const run = await executeInsightRunLifecycle({
|
||||
...options,
|
||||
projectId: original.projectId,
|
||||
input: {
|
||||
trigger: options.trigger ?? original.trigger,
|
||||
inputMetadata: options.inputMetadata ?? original.inputMetadata,
|
||||
lifecycle: {
|
||||
retryOfRunId: original.id,
|
||||
rootRunId: original.lifecycle.rootRunId ?? original.id,
|
||||
attempt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { run, retryOf: original };
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database } from "./db.js";
|
||||
import { toJsonNullable, fromJson } from "./db.js";
|
||||
import type {
|
||||
@@ -46,6 +47,10 @@ import type {
|
||||
InsightRunTrigger,
|
||||
InsightRunInputMetadata,
|
||||
InsightRunOutputMetadata,
|
||||
InsightRunLifecycle,
|
||||
InsightRunFailureClass,
|
||||
InsightRunEvent,
|
||||
InsightRunEventType,
|
||||
} from "./insight-types.js";
|
||||
import type { InsightStoreEvents } from "./insight-types.js";
|
||||
|
||||
@@ -63,6 +68,29 @@ function generateRunId(): string {
|
||||
return `INSR-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
function generateRunEventId(): string {
|
||||
return `INSEVT-${randomUUID()}`;
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set<InsightRunStatus>(["completed", "failed", "cancelled"]);
|
||||
const VALID_RUN_STATUS_TRANSITIONS: Record<InsightRunStatus, InsightRunStatus[]> = {
|
||||
pending: ["running", "completed", "failed", "cancelled"],
|
||||
running: ["completed", "failed", "cancelled"],
|
||||
completed: [],
|
||||
failed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export class InsightLifecycleError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "invalid_transition" | "terminal_immutable" | "active_run_conflict" | "not_retryable",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "InsightLifecycleError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fingerprint Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -403,14 +431,21 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
const now = new Date().toISOString();
|
||||
const id = generateRunId();
|
||||
const inputMetadata = input.inputMetadata ?? {};
|
||||
const lifecycle: InsightRunLifecycle = {
|
||||
attempt: input.lifecycle?.attempt ?? 1,
|
||||
maxAttempts: input.lifecycle?.maxAttempts ?? 1,
|
||||
rootRunId: input.lifecycle?.rootRunId,
|
||||
retryOfRunId: input.lifecycle?.retryOfRunId,
|
||||
...input.lifecycle,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO project_insight_runs (
|
||||
id, projectId, trigger, status, summary, error,
|
||||
insightsCreated, insightsUpdated,
|
||||
inputMetadata, outputMetadata,
|
||||
createdAt, startedAt, completedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
inputMetadata, outputMetadata, lifecycle,
|
||||
createdAt, startedAt, completedAt, cancelledAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
projectId,
|
||||
@@ -422,9 +457,11 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
0,
|
||||
toJsonNullable(inputMetadata) ?? null,
|
||||
null,
|
||||
toJsonNullable(lifecycle),
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
@@ -443,6 +480,8 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
createdAt: now,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
lifecycle,
|
||||
};
|
||||
|
||||
this.emit("run:created", run);
|
||||
@@ -499,9 +538,27 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
const existing = this.getRun(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const mutatingKeys = Object.keys(input);
|
||||
if (TERMINAL_RUN_STATUSES.has(existing.status) && mutatingKeys.length > 0) {
|
||||
throw new InsightLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
|
||||
}
|
||||
|
||||
if (input.status && input.status !== existing.status) {
|
||||
const allowed = VALID_RUN_STATUS_TRANSITIONS[existing.status];
|
||||
if (!allowed.includes(input.status)) {
|
||||
throw new InsightLifecycleError(
|
||||
`Invalid run status transition: ${existing.status} -> ${input.status}`,
|
||||
"invalid_transition",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const isTerminal = input.status !== undefined && ["completed", "failed", "cancelled"].includes(input.status);
|
||||
const autoComplete = isTerminal && input.completedAt === undefined && existing.completedAt === null;
|
||||
const nextStatus = input.status ?? existing.status;
|
||||
const isTerminal = TERMINAL_RUN_STATUSES.has(nextStatus);
|
||||
const lifecycle = { ...existing.lifecycle, ...(input.lifecycle ?? {}) };
|
||||
const autoCompleteAt = isTerminal && input.completedAt === undefined && existing.completedAt === null ? now : undefined;
|
||||
const autoCancelledAt = nextStatus === "cancelled" && input.cancelledAt === undefined && existing.cancelledAt === null ? now : undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
@@ -530,6 +587,10 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
sets.push("outputMetadata = ?");
|
||||
params.push(toJsonNullable(input.outputMetadata));
|
||||
}
|
||||
if (input.lifecycle !== undefined) {
|
||||
sets.push("lifecycle = ?");
|
||||
params.push(toJsonNullable(lifecycle));
|
||||
}
|
||||
if (input.startedAt !== undefined) {
|
||||
sets.push("startedAt = ?");
|
||||
params.push(input.startedAt);
|
||||
@@ -538,22 +599,29 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
sets.push("completedAt = ?");
|
||||
params.push(input.completedAt);
|
||||
}
|
||||
if (input.cancelledAt !== undefined) {
|
||||
sets.push("cancelledAt = ?");
|
||||
params.push(input.cancelledAt);
|
||||
}
|
||||
|
||||
if (autoCompleteAt !== undefined) {
|
||||
sets.push("completedAt = ?");
|
||||
params.push(autoCompleteAt);
|
||||
}
|
||||
if (autoCancelledAt !== undefined) {
|
||||
sets.push("cancelledAt = ?");
|
||||
params.push(autoCancelledAt);
|
||||
}
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
// Auto-set completedAt for terminal transitions
|
||||
if (autoComplete) {
|
||||
sets.push("completedAt = ?");
|
||||
params.push(now);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE project_insight_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = this.getRun(id)!;
|
||||
|
||||
if (isTerminal) {
|
||||
if (isTerminal && updated.status !== existing.status) {
|
||||
this.emit("run:completed", updated);
|
||||
}
|
||||
this.emit("run:updated", updated);
|
||||
@@ -573,21 +641,102 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
* @returns The created or existing run
|
||||
*/
|
||||
upsertRun(projectId: string, trigger: InsightRunTrigger, input: InsightRunCreateInput): InsightRun {
|
||||
// Find most recent pending/running run for this project + trigger
|
||||
const existing = this.findActiveRun(projectId, trigger);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return this.createRun(projectId, input);
|
||||
}
|
||||
|
||||
findActiveRun(projectId: string, trigger: InsightRunTrigger): InsightRun | undefined {
|
||||
const existingRow = this.db.prepare(`
|
||||
SELECT * FROM project_insight_runs
|
||||
SELECT id FROM project_insight_runs
|
||||
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running')
|
||||
ORDER BY createdAt DESC, id DESC
|
||||
LIMIT 1
|
||||
`).get(projectId, trigger) as Record<string, unknown> | undefined;
|
||||
`).get(projectId, trigger) as { id: string } | undefined;
|
||||
return existingRow ? this.getRun(existingRow.id) : undefined;
|
||||
}
|
||||
|
||||
if (existingRow) {
|
||||
return this.getRun(existingRow.id as string)!;
|
||||
createRunOrThrowConflict(projectId: string, input: InsightRunCreateInput): InsightRun {
|
||||
const existing = this.findActiveRun(projectId, input.trigger);
|
||||
if (existing) {
|
||||
throw new InsightLifecycleError(
|
||||
`Active run already exists for project ${projectId} trigger ${input.trigger}: ${existing.id}`,
|
||||
"active_run_conflict",
|
||||
);
|
||||
}
|
||||
|
||||
return this.createRun(projectId, input);
|
||||
}
|
||||
|
||||
appendRunEvent(
|
||||
runId: string,
|
||||
event: {
|
||||
type: InsightRunEventType;
|
||||
message: string;
|
||||
status?: InsightRunStatus;
|
||||
classification?: InsightRunFailureClass;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): InsightRunEvent {
|
||||
const run = this.getRun(runId);
|
||||
if (!run) {
|
||||
throw new Error(`Insight run not found: ${runId}`);
|
||||
}
|
||||
const createdAt = new Date().toISOString();
|
||||
const row = this.db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 as nextSeq FROM project_insight_run_events WHERE runId = ?").get(runId) as { nextSeq: number };
|
||||
const runEvent: InsightRunEvent = {
|
||||
id: generateRunEventId(),
|
||||
runId,
|
||||
seq: Number(row?.nextSeq ?? 1),
|
||||
type: event.type,
|
||||
message: event.message,
|
||||
status: event.status,
|
||||
classification: event.classification,
|
||||
metadata: event.metadata,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO project_insight_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
runEvent.id,
|
||||
runEvent.runId,
|
||||
runEvent.seq,
|
||||
runEvent.type,
|
||||
runEvent.message,
|
||||
runEvent.status ?? null,
|
||||
runEvent.classification ?? null,
|
||||
toJsonNullable(runEvent.metadata),
|
||||
runEvent.createdAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("run:event", { runId, event: runEvent });
|
||||
return runEvent;
|
||||
}
|
||||
|
||||
listRunEvents(runId: string): InsightRunEvent[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM project_insight_run_events
|
||||
WHERE runId = ?
|
||||
ORDER BY seq ASC
|
||||
`).all(runId) as Record<string, unknown>[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id as string,
|
||||
runId: row.runId as string,
|
||||
seq: Number(row.seq),
|
||||
type: row.type as InsightRunEventType,
|
||||
message: row.message as string,
|
||||
status: (row.status as InsightRunStatus | null) ?? undefined,
|
||||
classification: (row.classification as InsightRunFailureClass | null) ?? undefined,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
|
||||
createdAt: row.createdAt as string,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of runs matching the given filter.
|
||||
*/
|
||||
@@ -642,6 +791,11 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
|
||||
createdAt: row.createdAt as string,
|
||||
startedAt: row.startedAt as string | null,
|
||||
completedAt: row.completedAt as string | null,
|
||||
cancelledAt: row.cancelledAt as string | null,
|
||||
lifecycle: (() => {
|
||||
const m = fromJson<InsightRunLifecycle>(row.lifecycle as string | null);
|
||||
return m ?? {};
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,42 @@ export type InsightRunTrigger = "schedule" | "manual" | "task_completion" | "mer
|
||||
* Runs track the full lifecycle of an analysis pass — from scheduling
|
||||
* through input processing to output persistence.
|
||||
*/
|
||||
export type InsightRunFailureClass = "cancelled" | "timed_out" | "retryable_transient" | "non_retryable";
|
||||
|
||||
export interface InsightRunLifecycle {
|
||||
terminalReason?: "completed" | "cancelled" | "failed" | "timed_out";
|
||||
terminalCause?: string;
|
||||
failureClass?: InsightRunFailureClass;
|
||||
retryable?: boolean;
|
||||
cancellationRequestedAt?: string;
|
||||
timeoutAt?: string;
|
||||
retryOfRunId?: string;
|
||||
rootRunId?: string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export type InsightRunEventType =
|
||||
| "status_changed"
|
||||
| "retry_scheduled"
|
||||
| "cancel_requested"
|
||||
| "timeout"
|
||||
| "info"
|
||||
| "warning"
|
||||
| "error";
|
||||
|
||||
export interface InsightRunEvent {
|
||||
id: string;
|
||||
runId: string;
|
||||
seq: number;
|
||||
type: InsightRunEventType;
|
||||
message: string;
|
||||
status?: InsightRunStatus;
|
||||
classification?: InsightRunFailureClass;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InsightRun {
|
||||
/**
|
||||
* Unique identifier for this run (e.g., "INSR-xxx").
|
||||
@@ -344,6 +380,13 @@ export interface InsightRun {
|
||||
* When the run reached a terminal state.
|
||||
*/
|
||||
completedAt: string | null;
|
||||
|
||||
/**
|
||||
* When cancellation was applied.
|
||||
*/
|
||||
cancelledAt: string | null;
|
||||
|
||||
lifecycle: InsightRunLifecycle;
|
||||
}
|
||||
|
||||
// ── Run Input / Output Metadata ──────────────────────────────────────
|
||||
@@ -422,6 +465,7 @@ export interface InsightRunOutputMetadata {
|
||||
export interface InsightRunCreateInput {
|
||||
trigger: InsightRunTrigger;
|
||||
inputMetadata?: InsightRunInputMetadata;
|
||||
lifecycle?: InsightRunLifecycle;
|
||||
}
|
||||
|
||||
// ── Run Update Input ─────────────────────────────────────────────────
|
||||
@@ -437,8 +481,10 @@ export interface InsightRunUpdateInput {
|
||||
insightsCreated?: number;
|
||||
insightsUpdated?: number;
|
||||
outputMetadata?: InsightRunOutputMetadata;
|
||||
lifecycle?: InsightRunLifecycle;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
}
|
||||
|
||||
// ── Run List Options ─────────────────────────────────────────────────
|
||||
@@ -478,4 +524,6 @@ export interface InsightStoreEvents {
|
||||
"run:updated": [InsightRun];
|
||||
/** Emitted when a run reaches a terminal state */
|
||||
"run:completed": [InsightRun];
|
||||
/** Emitted when a durable run event is appended */
|
||||
"run:event": [{ runId: string; event: InsightRunEvent }];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ResearchResult,
|
||||
ResearchRun,
|
||||
ResearchRunCreateInput,
|
||||
ResearchRunEvent,
|
||||
ResearchRunFailureClass,
|
||||
ResearchRunListOptions,
|
||||
ResearchRunStatus,
|
||||
ResearchRunUpdateInput,
|
||||
@@ -26,6 +28,16 @@ function generateId(prefix: string): string {
|
||||
return `${prefix}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export class ResearchLifecycleError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "invalid_transition" | "terminal_immutable" | "active_run_conflict" | "not_retryable",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ResearchLifecycleError";
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRecord(
|
||||
currentValue: Record<string, unknown> | undefined,
|
||||
patchValue: Record<string, unknown> | undefined,
|
||||
@@ -35,6 +47,15 @@ function mergeRecord(
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES = new Set<ResearchRunStatus>(["completed", "failed", "cancelled"]);
|
||||
const VALID_STATUS_TRANSITIONS: Record<ResearchRunStatus, ResearchRunStatus[]> = {
|
||||
pending: ["running", "cancelled", "failed"],
|
||||
running: ["completed", "failed", "cancelled"],
|
||||
completed: [],
|
||||
failed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
constructor(private readonly db: Database) {
|
||||
super();
|
||||
@@ -48,26 +69,37 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
query: input.query,
|
||||
topic: input.topic,
|
||||
status: "pending",
|
||||
projectId: input.projectId,
|
||||
trigger: input.trigger,
|
||||
providerConfig: input.providerConfig,
|
||||
sources: input.sources ?? [],
|
||||
events: input.events ?? [],
|
||||
results: input.results,
|
||||
tags: input.tags ?? [],
|
||||
metadata: input.metadata,
|
||||
lifecycle: {
|
||||
attempt: input.lifecycle?.attempt ?? 1,
|
||||
maxAttempts: input.lifecycle?.maxAttempts ?? 1,
|
||||
rootRunId: input.lifecycle?.rootRunId,
|
||||
retryOfRunId: input.lifecycle?.retryOfRunId,
|
||||
...input.lifecycle,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO research_runs (
|
||||
id, query, topic, status, providerConfig, sources, events, results, error,
|
||||
tokenUsage, tags, metadata, createdAt, updatedAt, startedAt, completedAt, cancelledAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
id, query, topic, status, projectId, trigger, providerConfig, sources, events, results, error,
|
||||
tokenUsage, tags, metadata, lifecycle, createdAt, updatedAt, startedAt, completedAt, cancelledAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
run.id,
|
||||
run.query,
|
||||
run.topic ?? null,
|
||||
run.status,
|
||||
run.projectId ?? null,
|
||||
run.trigger ?? null,
|
||||
toJsonNullable(run.providerConfig),
|
||||
toJson(run.sources),
|
||||
toJson(run.events),
|
||||
@@ -76,6 +108,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
null,
|
||||
toJson(run.tags),
|
||||
toJsonNullable(run.metadata),
|
||||
toJsonNullable(run.lifecycle),
|
||||
run.createdAt,
|
||||
run.updatedAt,
|
||||
null,
|
||||
@@ -97,15 +130,31 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
const existing = this.getRun(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
if (TERMINAL_STATUSES.has(existing.status) && Object.keys(input).some((key) => key !== "events" && key !== "metadata")) {
|
||||
throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
|
||||
}
|
||||
|
||||
if (input.status && input.status !== existing.status) {
|
||||
const allowed = VALID_STATUS_TRANSITIONS[existing.status];
|
||||
if (!allowed.includes(input.status)) {
|
||||
throw new ResearchLifecycleError(
|
||||
`Invalid run status transition: ${existing.status} -> ${input.status}`,
|
||||
"invalid_transition",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const mergedProviderConfig = mergeRecord(existing.providerConfig, input.providerConfig);
|
||||
const mergedMetadata = mergeRecord(existing.metadata, input.metadata);
|
||||
const mergedLifecycle = { ...(existing.lifecycle ?? {}), ...(input.lifecycle ?? {}) };
|
||||
|
||||
const updated: ResearchRun = {
|
||||
...existing,
|
||||
...input,
|
||||
providerConfig: mergedProviderConfig,
|
||||
metadata: mergedMetadata,
|
||||
lifecycle: Object.keys(mergedLifecycle).length > 0 ? mergedLifecycle : undefined,
|
||||
error: input.error === null ? undefined : (input.error ?? existing.error),
|
||||
updatedAt: now,
|
||||
startedAt: input.startedAt === null ? undefined : (input.startedAt ?? existing.startedAt),
|
||||
@@ -180,7 +229,24 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
metadata: event.metadata,
|
||||
};
|
||||
|
||||
this.updateRun(runId, { events: [...run.events, created] });
|
||||
const seq = this.getNextEventSeq(runId);
|
||||
this.db.prepare(`
|
||||
INSERT INTO research_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
created.id,
|
||||
runId,
|
||||
seq,
|
||||
created.type,
|
||||
created.message,
|
||||
run.status,
|
||||
null,
|
||||
toJsonNullable(created.metadata),
|
||||
created.timestamp,
|
||||
);
|
||||
|
||||
this.persistRun({ ...run, events: [...run.events, created], updatedAt: new Date().toISOString() });
|
||||
this.db.bumpLastModified();
|
||||
this.emit("event:added", { runId, event: created });
|
||||
return created;
|
||||
}
|
||||
@@ -189,6 +255,62 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
return this.addEvent(runId, event);
|
||||
}
|
||||
|
||||
appendLifecycleEvent(
|
||||
runId: string,
|
||||
event: { type: ResearchEvent["type"]; message: string; status?: ResearchRunStatus; classification?: ResearchRunFailureClass; metadata?: Record<string, unknown> },
|
||||
): ResearchRunEvent {
|
||||
const run = this.getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
const createdAt = new Date().toISOString();
|
||||
const lifecycleEvent: ResearchRunEvent = {
|
||||
id: generateId("REVT"),
|
||||
runId,
|
||||
seq: this.getNextEventSeq(runId),
|
||||
type: event.type,
|
||||
message: event.message,
|
||||
status: event.status,
|
||||
classification: event.classification,
|
||||
metadata: event.metadata,
|
||||
createdAt,
|
||||
};
|
||||
this.db.prepare(`
|
||||
INSERT INTO research_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
lifecycleEvent.id,
|
||||
lifecycleEvent.runId,
|
||||
lifecycleEvent.seq,
|
||||
lifecycleEvent.type,
|
||||
lifecycleEvent.message,
|
||||
lifecycleEvent.status ?? null,
|
||||
lifecycleEvent.classification ?? null,
|
||||
toJsonNullable(lifecycleEvent.metadata),
|
||||
lifecycleEvent.createdAt,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
return lifecycleEvent;
|
||||
}
|
||||
|
||||
listRunEvents(runId: string): ResearchRunEvent[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM research_run_events
|
||||
WHERE runId = ?
|
||||
ORDER BY seq ASC
|
||||
`).all(runId) as Record<string, unknown>[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id as string,
|
||||
runId: row.runId as string,
|
||||
seq: Number(row.seq),
|
||||
type: row.type as ResearchEvent["type"],
|
||||
message: row.message as string,
|
||||
status: (row.status as ResearchRunStatus | null) ?? undefined,
|
||||
classification: (row.classification as ResearchRunFailureClass | null) ?? undefined,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
|
||||
createdAt: row.createdAt as string,
|
||||
}));
|
||||
}
|
||||
|
||||
addSource(runId: string, source: Omit<ResearchSource, "id">): ResearchSource {
|
||||
const run = this.getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
@@ -228,6 +350,9 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
const patch: ResearchRunUpdateInput = {
|
||||
...(extra ?? {}),
|
||||
status,
|
||||
lifecycle: {
|
||||
...(run.lifecycle ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (status === "running" && !run.startedAt) {
|
||||
@@ -240,9 +365,24 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
patch.cancelledAt = now;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "completed", retryable: false };
|
||||
} else if (status === "failed") {
|
||||
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "failed", retryable: patch.lifecycle?.failureClass === "retryable_transient" };
|
||||
} else if (status === "cancelled") {
|
||||
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "cancelled", retryable: false, failureClass: "cancelled" };
|
||||
}
|
||||
|
||||
const updated = this.updateRun(runId, patch);
|
||||
if (!updated) return;
|
||||
|
||||
this.appendLifecycleEvent(runId, {
|
||||
type: "status_changed",
|
||||
message: `Status changed to ${status}`,
|
||||
status,
|
||||
classification: updated.lifecycle?.failureClass,
|
||||
});
|
||||
|
||||
this.emit("run:status_changed", updated);
|
||||
if (status === "completed") this.emit("run:completed", updated);
|
||||
if (status === "failed") this.emit("run:failed", updated);
|
||||
@@ -319,17 +459,104 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
return { total, byStatus };
|
||||
}
|
||||
|
||||
getActiveRun(projectId: string, trigger: string): ResearchRun | undefined {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM research_runs
|
||||
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running')
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT 1
|
||||
`).get(projectId, trigger) as Record<string, unknown> | undefined;
|
||||
return row ? this.rowToRun(row) : undefined;
|
||||
}
|
||||
|
||||
assertNoActiveRun(projectId: string, trigger: string): void {
|
||||
const active = this.getActiveRun(projectId, trigger);
|
||||
if (active) {
|
||||
throw new ResearchLifecycleError(
|
||||
`Active run already exists for projectId=${projectId} trigger=${trigger}: ${active.id}`,
|
||||
"active_run_conflict",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
requestCancellation(runId: string, reason = "Cancelled by user"): ResearchRun {
|
||||
const run = this.getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
if (TERMINAL_STATUSES.has(run.status)) {
|
||||
throw new ResearchLifecycleError(`Run ${runId} is already terminal`, "invalid_transition");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated = this.updateRun(runId, {
|
||||
status: "cancelled",
|
||||
cancelledAt: run.cancelledAt ?? now,
|
||||
lifecycle: {
|
||||
...(run.lifecycle ?? {}),
|
||||
cancellationRequestedAt: now,
|
||||
terminalReason: "cancelled",
|
||||
terminalCause: reason,
|
||||
failureClass: "cancelled",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
if (!updated) throw new Error(`Research run not found: ${runId}`);
|
||||
this.appendLifecycleEvent(runId, { type: "cancel_requested", message: reason, status: "cancelled", classification: "cancelled" });
|
||||
return updated;
|
||||
}
|
||||
|
||||
createRetryRun(runId: string, maxAttempts?: number): ResearchRun {
|
||||
const run = this.getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
if (run.status !== "failed") {
|
||||
throw new ResearchLifecycleError(`Run ${runId} is not failed`, "invalid_transition");
|
||||
}
|
||||
if (!run.lifecycle?.retryable) {
|
||||
throw new ResearchLifecycleError(`Run ${runId} is non-retryable`, "not_retryable");
|
||||
}
|
||||
|
||||
const nextAttempt = (run.lifecycle?.attempt ?? 1) + 1;
|
||||
const rootRunId = run.lifecycle?.rootRunId ?? run.id;
|
||||
const retryRun = this.createRun({
|
||||
query: run.query,
|
||||
topic: run.topic,
|
||||
projectId: run.projectId,
|
||||
trigger: run.trigger,
|
||||
providerConfig: run.providerConfig,
|
||||
tags: run.tags,
|
||||
metadata: run.metadata,
|
||||
lifecycle: {
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: maxAttempts ?? run.lifecycle?.maxAttempts ?? nextAttempt,
|
||||
retryOfRunId: run.id,
|
||||
rootRunId,
|
||||
},
|
||||
});
|
||||
this.appendLifecycleEvent(retryRun.id, {
|
||||
type: "retry_scheduled",
|
||||
message: `Retry scheduled from ${run.id}`,
|
||||
metadata: { retryOfRunId: run.id, rootRunId, attempt: nextAttempt },
|
||||
});
|
||||
return retryRun;
|
||||
}
|
||||
|
||||
private getNextEventSeq(runId: string): number {
|
||||
const row = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM research_run_events WHERE runId = ?").get(runId) as { seq?: number };
|
||||
return Number(row?.seq ?? 0) + 1;
|
||||
}
|
||||
|
||||
private persistRun(run: ResearchRun): void {
|
||||
this.db.prepare(`
|
||||
UPDATE research_runs
|
||||
SET query = ?, topic = ?, status = ?, providerConfig = ?, sources = ?, events = ?,
|
||||
results = ?, error = ?, tokenUsage = ?, tags = ?, metadata = ?, updatedAt = ?,
|
||||
SET query = ?, topic = ?, status = ?, projectId = ?, trigger = ?, providerConfig = ?, sources = ?, events = ?,
|
||||
results = ?, error = ?, tokenUsage = ?, tags = ?, metadata = ?, lifecycle = ?, updatedAt = ?,
|
||||
startedAt = ?, completedAt = ?, cancelledAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
run.query,
|
||||
run.topic ?? null,
|
||||
run.status,
|
||||
run.projectId ?? null,
|
||||
run.trigger ?? null,
|
||||
toJsonNullable(run.providerConfig),
|
||||
toJson(run.sources),
|
||||
toJson(run.events),
|
||||
@@ -338,6 +565,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
toJsonNullable(run.tokenUsage),
|
||||
toJson(run.tags),
|
||||
toJsonNullable(run.metadata),
|
||||
toJsonNullable(run.lifecycle),
|
||||
run.updatedAt,
|
||||
run.startedAt ?? null,
|
||||
run.completedAt ?? null,
|
||||
@@ -354,6 +582,8 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
query: row.query as string,
|
||||
topic: (row.topic as string | null) ?? undefined,
|
||||
status: row.status as ResearchRunStatus,
|
||||
projectId: (row.projectId as string | null) ?? undefined,
|
||||
trigger: (row.trigger as string | null) ?? undefined,
|
||||
providerConfig: fromJson<Record<string, unknown>>(row.providerConfig as string | null),
|
||||
sources: fromJson<ResearchSource[]>(row.sources as string | null) ?? [],
|
||||
events: fromJson<ResearchEvent[]>(row.events as string | null) ?? [],
|
||||
@@ -362,6 +592,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||
tokenUsage: fromJson<ResearchRun["tokenUsage"]>(row.tokenUsage as string | null),
|
||||
tags: fromJson<string[]>(row.tags as string | null) ?? [],
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
|
||||
lifecycle: fromJson<ResearchRun["lifecycle"]>(row.lifecycle as string | null),
|
||||
createdAt: row.createdAt as string,
|
||||
updatedAt: row.updatedAt as string,
|
||||
startedAt: (row.startedAt as string | null) ?? undefined,
|
||||
|
||||
@@ -38,10 +38,48 @@ export const RESEARCH_EVENT_TYPES = [
|
||||
"source_added",
|
||||
"result_updated",
|
||||
"progress",
|
||||
"status_changed",
|
||||
"retry_scheduled",
|
||||
"cancel_requested",
|
||||
"timeout",
|
||||
] as const;
|
||||
|
||||
export type ResearchEventType = typeof RESEARCH_EVENT_TYPES[number];
|
||||
|
||||
export const RESEARCH_RUN_FAILURE_CLASSES = [
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
"retryable_transient",
|
||||
"non_retryable",
|
||||
] as const;
|
||||
|
||||
export type ResearchRunFailureClass = typeof RESEARCH_RUN_FAILURE_CLASSES[number];
|
||||
|
||||
export interface ResearchRunLifecycle {
|
||||
terminalReason?: "completed" | "cancelled" | "failed" | "timed_out";
|
||||
terminalCause?: string;
|
||||
failureClass?: ResearchRunFailureClass;
|
||||
retryable?: boolean;
|
||||
cancellationRequestedAt?: string;
|
||||
timeoutAt?: string;
|
||||
retryOfRunId?: string;
|
||||
rootRunId?: string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface ResearchRunEvent {
|
||||
id: string;
|
||||
runId: string;
|
||||
seq: number;
|
||||
type: ResearchEventType;
|
||||
message: string;
|
||||
status?: ResearchRunStatus;
|
||||
classification?: ResearchRunFailureClass;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ResearchSource {
|
||||
id: string;
|
||||
type: ResearchSourceType;
|
||||
@@ -89,6 +127,8 @@ export interface ResearchRun {
|
||||
query: string;
|
||||
topic?: string;
|
||||
status: ResearchRunStatus;
|
||||
projectId?: string;
|
||||
trigger?: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
sources: ResearchSource[];
|
||||
events: ResearchEvent[];
|
||||
@@ -97,6 +137,7 @@ export interface ResearchRun {
|
||||
tokenUsage?: ResearchTokenUsage;
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
lifecycle?: ResearchRunLifecycle;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
@@ -116,18 +157,23 @@ export interface ResearchExport {
|
||||
export interface ResearchRunCreateInput {
|
||||
query: string;
|
||||
topic?: string;
|
||||
projectId?: string;
|
||||
trigger?: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
sources?: ResearchSource[];
|
||||
events?: ResearchEvent[];
|
||||
results?: ResearchResult;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
lifecycle?: ResearchRunLifecycle;
|
||||
}
|
||||
|
||||
export interface ResearchRunUpdateInput {
|
||||
query?: string;
|
||||
topic?: string;
|
||||
status?: ResearchRunStatus;
|
||||
projectId?: string;
|
||||
trigger?: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
sources?: ResearchSource[];
|
||||
events?: ResearchEvent[];
|
||||
@@ -136,6 +182,7 @@ export interface ResearchRunUpdateInput {
|
||||
tokenUsage?: ResearchTokenUsage;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
lifecycle?: ResearchRunLifecycle;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,13 +15,15 @@ import type { Request, Response, NextFunction } from "express";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
InsightLifecycleError,
|
||||
InsightStore,
|
||||
executeInsightRunLifecycle,
|
||||
retryInsightRunLifecycle,
|
||||
type InsightCategory,
|
||||
type MemoryInsightCategory,
|
||||
type InsightStatus,
|
||||
type InsightListOptions,
|
||||
type InsightRunTrigger,
|
||||
type InsightRunCreateInput,
|
||||
type InsightRunListOptions,
|
||||
type InsightRunStatus,
|
||||
} from "@fusion/core";
|
||||
@@ -83,6 +85,108 @@ const INSIGHT_CATEGORY_BY_MEMORY_CATEGORY: Record<MemoryInsightCategory, Insight
|
||||
context: "other",
|
||||
};
|
||||
|
||||
const activeRunControllers = new Map<string, AbortController>();
|
||||
|
||||
async function withAbort<T>(signal: AbortSignal, task: Promise<T>): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
||||
});
|
||||
}
|
||||
|
||||
async function executeInsightAttempt(params: {
|
||||
rootDir: string;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
signal: AbortSignal;
|
||||
insightStore: InsightStore;
|
||||
}): Promise<{ summary: string; insightsCreated: number; insightsUpdated: number }> {
|
||||
const {
|
||||
readWorkingMemory,
|
||||
readInsightsMemory,
|
||||
writeInsightsMemory,
|
||||
buildInsightExtractionPrompt,
|
||||
parseInsightExtractionResponse,
|
||||
mergeInsights,
|
||||
computeInsightFingerprint,
|
||||
} = await import("@fusion/core");
|
||||
|
||||
const workingMemory = await readWorkingMemory(params.rootDir);
|
||||
if (!workingMemory.trim()) {
|
||||
throw new Error("No working memory to analyze");
|
||||
}
|
||||
|
||||
const existingInsights = await readInsightsMemory(params.rootDir);
|
||||
let responseText = "";
|
||||
const { session } = await createFnAgent({
|
||||
cwd: params.rootDir,
|
||||
systemPrompt: [
|
||||
"You extract durable project insights from working memory notes.",
|
||||
"Return only valid JSON that matches the requested schema.",
|
||||
"Do not execute tools or make code changes.",
|
||||
].join("\n"),
|
||||
tools: "readonly",
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildInsightExtractionPrompt(workingMemory, existingInsights);
|
||||
await withAbort(params.signal, promptWithFallback(session, prompt));
|
||||
} finally {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch {
|
||||
// Best-effort disposal
|
||||
}
|
||||
}
|
||||
|
||||
const parsedResult = parseInsightExtractionResponse(responseText);
|
||||
const mergedInsightsContent = mergeInsights(existingInsights ?? "", parsedResult.insights);
|
||||
await writeInsightsMemory(params.rootDir, mergedInsightsContent);
|
||||
|
||||
let insightsCreated = 0;
|
||||
let insightsUpdated = 0;
|
||||
|
||||
for (const insight of parsedResult.insights) {
|
||||
const category = INSIGHT_CATEGORY_BY_MEMORY_CATEGORY[insight.category] ?? "other";
|
||||
const title = toInsightTitle(insight.content);
|
||||
const fingerprint = computeInsightFingerprint(title, category);
|
||||
|
||||
const upsertedInsight = params.insightStore.upsertInsight(params.projectId, {
|
||||
title,
|
||||
content: insight.content,
|
||||
category,
|
||||
fingerprint,
|
||||
provenance: {
|
||||
trigger: "manual",
|
||||
description: "Manual insight generation",
|
||||
metadata: {
|
||||
runId: params.runId,
|
||||
extractedAt: insight.extractedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (upsertedInsight.createdAt === upsertedInsight.updatedAt) {
|
||||
insightsCreated += 1;
|
||||
} else {
|
||||
insightsUpdated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary: parsedResult.summary,
|
||||
insightsCreated,
|
||||
insightsUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
function toInsightTitle(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
@@ -192,17 +296,6 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
|
||||
// ── Trigger Insight Run ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a full manual insight-generation lifecycle for the current project.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Create run (pending)
|
||||
* 2. Mark run running
|
||||
* 3. Read working-memory context
|
||||
* 4. Execute AI extraction prompt
|
||||
* 5. Parse + persist extracted insights
|
||||
* 6. Mark run completed (or failed on error)
|
||||
*/
|
||||
router.post("/run", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projectId = getProjectId(req) ?? "";
|
||||
@@ -213,124 +306,40 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
throw badRequest(`Invalid trigger: ${trigger}`);
|
||||
}
|
||||
|
||||
const input: InsightRunCreateInput = {
|
||||
trigger,
|
||||
inputMetadata: req.body.inputMetadata,
|
||||
};
|
||||
const taskStore = requestContext.getStore();
|
||||
if (!taskStore) throw new ApiError(500, "Store context not available");
|
||||
const rootDir = taskStore.getRootDir();
|
||||
const controller = new AbortController();
|
||||
|
||||
const run = insightStore.createRun(projectId, input);
|
||||
insightStore.updateRun(run.id, {
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store: insightStore,
|
||||
projectId,
|
||||
input: {
|
||||
trigger,
|
||||
inputMetadata: req.body.inputMetadata,
|
||||
},
|
||||
signal: controller.signal,
|
||||
timeoutMs: typeof req.body.timeoutMs === "number" ? req.body.timeoutMs : 120_000,
|
||||
maxAttempts: 2,
|
||||
retryDelayMs: 250,
|
||||
executeAttempt: async ({ run, signal }) => {
|
||||
activeRunControllers.set(run.id, controller);
|
||||
return executeInsightAttempt({
|
||||
rootDir,
|
||||
projectId,
|
||||
runId: run.id,
|
||||
signal,
|
||||
insightStore,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const taskStore = requestContext.getStore();
|
||||
if (!taskStore) {
|
||||
throw new ApiError(500, "Store context not available");
|
||||
}
|
||||
const rootDir = taskStore.getRootDir();
|
||||
|
||||
const {
|
||||
readWorkingMemory,
|
||||
readInsightsMemory,
|
||||
writeInsightsMemory,
|
||||
buildInsightExtractionPrompt,
|
||||
parseInsightExtractionResponse,
|
||||
mergeInsights,
|
||||
computeInsightFingerprint,
|
||||
} = await import("@fusion/core");
|
||||
|
||||
const workingMemory = await readWorkingMemory(rootDir);
|
||||
if (!workingMemory.trim()) {
|
||||
const failedRun = insightStore.updateRun(run.id, {
|
||||
status: "failed",
|
||||
error: "No working memory to analyze",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
res.status(201).json(failedRun ?? run);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingInsights = await readInsightsMemory(rootDir);
|
||||
|
||||
try {
|
||||
let responseText = "";
|
||||
const { session } = await createFnAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: [
|
||||
"You extract durable project insights from working memory notes.",
|
||||
"Return only valid JSON that matches the requested schema.",
|
||||
"Do not execute tools or make code changes.",
|
||||
].join("\n"),
|
||||
tools: "readonly",
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildInsightExtractionPrompt(workingMemory, existingInsights);
|
||||
await promptWithFallback(session, prompt);
|
||||
} finally {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch {
|
||||
// Best-effort disposal
|
||||
}
|
||||
}
|
||||
|
||||
const parsedResult = parseInsightExtractionResponse(responseText);
|
||||
const mergedInsightsContent = mergeInsights(existingInsights ?? "", parsedResult.insights);
|
||||
await writeInsightsMemory(rootDir, mergedInsightsContent);
|
||||
|
||||
let insightsCreated = 0;
|
||||
let insightsUpdated = 0;
|
||||
|
||||
for (const insight of parsedResult.insights) {
|
||||
const category = INSIGHT_CATEGORY_BY_MEMORY_CATEGORY[insight.category] ?? "other";
|
||||
const title = toInsightTitle(insight.content);
|
||||
const fingerprint = computeInsightFingerprint(title, category);
|
||||
|
||||
const upsertedInsight = insightStore.upsertInsight(projectId, {
|
||||
title,
|
||||
content: insight.content,
|
||||
category,
|
||||
fingerprint,
|
||||
provenance: {
|
||||
trigger: "manual",
|
||||
description: "Manual insight generation",
|
||||
metadata: {
|
||||
runId: run.id,
|
||||
extractedAt: insight.extractedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (upsertedInsight.createdAt === upsertedInsight.updatedAt) {
|
||||
insightsCreated += 1;
|
||||
} else {
|
||||
insightsUpdated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const completedRun = insightStore.updateRun(run.id, {
|
||||
status: "completed",
|
||||
insightsCreated,
|
||||
insightsUpdated,
|
||||
completedAt: new Date().toISOString(),
|
||||
summary: parsedResult.summary,
|
||||
});
|
||||
|
||||
res.status(201).json(completedRun ?? run);
|
||||
} catch (err) {
|
||||
insightStore.updateRun(run.id, {
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
activeRunControllers.delete(run.id);
|
||||
res.status(201).json(run);
|
||||
} catch (error) {
|
||||
if (error instanceof InsightLifecycleError && error.code === "active_run_conflict") {
|
||||
throw new ApiError(409, error.message);
|
||||
}
|
||||
rethrowAsApiError(error, "Failed to create insight run");
|
||||
}
|
||||
});
|
||||
@@ -397,6 +406,108 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/runs/:id/events", (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const store = getInsightStore();
|
||||
const run = store.getRun(id);
|
||||
if (!run) throw notFound(`Run not found: ${id}`);
|
||||
res.json({ events: store.listRunEvents(id) });
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to list run events");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/runs/:id/cancel", (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const store = getInsightStore();
|
||||
const run = store.getRun(id);
|
||||
if (!run) throw notFound(`Run not found: ${id}`);
|
||||
if (!["pending", "running"].includes(run.status)) {
|
||||
throw new ApiError(409, `Run ${id} is already terminal`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
store.appendRunEvent(id, { type: "cancel_requested", status: run.status, message: "Cancellation requested" });
|
||||
const updated = store.updateRun(id, {
|
||||
lifecycle: { ...run.lifecycle, cancellationRequestedAt: now },
|
||||
});
|
||||
|
||||
if (run.status === "pending") {
|
||||
const cancelled = store.updateRun(id, {
|
||||
status: "cancelled",
|
||||
error: "Cancelled before execution started",
|
||||
cancelledAt: now,
|
||||
lifecycle: {
|
||||
...(updated?.lifecycle ?? run.lifecycle),
|
||||
terminalReason: "cancelled",
|
||||
terminalCause: "cancel_requested",
|
||||
failureClass: "cancelled",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
res.json(cancelled ?? updated ?? run);
|
||||
return;
|
||||
}
|
||||
|
||||
activeRunControllers.get(id)?.abort(new DOMException("Run cancelled", "AbortError"));
|
||||
res.json(updated ?? run);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to cancel run");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/runs/:id/retry", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const store = getInsightStore();
|
||||
const existing = store.getRun(id);
|
||||
if (!existing) throw notFound(`Run not found: ${id}`);
|
||||
if (existing.status !== "failed") {
|
||||
throw new ApiError(409, `Run ${id} must be failed to retry`);
|
||||
}
|
||||
if (!existing.lifecycle.retryable || existing.lifecycle.failureClass !== "retryable_transient") {
|
||||
throw new ApiError(409, `Run ${id} is non-retryable`);
|
||||
}
|
||||
|
||||
const taskStore = requestContext.getStore();
|
||||
if (!taskStore) throw new ApiError(500, "Store context not available");
|
||||
const rootDir = taskStore.getRootDir();
|
||||
const controller = new AbortController();
|
||||
|
||||
const { run } = await retryInsightRunLifecycle({
|
||||
store,
|
||||
runId: id,
|
||||
timeoutMs: typeof req.body?.timeoutMs === "number" ? req.body.timeoutMs : 120_000,
|
||||
maxAttempts: 2,
|
||||
retryDelayMs: 250,
|
||||
signal: controller.signal,
|
||||
executeAttempt: async ({ run, signal }) => {
|
||||
activeRunControllers.set(run.id, controller);
|
||||
return executeInsightAttempt({
|
||||
rootDir,
|
||||
projectId: existing.projectId,
|
||||
runId: run.id,
|
||||
signal,
|
||||
insightStore: store,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
activeRunControllers.delete(run.id);
|
||||
res.status(201).json(run);
|
||||
} catch (error) {
|
||||
if (error instanceof InsightLifecycleError && error.code === "not_retryable") {
|
||||
throw new ApiError(409, error.message);
|
||||
}
|
||||
if (error instanceof InsightLifecycleError && error.code === "active_run_conflict") {
|
||||
throw new ApiError(409, error.message);
|
||||
}
|
||||
rethrowAsApiError(error, "Failed to retry run");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Get Insight ────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/:id", (req: Request, res: Response) => {
|
||||
|
||||
Reference in New Issue
Block a user