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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user