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:
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