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:
Fusion
2026-05-04 11:09:38 -07:00
committed by gsxdsm
parent ad133f2cca
commit 3a97bf2386
6 changed files with 180 additions and 13 deletions

View File

@@ -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({

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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 } };