feat(FN-3826): canonicalize reports plugin manifest ID

Canonicalizes the Fusion reports plugin's manifest ID and exports a reports schema alias from the package index, with tests aligned to the new identifiers.

Fusion-Task-Id: FN-3826
This commit is contained in:
Fusion
2026-05-11 02:06:34 -07:00
committed by gsxdsm
parent e208363703
commit 2639849ee4
8 changed files with 336 additions and 4 deletions

View File

@@ -216,6 +216,12 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
addToast("Insight generation started", "success");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start generation";
if (message === "Insight generation is already running") {
setStatusMessage("Insight generation is already running. Showing the active run.");
setStatusType("info");
addToast("Insight generation is already running", "info");
return;
}
setStatusMessage(message);
setStatusType("error");
addToast(message, "error");

View File

@@ -69,6 +69,9 @@ vi.mock("lucide-react", () => ({
Clock: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
<span data-testid="clock-icon" className={className}>{`Clock-${size}`}</span>
),
Settings: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
<span data-testid="settings-icon" className={className}>{`Settings-${size}`}</span>
),
}));
import { useInsights } from "../../hooks/useInsights";
@@ -335,6 +338,46 @@ describe("InsightsView", () => {
expect(screen.getAllByText("No working memory to analyze").length).toBeGreaterThan(0);
});
it("should show friendly active-run conflict error and still render latest run details", () => {
mockUseInsights.mockReturnValue({
sections: mockSections,
loading: false,
error: null,
latestRun: {
id: "INSR-11",
projectId: "test",
trigger: "manual",
status: "running",
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: null,
},
isRunInFlight: false,
runError: "Insight generation is already running",
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn(),
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 0,
dismissedCount: 0,
});
render(<InsightsView {...defaultProps} />);
expect(screen.getByTestId("run-error")).toBeInTheDocument();
expect(screen.getByText("Insight generation is already running")).toBeInTheDocument();
expect(screen.getByTestId("latest-run")).toBeInTheDocument();
expect(screen.getByText("Latest run: running")).toBeInTheDocument();
});
it("should render global empty state when all sections are empty", () => {
mockUseInsights.mockReturnValue({
sections: mockSections,

View File

@@ -14,8 +14,20 @@ vi.mock("../../api", () => ({
archiveInsight: vi.fn(),
unarchiveInsight: vi.fn(),
triggerInsightRun: vi.fn(),
fetchInsightRun: vi.fn(),
fetchInsightRuns: vi.fn(),
getInsightCreateTaskData: vi.fn(),
ApiRequestError: class ApiRequestError extends Error {
status: number;
details?: Record<string, unknown>;
constructor(message: string, status: number, details?: Record<string, unknown>) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.details = details;
}
},
}));
// Mock lucide-react icons used in the view
@@ -45,8 +57,10 @@ import {
archiveInsight,
unarchiveInsight,
triggerInsightRun,
fetchInsightRun,
fetchInsightRuns,
getInsightCreateTaskData,
ApiRequestError,
} from "../../api";
const mockFetchInsights = vi.mocked(fetchInsights);
@@ -54,6 +68,7 @@ const mockDismissInsight = vi.mocked(dismissInsight);
const mockArchiveInsight = vi.mocked(archiveInsight);
const mockUnarchiveInsight = vi.mocked(unarchiveInsight);
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
const mockFetchInsightRun = vi.mocked(fetchInsightRun);
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
@@ -280,7 +295,7 @@ describe("useInsights", () => {
await result.current.runInsights();
});
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1");
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1", undefined, undefined);
expect(result.current.latestRun?.status).toBe("completed");
expect(result.current.isRunInFlight).toBe(false);
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
@@ -324,6 +339,88 @@ describe("useInsights", () => {
expect(mockFetchInsights).toHaveBeenCalledTimes(1);
});
it("should hydrate latestRun and set friendly error on structured active-run conflict", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockFetchInsightRun.mockResolvedValue({
id: "RUN-2",
projectId: "project-1",
trigger: "manual",
status: "running",
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: null,
});
mockTriggerInsightRun.mockRejectedValue(
new ApiRequestError("backend raw conflict", 409, {
code: "ACTIVE_RUN_CONFLICT",
activeRunId: "RUN-2",
activeRunStatus: "running",
}),
);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await expect(result.current.runInsights()).rejects.toBeInstanceOf(Error);
});
expect(mockFetchInsightRun).toHaveBeenCalledWith("RUN-2", "project-1");
expect(result.current.latestRun?.id).toBe("RUN-2");
expect(result.current.runError).toBe("Insight generation is already running");
expect(result.current.isRunInFlight).toBe(false);
});
it("should clear stale runError on orphan-recovery success path", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun
.mockRejectedValueOnce(new Error("Previous failure"))
.mockResolvedValueOnce({
id: "RUN-3",
projectId: "project-1",
trigger: "manual",
status: "completed",
summary: "Recovered and completed",
error: null,
insightsCreated: 1,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: "2024-01-01T00:00:10Z",
});
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await expect(result.current.runInsights()).rejects.toThrow("Previous failure");
});
expect(result.current.runError).toBe("Previous failure");
await act(async () => {
await result.current.runInsights();
});
expect(result.current.latestRun?.id).toBe("RUN-3");
expect(result.current.runError).toBeNull();
});
it("should propagate trigger errors and always clear in-flight state", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });

View File

@@ -11,11 +11,13 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import type { Insight, InsightCategory, InsightStatus, InsightRun } from "@fusion/core";
import {
ApiRequestError,
fetchInsights,
dismissInsight,
archiveInsight,
unarchiveInsight,
triggerInsightRun,
fetchInsightRun,
fetchInsightRuns,
getInsightCreateTaskData,
} from "../api";
@@ -203,6 +205,20 @@ export function useInsights(projectId?: string): UseInsightsResult {
setRunError(run.error);
}
} catch (err) {
if (err instanceof ApiRequestError && err.status === 409 && err.details?.code === "ACTIVE_RUN_CONFLICT") {
const activeRunId = typeof err.details.activeRunId === "string" ? err.details.activeRunId : null;
if (activeRunId) {
try {
const activeRun = await fetchInsightRun(activeRunId, projectId);
setLatestRun(activeRun);
} catch {
// Fall back to existing latest run state if hydration fails.
}
}
setRunError("Insight generation is already running");
throw err;
}
const message = err instanceof Error ? err.message : "Failed to generate insights";
setRunError(message);
throw err;

View File

@@ -259,8 +259,8 @@ 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" });
it("POST /api/insights/run returns structured 409 details when active run is still live", async () => {
const activeRun = storeA.getInsightStore().createRun("", { trigger: "manual" });
const res = await request(
app,
@@ -271,6 +271,79 @@ describe("Insights routes", () => {
);
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
error: "Insight generation is already running",
details: {
code: "ACTIVE_RUN_CONFLICT",
activeRunId: activeRun.id,
activeRunStatus: "pending",
trigger: "manual",
},
});
});
it("POST /api/insights/run recovers stale active run older than orphan grace", async () => {
const insightStore = storeA.getInsightStore();
const staleRun = insightStore.createRun("", { trigger: "manual" });
const staleAt = new Date(Date.now() - 45_000).toISOString();
storeA.getDatabase().prepare("UPDATE project_insight_runs SET startedAt = ? WHERE id = ?").run(staleAt, staleRun.id);
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
const recoveredRun = insightStore.getRun(staleRun.id);
expect(recoveredRun?.status).toBe("failed");
expect(recoveredRun?.lifecycle.terminalReason).toBe("failed");
expect(recoveredRun?.lifecycle.terminalCause).toBe("orphaned_active_run_recovered");
expect(recoveredRun?.lifecycle.failureClass).toBe("non_retryable");
expect(recoveredRun?.lifecycle.retryable).toBe(false);
const events = insightStore.listRunEvents(staleRun.id);
expect(events.some((event) => event.type === "warning" && event.message.includes("Recovered orphaned active run"))).toBe(true);
expect(events.some((event) => event.type === "status_changed" && event.status === "failed")).toBe(true);
});
it("POST /api/insights/run uses createdAt fallback for stale check when startedAt is null", async () => {
const insightStore = storeA.getInsightStore();
const staleRun = insightStore.createRun("", { trigger: "manual" });
const staleCreatedAt = new Date(Date.now() - 45_000).toISOString();
storeA.getDatabase().prepare("UPDATE project_insight_runs SET createdAt = ?, startedAt = NULL WHERE id = ?").run(staleCreatedAt, staleRun.id);
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(insightStore.getRun(staleRun.id)?.status).toBe("failed");
});
it("POST /api/insights/run does not recover young active runs before grace threshold", async () => {
const insightStore = storeA.getInsightStore();
const run = insightStore.createRun("", { trigger: "manual" });
const youngAt = new Date(Date.now() - 5_000).toISOString();
storeA.getDatabase().prepare("UPDATE project_insight_runs SET startedAt = ? WHERE id = ?").run(youngAt, run.id);
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({ trigger: "manual" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(insightStore.getRun(run.id)?.status).toBe("pending");
});
it("POST /api/insights/run persists completed run metadata", async () => {

View File

@@ -88,6 +88,80 @@ const INSIGHT_CATEGORY_BY_MEMORY_CATEGORY: Record<MemoryInsightCategory, Insight
};
const activeRunControllers = new Map<string, AbortController>();
const ORPHAN_GRACE_MS = 30_000;
function getRunAgeMs(run: { startedAt: string | null; createdAt: string }, nowMs: number): number {
const anchor = run.startedAt ?? run.createdAt;
const anchorMs = Date.parse(anchor);
if (!Number.isFinite(anchorMs)) return 0;
return Math.max(0, nowMs - anchorMs);
}
function maybeRecoverOrphanedActiveRun(params: {
insightStore: InsightStore;
run: ReturnType<InsightStore["getRun"]>;
trigger: InsightRunTrigger;
now: Date;
}): boolean {
const { insightStore, run, trigger, now } = params;
if (!run || !["pending", "running"].includes(run.status)) {
return false;
}
if (activeRunControllers.has(run.id)) {
return false;
}
const ageMs = getRunAgeMs(run, now.getTime());
if (ageMs <= ORPHAN_GRACE_MS) {
return false;
}
const nowIso = now.toISOString();
insightStore.appendRunEvent(run.id, {
type: "warning",
status: run.status,
classification: "non_retryable",
message: `Recovered orphaned active run after ${ageMs}ms without controller ownership`,
metadata: {
recovery: "orphaned_active_run",
trigger,
ageMs,
graceMs: ORPHAN_GRACE_MS,
hadController: false,
anchorTimestamp: run.startedAt ?? run.createdAt,
},
});
const failed = insightStore.updateRun(run.id, {
status: "failed",
summary: "Recovered orphaned run",
error: "Run was marked active but had no live controller after grace period",
completedAt: nowIso,
lifecycle: {
...run.lifecycle,
terminalReason: "failed",
terminalCause: "orphaned_active_run_recovered",
failureClass: "non_retryable",
retryable: false,
},
});
if (failed) {
insightStore.appendRunEvent(run.id, {
type: "status_changed",
status: "failed",
classification: "non_retryable",
message: "Run marked failed after orphaned active-run recovery",
metadata: {
recovery: "orphaned_active_run",
},
});
return true;
}
return false;
}
async function withAbort<T>(signal: AbortSignal, task: Promise<T>): Promise<T> {
if (signal.aborted) {
@@ -347,6 +421,16 @@ export function createInsightsRouter(store: TaskStore): Router {
};
}
const existingActiveRun = insightStore.findActiveRun(projectId, trigger);
if (existingActiveRun) {
maybeRecoverOrphanedActiveRun({
insightStore,
run: existingActiveRun,
trigger,
now: new Date(),
});
}
const run = await executeInsightRunLifecycle({
store: insightStore,
projectId,
@@ -377,7 +461,15 @@ export function createInsightsRouter(store: TaskStore): Router {
res.status(201).json(run);
} catch (error) {
if (error instanceof InsightLifecycleError && error.code === "active_run_conflict") {
throw new ApiError(409, error.message);
const projectId = getProjectId(req) ?? "";
const trigger: InsightRunTrigger = (req.body.trigger as InsightRunTrigger) ?? "manual";
const activeRun = getInsightStore().findActiveRun(projectId, trigger);
throw new ApiError(409, "Insight generation is already running", {
code: "ACTIVE_RUN_CONFLICT",
activeRunId: activeRun?.id,
activeRunStatus: activeRun?.status,
trigger,
});
}
rethrowAsApiError(error, "Failed to create insight run");
}