feat(FN-2991): merge fusion/fn-2991

Commits merged:
- fix(FN-2991): align failed research status badge semantics
- feat(FN-2991): add research dashboard view and navigation entry
- fix(FN-2991): complete Step 8 — resolve lint and typecheck gates
- test(FN-2991): address review feedback for Step 7
- test(FN-2991): complete Step 7 — add research store and route tests
- feat(FN-2991): complete Step 6 — add research client API helpers
- feat(FN-2991): complete Step 5 — add research REST routes
- feat(FN-2991): complete Step 4 — wire research store into core exports
- feat(FN-2991): complete Step 3 — implement research store
- feat(FN-2991): complete Step 2 — add research schema and migration
- feat(FN-2991): complete Step 1 — add research domain types

Files changed:
packages/core/src/__tests__/db.test.ts             |  26 +-
 packages/core/src/__tests__/insight-store.test.ts  |   8 +-
 packages/core/src/__tests__/mission-store.test.ts  |   2 +-
 packages/core/src/__tests__/research-store.test.ts | 118 +++++++
 packages/core/src/__tests__/roadmap-store.test.ts  |   2 +-
 packages/core/src/__tests__/run-audit.test.ts      |   2 +-
 packages/core/src/__tests__/task-documents.test.ts |   2 +-
 packages/core/src/db.ts                            |  82 ++++-
 packages/core/src/index.ts                         |  30 ++
 packages/core/src/research-store.ts                | 377 +++++++++++++++++++++
 packages/core/src/research-types.ts                | 162 +++++++++
 packages/core/src/store.ts                         |  14 +
 packages/dashboard/app/App.tsx                     |  12 +
 .../app/api/__tests__/research-api.test.ts         | 120 +++++++
 packages/dashboard/app/api/legacy.ts               | 132 +++++++-
 packages/dashboard/app/components/Header.tsx       |  23 +-
 packages/dashboard/app/components/MobileNavBar.tsx |  16 +-
 packages/dashboard/app/components/ResearchView.css | 110 ++++++
 packages/dashboard/app/components/ResearchView.tsx | 145 ++++++++
 .../app/components/__tests__/Header.test.tsx       |   4 +-
 .../app/components/__tests__/ResearchView.test.tsx | 170 ++++++++++
 packages/dashboard/app/hooks/useViewState.ts       |   3 +-
 packages/dashboard/src/research-routes.ts          | 223 ++++++++++++
 .../src/routes/register-integrated-routers.ts      |   2 +
 24 files changed, 1751 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-2991
This commit is contained in:
Fusion
2026-04-29 22:21:27 -07:00
committed by gsxdsm
parent e81d090d8e
commit 9d1489dcd0
24 changed files with 1751 additions and 34 deletions

View File

@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { createServer } from "../../../src/server.js";
import { request } from "../../../src/test-request.js";
const researchStore = {
listRuns: vi.fn(),
createRun: vi.fn(),
getRun: vi.fn(),
updateRun: vi.fn(),
deleteRun: vi.fn(),
appendEvent: vi.fn(),
addSource: vi.fn(),
updateSource: vi.fn(),
setResults: vi.fn(),
updateStatus: vi.fn(),
createExport: vi.fn(),
getExports: vi.fn(),
getExport: vi.fn(),
getStats: vi.fn(),
searchRuns: vi.fn(),
};
class MockStore extends EventEmitter {
getRootDir() { return "/tmp/fn-2991"; }
getFusionDir() { return "/tmp/fn-2991/.fusion"; }
getDatabase() { return { exec: vi.fn(), prepare: vi.fn(() => ({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() })) }; }
getResearchStore() { return researchStore; }
}
describe("research routes", () => {
const app = createServer(new MockStore() as any);
beforeEach(() => {
vi.clearAllMocks();
researchStore.listRuns.mockReturnValue([]);
researchStore.getRun.mockReturnValue(undefined);
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "pending", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.updateRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
researchStore.deleteRun.mockReturnValue(true);
researchStore.appendEvent.mockReturnValue({ id: "E1", timestamp: "x", type: "info", message: "ok" });
researchStore.addSource.mockReturnValue({ id: "S1", type: "web", reference: "https://e.com", status: "pending" });
researchStore.getExports.mockReturnValue([]);
researchStore.getStats.mockReturnValue({ total: 0, byStatus: { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 } });
researchStore.searchRuns.mockReturnValue([]);
});
it("supports run CRUD", async () => {
const list = await request(app, "GET", "/api/research/runs");
expect(list.status).toBe(200);
const created = await request(app, "POST", "/api/research/runs", JSON.stringify({ query: "topic" }), { "Content-Type": "application/json" });
expect(created.status).toBe(201);
researchStore.getRun.mockReturnValue(researchStore.createRun.mock.results[0]?.value ?? { id: "RR-1" });
const get = await request(app, "GET", "/api/research/runs/RR-1");
expect(get.status).toBe(200);
const patch = await request(app, "PATCH", "/api/research/runs/RR-1", JSON.stringify({ topic: "x" }), { "Content-Type": "application/json" });
expect(patch.status).toBe(200);
const del = await request(app, "DELETE", "/api/research/runs/RR-1");
expect(del.status).toBe(204);
});
it("supports events, sources, results and exports", async () => {
const evt = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "info", message: "hello" }), { "Content-Type": "application/json" });
expect(evt.status).toBe(201);
const src = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "https://x.com", status: "pending" }), { "Content-Type": "application/json" });
expect(src.status).toBe(201);
const srcPatch = await request(app, "PATCH", "/api/research/runs/RR-1/sources/S1", JSON.stringify({ status: "completed" }), { "Content-Type": "application/json" });
expect(srcPatch.status).toBe(204);
const results = await request(app, "PUT", "/api/research/runs/RR-1/results", JSON.stringify({ findings: [] }), { "Content-Type": "application/json" });
expect(results.status).toBe(204);
researchStore.createExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const createEx = await request(app, "POST", "/api/research/runs/RR-1/exports", JSON.stringify({ format: "json", content: "{}" }), { "Content-Type": "application/json" });
expect(createEx.status).toBe(201);
researchStore.getExports.mockReturnValue([{ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" }]);
const listEx = await request(app, "GET", "/api/research/runs/RR-1/exports");
expect(listEx.status).toBe(200);
expect((listEx.body as { exports: unknown[] }).exports).toHaveLength(1);
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const getEx = await request(app, "GET", "/api/research/exports/EX1");
expect(getEx.status).toBe(200);
});
it("supports stats, search and validation errors", async () => {
const stats = await request(app, "GET", "/api/research/stats");
expect(stats.status).toBe(200);
const search = await request(app, "GET", "/api/research/search?q=test");
expect(search.status).toBe(200);
const invalidStatus = await request(app, "PATCH", "/api/research/runs/RR-1/status", JSON.stringify({ status: "bogus" }), { "Content-Type": "application/json" });
expect(invalidStatus.status).toBe(400);
const invalidEvent = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "bad", message: "x" }), { "Content-Type": "application/json" });
expect(invalidEvent.status).toBe(400);
const invalidSourceType = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "bad", reference: "x", status: "pending" }), { "Content-Type": "application/json" });
expect(invalidSourceType.status).toBe(400);
const invalidSourceStatus = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "x", status: "bad" }), { "Content-Type": "application/json" });
expect(invalidSourceStatus.status).toBe(400);
researchStore.getExport.mockReturnValue(undefined);
const missingExport = await request(app, "GET", "/api/research/exports/EX-404");
expect(missingExport.status).toBe(404);
researchStore.getRun.mockReturnValue(undefined);
const missing = await request(app, "GET", "/api/research/runs/RR-404");
expect(missing.status).toBe(404);
});
});

View File

@@ -66,6 +66,14 @@ import type {
InsightStatus,
InsightRun,
InsightRunTrigger,
ResearchEvent,
ResearchExport,
ResearchResult,
ResearchRun,
ResearchRunCreateInput,
ResearchRunStatus,
ResearchRunUpdateInput,
ResearchSource,
TaskPriority,
TaskSourceIssue,
} from "@fusion/core";
@@ -1508,9 +1516,17 @@ export interface CustomProvider {
models?: { id: string; name: string }[];
}
export async function fetchCustomProviders(): Promise<CustomProvider[] & { providers: CustomProvider[] }> {
export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & { providers: CustomProviderConfig[] }> {
const providers = await api<CustomProvider[]>("/custom-providers");
return Object.assign(providers, { providers });
const legacyProviders = providers.map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
} satisfies CustomProviderConfig));
return Object.assign(legacyProviders, { providers: legacyProviders });
}
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
@@ -7734,3 +7750,115 @@ export function getInsightCreateTaskData(
method: "POST",
});
}
// ── Research API ────────────────────────────────────────────────────────────
export interface ResearchRunsListResponse {
runs: ResearchRun[];
count: number;
}
export interface ResearchStatsResponse {
total: number;
byStatus: Record<ResearchRunStatus, number>;
}
export function listResearchRuns(
options: {
status?: ResearchRunStatus;
search?: string;
tag?: string;
fromDate?: string;
toDate?: string;
limit?: number;
offset?: number;
} = {},
projectId?: string,
): Promise<ResearchRunsListResponse> {
const params = new URLSearchParams();
if (options.status) params.set("status", options.status);
if (options.search) params.set("search", options.search);
if (options.tag) params.set("tag", options.tag);
if (options.fromDate) params.set("fromDate", options.fromDate);
if (options.toDate) params.set("toDate", options.toDate);
if (options.limit !== undefined) params.set("limit", String(options.limit));
if (options.offset !== undefined) params.set("offset", String(options.offset));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return api<ResearchRunsListResponse>(withProjectId(`/research/runs${suffix}`, projectId));
}
export function createResearchRun(input: ResearchRunCreateInput, projectId?: string): Promise<ResearchRun> {
return api<ResearchRun>(withProjectId("/research/runs", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
export function getResearchRun(id: string, projectId?: string): Promise<ResearchRun> {
return api<ResearchRun>(withProjectId(`/research/runs/${encodeURIComponent(id)}`, projectId));
}
export function updateResearchRun(id: string, input: ResearchRunUpdateInput, projectId?: string): Promise<ResearchRun> {
return api<ResearchRun>(withProjectId(`/research/runs/${encodeURIComponent(id)}`, projectId), {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteResearchRun(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/research/runs/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
}
export function appendResearchEvent(id: string, event: Omit<ResearchEvent, "id" | "timestamp">, projectId?: string): Promise<ResearchEvent> {
return api<ResearchEvent>(withProjectId(`/research/runs/${encodeURIComponent(id)}/events`, projectId), {
method: "POST",
body: JSON.stringify(event),
});
}
export function addResearchSource(id: string, source: Omit<ResearchSource, "id">, projectId?: string): Promise<ResearchSource> {
return api<ResearchSource>(withProjectId(`/research/runs/${encodeURIComponent(id)}/sources`, projectId), {
method: "POST",
body: JSON.stringify(source),
});
}
export function updateResearchSource(id: string, sourceId: string, updates: Partial<ResearchSource>, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/research/runs/${encodeURIComponent(id)}/sources/${encodeURIComponent(sourceId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export function setResearchResults(id: string, results: ResearchResult, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/research/runs/${encodeURIComponent(id)}/results`, projectId), {
method: "PUT",
body: JSON.stringify(results),
});
}
export function updateResearchRunStatus(id: string, status: ResearchRunStatus, extra?: Partial<ResearchRun>, projectId?: string): Promise<ResearchRun> {
return api<ResearchRun>(withProjectId(`/research/runs/${encodeURIComponent(id)}/status`, projectId), {
method: "PATCH",
body: JSON.stringify({ status, extra }),
});
}
export function createResearchExport(id: string, format: ResearchExport["format"], content: string, projectId?: string): Promise<ResearchExport> {
return api<ResearchExport>(withProjectId(`/research/runs/${encodeURIComponent(id)}/exports`, projectId), {
method: "POST",
body: JSON.stringify({ format, content }),
});
}
export function getResearchExports(id: string, projectId?: string): Promise<{ exports: ResearchExport[] }> {
return api<{ exports: ResearchExport[] }>(withProjectId(`/research/runs/${encodeURIComponent(id)}/exports`, projectId));
}
export function getResearchStats(projectId?: string): Promise<ResearchStatsResponse> {
return api<ResearchStatsResponse>(withProjectId("/research/stats", projectId));
}
export function searchResearchRuns(query: string, projectId?: string): Promise<{ runs: ResearchRun[] }> {
return api<{ runs: ResearchRun[] }>(withProjectId(`/research/search?q=${encodeURIComponent(query)}`, projectId));
}