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:
@@ -131,7 +131,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -154,7 +154,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
|
|||||||
const db = new Database(fusionDir);
|
const db = new Database(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("priority");
|
expect(cols.map((col) => col.name)).toContain("priority");
|
||||||
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -994,7 +994,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||||
@@ -1068,7 +1068,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -1092,7 +1092,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -1196,7 +1196,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1647,7 +1647,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(fusionDir);
|
const db = createDatabase(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||||
const db1 = createDatabase(legacyDir);
|
const db1 = createDatabase(legacyDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(54);
|
expect(db1.getSchemaVersion()).toBe(55);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||||
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||||
// Now run init — this triggers the v32→v33 migration
|
// Now run init — this triggers the v32→v33 migration
|
||||||
db3.init();
|
db3.init();
|
||||||
expect(db3.getSchemaVersion()).toBe(54);
|
expect(db3.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
// Step 4: Verify insight tables exist after migration
|
// Step 4: Verify insight tables exist after migration
|
||||||
const tablesAfter = db3.prepare(
|
const tablesAfter = db3.prepare(
|
||||||
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
try {
|
try {
|
||||||
const db1 = createDatabase(testDir);
|
const db1 = createDatabase(testDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(54);
|
expect(db1.getSchemaVersion()).toBe(55);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
const db2 = createDatabase(testDir);
|
const db2 = createDatabase(testDir);
|
||||||
expect(() => db2.init()).not.toThrow();
|
expect(() => db2.init()).not.toThrow();
|
||||||
expect(db2.getSchemaVersion()).toBe(54);
|
expect(db2.getSchemaVersion()).toBe(55);
|
||||||
db2.close();
|
db2.close();
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
|||||||
|
|
||||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||||
it("schema version is 40 after migration", () => {
|
it("schema version is 40 after migration", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mission_features table has loop state columns", () => {
|
it("mission_features table has loop state columns", () => {
|
||||||
|
|||||||
118
packages/core/src/__tests__/research-store.test.ts
Normal file
118
packages/core/src/__tests__/research-store.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { createDatabase, type Database } from "../db.js";
|
||||||
|
import { ResearchStore } from "../research-store.js";
|
||||||
|
|
||||||
|
describe("ResearchStore", () => {
|
||||||
|
let db: Database;
|
||||||
|
let store: ResearchStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const fusionDir = mkdtempSync(join(tmpdir(), "fn-research-test-"));
|
||||||
|
db = createDatabase(fusionDir, { inMemory: true });
|
||||||
|
db.init();
|
||||||
|
store = new ResearchStore(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, gets, updates, lists and deletes runs", () => {
|
||||||
|
const run = store.createRun({ query: "test topic", tags: ["a"] });
|
||||||
|
expect(run.id).toMatch(/^RR-/);
|
||||||
|
expect(store.getRun(run.id)?.query).toBe("test topic");
|
||||||
|
|
||||||
|
const updated = store.updateRun(run.id, { topic: "new topic", error: "oops" });
|
||||||
|
expect(updated?.topic).toBe("new topic");
|
||||||
|
|
||||||
|
const listed = store.listRuns({ status: "pending" });
|
||||||
|
expect(listed.map((r) => r.id)).toContain(run.id);
|
||||||
|
|
||||||
|
expect(store.deleteRun(run.id)).toBe(true);
|
||||||
|
expect(store.getRun(run.id)).toBeUndefined();
|
||||||
|
expect(store.deleteRun("RR-missing")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles status transitions with lifecycle timestamps", () => {
|
||||||
|
const run = store.createRun({ query: "status test" });
|
||||||
|
store.updateStatus(run.id, "running");
|
||||||
|
const running = store.getRun(run.id)!;
|
||||||
|
expect(running.startedAt).toBeTruthy();
|
||||||
|
|
||||||
|
store.updateStatus(run.id, "completed");
|
||||||
|
const completed = store.getRun(run.id)!;
|
||||||
|
expect(completed.completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const failed = store.createRun({ query: "failure" });
|
||||||
|
store.updateStatus(failed.id, "failed");
|
||||||
|
expect(store.getRun(failed.id)?.completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const cancelled = store.createRun({ query: "cancel" });
|
||||||
|
store.updateStatus(cancelled.id, "cancelled");
|
||||||
|
expect(store.getRun(cancelled.id)?.cancelledAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends events, manages sources, and sets results", () => {
|
||||||
|
const run = store.createRun({ query: "events" });
|
||||||
|
const event = store.appendEvent(run.id, { type: "info", message: "started" });
|
||||||
|
expect(event.id).toMatch(/^REVT-/);
|
||||||
|
|
||||||
|
const source = store.addSource(run.id, {
|
||||||
|
type: "web",
|
||||||
|
reference: "https://example.com",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
store.updateSource(run.id, source.id, { status: "completed", title: "Example" });
|
||||||
|
store.setResults(run.id, {
|
||||||
|
summary: "Done",
|
||||||
|
findings: [{ heading: "H1", content: "C1", sources: [source.id], confidence: 0.8 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = store.getRun(run.id)!;
|
||||||
|
expect(next.events).toHaveLength(1);
|
||||||
|
expect(next.sources[0].status).toBe("completed");
|
||||||
|
expect(next.results?.summary).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports filtering, search, ordering, exports and stats", () => {
|
||||||
|
const r1 = store.createRun({ query: "alpha", topic: "first", tags: ["core"] });
|
||||||
|
const r2 = store.createRun({ query: "beta", topic: "second", tags: ["edge"] });
|
||||||
|
store.setResults(r2.id, { summary: "beta summary", findings: [] });
|
||||||
|
|
||||||
|
expect(store.listRuns({ tag: "core" }).map((r) => r.id)).toEqual([r1.id]);
|
||||||
|
expect(store.searchRuns("beta").map((r) => r.id)).toContain(r2.id);
|
||||||
|
|
||||||
|
const all = store.listRuns();
|
||||||
|
expect(all[0].createdAt <= all[1].createdAt).toBe(true);
|
||||||
|
|
||||||
|
const ex = store.createExport(r1.id, "json", "{}");
|
||||||
|
expect(store.getExports(r1.id)).toHaveLength(1);
|
||||||
|
expect(store.getExport(ex.id)?.runId).toBe(r1.id);
|
||||||
|
expect(store.getExport("REXP-missing")).toBeUndefined();
|
||||||
|
|
||||||
|
store.updateStatus(r1.id, "running");
|
||||||
|
store.updateStatus(r2.id, "completed");
|
||||||
|
const stats = store.getStats();
|
||||||
|
expect(stats.total).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(stats.byStatus.completed).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
store.deleteRun(r1.id);
|
||||||
|
expect(store.getExports(r1.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits status events and throws for missing run mutations", () => {
|
||||||
|
const onStatus = vi.fn();
|
||||||
|
const onCompleted = vi.fn();
|
||||||
|
store.on("run:status_changed", onStatus);
|
||||||
|
store.on("run:completed", onCompleted);
|
||||||
|
|
||||||
|
const run = store.createRun({ query: "events" });
|
||||||
|
store.updateStatus(run.id, "completed");
|
||||||
|
expect(onStatus).toHaveBeenCalled();
|
||||||
|
expect(onCompleted).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(() => store.appendEvent("missing", { type: "info", message: "x" })).toThrow(/not found/i);
|
||||||
|
expect(() => store.addSource("missing", { type: "web", reference: "x", status: "pending" })).toThrow(/not found/i);
|
||||||
|
expect(() => store.setResults("missing", { findings: [] })).toThrow(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
|||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 40 after init", () => {
|
it("schema version is 40 after init", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("schema version is bumped to 40", () => {
|
it("schema version is bumped to 40", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
|||||||
|
|
||||||
expect(tableNames.has("task_documents")).toBe(true);
|
expect(tableNames.has("task_documents")).toBe(true);
|
||||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(54);
|
expect(db.getSchemaVersion()).toBe(55);
|
||||||
|
|
||||||
const index = db
|
const index = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 54;
|
const SCHEMA_VERSION = 55;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
steeringComments: SteeringComment[] | undefined,
|
||||||
@@ -411,6 +411,41 @@ CREATE TABLE IF NOT EXISTS task_document_revisions (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key);
|
CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key);
|
||||||
|
|
||||||
|
-- Research runs persistence (FN-2991)
|
||||||
|
CREATE TABLE IF NOT EXISTS research_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
topic TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
providerConfig TEXT,
|
||||||
|
sources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
events TEXT NOT NULL DEFAULT '[]',
|
||||||
|
results TEXT,
|
||||||
|
error TEXT,
|
||||||
|
tokenUsage TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
startedAt TEXT,
|
||||||
|
completedAt TEXT,
|
||||||
|
cancelledAt TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxResearchRunsStatus ON research_runs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxResearchRunsCreatedAt ON research_runs(createdAt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxResearchRunsUpdatedAt ON research_runs(updatedAt);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS research_exports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
runId TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
filePath TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxResearchExportsRunId ON research_exports(runId);
|
||||||
|
|
||||||
-- Schema version tracking
|
-- Schema version tracking
|
||||||
CREATE TABLE IF NOT EXISTS __meta (
|
CREATE TABLE IF NOT EXISTS __meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -1999,6 +2034,51 @@ export class Database {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Research runs + exports persistence tables (FN-2991).
|
||||||
|
if (version < 55) {
|
||||||
|
this.applyMigration(55, () => {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS research_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
topic TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
providerConfig TEXT,
|
||||||
|
sources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
events TEXT NOT NULL DEFAULT '[]',
|
||||||
|
results TEXT,
|
||||||
|
error TEXT,
|
||||||
|
tokenUsage TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
startedAt TEXT,
|
||||||
|
completedAt TEXT,
|
||||||
|
cancelledAt TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsStatus ON research_runs(status)`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsCreatedAt ON research_runs(createdAt)`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsUpdatedAt ON research_runs(updatedAt)`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS research_exports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
runId TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
filePath TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchExportsRunId ON research_exports(runId)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -575,6 +575,36 @@ export type {
|
|||||||
InsightRunListOptions,
|
InsightRunListOptions,
|
||||||
InsightStoreEvents,
|
InsightStoreEvents,
|
||||||
} from "./insight-types.js";
|
} from "./insight-types.js";
|
||||||
|
|
||||||
|
// ── Research System ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export { ResearchStore } from "./research-store.js";
|
||||||
|
export {
|
||||||
|
RESEARCH_RUN_STATUSES,
|
||||||
|
RESEARCH_SOURCE_STATUSES,
|
||||||
|
RESEARCH_EXPORT_FORMATS,
|
||||||
|
RESEARCH_SOURCE_TYPES,
|
||||||
|
RESEARCH_EVENT_TYPES,
|
||||||
|
} from "./research-types.js";
|
||||||
|
export type {
|
||||||
|
ResearchRunStatus,
|
||||||
|
ResearchSourceStatus,
|
||||||
|
ResearchExportFormat,
|
||||||
|
ResearchSourceType,
|
||||||
|
ResearchEventType,
|
||||||
|
ResearchSource,
|
||||||
|
ResearchEvent,
|
||||||
|
ResearchFinding,
|
||||||
|
ResearchResult,
|
||||||
|
ResearchTokenUsage,
|
||||||
|
ResearchRun,
|
||||||
|
ResearchExport,
|
||||||
|
ResearchRunCreateInput,
|
||||||
|
ResearchRunUpdateInput,
|
||||||
|
ResearchRunListOptions,
|
||||||
|
ResearchStoreEvents,
|
||||||
|
} from "./research-types.js";
|
||||||
|
|
||||||
export { TodoStore } from "./todo-store.js";
|
export { TodoStore } from "./todo-store.js";
|
||||||
export type { TodoStoreEvents } from "./todo-store.js";
|
export type { TodoStoreEvents } from "./todo-store.js";
|
||||||
|
|
||||||
|
|||||||
377
packages/core/src/research-store.ts
Normal file
377
packages/core/src/research-store.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Database } from "./db.js";
|
||||||
|
import { fromJson, toJson, toJsonNullable } from "./db.js";
|
||||||
|
import type {
|
||||||
|
ResearchEvent,
|
||||||
|
ResearchExport,
|
||||||
|
ResearchExportFormat,
|
||||||
|
ResearchResult,
|
||||||
|
ResearchRun,
|
||||||
|
ResearchRunCreateInput,
|
||||||
|
ResearchRunListOptions,
|
||||||
|
ResearchRunStatus,
|
||||||
|
ResearchRunUpdateInput,
|
||||||
|
ResearchSource,
|
||||||
|
ResearchStoreEvents,
|
||||||
|
} from "./research-types.js";
|
||||||
|
|
||||||
|
function generateRunId(): string {
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase();
|
||||||
|
const random = Math.random().toString(36).slice(2, 7).toUpperCase();
|
||||||
|
return `RR-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(prefix: string): string {
|
||||||
|
return `${prefix}-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRecord(
|
||||||
|
currentValue: Record<string, unknown> | undefined,
|
||||||
|
patchValue: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!patchValue) return currentValue;
|
||||||
|
const merged = { ...(currentValue ?? {}), ...patchValue };
|
||||||
|
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
|
||||||
|
constructor(private readonly db: Database) {
|
||||||
|
super();
|
||||||
|
this.setMaxListeners(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRun(input: ResearchRunCreateInput): ResearchRun {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const run: ResearchRun = {
|
||||||
|
id: generateRunId(),
|
||||||
|
query: input.query,
|
||||||
|
topic: input.topic,
|
||||||
|
status: "pending",
|
||||||
|
providerConfig: input.providerConfig,
|
||||||
|
sources: input.sources ?? [],
|
||||||
|
events: input.events ?? [],
|
||||||
|
results: input.results,
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
metadata: input.metadata,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO research_runs (
|
||||||
|
id, query, topic, status, providerConfig, sources, events, results, error,
|
||||||
|
tokenUsage, tags, metadata, createdAt, updatedAt, startedAt, completedAt, cancelledAt
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
run.id,
|
||||||
|
run.query,
|
||||||
|
run.topic ?? null,
|
||||||
|
run.status,
|
||||||
|
toJsonNullable(run.providerConfig),
|
||||||
|
toJson(run.sources),
|
||||||
|
toJson(run.events),
|
||||||
|
toJsonNullable(run.results),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
toJson(run.tags),
|
||||||
|
toJsonNullable(run.metadata),
|
||||||
|
run.createdAt,
|
||||||
|
run.updatedAt,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("run:created", run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRun(id: string): ResearchRun | undefined {
|
||||||
|
const row = this.db.prepare("SELECT * FROM research_runs WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||||
|
return row ? this.rowToRun(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRun(id: string, input: ResearchRunUpdateInput): ResearchRun | undefined {
|
||||||
|
const existing = this.getRun(id);
|
||||||
|
if (!existing) return undefined;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const mergedProviderConfig = mergeRecord(existing.providerConfig, input.providerConfig);
|
||||||
|
const mergedMetadata = mergeRecord(existing.metadata, input.metadata);
|
||||||
|
|
||||||
|
const updated: ResearchRun = {
|
||||||
|
...existing,
|
||||||
|
...input,
|
||||||
|
providerConfig: mergedProviderConfig,
|
||||||
|
metadata: mergedMetadata,
|
||||||
|
error: input.error === null ? undefined : (input.error ?? existing.error),
|
||||||
|
updatedAt: now,
|
||||||
|
startedAt: input.startedAt === null ? undefined : (input.startedAt ?? existing.startedAt),
|
||||||
|
completedAt: input.completedAt === null ? undefined : (input.completedAt ?? existing.completedAt),
|
||||||
|
cancelledAt: input.cancelledAt === null ? undefined : (input.cancelledAt ?? existing.cancelledAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.persistRun(updated);
|
||||||
|
this.emit("run:updated", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
listRuns(options: ResearchRunListOptions = {}): ResearchRun[] {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: Array<string | number> = [];
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
conditions.push("status = ?");
|
||||||
|
params.push(options.status);
|
||||||
|
}
|
||||||
|
if (options.fromDate) {
|
||||||
|
conditions.push("createdAt >= ?");
|
||||||
|
params.push(options.fromDate);
|
||||||
|
}
|
||||||
|
if (options.toDate) {
|
||||||
|
conditions.push("createdAt <= ?");
|
||||||
|
params.push(options.toDate);
|
||||||
|
}
|
||||||
|
if (options.tag) {
|
||||||
|
conditions.push("tags LIKE ?");
|
||||||
|
params.push(`%"${options.tag}"%`);
|
||||||
|
}
|
||||||
|
if (options.search) {
|
||||||
|
conditions.push("(query LIKE ? OR COALESCE(topic, '') LIKE ?)");
|
||||||
|
params.push(`%${options.search}%`, `%${options.search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const limit = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
|
||||||
|
const offset = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
|
||||||
|
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT * FROM research_runs
|
||||||
|
${where}
|
||||||
|
ORDER BY createdAt ASC, id ASC
|
||||||
|
${limit}
|
||||||
|
${offset}
|
||||||
|
`).all(...params) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return rows.map((row) => this.rowToRun(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRun(id: string): boolean {
|
||||||
|
const result = this.db.prepare("DELETE FROM research_runs WHERE id = ?").run(id) as { changes?: number };
|
||||||
|
const deleted = (result?.changes ?? 0) > 0;
|
||||||
|
if (deleted) {
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("run:deleted", id);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendEvent(runId: string, event: Omit<ResearchEvent, "id" | "timestamp">): ResearchEvent {
|
||||||
|
const run = this.getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const created: ResearchEvent = {
|
||||||
|
id: generateId("REVT"),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: event.type,
|
||||||
|
message: event.message,
|
||||||
|
metadata: event.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateRun(runId, { events: [...run.events, created] });
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSource(runId: string, source: Omit<ResearchSource, "id">): ResearchSource {
|
||||||
|
const run = this.getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const created: ResearchSource = { ...source, id: generateId("RSRC") };
|
||||||
|
this.updateRun(runId, { sources: [...run.sources, created] });
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSource(runId: string, sourceId: string, updates: Partial<ResearchSource>): void {
|
||||||
|
const run = this.getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const next = run.sources.map((source) => {
|
||||||
|
if (source.id !== sourceId) return source;
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
...updates,
|
||||||
|
id: source.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateRun(runId, { sources: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(runId: string, results: ResearchResult): void {
|
||||||
|
const updated = this.updateRun(runId, { results });
|
||||||
|
if (!updated) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(runId: string, status: ResearchRunStatus, extra?: Partial<ResearchRun>): void {
|
||||||
|
const run = this.getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch: ResearchRunUpdateInput = {
|
||||||
|
...(extra ?? {}),
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "running" && !run.startedAt) {
|
||||||
|
patch.startedAt = now;
|
||||||
|
}
|
||||||
|
if ((status === "completed" || status === "failed") && !run.completedAt) {
|
||||||
|
patch.completedAt = now;
|
||||||
|
}
|
||||||
|
if (status === "cancelled" && !run.cancelledAt) {
|
||||||
|
patch.cancelledAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = this.updateRun(runId, patch);
|
||||||
|
if (!updated) return;
|
||||||
|
|
||||||
|
this.emit("run:status_changed", updated);
|
||||||
|
if (status === "completed") this.emit("run:completed", updated);
|
||||||
|
if (status === "failed") this.emit("run:failed", updated);
|
||||||
|
if (status === "cancelled") this.emit("run:cancelled", updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
createExport(runId: string, format: ResearchExportFormat, content: string): ResearchExport {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const exportRecord: ResearchExport = {
|
||||||
|
id: generateId("REXP"),
|
||||||
|
runId,
|
||||||
|
format,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO research_exports (id, runId, format, content, filePath, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(exportRecord.id, runId, format, content, null, now);
|
||||||
|
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
return exportRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
getExports(runId: string): ResearchExport[] {
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT * FROM research_exports
|
||||||
|
WHERE runId = ?
|
||||||
|
ORDER BY createdAt ASC, id ASC
|
||||||
|
`).all(runId) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return rows.map((row) => this.rowToExport(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getExport(id: string): ResearchExport | undefined {
|
||||||
|
const row = this.db.prepare("SELECT * FROM research_exports WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||||
|
return row ? this.rowToExport(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchRuns(query: string): ResearchRun[] {
|
||||||
|
const q = `%${query}%`;
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT * FROM research_runs
|
||||||
|
WHERE query LIKE ?
|
||||||
|
OR COALESCE(topic, '') LIKE ?
|
||||||
|
OR COALESCE(json_extract(results, '$.summary'), '') LIKE ?
|
||||||
|
ORDER BY createdAt ASC, id ASC
|
||||||
|
`).all(q, q, q) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return rows.map((row) => this.rowToRun(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): { total: number; byStatus: Record<ResearchRunStatus, number> } {
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM research_runs
|
||||||
|
GROUP BY status
|
||||||
|
`).all() as Array<{ status: ResearchRunStatus; count: number }>;
|
||||||
|
|
||||||
|
const byStatus: Record<ResearchRunStatus, number> = {
|
||||||
|
pending: 0,
|
||||||
|
running: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
byStatus[row.status] = row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Object.values(byStatus).reduce((acc, value) => acc + value, 0);
|
||||||
|
return { total, byStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistRun(run: ResearchRun): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE research_runs
|
||||||
|
SET query = ?, topic = ?, status = ?, providerConfig = ?, sources = ?, events = ?,
|
||||||
|
results = ?, error = ?, tokenUsage = ?, tags = ?, metadata = ?, updatedAt = ?,
|
||||||
|
startedAt = ?, completedAt = ?, cancelledAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
run.query,
|
||||||
|
run.topic ?? null,
|
||||||
|
run.status,
|
||||||
|
toJsonNullable(run.providerConfig),
|
||||||
|
toJson(run.sources),
|
||||||
|
toJson(run.events),
|
||||||
|
toJsonNullable(run.results),
|
||||||
|
run.error ?? null,
|
||||||
|
toJsonNullable(run.tokenUsage),
|
||||||
|
toJson(run.tags),
|
||||||
|
toJsonNullable(run.metadata),
|
||||||
|
run.updatedAt,
|
||||||
|
run.startedAt ?? null,
|
||||||
|
run.completedAt ?? null,
|
||||||
|
run.cancelledAt ?? null,
|
||||||
|
run.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToRun(row: Record<string, unknown>): ResearchRun {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
query: row.query as string,
|
||||||
|
topic: (row.topic as string | null) ?? undefined,
|
||||||
|
status: row.status as ResearchRunStatus,
|
||||||
|
providerConfig: fromJson<Record<string, unknown>>(row.providerConfig as string | null),
|
||||||
|
sources: fromJson<ResearchSource[]>(row.sources as string | null) ?? [],
|
||||||
|
events: fromJson<ResearchEvent[]>(row.events as string | null) ?? [],
|
||||||
|
results: fromJson<ResearchResult>(row.results as string | null),
|
||||||
|
error: (row.error as string | null) ?? undefined,
|
||||||
|
tokenUsage: fromJson<ResearchRun["tokenUsage"]>(row.tokenUsage as string | null),
|
||||||
|
tags: fromJson<string[]>(row.tags as string | null) ?? [],
|
||||||
|
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
|
||||||
|
createdAt: row.createdAt as string,
|
||||||
|
updatedAt: row.updatedAt as string,
|
||||||
|
startedAt: (row.startedAt as string | null) ?? undefined,
|
||||||
|
completedAt: (row.completedAt as string | null) ?? undefined,
|
||||||
|
cancelledAt: (row.cancelledAt as string | null) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToExport(row: Record<string, unknown>): ResearchExport {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
runId: row.runId as string,
|
||||||
|
format: row.format as ResearchExportFormat,
|
||||||
|
content: row.content as string,
|
||||||
|
filePath: (row.filePath as string | null) ?? undefined,
|
||||||
|
createdAt: row.createdAt as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
162
packages/core/src/research-types.ts
Normal file
162
packages/core/src/research-types.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Research Domain Types
|
||||||
|
*
|
||||||
|
* Contracts for Fusion-native research run persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const RESEARCH_RUN_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResearchRunStatus = typeof RESEARCH_RUN_STATUSES[number];
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCE_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"fetching",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResearchSourceStatus = typeof RESEARCH_SOURCE_STATUSES[number];
|
||||||
|
|
||||||
|
export const RESEARCH_EXPORT_FORMATS = ["json", "markdown", "pdf"] as const;
|
||||||
|
|
||||||
|
export type ResearchExportFormat = typeof RESEARCH_EXPORT_FORMATS[number];
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCE_TYPES = ["web", "github", "local", "llm", "other"] as const;
|
||||||
|
|
||||||
|
export type ResearchSourceType = typeof RESEARCH_SOURCE_TYPES[number];
|
||||||
|
|
||||||
|
export const RESEARCH_EVENT_TYPES = [
|
||||||
|
"info",
|
||||||
|
"warning",
|
||||||
|
"error",
|
||||||
|
"source_added",
|
||||||
|
"result_updated",
|
||||||
|
"progress",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResearchEventType = typeof RESEARCH_EVENT_TYPES[number];
|
||||||
|
|
||||||
|
export interface ResearchSource {
|
||||||
|
id: string;
|
||||||
|
type: ResearchSourceType;
|
||||||
|
reference: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status: ResearchSourceStatus;
|
||||||
|
fetchedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchEvent {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
type: ResearchEventType;
|
||||||
|
message: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchFinding {
|
||||||
|
heading: string;
|
||||||
|
content: string;
|
||||||
|
sources: string[];
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchResult {
|
||||||
|
summary?: string;
|
||||||
|
findings: ResearchFinding[];
|
||||||
|
citations?: string[];
|
||||||
|
synthesizedOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchTokenUsage {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
cachedTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchRun {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
topic?: string;
|
||||||
|
status: ResearchRunStatus;
|
||||||
|
providerConfig?: Record<string, unknown>;
|
||||||
|
sources: ResearchSource[];
|
||||||
|
events: ResearchEvent[];
|
||||||
|
results?: ResearchResult;
|
||||||
|
error?: string;
|
||||||
|
tokenUsage?: ResearchTokenUsage;
|
||||||
|
tags: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
cancelledAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchExport {
|
||||||
|
id: string;
|
||||||
|
runId: string;
|
||||||
|
format: ResearchExportFormat;
|
||||||
|
content: string;
|
||||||
|
filePath?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchRunCreateInput {
|
||||||
|
query: string;
|
||||||
|
topic?: string;
|
||||||
|
providerConfig?: Record<string, unknown>;
|
||||||
|
sources?: ResearchSource[];
|
||||||
|
events?: ResearchEvent[];
|
||||||
|
results?: ResearchResult;
|
||||||
|
tags?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchRunUpdateInput {
|
||||||
|
query?: string;
|
||||||
|
topic?: string;
|
||||||
|
status?: ResearchRunStatus;
|
||||||
|
providerConfig?: Record<string, unknown>;
|
||||||
|
sources?: ResearchSource[];
|
||||||
|
events?: ResearchEvent[];
|
||||||
|
results?: ResearchResult;
|
||||||
|
error?: string | null;
|
||||||
|
tokenUsage?: ResearchTokenUsage;
|
||||||
|
tags?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
startedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
cancelledAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchRunListOptions {
|
||||||
|
status?: ResearchRunStatus;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
tag?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchStoreEvents {
|
||||||
|
"run:created": [ResearchRun];
|
||||||
|
"run:updated": [ResearchRun];
|
||||||
|
"run:deleted": [string];
|
||||||
|
"run:status_changed": [ResearchRun];
|
||||||
|
"run:completed": [ResearchRun];
|
||||||
|
"run:failed": [ResearchRun];
|
||||||
|
"run:cancelled": [ResearchRun];
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { MissionStore } from "./mission-store.js";
|
|||||||
import { PluginStore } from "./plugin-store.js";
|
import { PluginStore } from "./plugin-store.js";
|
||||||
import { RoadmapStore } from "./roadmap-store.js";
|
import { RoadmapStore } from "./roadmap-store.js";
|
||||||
import { InsightStore } from "./insight-store.js";
|
import { InsightStore } from "./insight-store.js";
|
||||||
|
import { ResearchStore } from "./research-store.js";
|
||||||
import { TodoStore } from "./todo-store.js";
|
import { TodoStore } from "./todo-store.js";
|
||||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||||
import { CentralCore } from "./central-core.js";
|
import { CentralCore } from "./central-core.js";
|
||||||
@@ -408,6 +409,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private roadmapStore: RoadmapStore | null = null;
|
private roadmapStore: RoadmapStore | null = null;
|
||||||
/** Cached InsightStore instance */
|
/** Cached InsightStore instance */
|
||||||
private insightStore: InsightStore | null = null;
|
private insightStore: InsightStore | null = null;
|
||||||
|
/** Cached ResearchStore instance */
|
||||||
|
private researchStore: ResearchStore | null = null;
|
||||||
/** Cached TodoStore instance */
|
/** Cached TodoStore instance */
|
||||||
private todoStore: TodoStore | null = null;
|
private todoStore: TodoStore | null = null;
|
||||||
|
|
||||||
@@ -6060,6 +6063,17 @@ ${notificationsSection}`;
|
|||||||
return this.insightStore;
|
return this.insightStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ResearchStore instance for research run operations.
|
||||||
|
* Lazily initializes the ResearchStore on first access.
|
||||||
|
*/
|
||||||
|
getResearchStore(): ResearchStore {
|
||||||
|
if (!this.researchStore) {
|
||||||
|
this.researchStore = new ResearchStore(this.db);
|
||||||
|
}
|
||||||
|
return this.researchStore;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the TodoStore instance for project-scoped todo list operations.
|
* Get the TodoStore instance for project-scoped todo list operations.
|
||||||
* Lazily initializes the TodoStore on first access.
|
* Lazily initializes the TodoStore on first access.
|
||||||
|
|||||||
@@ -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 AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
|
||||||
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
|
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
|
||||||
const InsightsView = lazy(() => import("./components/InsightsView").then((m) => ({ default: m.InsightsView })));
|
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 NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
|
||||||
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
|
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
|
||||||
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
|
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
|
||||||
@@ -89,6 +90,7 @@ function prefetchLazyViews() {
|
|||||||
void import("./components/AgentsView");
|
void import("./components/AgentsView");
|
||||||
void import("./components/DocumentsView");
|
void import("./components/DocumentsView");
|
||||||
void import("./components/InsightsView");
|
void import("./components/InsightsView");
|
||||||
|
void import("./components/ResearchView");
|
||||||
void import("./components/NodesView");
|
void import("./components/NodesView");
|
||||||
void import("./components/ChatView");
|
void import("./components/ChatView");
|
||||||
void import("./components/RoadmapsView");
|
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 (taskView === "memory") {
|
||||||
if (!settingsLoaded || !memoryEnabled) {
|
if (!settingsLoaded || !memoryEnabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
120
packages/dashboard/app/api/__tests__/research-api.test.ts
Normal file
120
packages/dashboard/app/api/__tests__/research-api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,6 +66,14 @@ import type {
|
|||||||
InsightStatus,
|
InsightStatus,
|
||||||
InsightRun,
|
InsightRun,
|
||||||
InsightRunTrigger,
|
InsightRunTrigger,
|
||||||
|
ResearchEvent,
|
||||||
|
ResearchExport,
|
||||||
|
ResearchResult,
|
||||||
|
ResearchRun,
|
||||||
|
ResearchRunCreateInput,
|
||||||
|
ResearchRunStatus,
|
||||||
|
ResearchRunUpdateInput,
|
||||||
|
ResearchSource,
|
||||||
TaskPriority,
|
TaskPriority,
|
||||||
TaskSourceIssue,
|
TaskSourceIssue,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -1508,9 +1516,17 @@ export interface CustomProvider {
|
|||||||
models?: { id: string; name: string }[];
|
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");
|
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> {
|
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
|
||||||
@@ -7734,3 +7750,115 @@ export function getInsightCreateTaskData(
|
|||||||
method: "POST",
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ export interface HeaderProps {
|
|||||||
enginePaused?: boolean;
|
enginePaused?: boolean;
|
||||||
onToggleGlobalPause?: () => void;
|
onToggleGlobalPause?: () => void;
|
||||||
onToggleEnginePause?: () => void;
|
onToggleEnginePause?: () => void;
|
||||||
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";
|
||||||
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 to show the skills tab in the view toggle */
|
/** Whether to show the skills tab in the view toggle */
|
||||||
showSkillsTab?: boolean;
|
showSkillsTab?: boolean;
|
||||||
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
||||||
@@ -333,9 +333,10 @@ export function Header({
|
|||||||
experimentalFeatures?.roadmap ||
|
experimentalFeatures?.roadmap ||
|
||||||
showSkillsTab ||
|
showSkillsTab ||
|
||||||
experimentalFeatures?.memoryView ||
|
experimentalFeatures?.memoryView ||
|
||||||
experimentalFeatures?.devServerView
|
experimentalFeatures?.devServerView ||
|
||||||
|
!hideFullNav
|
||||||
);
|
);
|
||||||
}, [experimentalFeatures, showSkillsTab]);
|
}, [experimentalFeatures, showSkillsTab, hideFullNav]);
|
||||||
|
|
||||||
const getEffectiveViewport = useCallback(() => {
|
const getEffectiveViewport = useCallback(() => {
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
@@ -1109,7 +1110,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
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)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1126,6 +1127,18 @@ export function Header({
|
|||||||
role="menu"
|
role="menu"
|
||||||
aria-label="More views"
|
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 && (
|
{experimentalFeatures?.insights && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}
|
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Settings,
|
Settings,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -33,9 +34,9 @@ import { useViewportMode } from "./Header";
|
|||||||
|
|
||||||
export interface MobileNavBarProps {
|
export interface MobileNavBarProps {
|
||||||
/** Current task view mode */
|
/** 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 */
|
/** 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 */
|
/** Whether the ExecutorStatusBar footer is visible */
|
||||||
footerVisible: boolean;
|
footerVisible: boolean;
|
||||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||||
@@ -189,6 +190,7 @@ export function MobileNavBar({
|
|||||||
|
|
||||||
const isMoreActive =
|
const isMoreActive =
|
||||||
view === "documents"
|
view === "documents"
|
||||||
|
|| view === "research"
|
||||||
|| view === "insights"
|
|| view === "insights"
|
||||||
|| view === "memory"
|
|| view === "memory"
|
||||||
|| view === "devserver"
|
|| view === "devserver"
|
||||||
@@ -562,6 +564,16 @@ export function MobileNavBar({
|
|||||||
</button>
|
</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 && (
|
{experimentalFeatures?.insights && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
110
packages/dashboard/app/components/ResearchView.css
Normal file
110
packages/dashboard/app/components/ResearchView.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
packages/dashboard/app/components/ResearchView.tsx
Normal file
145
packages/dashboard/app/components/ResearchView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -167,13 +167,13 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
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({
|
renderHeader({
|
||||||
onChangeView: noop,
|
onChangeView: noop,
|
||||||
showSkillsTab: false,
|
showSkillsTab: false,
|
||||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
|||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
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[] = [
|
const TASK_VIEWS: readonly TaskView[] = [
|
||||||
"board",
|
"board",
|
||||||
@@ -13,6 +13,7 @@ const TASK_VIEWS: readonly TaskView[] = [
|
|||||||
"missions",
|
"missions",
|
||||||
"chat",
|
"chat",
|
||||||
"documents",
|
"documents",
|
||||||
|
"research",
|
||||||
"roadmaps",
|
"roadmaps",
|
||||||
"skills",
|
"skills",
|
||||||
"mailbox",
|
"mailbox",
|
||||||
|
|||||||
223
packages/dashboard/src/research-routes.ts
Normal file
223
packages/dashboard/src/research-routes.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { ServerOptions } from "../server.js";
|
|||||||
import { createMissionRouter } from "../mission-routes.js";
|
import { createMissionRouter } from "../mission-routes.js";
|
||||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
import { createRoadmapRouter } from "../roadmap-routes.js";
|
||||||
import { createInsightsRouter } from "../insights-routes.js";
|
import { createInsightsRouter } from "../insights-routes.js";
|
||||||
|
import { createResearchRouter } from "../research-routes.js";
|
||||||
import { createTodoRouter } from "../todo-routes.js";
|
import { createTodoRouter } from "../todo-routes.js";
|
||||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||||
import type { AiSessionStore } from "../ai-session-store.js";
|
import type { AiSessionStore } from "../ai-session-store.js";
|
||||||
@@ -33,6 +34,7 @@ export function registerIntegratedRouters({
|
|||||||
|
|
||||||
router.use("/roadmaps", createRoadmapRouter(store));
|
router.use("/roadmaps", createRoadmapRouter(store));
|
||||||
router.use("/insights", createInsightsRouter(store));
|
router.use("/insights", createInsightsRouter(store));
|
||||||
|
router.use("/research", createResearchRouter(store));
|
||||||
router.use("/todos", createTodoRouter(store));
|
router.use("/todos", createTodoRouter(store));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user