feat(FN-3292): document research boundary contract
Documents the research boundary contract in the architecture docs and research hardening preflight guide, updating architecture documentation and adding a new boundary definition file. Fusion-Task-Id: FN-3292
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResearchRun, ResearchSource } from "@fusion/core";
|
||||
import { createDatabase, type Database, ResearchStore, type ResearchRun, type ResearchSource } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "../research-orchestrator.js";
|
||||
|
||||
function createHarness() {
|
||||
@@ -165,6 +168,12 @@ describe("ResearchOrchestrator", () => {
|
||||
|
||||
const run = await orchestrator.startRun(runId, "fallback query");
|
||||
expect(run.status).toBe("completed");
|
||||
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
|
||||
"https://backup.com",
|
||||
"backup",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(store.addEvent).toHaveBeenCalledWith(
|
||||
runId,
|
||||
expect.objectContaining({
|
||||
@@ -174,6 +183,43 @@ describe("ResearchOrchestrator", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("continues with partial fetched sources when one fetch step fails", async () => {
|
||||
const { store, stepRunner } = createHarness();
|
||||
stepRunner.runSourceQuery.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [
|
||||
{ type: "web", reference: "https://a.example", status: "pending" },
|
||||
{ type: "web", reference: "https://b.example", status: "pending" },
|
||||
],
|
||||
} as never);
|
||||
stepRunner.runContentFetch
|
||||
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "fetch failed", retryable: true } } as never)
|
||||
.mockResolvedValueOnce({ ok: true, data: { content: "good", metadata: {} } } 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 run = await orchestrator.startRun(runId, "partial fetch");
|
||||
expect(run.status).toBe("completed");
|
||||
expect(store.addEvent).toHaveBeenCalledWith(
|
||||
runId,
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
metadata: expect.objectContaining({ orchestrationEventType: "step-failed" }),
|
||||
}),
|
||||
);
|
||||
expect(store.setResults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits step-failed for timeout-classified step errors", async () => {
|
||||
const { store, stepRunner } = createHarness();
|
||||
stepRunner.runSourceQuery
|
||||
@@ -237,6 +283,55 @@ describe("ResearchOrchestrator", () => {
|
||||
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("persists provider-substitution lifecycle with real ResearchStore", async () => {
|
||||
const fusionDir = mkdtempSync(join(tmpdir(), "fn-research-orch-"));
|
||||
const db: Database = createDatabase(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
const store = new ResearchStore(db);
|
||||
|
||||
const stepRunner = {
|
||||
runSourceQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "primary down", retryable: true } })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [{ type: "web", reference: "https://backup.example", status: "pending", metadata: { origin: "backup" } }],
|
||||
}),
|
||||
runContentFetch: vi.fn(async () => ({ ok: true, data: { content: "backup content", metadata: { fetchedBy: "backup" } } })),
|
||||
runSynthesis: vi.fn(async () => ({ ok: true, data: { output: "summary", citations: ["src-1"], confidence: 0.7 } })),
|
||||
};
|
||||
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
store,
|
||||
stepRunner,
|
||||
maxConcurrentRuns: 1,
|
||||
});
|
||||
|
||||
const runId = orchestrator.createRun({
|
||||
providers: [{ type: "primary" }, { type: "backup" }],
|
||||
maxSources: 2,
|
||||
maxSynthesisRounds: 1,
|
||||
});
|
||||
|
||||
const run = await orchestrator.startRun(runId, "provider substitution");
|
||||
expect(run.status).toBe("completed");
|
||||
|
||||
const persisted = store.getRun(runId)!;
|
||||
expect(persisted.sources).toHaveLength(1);
|
||||
expect(persisted.sources[0].metadata?.providerType).toBe("backup");
|
||||
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
|
||||
"https://backup.example",
|
||||
"backup",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const runEvents = store.listRunEvents(runId);
|
||||
expect(runEvents.some((event) => event.status === "completed")).toBe(true);
|
||||
expect(persisted.events.some((event) => event.metadata?.orchestrationEventType === "step-failed")).toBe(true);
|
||||
expect(persisted.results?.summary).toBe("summary");
|
||||
});
|
||||
|
||||
it("retries failed run with inherited config", () => {
|
||||
const { store } = createHarness();
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ResearchStepRunner } from "../research-step-runner.js";
|
||||
|
||||
describe("ResearchStepRunner", () => {
|
||||
@@ -77,6 +77,38 @@ describe("ResearchStepRunner", () => {
|
||||
expect(result.error?.code).toBe("provider_not_configured");
|
||||
});
|
||||
|
||||
it("prefers requested provider for content fetch and falls back when unavailable", async () => {
|
||||
const fetchPrimary = vi.fn(async () => ({ content: "primary", metadata: { provider: "primary" } }));
|
||||
const fetchFallback = vi.fn(async () => ({ content: "fallback", metadata: { provider: "fallback" } }));
|
||||
|
||||
const runner = new ResearchStepRunner({
|
||||
providers: [
|
||||
{
|
||||
type: "primary",
|
||||
isConfigured: () => true,
|
||||
search: async () => [],
|
||||
fetchContent: fetchPrimary,
|
||||
},
|
||||
{
|
||||
type: "fallback",
|
||||
isConfigured: () => true,
|
||||
search: async () => [],
|
||||
fetchContent: fetchFallback,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const requested = await runner.runContentFetch("https://example.com", "fallback");
|
||||
expect(requested.ok).toBe(true);
|
||||
expect(requested.data?.metadata.provider).toBe("fallback");
|
||||
|
||||
const missing = await runner.runContentFetch("https://example.com", "missing");
|
||||
expect(missing.ok).toBe(true);
|
||||
expect(missing.data?.metadata.provider).toBe("primary");
|
||||
expect(fetchPrimary).toHaveBeenCalledTimes(1);
|
||||
expect(fetchFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -260,7 +260,13 @@ export class ResearchOrchestrator {
|
||||
}
|
||||
|
||||
for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) {
|
||||
const saved = this.store.addSource(runId, source);
|
||||
const saved = this.store.addSource(runId, {
|
||||
...source,
|
||||
metadata: {
|
||||
...(source.metadata ?? {}),
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
allSources.push(saved);
|
||||
this.store.addEvent(runId, {
|
||||
type: "source_added",
|
||||
@@ -298,7 +304,9 @@ export class ResearchOrchestrator {
|
||||
});
|
||||
this.stepStarted(runId, step);
|
||||
|
||||
const result = await this.stepRunner.runContentFetch(source.reference, provider?.config, signal);
|
||||
const sourceProvider = this.getSourceProviderType(source);
|
||||
const providerConfig = sourceProvider ? config.providers.find((p) => p.type === sourceProvider)?.config : provider?.config;
|
||||
const result = await this.stepRunner.runContentFetch(source.reference, sourceProvider, providerConfig, signal);
|
||||
if (!result.ok || !result.data) {
|
||||
this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error);
|
||||
continue;
|
||||
@@ -547,6 +555,11 @@ export class ResearchOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private getSourceProviderType(source: ResearchSource): string | undefined {
|
||||
const providerType = source.metadata?.providerType;
|
||||
return typeof providerType === "string" && providerType.length > 0 ? providerType : undefined;
|
||||
}
|
||||
|
||||
private canWriteRunData(runId: string): boolean {
|
||||
const run = this.store.getRun(runId);
|
||||
if (!run) return false;
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ResearchStepRunnerApi {
|
||||
): Promise<ResearchStepResult<ResearchSource[]>>;
|
||||
runContentFetch(
|
||||
url: string,
|
||||
providerType?: string,
|
||||
config?: ResearchProviderConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>>;
|
||||
@@ -118,10 +119,11 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
|
||||
|
||||
async runContentFetch(
|
||||
url: string,
|
||||
providerType?: string,
|
||||
config: ResearchProviderConfig = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>> {
|
||||
const provider = this.findFirstConfiguredProvider();
|
||||
const provider = this.resolveContentProvider(providerType);
|
||||
if (!provider) {
|
||||
return this.unconfigured("no configured provider available for content fetch");
|
||||
}
|
||||
@@ -169,6 +171,14 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private resolveContentProvider(providerType?: string): ResearchProvider | undefined {
|
||||
if (providerType) {
|
||||
const selected = this.providers.get(providerType);
|
||||
if (selected?.isConfigured()) return selected;
|
||||
}
|
||||
return this.findFirstConfiguredProvider();
|
||||
}
|
||||
|
||||
private classifyError<T>(step: string, error: unknown): ResearchStepResult<T> {
|
||||
if (error instanceof ResearchStepTimeoutError) {
|
||||
return { ok: false, error: { code: "timeout", message: error.message, retryable: true } };
|
||||
|
||||
Reference in New Issue
Block a user