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 b78dd3e055
commit c5de7e183b
24 changed files with 1751 additions and 34 deletions

View File

@@ -66,6 +66,7 @@ const DASHBOARD_READY_SETTLE_DELAY_MS = IS_TEST_ENV ? 0 : 200;
const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
const InsightsView = lazy(() => import("./components/InsightsView").then((m) => ({ default: m.InsightsView })));
const ResearchView = lazy(() => import("./components/ResearchView").then((m) => ({ default: m.ResearchView })));
const NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
@@ -89,6 +90,7 @@ function prefetchLazyViews() {
void import("./components/AgentsView");
void import("./components/DocumentsView");
void import("./components/InsightsView");
void import("./components/ResearchView");
void import("./components/NodesView");
void import("./components/ChatView");
void import("./components/RoadmapsView");
@@ -718,6 +720,16 @@ function AppInner() {
);
}
if (taskView === "research") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ResearchView projectId={currentProject?.id} addToast={addToast} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "memory") {
if (!settingsLoaded || !memoryEnabled) {
return null;

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));
}

View File

@@ -193,8 +193,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
/** Whether to show the skills tab in the view toggle */
showSkillsTab?: boolean;
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
@@ -333,9 +333,10 @@ export function Header({
experimentalFeatures?.roadmap ||
showSkillsTab ||
experimentalFeatures?.memoryView ||
experimentalFeatures?.devServerView
experimentalFeatures?.devServerView ||
!hideFullNav
);
}, [experimentalFeatures, showSkillsTab]);
}, [experimentalFeatures, showSkillsTab, hideFullNav]);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
@@ -1109,7 +1110,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1126,6 +1127,18 @@ export function Header({
role="menu"
aria-label="More views"
>
<button
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
onClick={() => {
onChangeView("research");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-research"
>
<Search size={14} />
<span>Research</span>
</button>
{experimentalFeatures?.insights && (
<button
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}

View File

@@ -21,6 +21,7 @@ import {
Play,
Settings,
Monitor,
Search,
Sparkles,
Target,
Terminal,
@@ -33,9 +34,9 @@ import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -189,6 +190,7 @@ export function MobileNavBar({
const isMoreActive =
view === "documents"
|| view === "research"
|| view === "insights"
|| view === "memory"
|| view === "devserver"
@@ -562,6 +564,16 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-research"
onClick={() => handleMoreAction(() => onChangeView("research"))}
>
<Search />
<span>Research</span>
</button>
{experimentalFeatures?.insights && (
<button
type="button"

View File

@@ -0,0 +1,110 @@
.research-view {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
}
.research-view__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.research-view__title {
margin: 0;
color: var(--text);
}
.research-view__subtitle {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
}
.research-view__state {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
}
.research-view__state--error {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 12%, var(--card));
}
.research-view__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.research-view__stat-card {
padding: var(--space-lg);
}
.research-view__stat-label {
color: var(--text-muted);
}
.research-view__stat-value {
margin-top: var(--space-xs);
color: var(--text);
font-family: var(--font-mono);
}
.research-view__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.research-view__run-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.research-view__run-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.research-view__status-badge--failed {
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 18%, transparent);
color: var(--color-error);
}
.research-view__run-title {
margin: 0;
color: var(--text);
}
.research-view__run-query {
margin: 0;
color: var(--text-muted);
}
.research-view__hint {
margin: 0;
color: var(--text-muted);
}
@media (max-width: 768px) {
.research-view {
padding: var(--space-md);
}
.research-view__header {
flex-direction: column;
}
.research-view__stats,
.research-view__list {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResearchRun, ResearchRunStatus } from "@fusion/core";
import { getResearchStats, listResearchRuns } from "../api";
import "./ResearchView.css";
interface ResearchViewProps {
projectId?: string;
addToast?: (message: string, type?: "success" | "error" | "info") => void;
}
interface ResearchStats {
total: number;
byStatus: Record<ResearchRunStatus, number>;
}
const STATUS_LABELS: Record<ResearchRunStatus, string> = {
pending: "Pending",
running: "Running",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
};
export function ResearchView({ projectId, addToast }: ResearchViewProps) {
const [runs, setRuns] = useState<ResearchRun[]>([]);
const [stats, setStats] = useState<ResearchStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [runsResponse, statsResponse] = await Promise.all([
listResearchRuns({ limit: 50 }, projectId),
getResearchStats(projectId),
]);
setRuns(runsResponse.runs);
setStats(statsResponse);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load research runs";
setError(message);
addToast?.(message, "error");
} finally {
setIsLoading(false);
}
}, [projectId, addToast]);
useEffect(() => {
void load();
}, [load]);
const hasResults = useMemo(
() => runs.some((run) => run.status === "completed" && run.results?.summary),
[runs],
);
return (
<section className="research-view" aria-label="Research view">
<header className="research-view__header">
<div>
<h2 className="research-view__title">Research</h2>
<p className="research-view__subtitle">Track synthesis runs, source collection, and export artifacts.</p>
</div>
<button className="btn" type="button" onClick={() => void load()}>
Refresh
</button>
</header>
{isLoading && (
<div className="research-view__state card" data-testid="research-state-loading">
Loading research runs
</div>
)}
{!isLoading && error && (
<div className="research-view__state research-view__state--error card" data-testid="research-state-error">
<p>{error}</p>
<button className="btn btn-danger" type="button" onClick={() => void load()}>
Retry
</button>
</div>
)}
{!isLoading && !error && runs.length === 0 && (
<div className="research-view__state card" data-testid="research-state-empty">
No research runs yet. Start a run from the API or upcoming orchestration workflow.
</div>
)}
{!isLoading && !error && runs.length > 0 && (
<>
<div className="research-view__stats" data-testid="research-state-running">
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Total Runs</div>
<div className="research-view__stat-value">{stats?.total ?? runs.length}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Running</div>
<div className="research-view__stat-value">{stats?.byStatus.running ?? 0}</div>
</div>
<div className="card research-view__stat-card">
<div className="research-view__stat-label">Completed</div>
<div className="research-view__stat-value">{stats?.byStatus.completed ?? 0}</div>
</div>
</div>
<div className="research-view__list">
{runs.map((run) => (
<article key={run.id} className="card research-view__run-card">
<div className="research-view__run-head">
<span
className={`card-status-badge ${
run.status === "failed"
? "research-view__status-badge--failed"
: `card-status-badge--${
run.status === "pending"
? "todo"
: run.status === "running"
? "in-progress"
: run.status === "completed"
? "done"
: "archived"
}`
}`}
>
{STATUS_LABELS[run.status]}
</span>
<span className="card-id">{run.id}</span>
</div>
<h3 className="research-view__run-title">{run.topic || run.query}</h3>
<p className="research-view__run-query">{run.query}</p>
{run.results?.summary && <p data-testid="research-state-results">{run.results.summary}</p>}
</article>
))}
</div>
{!hasResults && (
<p className="research-view__hint">Runs are active, but no summarized results are available yet.</p>
)}
</>
)}
</section>
);
}

View File

@@ -167,13 +167,13 @@ describe("Header", () => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("does not render view overflow trigger when all overflow feature flags are false", () => {
it("renders view overflow trigger for research even when all optional feature flags are false", () => {
renderHeader({
onChangeView: noop,
showSkillsTab: false,
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: false },
});
expect(screen.queryByTestId("view-toggle-overflow-trigger")).toBeNull();
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
});

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Header } from "../Header";
import { ResearchView } from "../ResearchView";
const mockListResearchRuns = vi.fn();
const mockGetResearchStats = vi.fn();
vi.mock("../../api", () => ({
fetchScripts: vi.fn().mockResolvedValue({}),
listResearchRuns: (...args: unknown[]) => mockListResearchRuns(...args),
getResearchStats: (...args: unknown[]) => mockGetResearchStats(...args),
}));
function mockMatchMediaDesktop() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
describe("Research navigation", () => {
it("shows research in header overflow and activates view change", async () => {
mockMatchMediaDesktop();
const onChangeView = vi.fn();
render(
<Header
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={vi.fn()}
onToggleEnginePause={vi.fn()}
view="board"
onChangeView={onChangeView}
/>,
);
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
await waitFor(() => {
expect(screen.getByTestId("view-overflow-research")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-overflow-research"));
expect(onChangeView).toHaveBeenCalledWith("research");
});
});
describe("ResearchView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders empty state", async () => {
mockListResearchRuns.mockResolvedValue({ runs: [] });
mockGetResearchStats.mockResolvedValue({
total: 0,
byStatus: { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 },
});
render(<ResearchView projectId="p1" />);
await waitFor(() => {
expect(screen.getByTestId("research-state-empty")).toBeInTheDocument();
});
});
it("renders loading and then running/results states", async () => {
mockListResearchRuns.mockResolvedValue({
runs: [
{
id: "RR-1",
query: "evaluate release automation",
topic: "Release automation",
status: "running",
providerConfig: {},
sources: [],
events: [],
results: { summary: "Initial synthesis complete", findings: [], citations: [], synthesizedOutput: "" },
error: null,
tokenUsage: null,
tags: [],
metadata: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
cancelledAt: null,
},
],
});
mockGetResearchStats.mockResolvedValue({
total: 1,
byStatus: { pending: 0, running: 1, completed: 0, failed: 0, cancelled: 0 },
});
render(<ResearchView projectId="p1" />);
expect(screen.getByTestId("research-state-loading")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("research-state-running")).toBeInTheDocument();
expect(screen.getByTestId("research-state-results")).toBeInTheDocument();
});
});
it("uses failure badge treatment for failed runs", async () => {
mockListResearchRuns.mockResolvedValue({
runs: [
{
id: "RR-2",
query: "evaluate failed orchestration",
topic: "Failure case",
status: "failed",
providerConfig: {},
sources: [],
events: [],
results: null,
error: "provider timeout",
tokenUsage: null,
tags: [],
metadata: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
cancelledAt: null,
},
],
});
mockGetResearchStats.mockResolvedValue({
total: 1,
byStatus: { pending: 0, running: 0, completed: 0, failed: 1, cancelled: 0 },
});
render(<ResearchView projectId="p1" />);
await waitFor(() => {
expect(screen.getByText("Failed")).toHaveClass("research-view__status-badge--failed");
});
});
it("renders error state when fetch fails", async () => {
mockListResearchRuns.mockRejectedValue(new Error("boom"));
mockGetResearchStats.mockResolvedValue({
total: 0,
byStatus: { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 },
});
render(<ResearchView projectId="p1" />);
await waitFor(() => {
expect(screen.getByTestId("research-state-error")).toBeInTheDocument();
});
});
it("includes mobile layout media rule", async () => {
const css = await import("../ResearchView.css?inline");
expect(css.default).toContain("@media (max-width: 768px)");
expect(css.default).toContain(".research-view__stats");
});
});

View File

@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
const TASK_VIEWS: readonly TaskView[] = [
"board",
@@ -13,6 +13,7 @@ const TASK_VIEWS: readonly TaskView[] = [
"missions",
"chat",
"documents",
"research",
"roadmaps",
"skills",
"mailbox",

View File

@@ -0,0 +1,223 @@
import { Router } from "express";
import type { NextFunction, Request, Response } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import type { TaskStore } from "@fusion/core";
import {
RESEARCH_EVENT_TYPES,
RESEARCH_EXPORT_FORMATS,
RESEARCH_RUN_STATUSES,
RESEARCH_SOURCE_STATUSES,
RESEARCH_SOURCE_TYPES,
type ResearchRunCreateInput,
type ResearchRunListOptions,
type ResearchRunStatus,
} from "@fusion/core";
import { ApiError, badRequest, notFound } from "./api-error.js";
function rethrowAsApiError(error: unknown, fallback = "Internal server error"): never {
if (error instanceof ApiError) throw error;
if (error instanceof Error) throw new ApiError(500, error.message);
throw new ApiError(500, fallback);
}
function getProjectId(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) return req.query.projectId;
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) return req.body.projectId;
return undefined;
}
export function createResearchRouter(store: TaskStore): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
router.use((req: Request, _res: Response, next: NextFunction) => {
const projectId = getProjectId(req);
if (!projectId) {
requestContext.run(store, () => next());
return;
}
import("./project-store-resolver.js")
.then(({ getOrCreateProjectStore }) => getOrCreateProjectStore(projectId))
.then((scopedStore) => requestContext.run(scopedStore, () => next()))
.catch((error) => rethrowAsApiError(error, "Failed to resolve project store"));
});
const getStore = () => {
const scoped = requestContext.getStore();
if (!scoped) throw new ApiError(500, "Store context not available");
return scoped.getResearchStore();
};
router.get("/runs", (req, res) => {
try {
const options: ResearchRunListOptions = {};
if (typeof req.query.status === "string") {
if (!RESEARCH_RUN_STATUSES.includes(req.query.status as ResearchRunStatus)) {
throw badRequest(`Invalid status: ${req.query.status}`);
}
options.status = req.query.status as ResearchRunStatus;
}
if (typeof req.query.search === "string") options.search = req.query.search;
if (typeof req.query.tag === "string") options.tag = req.query.tag;
if (typeof req.query.fromDate === "string") options.fromDate = req.query.fromDate;
if (typeof req.query.toDate === "string") options.toDate = req.query.toDate;
if (typeof req.query.limit === "string") options.limit = Number.parseInt(req.query.limit, 10);
if (typeof req.query.offset === "string") options.offset = Number.parseInt(req.query.offset, 10);
const runs = getStore().listRuns(options);
res.json({ runs, count: runs.length });
} catch (error) {
rethrowAsApiError(error, "Failed to list research runs");
}
});
router.post("/runs", (req, res) => {
try {
if (typeof req.body?.query !== "string" || !req.body.query.trim()) {
throw badRequest("query is required");
}
const input = req.body as ResearchRunCreateInput;
const run = getStore().createRun(input);
res.status(201).json(run);
} catch (error) {
rethrowAsApiError(error, "Failed to create research run");
}
});
router.get("/runs/:id", (req, res) => {
try {
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
res.json(run);
} catch (error) {
rethrowAsApiError(error, "Failed to get research run");
}
});
router.patch("/runs/:id", (req, res) => {
try {
const updated = getStore().updateRun(req.params.id, req.body ?? {});
if (!updated) throw notFound(`Run not found: ${req.params.id}`);
res.json(updated);
} catch (error) {
rethrowAsApiError(error, "Failed to update research run");
}
});
router.delete("/runs/:id", (req, res) => {
try {
const deleted = getStore().deleteRun(req.params.id);
if (!deleted) throw notFound(`Run not found: ${req.params.id}`);
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to delete research run");
}
});
router.post("/runs/:id/events", (req, res) => {
try {
const { type, message, metadata } = req.body ?? {};
if (!RESEARCH_EVENT_TYPES.includes(type)) throw badRequest(`Invalid event type: ${String(type)}`);
if (typeof message !== "string" || !message.trim()) throw badRequest("message is required");
const event = getStore().appendEvent(req.params.id, { type, message, metadata });
res.status(201).json(event);
} catch (error) {
rethrowAsApiError(error, "Failed to append research event");
}
});
router.post("/runs/:id/sources", (req, res) => {
try {
const { type, status } = req.body ?? {};
if (!RESEARCH_SOURCE_TYPES.includes(type)) throw badRequest(`Invalid source type: ${String(type)}`);
if (!RESEARCH_SOURCE_STATUSES.includes(status)) throw badRequest(`Invalid source status: ${String(status)}`);
const source = getStore().addSource(req.params.id, req.body);
res.status(201).json(source);
} catch (error) {
rethrowAsApiError(error, "Failed to add research source");
}
});
router.patch("/runs/:id/sources/:sourceId", (req, res) => {
try {
getStore().updateSource(req.params.id, req.params.sourceId, req.body ?? {});
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to update research source");
}
});
router.put("/runs/:id/results", (req, res) => {
try {
getStore().setResults(req.params.id, req.body);
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to set research results");
}
});
router.patch("/runs/:id/status", (req, res) => {
try {
const status = req.body?.status as ResearchRunStatus | undefined;
if (!status || !RESEARCH_RUN_STATUSES.includes(status)) throw badRequest(`Invalid status: ${String(status)}`);
getStore().updateStatus(req.params.id, status, req.body?.extra);
const run = getStore().getRun(req.params.id);
if (!run) throw notFound(`Run not found: ${req.params.id}`);
res.json(run);
} catch (error) {
rethrowAsApiError(error, "Failed to update research status");
}
});
router.post("/runs/:id/exports", (req, res) => {
try {
const format = req.body?.format;
const content = req.body?.content;
if (!RESEARCH_EXPORT_FORMATS.includes(format)) throw badRequest(`Invalid export format: ${String(format)}`);
if (typeof content !== "string") throw badRequest("content is required");
const exportRow = getStore().createExport(req.params.id, format, content);
res.status(201).json(exportRow);
} catch (error) {
rethrowAsApiError(error, "Failed to create research export");
}
});
router.get("/runs/:id/exports", (req, res) => {
try {
res.json({ exports: getStore().getExports(req.params.id) });
} catch (error) {
rethrowAsApiError(error, "Failed to list research exports");
}
});
router.get("/exports/:exportId", (req, res) => {
try {
const exportRow = getStore().getExport(req.params.exportId);
if (!exportRow) throw notFound(`Export not found: ${req.params.exportId}`);
res.json(exportRow);
} catch (error) {
rethrowAsApiError(error, "Failed to get research export");
}
});
router.get("/stats", (_req, res) => {
try {
res.json(getStore().getStats());
} catch (error) {
rethrowAsApiError(error, "Failed to get research stats");
}
});
router.get("/search", (req, res) => {
try {
const q = String(req.query.q ?? "").trim();
if (!q) throw badRequest("q is required");
res.json({ runs: getStore().searchRuns(q) });
} catch (error) {
rethrowAsApiError(error, "Failed to search research runs");
}
});
return router;
}

View File

@@ -4,6 +4,7 @@ import type { ServerOptions } from "../server.js";
import { createMissionRouter } from "../mission-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createInsightsRouter } from "../insights-routes.js";
import { createResearchRouter } from "../research-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -33,6 +34,7 @@ export function registerIntegratedRouters({
router.use("/roadmaps", createRoadmapRouter(store));
router.use("/insights", createInsightsRouter(store));
router.use("/research", createResearchRouter(store));
router.use("/todos", createTodoRouter(store));
}