feat(FN-1974): merge fusion/fn-1974
This commit is contained in:
@@ -12,7 +12,6 @@ vi.mock("../../api", () => ({
|
||||
dismissInsight: vi.fn(),
|
||||
triggerInsightRun: vi.fn(),
|
||||
fetchInsightRuns: vi.fn(),
|
||||
fetchInsightRun: vi.fn(),
|
||||
getInsightCreateTaskData: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
dismissInsight,
|
||||
triggerInsightRun,
|
||||
fetchInsightRuns,
|
||||
fetchInsightRun,
|
||||
getInsightCreateTaskData,
|
||||
} from "../../api";
|
||||
|
||||
@@ -50,7 +48,6 @@ const mockFetchInsights = vi.mocked(fetchInsights);
|
||||
const mockDismissInsight = vi.mocked(dismissInsight);
|
||||
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
|
||||
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
|
||||
const mockFetchInsightRun = vi.mocked(fetchInsightRun);
|
||||
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
|
||||
|
||||
describe("useInsights", () => {
|
||||
@@ -198,27 +195,26 @@ describe("useInsights", () => {
|
||||
});
|
||||
|
||||
describe("runInsights", () => {
|
||||
it("should trigger manual insight run", async () => {
|
||||
const mockRun = {
|
||||
it("should trigger manual insight run and refresh when run completes", async () => {
|
||||
const completedRun = {
|
||||
id: "RUN-1",
|
||||
projectId: "project-1",
|
||||
trigger: "manual" as const,
|
||||
status: "pending" as const,
|
||||
summary: null,
|
||||
status: "completed" as const,
|
||||
summary: "Generated insights",
|
||||
error: null,
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
insightsCreated: 3,
|
||||
insightsUpdated: 1,
|
||||
inputMetadata: {},
|
||||
outputMetadata: {},
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
startedAt: "2024-01-01T00:00:10Z",
|
||||
completedAt: "2024-01-01T00:01:00Z",
|
||||
};
|
||||
|
||||
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
|
||||
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
|
||||
mockTriggerInsightRun.mockResolvedValue(mockRun);
|
||||
mockFetchInsightRun.mockResolvedValue({ ...mockRun, status: "completed" });
|
||||
mockTriggerInsightRun.mockResolvedValue(completedRun);
|
||||
|
||||
const { result } = renderHook(() => useInsights("project-1"));
|
||||
|
||||
@@ -231,10 +227,50 @@ describe("useInsights", () => {
|
||||
});
|
||||
|
||||
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1");
|
||||
expect(result.current.latestRun?.status).toBe("completed");
|
||||
expect(result.current.isRunInFlight).toBe(false);
|
||||
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(mockFetchInsightRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle run errors", async () => {
|
||||
it("should surface failed run errors from triggerInsightRun response", async () => {
|
||||
const failedRun = {
|
||||
id: "RUN-1",
|
||||
projectId: "project-1",
|
||||
trigger: "manual" as const,
|
||||
status: "failed" as const,
|
||||
summary: null,
|
||||
error: "No working memory to analyze",
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
inputMetadata: {},
|
||||
outputMetadata: {},
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
startedAt: "2024-01-01T00:00:10Z",
|
||||
completedAt: "2024-01-01T00:01:00Z",
|
||||
};
|
||||
|
||||
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
|
||||
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
|
||||
mockTriggerInsightRun.mockResolvedValue(failedRun);
|
||||
|
||||
const { result } = renderHook(() => useInsights("project-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runInsights();
|
||||
});
|
||||
|
||||
expect(result.current.runError).toBe("No working memory to analyze");
|
||||
expect(result.current.isRunInFlight).toBe(false);
|
||||
// Initial load only; failed run should not trigger refresh.
|
||||
expect(mockFetchInsights).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should propagate trigger errors and always clear in-flight state", async () => {
|
||||
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
|
||||
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
|
||||
mockTriggerInsightRun.mockRejectedValue(new Error("Run failed"));
|
||||
@@ -245,8 +281,19 @@ describe("useInsights", () => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Test that runInsights throws
|
||||
await expect(result.current.runInsights()).rejects.toThrow("Run failed");
|
||||
let thrown: unknown = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.runInsights();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toBe("Run failed");
|
||||
expect(result.current.runError).toBe("Run failed");
|
||||
expect(result.current.isRunInFlight).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
dismissInsight,
|
||||
triggerInsightRun,
|
||||
fetchInsightRuns,
|
||||
fetchInsightRun,
|
||||
getInsightCreateTaskData,
|
||||
type InsightsListResponse,
|
||||
type RunsListResponse,
|
||||
} from "../api";
|
||||
|
||||
// Canonical insight categories (in display order)
|
||||
@@ -196,30 +193,13 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
const run = await triggerInsightRun("manual", undefined, projectId);
|
||||
setLatestRun(run);
|
||||
|
||||
// Poll for completion (simple approach)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const updatedRun = await fetchInsightRun(run.id, projectId);
|
||||
if (updatedRun.status === "completed" || updatedRun.status === "failed") {
|
||||
clearInterval(pollInterval);
|
||||
setLatestRun(updatedRun);
|
||||
if (updatedRun.status === "failed" && updatedRun.error) {
|
||||
setRunError(updatedRun.error);
|
||||
}
|
||||
// Refresh insights list
|
||||
await refresh();
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Fallback: stop polling after 60 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 60000);
|
||||
if (run.status === "completed") {
|
||||
await refresh();
|
||||
} else if (run.status === "failed" && run.error) {
|
||||
setRunError(run.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to start insight generation";
|
||||
const message = err instanceof Error ? err.message : "Failed to generate insights";
|
||||
setRunError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as coreModule from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const piMocks = vi.hoisted(() => ({
|
||||
createKbAgent: vi.fn(),
|
||||
promptWithFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/engine")>("@fusion/engine");
|
||||
return {
|
||||
...actual,
|
||||
createKbAgent: piMocks.createKbAgent,
|
||||
promptWithFallback: piMocks.promptWithFallback,
|
||||
};
|
||||
});
|
||||
|
||||
const mockListRuns = vi.fn().mockReturnValue([]);
|
||||
const mockGetRun = vi.fn();
|
||||
const mockCreateRun = vi.fn();
|
||||
const mockUpdateRun = vi.fn();
|
||||
const mockListInsights = vi.fn().mockReturnValue([]);
|
||||
const mockCountInsights = vi.fn().mockReturnValue(0);
|
||||
const mockGetInsight = vi.fn();
|
||||
const mockUpdateInsight = vi.fn();
|
||||
const mockDeleteInsight = vi.fn();
|
||||
const mockUpsertInsight = vi.fn();
|
||||
|
||||
const readWorkingMemorySpy = vi.spyOn(coreModule, "readWorkingMemory");
|
||||
const readInsightsMemorySpy = vi.spyOn(coreModule, "readInsightsMemory");
|
||||
const writeInsightsMemorySpy = vi.spyOn(coreModule, "writeInsightsMemory");
|
||||
const buildPromptSpy = vi.spyOn(coreModule, "buildInsightExtractionPrompt");
|
||||
const parseResponseSpy = vi.spyOn(coreModule, "parseInsightExtractionResponse");
|
||||
const mergeInsightsSpy = vi.spyOn(coreModule, "mergeInsights");
|
||||
const computeFingerprintSpy = vi.spyOn(coreModule, "computeInsightFingerprint");
|
||||
|
||||
const mockInsightStore = {
|
||||
listRuns: mockListRuns,
|
||||
getRun: mockGetRun,
|
||||
createRun: mockCreateRun,
|
||||
updateRun: mockUpdateRun,
|
||||
listInsights: mockListInsights,
|
||||
countInsights: mockCountInsights,
|
||||
getInsight: mockGetInsight,
|
||||
updateInsight: mockUpdateInsight,
|
||||
deleteInsight: mockDeleteInsight,
|
||||
upsertInsight: mockUpsertInsight,
|
||||
};
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
@@ -49,14 +76,80 @@ describe("Insights routes", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
let runRecord = {
|
||||
id: "IR-run-new",
|
||||
projectId: "",
|
||||
trigger: "manual" as const,
|
||||
status: "pending" as const,
|
||||
summary: null,
|
||||
error: null,
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
inputMetadata: {},
|
||||
outputMetadata: {},
|
||||
createdAt: "2026-04-16T00:00:00.000Z",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
mockListRuns.mockReturnValue([]);
|
||||
mockGetRun.mockReturnValue(null);
|
||||
mockCreateRun.mockReturnValue({ id: "IR-test-1", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
mockCreateRun.mockImplementation((projectId: string, input: { trigger: "manual" }) => {
|
||||
runRecord = {
|
||||
...runRecord,
|
||||
projectId,
|
||||
trigger: input.trigger,
|
||||
};
|
||||
return { ...runRecord };
|
||||
});
|
||||
mockUpdateRun.mockImplementation((_id: string, input: Record<string, unknown>) => {
|
||||
runRecord = {
|
||||
...runRecord,
|
||||
...input,
|
||||
} as typeof runRecord;
|
||||
return { ...runRecord };
|
||||
});
|
||||
mockListInsights.mockReturnValue([]);
|
||||
mockCountInsights.mockReturnValue(0);
|
||||
mockGetInsight.mockReturnValue(null);
|
||||
mockUpdateInsight.mockReturnValue(null);
|
||||
mockDeleteInsight.mockReturnValue(false);
|
||||
mockUpsertInsight.mockImplementation((_projectId: string, input: { title: string; content: string; category: string; fingerprint: string; provenance: Record<string, unknown> }) => ({
|
||||
id: "INS-created-1",
|
||||
projectId: _projectId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
category: input.category,
|
||||
status: "confirmed",
|
||||
fingerprint: input.fingerprint,
|
||||
provenance: input.provenance,
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:10:00.000Z",
|
||||
updatedAt: "2026-04-16T00:10:00.000Z",
|
||||
}));
|
||||
|
||||
readWorkingMemorySpy.mockResolvedValue("Test working memory content");
|
||||
readInsightsMemorySpy.mockResolvedValue(null);
|
||||
writeInsightsMemorySpy.mockResolvedValue(undefined);
|
||||
buildPromptSpy.mockReturnValue("Test prompt");
|
||||
parseResponseSpy.mockReturnValue({
|
||||
summary: "Test extraction",
|
||||
insights: [],
|
||||
extractedAt: "2026-04-16T00:00:00.000Z",
|
||||
});
|
||||
mergeInsightsSpy.mockReturnValue("# merged insights");
|
||||
computeFingerprintSpy.mockImplementation((title: string, category: string) => `fp-${category}-${title.length}`);
|
||||
|
||||
piMocks.createKbAgent.mockImplementation((options: { onText?: (delta: string) => void }) => {
|
||||
options.onText?.('{"summary":"Test extraction","insights":[]}');
|
||||
return {
|
||||
session: {
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
piMocks.promptWithFallback.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Route ordering regression: static routes must not be shadowed by /:id ──
|
||||
@@ -78,8 +171,48 @@ describe("Insights routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/insights/run creates a run successfully (not shadowed by /:id)", async () => {
|
||||
mockCreateRun.mockReturnValue({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
it("POST /api/insights/run executes AI extraction and returns completed run", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockCreateRun).toHaveBeenCalledWith("", { trigger: "manual", inputMetadata: undefined });
|
||||
expect(mockUpdateRun).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"IR-run-new",
|
||||
expect.objectContaining({ status: "running", startedAt: expect.any(String) }),
|
||||
);
|
||||
expect(readWorkingMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909");
|
||||
expect(buildPromptSpy).toHaveBeenCalledWith("Test working memory content", null);
|
||||
expect(piMocks.createKbAgent).toHaveBeenCalledTimes(1);
|
||||
expect(piMocks.promptWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(parseResponseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
insightsCreated: 0,
|
||||
insightsUpdated: 0,
|
||||
summary: "Test extraction",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "IR-run-new",
|
||||
status: "completed",
|
||||
summary: "Test extraction",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run marks run failed when working memory is empty", async () => {
|
||||
readWorkingMemorySpy.mockResolvedValue(" \n ");
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
@@ -90,7 +223,119 @@ describe("Insights routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ id: "IR-run-new", trigger: "manual", status: "running", projectId: "proj", createdAt: "2026-04-16T00:00:00.000Z" });
|
||||
expect(mockUpdateRun).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
error: "No working memory to analyze",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(piMocks.createKbAgent).not.toHaveBeenCalled();
|
||||
expect(piMocks.promptWithFallback).not.toHaveBeenCalled();
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "IR-run-new",
|
||||
status: "failed",
|
||||
error: "No working memory to analyze",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run marks run failed and returns 500 when AI execution errors", async () => {
|
||||
piMocks.promptWithFallback.mockRejectedValue(new Error("AI execution failed"));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect((res.body as { error?: string }).error).toContain("AI execution failed");
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
error: "AI execution failed",
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run persists generated insights and tracks created vs updated counts", async () => {
|
||||
const longContent = "x".repeat(110);
|
||||
parseResponseSpy.mockReturnValue({
|
||||
summary: "Generated two insights",
|
||||
insights: [
|
||||
{
|
||||
category: "pattern",
|
||||
content: "Prefer shared hooks for common dashboard logic",
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
},
|
||||
{
|
||||
category: "pitfall",
|
||||
content: longContent,
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
},
|
||||
],
|
||||
extractedAt: "2026-04-16T00:20:00.000Z",
|
||||
});
|
||||
mergeInsightsSpy.mockReturnValue("# merged result");
|
||||
|
||||
mockUpsertInsight
|
||||
.mockReturnValueOnce({
|
||||
id: "INS-created",
|
||||
projectId: "",
|
||||
title: "Prefer shared hooks for common dashboard logic",
|
||||
content: "Prefer shared hooks for common dashboard logic",
|
||||
category: "workflow",
|
||||
status: "confirmed",
|
||||
fingerprint: "fp-workflow-46",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:20:01.000Z",
|
||||
updatedAt: "2026-04-16T00:20:01.000Z",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
id: "INS-updated",
|
||||
projectId: "",
|
||||
title: `${"x".repeat(100)}...`,
|
||||
content: longContent,
|
||||
category: "quality",
|
||||
status: "confirmed",
|
||||
fingerprint: "fp-quality-103",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: "IR-run-new",
|
||||
createdAt: "2026-04-16T00:00:00.000Z",
|
||||
updatedAt: "2026-04-16T00:20:01.000Z",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({ trigger: "manual" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(writeInsightsMemorySpy).toHaveBeenCalledWith("/tmp/fn-1909", "# merged result");
|
||||
expect(mockUpsertInsight).toHaveBeenCalledTimes(2);
|
||||
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(1, "Prefer shared hooks for common dashboard logic", "workflow");
|
||||
expect(computeFingerprintSpy).toHaveBeenNthCalledWith(2, `${"x".repeat(100)}...`, "quality");
|
||||
expect(mockUpdateRun).toHaveBeenLastCalledWith(
|
||||
"IR-run-new",
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
insightsCreated: 1,
|
||||
insightsUpdated: 1,
|
||||
summary: "Generated two insights",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /api/insights/runs/:id returns run by id", async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
InsightStore,
|
||||
type InsightCategory,
|
||||
type MemoryInsightCategory,
|
||||
type InsightStatus,
|
||||
type InsightListOptions,
|
||||
type InsightRunTrigger,
|
||||
@@ -71,6 +72,27 @@ const VALID_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dis
|
||||
// Valid run triggers
|
||||
const VALID_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
|
||||
|
||||
const INSIGHT_CATEGORY_BY_MEMORY_CATEGORY: Record<MemoryInsightCategory, InsightCategory> = {
|
||||
pattern: "workflow",
|
||||
principle: "architecture",
|
||||
convention: "workflow",
|
||||
pitfall: "quality",
|
||||
context: "other",
|
||||
};
|
||||
|
||||
function toInsightTitle(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return "Untitled insight";
|
||||
}
|
||||
|
||||
if (trimmed.length <= 100) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, 100).trimEnd()}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the insights router.
|
||||
*/
|
||||
@@ -167,10 +189,21 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
|
||||
// ── Trigger Insight Run ───────────────────────────────────────────────
|
||||
|
||||
router.post("/run", (req: Request, res: Response) => {
|
||||
/**
|
||||
* 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) ?? "";
|
||||
const store = getInsightStore();
|
||||
const insightStore = getInsightStore();
|
||||
const trigger: InsightRunTrigger = (req.body.trigger as InsightRunTrigger) ?? "manual";
|
||||
|
||||
if (!VALID_TRIGGERS.includes(trigger)) {
|
||||
@@ -182,8 +215,120 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
inputMetadata: req.body.inputMetadata,
|
||||
};
|
||||
|
||||
const run = store.createRun(projectId, input);
|
||||
res.status(201).json(run);
|
||||
const run = insightStore.createRun(projectId, input);
|
||||
insightStore.updateRun(run.id, {
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
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 {
|
||||
const { createKbAgent, promptWithFallback } = await import("@fusion/engine");
|
||||
|
||||
let responseText = "";
|
||||
const { session } = await createKbAgent({
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to create insight run");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user