feat(FN-2997): merge fusion/fn-2997-2
Merges the FN-2997 research CLI feature (275-line `research` command with routing, error handling, and docs), a droid CLI provider card for onboarding, a consolidated mock helpers module shared across CLI/dashboard/engine, the SSE architecture reference documentation, and the droid CLI probe module Fusion-Task-Id: FN-2997
This commit is contained in:
@@ -92,6 +92,13 @@ const commandMocks = vi.hoisted(() => ({
|
||||
runPluginEnable: vi.fn(),
|
||||
runPluginDisable: vi.fn(),
|
||||
runPluginCreate: vi.fn(),
|
||||
|
||||
runResearchCreate: vi.fn(),
|
||||
runResearchList: vi.fn(),
|
||||
runResearchShow: vi.fn(),
|
||||
runResearchExport: vi.fn(),
|
||||
runResearchCancel: vi.fn(),
|
||||
runResearchRetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
|
||||
@@ -210,6 +217,15 @@ vi.mock("../commands/plugin-scaffold.js", () => ({
|
||||
runPluginCreate: commandMocks.runPluginCreate,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/research.js", () => ({
|
||||
runResearchCreate: commandMocks.runResearchCreate,
|
||||
runResearchList: commandMocks.runResearchList,
|
||||
runResearchShow: commandMocks.runResearchShow,
|
||||
runResearchExport: commandMocks.runResearchExport,
|
||||
runResearchCancel: commandMocks.runResearchCancel,
|
||||
runResearchRetry: commandMocks.runResearchRetry,
|
||||
}));
|
||||
|
||||
const originalArgv = process.argv;
|
||||
const originalExit = process.exit;
|
||||
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
||||
@@ -504,6 +520,66 @@ describe("bin command routing and fallbacks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes research create with options", async () => {
|
||||
await runBin(["research", "create", "--query", "hello world", "--wait", "--max-wait-ms", "1200", "--json", "--project", "alpha"]);
|
||||
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
|
||||
query: "hello world",
|
||||
waitForCompletion: true,
|
||||
maxWaitMs: 1200,
|
||||
json: true,
|
||||
projectName: "alpha",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports positional research query and rejects missing query", async () => {
|
||||
await runBin(["research", "create", "hello", "world"]);
|
||||
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
|
||||
query: "hello world",
|
||||
waitForCompletion: false,
|
||||
maxWaitMs: undefined,
|
||||
json: false,
|
||||
projectName: undefined,
|
||||
});
|
||||
|
||||
await expect(runBin(["research", "create", "--wait"]))
|
||||
.rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
|
||||
});
|
||||
|
||||
it("routes research export", async () => {
|
||||
await runBin(["research", "export", "RR-001", "--format", "json", "--output", "./out.json"]);
|
||||
expect(commandMocks.runResearchExport).toHaveBeenCalledWith({
|
||||
runId: "RR-001",
|
||||
format: "json",
|
||||
output: "./out.json",
|
||||
json: false,
|
||||
projectName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes research list/show/cancel/retry", async () => {
|
||||
await runBin(["research", "ls", "--status", "failed", "--limit", "5", "--json"]);
|
||||
await runBin(["research", "show", "RR-001", "--json"]);
|
||||
await runBin(["research", "cancel", "RR-001"]);
|
||||
await runBin(["research", "retry", "RR-002", "--json"]);
|
||||
|
||||
expect(commandMocks.runResearchList).toHaveBeenCalledWith({
|
||||
status: "failed",
|
||||
limit: 5,
|
||||
json: true,
|
||||
projectName: undefined,
|
||||
});
|
||||
expect(commandMocks.runResearchShow).toHaveBeenCalledWith("RR-001", { json: true, projectName: undefined });
|
||||
expect(commandMocks.runResearchCancel).toHaveBeenCalledWith("RR-001", { json: false, projectName: undefined });
|
||||
expect(commandMocks.runResearchRetry).toHaveBeenCalledWith("RR-002", { json: true, projectName: undefined });
|
||||
});
|
||||
|
||||
it("shows research subcommand guidance on unknown subcommand", async () => {
|
||||
await expect(runBin(["research", "oops"])).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: research oops");
|
||||
expect(logSpy).toHaveBeenCalledWith("Try: fn research create | list | show | export | cancel | retry");
|
||||
});
|
||||
|
||||
it("routes desktop flags to runDesktop", async () => {
|
||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
||||
|
||||
@@ -134,6 +134,7 @@ async function loadCommandHandlers() {
|
||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
|
||||
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
|
||||
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
||||
|
||||
return {
|
||||
runDashboard,
|
||||
@@ -214,6 +215,12 @@ async function loadCommandHandlers() {
|
||||
runPluginCreate,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
runResearchCreate,
|
||||
runResearchList,
|
||||
runResearchShow,
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +269,17 @@ Usage:
|
||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||
Create a GitHub PR for an in-review task
|
||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
|
||||
Create and optionally wait for a research run
|
||||
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
||||
List research runs
|
||||
fn research show <run-id> [--json] Show research run details
|
||||
fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]
|
||||
Export research run results
|
||||
fn research cancel <run-id> [--json]
|
||||
Cancel an active research run
|
||||
fn research retry <run-id> [--json]
|
||||
Retry a failed/cancelled research run
|
||||
fn mission create [title] [desc] Create a new mission
|
||||
fn mission list | ls List missions
|
||||
fn mission show | info <id> Show mission details
|
||||
@@ -489,6 +507,12 @@ async function main() {
|
||||
runPluginCreate,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
runResearchCreate,
|
||||
runResearchList,
|
||||
runResearchShow,
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
} = await loadCommandHandlers();
|
||||
|
||||
try {
|
||||
@@ -705,6 +729,85 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "research": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "create": {
|
||||
const query = getFlagValue(args, "--query") ?? args.slice(2).filter((value) => !value.startsWith("--")).join(" ").trim();
|
||||
if (!query) {
|
||||
console.error("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runResearchCreate({
|
||||
query,
|
||||
waitForCompletion: args.includes("--wait"),
|
||||
maxWaitMs: getFlagValueNumber(args, "--max-wait-ms"),
|
||||
json: args.includes("--json"),
|
||||
projectName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
case "ls": {
|
||||
const status = getFlagValue(args, "--status");
|
||||
await runResearchList({
|
||||
status,
|
||||
limit: getFlagValueNumber(args, "--limit"),
|
||||
json: args.includes("--json"),
|
||||
projectName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "show": {
|
||||
const runId = args[2];
|
||||
if (!runId) {
|
||||
console.error("Usage: fn research show <run-id> [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runResearchShow(runId, { json: args.includes("--json"), projectName });
|
||||
break;
|
||||
}
|
||||
case "export": {
|
||||
const runId = args[2];
|
||||
if (!runId) {
|
||||
console.error("Usage: fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runResearchExport({
|
||||
runId,
|
||||
format: getFlagValue(args, "--format"),
|
||||
output: getFlagValue(args, "--output"),
|
||||
json: args.includes("--json"),
|
||||
projectName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "cancel": {
|
||||
const runId = args[2];
|
||||
if (!runId) {
|
||||
console.error("Usage: fn research cancel <run-id> [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runResearchCancel(runId, { json: args.includes("--json"), projectName });
|
||||
break;
|
||||
}
|
||||
case "retry": {
|
||||
const runId = args[2];
|
||||
if (!runId) {
|
||||
console.error("Usage: fn research retry <run-id> [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runResearchRetry(runId, { json: args.includes("--json"), projectName });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: research ${subcommand || ""}`);
|
||||
console.log("Try: fn research create | list | show | export | cancel | retry");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
|
||||
157
packages/cli/src/commands/__tests__/research.test.ts
Normal file
157
packages/cli/src/commands/__tests__/research.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js";
|
||||
|
||||
const mockRun = {
|
||||
id: "RR-001",
|
||||
query: "test query",
|
||||
topic: "test query",
|
||||
status: "completed",
|
||||
sources: [],
|
||||
events: [],
|
||||
tags: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
results: { summary: "done", findings: [], citations: [] },
|
||||
};
|
||||
|
||||
const researchStoreMock = {
|
||||
getRun: vi.fn(() => mockRun),
|
||||
listRuns: vi.fn(() => [mockRun]),
|
||||
createExport: vi.fn(),
|
||||
};
|
||||
|
||||
const storeMock = {
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchTavilyApiKey: "x" })),
|
||||
getResearchStore: vi.fn(() => researchStoreMock),
|
||||
};
|
||||
|
||||
const orchestratorMock = {
|
||||
createRun: vi.fn(() => "RR-002"),
|
||||
startRun: vi.fn(async () => ({ ...mockRun, id: "RR-002", status: "running" })),
|
||||
cancelRun: vi.fn(() => true),
|
||||
retryRun: vi.fn(() => "RR-003"),
|
||||
};
|
||||
|
||||
const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({
|
||||
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })),
|
||||
providerRegistryMock: vi.fn(() => ({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) })),
|
||||
writeFileMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: vi.fn(() => storeMock),
|
||||
resolveResearchSettings: resolveResearchSettingsMock,
|
||||
RESEARCH_RUN_STATUSES: ["pending", "running", "completed", "failed", "cancelled"],
|
||||
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ResearchProviderRegistry: providerRegistryMock,
|
||||
ResearchStepRunner: vi.fn(),
|
||||
ResearchOrchestrator: vi.fn(() => orchestratorMock),
|
||||
}));
|
||||
|
||||
vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) }));
|
||||
vi.mock("node:fs/promises", () => ({ writeFile: writeFileMock }));
|
||||
|
||||
describe("research commands", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const originalExit = process.exit;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exit = vi.fn(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as typeof process.exit);
|
||||
resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
|
||||
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) });
|
||||
researchStoreMock.getRun.mockReturnValue(mockRun);
|
||||
researchStoreMock.listRuns.mockReturnValue([mockRun]);
|
||||
orchestratorMock.retryRun.mockReturnValue("RR-003");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit;
|
||||
});
|
||||
|
||||
it("creates a run", async () => {
|
||||
await runResearchCreate({ query: "hello" });
|
||||
expect(orchestratorMock.createRun).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created research run"));
|
||||
});
|
||||
|
||||
it("lists runs as json", async () => {
|
||||
await runResearchList({ json: true, status: "completed", limit: 3 });
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"'));
|
||||
expect(researchStoreMock.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 });
|
||||
});
|
||||
|
||||
it("rejects invalid list status", async () => {
|
||||
await expect(runResearchList({ status: "wat" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid status: wat");
|
||||
});
|
||||
|
||||
it("shows one run", async () => {
|
||||
await runResearchShow("RR-001");
|
||||
expect(logSpy).toHaveBeenCalledWith("Run: RR-001");
|
||||
});
|
||||
|
||||
it("fails show on missing run", async () => {
|
||||
researchStoreMock.getRun.mockReturnValue(undefined);
|
||||
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Research run not found: RR-404");
|
||||
});
|
||||
|
||||
it("exports with explicit output path", async () => {
|
||||
await runResearchExport({ runId: "RR-001", format: "json", output: "./out.json" });
|
||||
const writeArgs = writeFileMock.mock.calls[0]!;
|
||||
expect(String(writeArgs[0])).toContain("out.json");
|
||||
expect(String(writeArgs[1])).toContain('"id": "RR-001"');
|
||||
expect(String(writeArgs[1])).toContain('"status": "completed"');
|
||||
expect(String(writeArgs[1])).toContain('"query": "test query"');
|
||||
expect(researchStoreMock.createExport).toHaveBeenCalledWith("RR-001", "json", expect.stringContaining('"id": "RR-001"'));
|
||||
});
|
||||
|
||||
it("exports markdown to generated path", async () => {
|
||||
await runResearchExport({ runId: "RR-001", format: "markdown" });
|
||||
expect(writeFileMock).toHaveBeenCalledWith(expect.stringContaining("research-rr-001.md"), expect.stringContaining("## Summary"), "utf8");
|
||||
});
|
||||
|
||||
it("cancels a run", async () => {
|
||||
await runResearchCancel("RR-001", { json: true });
|
||||
expect(orchestratorMock.cancelRun).toHaveBeenCalledWith("RR-001");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"cancelled"'));
|
||||
});
|
||||
|
||||
it("retries a run", async () => {
|
||||
researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "pending" } : mockRun));
|
||||
await runResearchRetry("RR-001", { json: true });
|
||||
expect(orchestratorMock.retryRun).toHaveBeenCalledWith("RR-001");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"'));
|
||||
});
|
||||
|
||||
it("errors when research is disabled", async () => {
|
||||
resolveResearchSettingsMock.mockReturnValue({ enabled: false, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
|
||||
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: feature-disabled: Research is disabled in settings.");
|
||||
});
|
||||
|
||||
it("errors when providers are unavailable", async () => {
|
||||
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => [], getProvider: () => undefined });
|
||||
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable"));
|
||||
});
|
||||
|
||||
it("errors on invalid export format", async () => {
|
||||
await expect(runResearchExport({ runId: "RR-001", format: "xml" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Unsupported export format: xml");
|
||||
});
|
||||
|
||||
it("errors on write failure", async () => {
|
||||
writeFileMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
await expect(runResearchExport({ runId: "RR-001", format: "json", output: "./x.json" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: disk full");
|
||||
});
|
||||
});
|
||||
275
packages/cli/src/commands/research.ts
Normal file
275
packages/cli/src/commands/research.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
RESEARCH_EXPORT_FORMATS,
|
||||
RESEARCH_RUN_STATUSES,
|
||||
ResearchRunStatus,
|
||||
TaskStore,
|
||||
resolveResearchSettings,
|
||||
type ResearchExportFormat,
|
||||
type ResearchRun,
|
||||
} from "@fusion/core";
|
||||
import { ResearchOrchestrator, ResearchProviderRegistry, ResearchStepRunner } from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
interface ResearchCommandOptions {
|
||||
projectName?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ResearchCreateOptions extends ResearchCommandOptions {
|
||||
query: string;
|
||||
waitForCompletion?: boolean;
|
||||
maxWaitMs?: number;
|
||||
}
|
||||
|
||||
interface ResearchListOptions extends ResearchCommandOptions {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface ResearchExportOptions extends ResearchCommandOptions {
|
||||
runId: string;
|
||||
format?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
async function getStore(projectName?: string): Promise<TaskStore> {
|
||||
const project = projectName ? await resolveProject(projectName) : undefined;
|
||||
const store = new TaskStore(project?.projectPath ?? process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
async function getResearchRuntime(store: TaskStore) {
|
||||
const settings = await store.getSettings();
|
||||
const resolved = resolveResearchSettings(settings);
|
||||
if (!resolved.enabled) {
|
||||
throw new Error("feature-disabled: Research is disabled in settings.");
|
||||
}
|
||||
|
||||
const registry = new ResearchProviderRegistry(settings, process.cwd());
|
||||
const availableProviderTypes = registry.getAvailableProviders();
|
||||
if (availableProviderTypes.length === 0) {
|
||||
throw new Error("provider-unavailable: Research providers are not configured. Add provider credentials in settings.");
|
||||
}
|
||||
|
||||
const stepRunner = new ResearchStepRunner({
|
||||
providers: availableProviderTypes
|
||||
.map((type) => registry.getProvider(type))
|
||||
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)),
|
||||
});
|
||||
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
store: store.getResearchStore(),
|
||||
stepRunner,
|
||||
maxConcurrentRuns: resolved.limits.maxConcurrentRuns,
|
||||
});
|
||||
|
||||
return { orchestrator, settings, resolved, availableProviderTypes };
|
||||
}
|
||||
|
||||
function printRun(run: ResearchRun): void {
|
||||
console.log(`Run: ${run.id}`);
|
||||
console.log(`Status: ${run.status}`);
|
||||
console.log(`Query: ${run.query}`);
|
||||
console.log(`Created: ${run.createdAt}`);
|
||||
console.log(`Updated: ${run.updatedAt}`);
|
||||
if (run.startedAt) console.log(`Started: ${run.startedAt}`);
|
||||
if (run.completedAt) console.log(`Completed: ${run.completedAt}`);
|
||||
if (run.cancelledAt) console.log(`Cancelled: ${run.cancelledAt}`);
|
||||
if (run.results?.summary) console.log(`Summary: ${run.results.summary}`);
|
||||
if (run.error) console.log(`Error: ${run.error}`);
|
||||
}
|
||||
|
||||
function jsonOut(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function handleError(error: unknown): never {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function runResearchCreate(options: ResearchCreateOptions): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
|
||||
|
||||
const runId = orchestrator.createRun({
|
||||
providers: availableProviderTypes
|
||||
.filter((type) => type !== "llm-synthesis")
|
||||
.map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })),
|
||||
maxSources: resolved.limits.maxSourcesPerRun,
|
||||
maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2),
|
||||
phaseTimeoutMs: resolved.limits.maxDurationMs,
|
||||
stepTimeoutMs: resolved.limits.requestTimeoutMs,
|
||||
});
|
||||
|
||||
const runPromise = orchestrator.startRun(runId, options.query);
|
||||
if (!options.waitForCompletion) {
|
||||
const run = store.getResearchStore().getRun(runId);
|
||||
if (options.json) {
|
||||
jsonOut(run);
|
||||
} else {
|
||||
console.log(`Created research run ${runId}.`);
|
||||
if (run) printRun(run);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs));
|
||||
const completed = await Promise.race([
|
||||
runPromise,
|
||||
new Promise<ResearchRun>((resolveRun) => setTimeout(() => {
|
||||
const latest = store.getResearchStore().getRun(runId);
|
||||
resolveRun(latest ?? ({
|
||||
id: runId,
|
||||
query: options.query,
|
||||
status: "running",
|
||||
sources: [],
|
||||
events: [],
|
||||
tags: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as ResearchRun));
|
||||
}, maxWaitMs)),
|
||||
]);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut(completed);
|
||||
} else {
|
||||
printRun(completed);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runResearchList(options: ResearchListOptions = {}): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
|
||||
throw new Error(`Invalid status: ${options.status}`);
|
||||
}
|
||||
|
||||
const runs = store.getResearchStore().listRuns({
|
||||
status: options.status as ResearchRunStatus | undefined,
|
||||
limit: options.limit ? Math.max(1, options.limit) : 20,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
jsonOut({ runs });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runs.length) {
|
||||
console.log("No research runs found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
console.log(`${run.id} [${run.status}] ${run.query}`);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut(run);
|
||||
return;
|
||||
}
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(run: ResearchRun): string {
|
||||
const citations = run.results?.citations?.length
|
||||
? `\n## Citations\n${run.results.citations.map((citation) => `- ${citation}`).join("\n")}`
|
||||
: "";
|
||||
return `# ${run.topic || run.query}\n\n## Summary\n${run.results?.summary ?? ""}${citations}\n`;
|
||||
}
|
||||
|
||||
export async function runResearchExport(options: ResearchExportOptions): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(options.runId);
|
||||
if (!run) throw new Error(`Research run not found: ${options.runId}`);
|
||||
|
||||
const format = (options.format ?? "markdown") as ResearchExportFormat;
|
||||
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
}
|
||||
|
||||
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
|
||||
const ext = format === "json" ? "json" : "md";
|
||||
const outputPath = options.output
|
||||
? resolve(options.output)
|
||||
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
|
||||
|
||||
await writeFile(outputPath, content, "utf8");
|
||||
store.getResearchStore().createExport(run.id, format, content);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const run = store.getResearchStore().getRun(runId);
|
||||
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||
|
||||
const { orchestrator } = await getResearchRuntime(store);
|
||||
const cancelled = orchestrator.cancelRun(runId);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut({ cancelled, run });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||
try {
|
||||
const store = await getStore(options.projectName);
|
||||
const existing = store.getResearchStore().getRun(runId);
|
||||
if (!existing) throw new Error(`Research run not found: ${runId}`);
|
||||
|
||||
const { orchestrator } = await getResearchRuntime(store);
|
||||
const newRunId = orchestrator.retryRun(runId);
|
||||
const run = store.getResearchStore().getRun(newRunId);
|
||||
|
||||
if (options.json) {
|
||||
jsonOut({ retryOf: runId, run });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Created retry run ${newRunId} from ${runId}.`);
|
||||
if (run) printRun(run);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user