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:
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
256
packages/engine/src/__tests__/research-orchestrator.test.ts
Normal file
256
packages/engine/src/__tests__/research-orchestrator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
packages/engine/src/__tests__/research-step-runner.test.ts
Normal file
102
packages/engine/src/__tests__/research-step-runner.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
517
packages/engine/src/research-orchestrator.ts
Normal file
517
packages/engine/src/research-orchestrator.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
packages/engine/src/research-step-runner.ts
Normal file
235
packages/engine/src/research-step-runner.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user