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

Commits merged:
- fix(FN-2992): align ResearchView styles with design tokens
- fix(FN-2992): align Research view with standalone layout conventions
- test(FN-2992): complete Step 6 — expand orchestration verification
- feat(FN-2992): complete Step 5 — wire research settings and engine exports
- test(FN-2992): cover provider error classification in step runner
- feat(FN-2992): complete Step 4 — implement research step runner
- test(FN-2992): add orchestrator lifecycle regression coverage
- feat(FN-2992): complete Step 3 — add research orchestrator lifecycle
- feat(FN-2992): complete Step 2 — align research store API events
- feat(FN-2992): complete Step 1 — define orchestration domain types
- feat(FN-2992): complete Step 1 — define orchestration domain types

Files changed:
packages/core/src/index.ts                         |  15 +
 packages/core/src/research-store.ts                |   8 +-
 packages/core/src/research-types.ts                | 109 +++++
 packages/core/src/settings-schema.ts               |  10 +
 packages/core/src/types.ts                         |  31 ++
 packages/dashboard/app/components/ResearchView.css |  87 +++-
 packages/dashboard/app/components/ResearchView.tsx | 144 +++---
 .../app/components/__tests__/ResearchView.test.tsx |   3 +-
 .../src/__tests__/research-orchestrator.test.ts    | 256 ++++++++++
 .../src/__tests__/research-step-runner.test.ts     | 102 ++++
 packages/engine/src/index.ts                       |  11 +
 packages/engine/src/project-engine.ts              |  17 +
 packages/engine/src/research-orchestrator.ts       | 517 +++++++++++++++++++++
 packages/engine/src/research-step-runner.ts        | 235 ++++++++++
 14 files changed, 1464 insertions(+), 81 deletions(-)

Fusion-Task-Id: FN-2992
This commit is contained in:
Fusion
2026-04-30 00:47:01 -07:00
committed by gsxdsm
parent b359cb8672
commit a227d5b305
14 changed files with 1466 additions and 83 deletions

View File

@@ -585,6 +585,8 @@ export {
RESEARCH_EXPORT_FORMATS, RESEARCH_EXPORT_FORMATS,
RESEARCH_SOURCE_TYPES, RESEARCH_SOURCE_TYPES,
RESEARCH_EVENT_TYPES, RESEARCH_EVENT_TYPES,
RESEARCH_ORCHESTRATION_PHASES,
RESEARCH_ORCHESTRATION_STEP_STATUSES,
} from "./research-types.js"; } from "./research-types.js";
export type { export type {
ResearchRunStatus, ResearchRunStatus,
@@ -603,6 +605,19 @@ export type {
ResearchRunUpdateInput, ResearchRunUpdateInput,
ResearchRunListOptions, ResearchRunListOptions,
ResearchStoreEvents, ResearchStoreEvents,
ResearchOrchestrationPhase,
ResearchOrchestrationStepStatus,
ResearchOrchestrationStepType,
ResearchOrchestrationStep,
ResearchOrchestrationEventType,
ResearchOrchestrationEvent,
ResearchProviderConfig,
ResearchOrchestrationProvider,
ResearchModelSettings,
ResearchOrchestrationConfig,
ResearchSynthesisRequest,
ResearchSynthesisResult,
ResearchCancellationState,
} from "./research-types.js"; } from "./research-types.js";
export { TodoStore } from "./todo-store.js"; export { TodoStore } from "./todo-store.js";

View File

@@ -168,7 +168,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
return deleted; return deleted;
} }
appendEvent(runId: string, event: Omit<ResearchEvent, "id" | "timestamp">): ResearchEvent { addEvent(runId: string, event: Omit<ResearchEvent, "id" | "timestamp">): ResearchEvent {
const run = this.getRun(runId); const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`); if (!run) throw new Error(`Research run not found: ${runId}`);
@@ -181,15 +181,21 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
}; };
this.updateRun(runId, { events: [...run.events, created] }); this.updateRun(runId, { events: [...run.events, created] });
this.emit("event:added", { runId, event: created });
return created; return created;
} }
appendEvent(runId: string, event: Omit<ResearchEvent, "id" | "timestamp">): ResearchEvent {
return this.addEvent(runId, event);
}
addSource(runId: string, source: Omit<ResearchSource, "id">): ResearchSource { addSource(runId: string, source: Omit<ResearchSource, "id">): ResearchSource {
const run = this.getRun(runId); const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`); if (!run) throw new Error(`Research run not found: ${runId}`);
const created: ResearchSource = { ...source, id: generateId("RSRC") }; const created: ResearchSource = { ...source, id: generateId("RSRC") };
this.updateRun(runId, { sources: [...run.sources, created] }); this.updateRun(runId, { sources: [...run.sources, created] });
this.emit("source:added", { runId, source: created });
return created; return created;
} }

View File

@@ -159,4 +159,113 @@ export interface ResearchStoreEvents {
"run:completed": [ResearchRun]; "run:completed": [ResearchRun];
"run:failed": [ResearchRun]; "run:failed": [ResearchRun];
"run:cancelled": [ResearchRun]; "run:cancelled": [ResearchRun];
"event:added": [{ runId: string; event: ResearchEvent }];
"source:added": [{ runId: string; source: ResearchSource }];
}
export const RESEARCH_ORCHESTRATION_PHASES = [
"planning",
"searching",
"fetching",
"synthesizing",
"finalizing",
"completed",
"failed",
"cancelled",
] as const;
export type ResearchOrchestrationPhase = typeof RESEARCH_ORCHESTRATION_PHASES[number];
export const RESEARCH_ORCHESTRATION_STEP_STATUSES = ["pending", "running", "completed", "failed", "skipped"] as const;
export type ResearchOrchestrationStepStatus = typeof RESEARCH_ORCHESTRATION_STEP_STATUSES[number];
export type ResearchOrchestrationStepType = "source-query" | "content-fetch" | "synthesis-pass";
export interface ResearchOrchestrationStep {
id: string;
type: ResearchOrchestrationStepType;
phase: ResearchOrchestrationPhase;
status: ResearchOrchestrationStepStatus;
order: number;
name: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
startedAt?: string;
completedAt?: string;
}
export type ResearchOrchestrationEventType =
| "phase-changed"
| "step-started"
| "step-completed"
| "step-failed"
| "source-found"
| "synthesis-progress"
| "run-cancelled";
export interface ResearchOrchestrationEvent {
type: ResearchOrchestrationEventType;
phase: ResearchOrchestrationPhase;
message: string;
stepId?: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface ResearchProviderConfig {
timeoutMs?: number;
rateLimitPerMinute?: number;
maxResults?: number;
metadata?: Record<string, unknown>;
}
export interface ResearchOrchestrationProvider {
type: string;
config?: ResearchProviderConfig;
}
export interface ResearchModelSettings {
provider?: string;
modelId?: string;
temperature?: number;
maxTokens?: number;
timeoutMs?: number;
}
export interface ResearchOrchestrationConfig {
providers: ResearchOrchestrationProvider[];
maxSources: number;
maxSynthesisRounds: number;
phaseTimeoutMs?: number;
stepTimeoutMs?: number;
rateLimitPerMinute?: number;
synthesisModel?: ResearchModelSettings;
metadata?: Record<string, unknown>;
}
export interface ResearchSynthesisRequest {
query: string;
sources: ResearchSource[];
round: number;
desiredFormat?: "markdown" | "json" | "bullets";
instructions?: string;
}
export interface ResearchSynthesisResult {
output: string;
citations: string[];
confidence?: number;
usage?: ResearchTokenUsage;
metadata?: Record<string, unknown>;
}
export interface ResearchCancellationState {
runId: string;
controller: AbortController;
requestedAt: string;
acknowledgedAt?: string;
gracefulShutdown: boolean;
reason?: string;
} }

View File

@@ -64,6 +64,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
// Dashboard TUI memory guard // Dashboard TUI memory guard
vitestAutoKillEnabled: true, vitestAutoKillEnabled: true,
vitestKillThresholdPct: 90, vitestKillThresholdPct: 90,
researchGlobalEnabled: true,
researchGlobalMaxConcurrentRuns: 3,
researchGlobalDefaultTimeout: 300000,
researchGlobalMaxSourcesPerRun: 20,
researchGlobalMaxSynthesisRounds: 2,
} satisfies CompleteSettings<GlobalSettings>; } satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */ /** Default values for project-level settings. */
@@ -212,6 +217,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
reflectionAfterTask: true, reflectionAfterTask: true,
reviewHandoffPolicy: "disabled", reviewHandoffPolicy: "disabled",
showQuickChatFAB: false, showQuickChatFAB: false,
researchEnabled: true,
researchMaxConcurrentRuns: 3,
researchDefaultTimeout: 300000,
researchMaxSourcesPerRun: 20,
researchMaxSynthesisRounds: 2,
experimentalFeatures: {}, experimentalFeatures: {},
} satisfies CompleteSettings<ProjectSettings>; } satisfies CompleteSettings<ProjectSettings>;

View File

@@ -1389,6 +1389,22 @@ export interface GlobalSettings {
* triggers a vitest auto-kill. Clamped to [50, 99] in the UI. * triggers a vitest auto-kill. Clamped to [50, 99] in the UI.
* Default: 90. */ * Default: 90. */
vitestKillThresholdPct?: number; vitestKillThresholdPct?: number;
/** Enable or disable the research subsystem globally.
* When false, dashboard/API entrypoints should reject new research runs.
* Default: true when research store exists. */
researchGlobalEnabled?: boolean;
/** Maximum concurrent research runs allowed by default.
* Default: 3. */
researchGlobalMaxConcurrentRuns?: number;
/** Default timeout for end-to-end research runs in milliseconds.
* Default: 300000 (5 minutes). */
researchGlobalDefaultTimeout?: number;
/** Default maximum number of sources the orchestrator may fetch per run.
* Default: 20. */
researchGlobalMaxSourcesPerRun?: number;
/** Default maximum number of synthesis rounds per run.
* Default: 2. */
researchGlobalMaxSynthesisRounds?: number;
} }
export type RemoteAccessProvider = "tailscale" | "cloudflare"; export type RemoteAccessProvider = "tailscale" | "cloudflare";
@@ -1514,6 +1530,21 @@ export interface ProjectSettings {
* - "block": prevent execution until the selected node is healthy/available (default) * - "block": prevent execution until the selected node is healthy/available (default)
* - "fallback-local": run on the local node when the selected node is unavailable */ * - "fallback-local": run on the local node when the selected node is unavailable */
unavailableNodePolicy?: UnavailableNodePolicy; unavailableNodePolicy?: UnavailableNodePolicy;
/** Enable or disable the research subsystem for this project.
* When undefined, falls back to global settings. */
researchEnabled?: boolean;
/** Project-level maximum concurrent research runs.
* When undefined, falls back to global settings (default 3). */
researchMaxConcurrentRuns?: number;
/** Project-level default run timeout in milliseconds.
* When undefined, falls back to global settings (default 300000). */
researchDefaultTimeout?: number;
/** Project-level source fetch cap per run.
* When undefined, falls back to global settings (default 20). */
researchMaxSourcesPerRun?: number;
/** Project-level synthesis round cap per run.
* When undefined, falls back to global settings (default 2). */
researchMaxSynthesisRounds?: number;
/** ID of the pinned default execution node. Tasks without a per-task override run on this node. */ /** ID of the pinned default execution node. Tasks without a per-task override run on this node. */
defaultNodeId?: string; defaultNodeId?: string;
/** Shell command to run inside each new worktree immediately after creation. /** Shell command to run inside each new worktree immediately after creation.

View File

@@ -1,8 +1,17 @@
.research-view { .research-view {
--research-font-size-label: 0.75rem;
--research-font-size-body: 0.8125rem;
--research-font-size-title: 1.125rem;
--research-font-size-subtitle: 0.8125rem;
--research-font-size-state-title: 1rem;
--research-font-size-run-title: 0.9375rem;
--research-font-size-stat-value: 1.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-lg); height: 100%;
padding: var(--space-lg); min-height: 0;
overflow: hidden;
} }
.research-view__header { .research-view__header {
@@ -10,16 +19,35 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.research-view__content {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
} }
.research-view__title { .research-view__title {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-size: var(--research-font-size-title);
font-weight: 600;
line-height: 1.3;
} }
.research-view__subtitle { .research-view__subtitle {
margin: var(--space-xs) 0 0; margin: var(--space-xs) 0 0;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--research-font-size-subtitle);
line-height: 1.5;
} }
.research-view__state { .research-view__state {
@@ -28,7 +56,6 @@
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-lg); padding: var(--space-lg);
} }
.research-view__state--error { .research-view__state--error {
border-color: var(--color-error); border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 12%, var(--card)); background: color-mix(in srgb, var(--color-error) 12%, var(--card));
@@ -46,12 +73,20 @@
.research-view__stat-label { .research-view__stat-label {
color: var(--text-muted); color: var(--text-muted);
font-size: var(--research-font-size-label);
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
text-transform: uppercase;
} }
.research-view__stat-value { .research-view__stat-value {
margin-top: var(--space-xs); margin-top: var(--space-xs);
color: var(--text); color: var(--text);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--research-font-size-stat-value);
font-weight: 600;
line-height: 1.2;
} }
.research-view__list { .research-view__list {
@@ -73,34 +108,62 @@
gap: var(--space-sm); 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 { .research-view__run-title {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-size: var(--research-font-size-run-title);
font-weight: 600;
line-height: 1.4;
} }
.research-view__run-query { .research-view__run-query {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
.research-view__run-summary {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-body);
line-height: 1.5;
}
.research-view__state-title {
margin: 0;
color: var(--text);
font-size: var(--research-font-size-state-title);
font-weight: 600;
line-height: 1.3;
}
.research-view__state-copy {
margin: 0;
color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
} }
.research-view__hint { .research-view__hint {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--research-font-size-body);
line-height: 1.5;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.research-view { .research-view__header {
flex-direction: column;
padding: var(--space-md); padding: var(--space-md);
} }
.research-view__header { .research-view__title {
flex-direction: column; font-size: var(--research-font-size-state-title);
}
.research-view__content {
padding: var(--space-md);
} }
.research-view__stats, .research-view__stats,

View File

@@ -67,79 +67,89 @@ export function ResearchView({ projectId, addToast }: ResearchViewProps) {
</button> </button>
</header> </header>
{isLoading && ( <div className="research-view__content">
<div className="research-view__state card" data-testid="research-state-loading"> {isLoading && (
Loading research runs <div className="research-view__state card" data-testid="research-state-loading">
</div> Loading research runs
)}
{!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>
)}
<div className="research-view__list"> {!isLoading && error && (
{runs.map((run) => ( <div className="research-view__state research-view__state--error card" data-testid="research-state-error">
<article key={run.id} className="card research-view__run-card"> <p>{error}</p>
<div className="research-view__run-head"> <button className="btn btn-danger" type="button" onClick={() => void load()}>
<span Retry
className={`card-status-badge ${ </button>
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> </div>
)}
{!hasResults && ( {!isLoading && !error && runs.length === 0 && (
<p className="research-view__hint">Runs are active, but no summarized results are available yet.</p> <div className="research-view__state card" data-testid="research-state-empty">
)} <p className="research-view__state-title">No research runs yet</p>
</> <p className="research-view__state-copy">
)} Connect a research provider to begin collecting sources and generating synthesis reports. New runs can be
started through the API today and will appear here automatically.
</p>
</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"
? "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 className="research-view__run-summary" 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>
)}
</>
)}
</div>
</section> </section>
); );
} }

View File

@@ -144,7 +144,8 @@ describe("ResearchView", () => {
render(<ResearchView projectId="p1" />); render(<ResearchView projectId="p1" />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Failed")).toHaveClass("research-view__status-badge--failed"); expect(screen.getByText("Failed")).toHaveClass("failed");
expect(screen.getByText("Failed")).toHaveClass("card-status-badge");
}); });
}); });

View File

@@ -0,0 +1,256 @@
import { describe, expect, it, vi } from "vitest";
import type { ResearchRun, ResearchSource } from "@fusion/core";
import { ResearchOrchestrator } from "../research-orchestrator.js";
function createHarness() {
const runs = new Map<string, ResearchRun>();
const counter = { value: 0 };
const store = {
createRun: vi.fn((input: { query: string; providerConfig?: Record<string, unknown>; metadata?: Record<string, unknown> }) => {
const id = `RR-test-${++counter.value}`;
const now = new Date().toISOString();
const run: ResearchRun = {
id,
query: input.query,
status: "pending",
providerConfig: input.providerConfig,
sources: [],
events: [],
tags: [],
metadata: input.metadata,
createdAt: now,
updatedAt: now,
};
runs.set(id, run);
return run;
}),
getRun: vi.fn((id: string) => runs.get(id)),
updateRun: vi.fn((id: string, patch: Partial<ResearchRun>) => {
const run = runs.get(id);
if (!run) return undefined;
const next = { ...run, ...patch, updatedAt: new Date().toISOString() };
runs.set(id, next);
return next;
}),
addEvent: vi.fn((id: string, event: { type: string; message: string; metadata?: Record<string, unknown> }) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
run.events.push({
id: `evt-${run.events.length + 1}`,
timestamp: new Date().toISOString(),
type: event.type as never,
message: event.message,
metadata: event.metadata,
});
return run.events.at(-1)!;
}),
addSource: vi.fn((id: string, source: Omit<ResearchSource, "id">) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
const created: ResearchSource = { ...source, id: `src-${run.sources.length + 1}` };
run.sources.push(created);
return created;
}),
updateSource: vi.fn((id: string, sourceId: string, patch: Partial<ResearchSource>) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
run.sources = run.sources.map((s) => (s.id === sourceId ? { ...s, ...patch } : s));
}),
setResults: vi.fn((id: string, results: ResearchRun["results"]) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
run.results = results;
}),
updateStatus: vi.fn((id: string, status: ResearchRun["status"], extra?: Partial<ResearchRun>) => {
const run = runs.get(id);
if (!run) throw new Error("missing run");
runs.set(id, { ...run, ...extra, status });
}),
};
const stepRunner = {
runSourceQuery: vi.fn(async () => ({ ok: true, data: [{ type: "web", reference: "https://example.com", status: "pending" }] })),
runContentFetch: vi.fn(async () => ({ ok: true, data: { content: "body", metadata: { lang: "en" } } })),
runSynthesis: vi.fn(async () => ({ ok: true, data: { output: "summary", citations: ["src-1"], confidence: 0.9 } })),
};
return { store, stepRunner, runs };
}
describe("ResearchOrchestrator", () => {
it("runs full lifecycle and completes", async () => {
const { store, stepRunner } = createHarness();
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 2,
});
const runId = orchestrator.createRun({
providers: [{ type: "web" }],
maxSources: 2,
maxSynthesisRounds: 1,
});
const run = await orchestrator.startRun(runId, "fusion research");
expect(run.status).toBe("completed");
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(1);
expect(stepRunner.runContentFetch).toHaveBeenCalledTimes(1);
expect(stepRunner.runSynthesis).toHaveBeenCalledTimes(1);
const status = orchestrator.getRunStatus(runId);
expect(status.phase).toBe("completed");
});
it("cancels a running run", async () => {
const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery.mockImplementation(
(async (_query: string, _provider: string, _config: unknown, signal?: AbortSignal) => {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 100);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("aborted"));
});
});
return { ok: true, data: [] };
}) as never,
);
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 1,
});
const runId = orchestrator.createRun({
providers: [{ type: "web" }],
maxSources: 2,
maxSynthesisRounds: 1,
});
const runPromise = orchestrator.startRun(runId, "cancel me");
await Promise.resolve();
expect(orchestrator.cancelRun(runId)).toBe(true);
const run = await runPromise;
expect(run.status).toBe("cancelled");
});
it("records step failures and continues when later providers succeed", async () => {
const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "provider down" } } as never)
.mockResolvedValueOnce({ ok: true, data: [{ type: "web", reference: "https://backup.com", status: "pending" }] } as never);
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 2,
});
const runId = orchestrator.createRun({
providers: [{ type: "primary" }, { type: "backup" }],
maxSources: 2,
maxSynthesisRounds: 1,
});
const run = await orchestrator.startRun(runId, "fallback query");
expect(run.status).toBe("completed");
expect(store.addEvent).toHaveBeenCalledWith(
runId,
expect.objectContaining({
type: "error",
metadata: expect.objectContaining({ orchestrationEventType: "step-failed" }),
}),
);
});
it("emits step-failed for timeout-classified step errors", async () => {
const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery
.mockResolvedValueOnce({ ok: false, error: { code: "timeout", message: "search timed out" } } as never)
.mockResolvedValueOnce({ ok: true, data: [{ type: "web", reference: "https://backup.com", status: "pending" }] } as never);
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 1,
});
const runId = orchestrator.createRun({
providers: [{ type: "slow" }, { type: "backup" }],
maxSources: 1,
maxSynthesisRounds: 1,
});
await orchestrator.startRun(runId, "timeout query");
expect(store.addEvent).toHaveBeenCalledWith(
runId,
expect.objectContaining({
type: "error",
message: expect.stringContaining("failed"),
metadata: expect.objectContaining({ orchestrationEventType: "step-failed" }),
}),
);
});
it("respects max concurrent run limit", async () => {
const { store, stepRunner } = createHarness();
let releaseFirst: (() => void) | undefined;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
stepRunner.runSourceQuery.mockImplementationOnce(async () => {
await firstBlocked;
return { ok: true, data: [{ type: "web", reference: "https://example.com/a", status: "pending" }] };
});
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 1,
});
const runA = orchestrator.createRun({ providers: [{ type: "web" }], maxSources: 1, maxSynthesisRounds: 1 });
const runB = orchestrator.createRun({ providers: [{ type: "web" }], maxSources: 1, maxSynthesisRounds: 1 });
const p1 = orchestrator.startRun(runA, "A");
await Promise.resolve();
const p2 = orchestrator.startRun(runB, "B");
await Promise.resolve();
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(1);
releaseFirst?.();
await p1;
await p2;
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2);
});
it("retries failed run with inherited config", () => {
const { store } = createHarness();
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: {
runSourceQuery: vi.fn(),
runContentFetch: vi.fn(),
runSynthesis: vi.fn(),
},
});
const baseId = orchestrator.createRun({
providers: [{ type: "web", config: { timeoutMs: 1000 } }],
maxSources: 1,
maxSynthesisRounds: 1,
});
store.updateStatus(baseId, "failed", { error: "boom" });
const retryId = orchestrator.retryRun(baseId);
expect(retryId).not.toBe(baseId);
const retried = store.getRun(retryId)!;
expect(retried.metadata?.retryOfRunId).toBe(baseId);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { ResearchStepRunner } from "../research-step-runner.js";
describe("ResearchStepRunner", () => {
it("returns provider_not_configured when provider missing", async () => {
const runner = new ResearchStepRunner();
const result = await runner.runSourceQuery("hello", "web");
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("provider_not_configured");
});
it("classifies timeout errors", async () => {
const provider = {
type: "web",
isConfigured: () => true,
search: async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
return [];
},
fetchContent: async () => ({ content: "", metadata: {} }),
};
const runner = new ResearchStepRunner({ providers: [provider] });
const result = await runner.runSourceQuery("q", "web", { timeoutMs: 1 });
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("timeout");
});
it("classifies provider errors", async () => {
const provider = {
type: "web",
isConfigured: () => true,
search: async () => {
throw new Error("rate limit exceeded");
},
fetchContent: async () => ({ content: "", metadata: {} }),
};
const runner = new ResearchStepRunner({ providers: [provider] });
const result = await runner.runSourceQuery("q", "web");
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("provider_error");
expect(result.error?.message).toContain("rate limit exceeded");
});
it("propagates abort signals", async () => {
const provider = {
type: "web",
isConfigured: () => true,
search: async (_query: string, _options: unknown, signal?: AbortSignal) => {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 50);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("aborted by user"));
});
});
return [];
},
fetchContent: async () => ({ content: "", metadata: {} }),
};
const runner = new ResearchStepRunner({ providers: [provider] });
const ac = new AbortController();
const promise = runner.runSourceQuery("q", "web", { timeoutMs: 3000 }, ac.signal);
ac.abort();
const result = await promise;
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("aborted");
});
it("returns provider_not_configured for content fetch without configured providers", async () => {
const runner = new ResearchStepRunner();
const result = await runner.runContentFetch("https://example.com");
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("provider_not_configured");
});
it("returns provider_not_configured for synthesis when no runner configured", async () => {
const runner = new ResearchStepRunner();
const result = await runner.runSynthesis({ query: "q", sources: [], round: 1 });
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("provider_not_configured");
});
it("classifies synthesis timeout", async () => {
const runner = new ResearchStepRunner({
synthesisRunner: async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
return { output: "done", citations: [] };
},
});
const result = await runner.runSynthesis(
{ query: "q", sources: [], round: 1 },
{ timeoutMs: 1 },
);
expect(result.ok).toBe(false);
expect(result.error?.code).toBe("timeout");
});
});

View File

@@ -53,6 +53,17 @@ export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWo
export { createLogger, type Logger } from "./logger.js"; export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js"; export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js"; export { withRateLimitRetry } from "./rate-limit-retry.js";
export { ResearchOrchestrator, type ResearchOrchestratorOptions, type ResearchOrchestratorStatus, type ResearchOrchestratorStartOptions } from "./research-orchestrator.js";
export {
ResearchStepRunner,
ResearchStepTimeoutError,
ResearchStepAbortError,
ResearchStepProviderError,
type ResearchProvider,
type ResearchStepRunnerApi,
type ResearchStepRunnerOptions,
type ResearchStepResult,
} from "./research-step-runner.js";
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js"; export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
export { PrCommentHandler } from "./pr-comment-handler.js"; export { PrCommentHandler } from "./pr-comment-handler.js";
export { export {

View File

@@ -24,6 +24,8 @@ import { aiMergeTask } from "./merger.js";
import { PRIORITY_MERGE } from "./concurrency.js"; import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js"; import { runtimeLog } from "./logger.js";
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchStepRunner } from "./research-step-runner.js";
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js"; import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
import type { import type {
TunnelProvider, TunnelProvider,
@@ -142,6 +144,7 @@ export class ProjectEngine {
private gridlockDetector?: GridlockDetector; private gridlockDetector?: GridlockDetector;
private cronRunner?: CronRunner; private cronRunner?: CronRunner;
private automationStore?: AutomationStoreType; private automationStore?: AutomationStoreType;
private researchOrchestrator?: ResearchOrchestrator;
private remoteTunnelManager?: TunnelProcessManager; private remoteTunnelManager?: TunnelProcessManager;
private remoteTunnelRestoreDiagnostics: TunnelRestoreDiagnostics = { private remoteTunnelRestoreDiagnostics: TunnelRestoreDiagnostics = {
outcome: "skipped", outcome: "skipped",
@@ -243,6 +246,15 @@ export class ProjectEngine {
const store = this.runtime.getTaskStore(); const store = this.runtime.getTaskStore();
const cwd = this.config.workingDirectory; const cwd = this.config.workingDirectory;
const settings = await store.getSettings();
if (typeof (store as { getResearchStore?: () => unknown }).getResearchStore === "function") {
this.researchOrchestrator = new ResearchOrchestrator({
store: store.getResearchStore(),
stepRunner: new ResearchStepRunner(),
maxConcurrentRuns: settings.researchMaxConcurrentRuns ?? 3,
});
}
this.remoteTunnelManager = new TunnelProcessManager(); this.remoteTunnelManager = new TunnelProcessManager();
try { try {
@@ -546,6 +558,11 @@ export class ProjectEngine {
return this.runtime.getRoutineStore(); return this.runtime.getRoutineStore();
} }
/** Get the ResearchOrchestrator (if initialized). Returns undefined before start(). */
getResearchOrchestrator(): ResearchOrchestrator | undefined {
return this.researchOrchestrator;
}
/** Get the remote tunnel manager (available after start()). */ /** Get the remote tunnel manager (available after start()). */
getRemoteTunnelManager(): TunnelProcessManager | undefined { getRemoteTunnelManager(): TunnelProcessManager | undefined {
return this.remoteTunnelManager; return this.remoteTunnelManager;

View File

@@ -0,0 +1,517 @@
import type { ResearchStore } from "@fusion/core";
import type {
ResearchCancellationState,
ResearchOrchestrationConfig,
ResearchOrchestrationPhase,
ResearchOrchestrationStep,
ResearchRun,
ResearchSource,
ResearchSynthesisRequest,
} from "@fusion/core";
import { AgentSemaphore } from "./concurrency.js";
import { createLogger, formatError } from "./logger.js";
import type { ResearchStepRunnerApi } from "./research-step-runner.js";
const log = createLogger("research-orchestrator");
export interface ResearchOrchestratorStatus {
runId: string;
status: ResearchRun["status"];
phase: ResearchOrchestrationPhase;
stepIndex: number;
totalSteps: number;
progress: number;
active: boolean;
}
export interface ResearchOrchestratorStartOptions {
abortSignal?: AbortSignal;
}
export interface ResearchOrchestratorOptions {
store: ResearchStore;
stepRunner: ResearchStepRunnerApi;
maxConcurrentRuns?: number;
}
interface ActiveRunState {
controller: AbortController;
phase: ResearchOrchestrationPhase;
stepIndex: number;
totalSteps: number;
config: ResearchOrchestrationConfig;
}
export class ResearchOrchestrator {
private readonly store: ResearchStore;
private readonly stepRunner: ResearchStepRunnerApi;
private readonly semaphore: AgentSemaphore;
private readonly activeRuns = new Map<string, ActiveRunState>();
private readonly cancellation = new Map<string, ResearchCancellationState>();
constructor(options: ResearchOrchestratorOptions) {
this.store = options.store;
this.stepRunner = options.stepRunner;
this.semaphore = new AgentSemaphore(options.maxConcurrentRuns ?? 3);
}
createRun(config: ResearchOrchestrationConfig): string {
const run = this.store.createRun({
query: "",
providerConfig: config as unknown as Record<string, unknown>,
metadata: {
orchestration: {
phase: "planning",
stepIndex: 0,
totalSteps: this.computeTotalSteps(config),
},
},
});
return run.id;
}
async startRun(runId: string, query: string, options: ResearchOrchestratorStartOptions = {}): Promise<ResearchRun> {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const config = (run.providerConfig ?? {}) as unknown as ResearchOrchestrationConfig;
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", () => controller.abort(options.abortSignal?.reason), { once: true });
}
const totalSteps = this.computeTotalSteps(config);
this.activeRuns.set(runId, {
controller,
phase: "planning",
stepIndex: 0,
totalSteps,
config,
});
await this.semaphore.run(async () => {
this.store.updateRun(runId, { query, status: "running", startedAt: new Date().toISOString(), error: null });
await this.runPhases(runId, query, config, controller.signal);
});
const updated = this.store.getRun(runId);
if (!updated) throw new Error(`Research run not found after start: ${runId}`);
return updated;
}
cancelRun(runId: string): boolean {
const active = this.activeRuns.get(runId);
if (!active) return false;
const state: ResearchCancellationState = {
runId,
controller: active.controller,
requestedAt: new Date().toISOString(),
gracefulShutdown: true,
reason: "Cancelled by user",
};
this.cancellation.set(runId, state);
active.controller.abort(new Error("Research run cancelled"));
return true;
}
retryRun(runId: string): string {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (run.status !== "failed" && run.status !== "cancelled") {
throw new Error(`Research run ${runId} is not retryable (status=${run.status})`);
}
const next = this.store.createRun({
query: run.query,
topic: run.topic,
providerConfig: run.providerConfig,
tags: [...run.tags],
metadata: {
...(run.metadata ?? {}),
retryOfRunId: run.id,
},
});
this.store.addEvent(next.id, {
type: "info",
message: `Retry run created from ${run.id}`,
metadata: { retryOfRunId: run.id },
});
return next.id;
}
getRunStatus(runId: string): ResearchOrchestratorStatus {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const active = this.activeRuns.get(runId);
const metadata = (run.metadata?.orchestration as Record<string, unknown> | undefined) ?? {};
const phase = (active?.phase ?? metadata.phase ?? this.statusToPhase(run.status)) as ResearchOrchestrationPhase;
const stepIndex = active?.stepIndex ?? Number(metadata.stepIndex ?? 0);
const totalSteps = active?.totalSteps ?? Number(metadata.totalSteps ?? 0);
return {
runId,
status: run.status,
phase,
stepIndex,
totalSteps,
progress: totalSteps > 0 ? Math.min(1, stepIndex / totalSteps) : 0,
active: this.activeRuns.has(runId),
};
}
private async runPhases(
runId: string,
query: string,
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<void> {
try {
await this.runPlanning(runId, query, config, signal);
const sources = await this.runSearching(runId, query, config, signal);
const fetchedSources = await this.runFetching(runId, sources, config, signal);
const synthesis = await this.runSynthesis(runId, query, fetchedSources, config, signal);
await this.runFinalizing(runId, synthesis.output, synthesis.citations, synthesis.confidence, signal);
this.store.updateStatus(runId, "completed");
this.transitionPhase(runId, "completed", "Research run completed");
} catch (err) {
if (signal.aborted) {
this.onCancelled(runId);
} else {
const { message, detail } = formatError(err);
this.store.addEvent(runId, {
type: "error",
message: `Research run failed: ${message}`,
metadata: { detail },
});
this.store.updateStatus(runId, "failed", { error: message });
this.transitionPhase(runId, "failed", "Research run failed", { error: message });
}
} finally {
this.activeRuns.delete(runId);
this.cancellation.delete(runId);
}
}
private async runPlanning(runId: string, query: string, config: ResearchOrchestrationConfig, _signal: AbortSignal): Promise<void> {
this.transitionPhase(runId, "planning", "Planning research execution");
this.stepStarted(runId, {
id: `${runId}-planning`,
type: "synthesis-pass",
phase: "planning",
status: "running",
order: 0,
name: "Create plan",
input: { query, providerCount: config.providers.length },
startedAt: new Date().toISOString(),
});
this.stepCompleted(runId, `${runId}-planning`, { query });
}
private async runSearching(
runId: string,
query: string,
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<ResearchSource[]> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "searching", "Searching sources");
const allSources: ResearchSource[] = [];
for (const provider of config.providers) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "source-query", "searching", `Search with ${provider.type}`, {
query,
provider: provider.type,
});
this.stepStarted(runId, step);
const result = await this.stepRunner.runSourceQuery(query, provider.type, provider.config, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? `Provider ${provider.type} returned no data`, result.error);
continue;
}
for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) {
const saved = this.store.addSource(runId, source);
allSources.push(saved);
this.store.addEvent(runId, {
type: "source_added",
message: `Source found: ${saved.reference}`,
metadata: { sourceId: saved.id, provider: provider.type },
});
}
this.stepCompleted(runId, step.id, { sourceCount: result.data.length });
if (allSources.length >= config.maxSources) break;
}
if (allSources.length === 0) {
throw new Error("No sources discovered during search phase");
}
return allSources;
}
private async runFetching(
runId: string,
sources: ResearchSource[],
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<ResearchSource[]> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "fetching", "Fetching source content");
const fetched: ResearchSource[] = [];
const provider = config.providers[0];
for (const source of sources.slice(0, config.maxSources)) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "content-fetch", "fetching", `Fetch ${source.reference}`, {
sourceId: source.id,
});
this.stepStarted(runId, step);
const result = await this.stepRunner.runContentFetch(source.reference, provider?.config, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error);
continue;
}
const updated: ResearchSource = {
...source,
content: result.data.content,
metadata: {
...(source.metadata ?? {}),
...(result.data.metadata ?? {}),
},
status: "completed",
fetchedAt: new Date().toISOString(),
};
this.store.updateSource(runId, source.id, updated);
fetched.push(updated);
this.stepCompleted(runId, step.id, { fetched: true });
}
if (fetched.length === 0) {
throw new Error("No source content fetched");
}
return fetched;
}
private async runSynthesis(
runId: string,
query: string,
sources: ResearchSource[],
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<{ output: string; citations: string[]; confidence?: number }> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "synthesizing", "Synthesizing findings");
let final: { output: string; citations: string[]; confidence?: number } | undefined;
for (let round = 1; round <= Math.max(1, config.maxSynthesisRounds); round++) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "synthesis-pass", "synthesizing", `Synthesis round ${round}`, {
round,
});
this.stepStarted(runId, step);
const request: ResearchSynthesisRequest = {
query,
sources,
round,
desiredFormat: "markdown",
};
const result = await this.stepRunner.runSynthesis(request, config.synthesisModel, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? "Synthesis failed", result.error);
continue;
}
final = result.data;
this.store.addEvent(runId, {
type: "progress",
message: `Synthesis round ${round} completed`,
metadata: { round, confidence: result.data.confidence },
});
this.stepCompleted(runId, step.id, { round, citations: result.data.citations.length });
}
if (!final) {
throw new Error("All synthesis rounds failed");
}
return final;
}
private async runFinalizing(
runId: string,
output: string,
citations: string[],
confidence: number | undefined,
signal: AbortSignal,
): Promise<void> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "finalizing", "Finalizing research results");
this.store.setResults(runId, {
summary: output,
findings: [
{
heading: "Synthesis",
content: output,
sources: citations,
confidence,
},
],
citations,
synthesizedOutput: output,
});
}
private onCancelled(runId: string): void {
const cancellation = this.cancellation.get(runId);
this.store.addEvent(runId, {
type: "warning",
message: "Research run cancelled",
metadata: {
requestedAt: cancellation?.requestedAt,
reason: cancellation?.reason,
},
});
this.store.updateStatus(runId, "cancelled", {
cancelledAt: new Date().toISOString(),
error: cancellation?.reason,
});
this.transitionPhase(runId, "cancelled", "Research run cancelled");
}
private transitionPhase(
runId: string,
phase: ResearchOrchestrationPhase,
message: string,
metadata?: Record<string, unknown>,
): void {
const active = this.activeRuns.get(runId);
if (active) {
active.phase = phase;
}
this.store.updateRun(runId, {
metadata: {
orchestration: {
phase,
stepIndex: active?.stepIndex ?? 0,
totalSteps: active?.totalSteps ?? 0,
},
},
});
this.store.addEvent(runId, {
type: "progress",
message,
metadata: {
orchestrationEventType: "phase-changed",
phase,
...(metadata ?? {}),
},
});
log.log(`${runId}: phase changed -> ${phase}`);
}
private stepStarted(runId: string, step: ResearchOrchestrationStep): void {
this.bumpStep(runId, step.order);
this.store.addEvent(runId, {
type: "progress",
message: `${step.name} started`,
metadata: {
orchestrationEventType: "step-started",
step,
},
});
}
private stepCompleted(runId: string, stepId: string, output?: Record<string, unknown>): void {
this.store.addEvent(runId, {
type: "progress",
message: `${stepId} completed`,
metadata: {
orchestrationEventType: "step-completed",
stepId,
output,
},
});
}
private stepFailed(
runId: string,
stepId: string,
errorMessage: string,
errorMeta?: Record<string, unknown>,
): void {
this.store.addEvent(runId, {
type: "error",
message: `${stepId} failed: ${errorMessage}`,
metadata: {
orchestrationEventType: "step-failed",
stepId,
...(errorMeta ?? {}),
},
});
}
private bumpStep(runId: string, stepIndex: number): void {
const active = this.activeRuns.get(runId);
if (!active) return;
active.stepIndex = stepIndex;
this.store.updateRun(runId, {
metadata: {
orchestration: {
phase: active.phase,
stepIndex: active.stepIndex,
totalSteps: active.totalSteps,
},
},
});
}
private createStep(
runId: string,
type: ResearchOrchestrationStep["type"],
phase: ResearchOrchestrationPhase,
name: string,
input?: Record<string, unknown>,
): ResearchOrchestrationStep {
const active = this.activeRuns.get(runId);
const order = (active?.stepIndex ?? 0) + 1;
return {
id: `${runId}-${phase}-${order}`,
type,
phase,
status: "running",
order,
name,
input,
startedAt: new Date().toISOString(),
};
}
private computeTotalSteps(config: ResearchOrchestrationConfig): number {
const providers = Math.max(1, config.providers.length);
return 1 + providers + Math.max(1, config.maxSources) + Math.max(1, config.maxSynthesisRounds) + 1;
}
private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase {
if (status === "completed") return "completed";
if (status === "failed") return "failed";
if (status === "cancelled") return "cancelled";
return "planning";
}
private throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new Error("Research run aborted");
}
}
}

View File

@@ -0,0 +1,235 @@
import type {
ResearchModelSettings,
ResearchProviderConfig,
ResearchSource,
ResearchSynthesisRequest,
ResearchSynthesisResult,
} from "@fusion/core";
import { createLogger, formatError } from "./logger.js";
const log = createLogger("research-step-runner");
const DEFAULT_QUERY_TIMEOUT_MS = 30_000;
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
const DEFAULT_SYNTHESIS_TIMEOUT_MS = 120_000;
export class ResearchStepTimeoutError extends Error {
constructor(step: string, timeoutMs: number) {
super(`${step} timed out after ${timeoutMs}ms`);
this.name = "ResearchStepTimeoutError";
}
}
export class ResearchStepAbortError extends Error {
constructor(step: string) {
super(`${step} aborted`);
this.name = "ResearchStepAbortError";
}
}
export class ResearchStepProviderError extends Error {
constructor(step: string, message: string) {
super(`${step} provider error: ${message}`);
this.name = "ResearchStepProviderError";
}
}
export interface ResearchProvider {
readonly type: string;
search(query: string, options: ResearchProviderConfig, signal?: AbortSignal): Promise<ResearchSource[]>;
fetchContent(
url: string,
options: ResearchProviderConfig,
signal?: AbortSignal,
): Promise<{ content: string; metadata: Record<string, unknown> }>;
isConfigured(): boolean;
}
export interface ResearchStepResult<T> {
ok: boolean;
data?: T;
error?: {
code: "provider_not_configured" | "timeout" | "aborted" | "provider_error";
message: string;
retryable: boolean;
};
}
export interface ResearchStepRunnerApi {
runSourceQuery(
query: string,
providerType: string,
config?: ResearchProviderConfig,
signal?: AbortSignal,
): Promise<ResearchStepResult<ResearchSource[]>>;
runContentFetch(
url: string,
config?: ResearchProviderConfig,
signal?: AbortSignal,
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>>;
runSynthesis(
request: ResearchSynthesisRequest,
modelSettings?: ResearchModelSettings,
signal?: AbortSignal,
): Promise<ResearchStepResult<ResearchSynthesisResult>>;
}
export interface ResearchStepRunnerOptions {
providers?: ResearchProvider[];
synthesisRunner?: (
request: ResearchSynthesisRequest,
modelSettings: ResearchModelSettings,
signal?: AbortSignal,
) => Promise<ResearchSynthesisResult>;
}
export class ResearchStepRunner implements ResearchStepRunnerApi {
private readonly providers: Map<string, ResearchProvider>;
private readonly synthesisRunner?: ResearchStepRunnerOptions["synthesisRunner"];
constructor(options: ResearchStepRunnerOptions = {}) {
this.providers = new Map((options.providers ?? []).map((provider) => [provider.type, provider]));
this.synthesisRunner = options.synthesisRunner;
}
async runSourceQuery(
query: string,
providerType: string,
config: ResearchProviderConfig = {},
signal?: AbortSignal,
): Promise<ResearchStepResult<ResearchSource[]>> {
const provider = this.providers.get(providerType);
if (!provider || !provider.isConfigured()) {
return this.unconfigured(`provider ${providerType} is not configured`);
}
try {
const data = await this.withTimeout(
`source-query:${providerType}`,
provider.search(query, config, signal),
config.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
signal,
);
return { ok: true, data };
} catch (error) {
return this.classifyError("source-query", error);
}
}
async runContentFetch(
url: string,
config: ResearchProviderConfig = {},
signal?: AbortSignal,
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>> {
const provider = this.findFirstConfiguredProvider();
if (!provider) {
return this.unconfigured("no configured provider available for content fetch");
}
try {
const data = await this.withTimeout(
`content-fetch:${provider.type}`,
provider.fetchContent(url, config, signal),
config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
signal,
);
return { ok: true, data };
} catch (error) {
return this.classifyError("content-fetch", error);
}
}
async runSynthesis(
request: ResearchSynthesisRequest,
modelSettings: ResearchModelSettings = {},
signal?: AbortSignal,
): Promise<ResearchStepResult<ResearchSynthesisResult>> {
if (!this.synthesisRunner) {
return this.unconfigured("synthesis provider is not configured");
}
try {
const timeoutMs = modelSettings.timeoutMs ?? DEFAULT_SYNTHESIS_TIMEOUT_MS;
const data = await this.withTimeout(
"synthesis",
this.synthesisRunner(request, modelSettings, signal),
timeoutMs,
signal,
);
return { ok: true, data };
} catch (error) {
return this.classifyError("synthesis", error);
}
}
private findFirstConfiguredProvider(): ResearchProvider | undefined {
for (const provider of this.providers.values()) {
if (provider.isConfigured()) return provider;
}
return undefined;
}
private classifyError<T>(step: string, error: unknown): ResearchStepResult<T> {
if (error instanceof ResearchStepTimeoutError) {
return { ok: false, error: { code: "timeout", message: error.message, retryable: true } };
}
if (error instanceof ResearchStepAbortError) {
return { ok: false, error: { code: "aborted", message: error.message, retryable: false } };
}
const { message, detail } = formatError(error);
log.warn(`${step} failed`, detail);
return {
ok: false,
error: {
code: "provider_error",
message,
retryable: true,
},
};
}
private unconfigured<T>(message: string): ResearchStepResult<T> {
return {
ok: false,
error: {
code: "provider_not_configured",
message,
retryable: false,
},
};
}
private async withTimeout<T>(
step: string,
promise: Promise<T>,
timeoutMs: number,
signal?: AbortSignal,
): Promise<T> {
if (signal?.aborted) {
throw new ResearchStepAbortError(step);
}
let timeoutId: NodeJS.Timeout | undefined;
let abortListener: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (!signal) return;
abortListener = () => reject(new ResearchStepAbortError(step));
signal.addEventListener("abort", abortListener, { once: true });
});
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new ResearchStepTimeoutError(step, timeoutMs)), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise, abortPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
}
}
}