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:
Fusion
2026-05-01 06:18:21 -07:00
committed by gsxdsm
parent bdf5e84981
commit cf0ea341a1
9 changed files with 675 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a new `fn research` command group for managing research runs from the CLI, including create, list, show, export, cancel, and retry flows with JSON-friendly output options.

View File

@@ -48,6 +48,35 @@ During fresh initialization, Fusion also installs the bundled `fusion` skill int
---
## `fn research`
Manage persisted research runs from the CLI.
```bash
fn research create --query "Compare sqlite WAL vs rollback journal"
fn research create --query "Rust async runtime trade-offs" --wait --max-wait-ms 120000
fn research list --status failed --limit 20
fn research show RR-001
fn research export RR-001 --format json --output ./artifacts/research-RR-001.json
fn research cancel RR-001
fn research retry RR-001 --json
```
| Subcommand | Description |
|---|---|
| `fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]` | Create a run and optionally wait for completion. |
| `fn research list \| ls [--status <status>] [--limit <n>] [--json]` | List recent runs (statuses: `pending`, `running`, `completed`, `failed`, `cancelled`). |
| `fn research show <run-id> [--json]` | Show one run with timestamps, summary, and error details. |
| `fn research export <run-id> [--format <json\|markdown\|pdf>] [--output <path>] [--json]` | Export run results and persist an export record. |
| `fn research cancel <run-id> [--json]` | Request cancellation for an active run. |
| `fn research retry <run-id> [--json]` | Create a new retry run from a failed/cancelled run. |
Disabled/setup behavior mirrors dashboard and agent surfaces:
- Feature disabled → `feature-disabled` error (enable research in settings)
- Provider unconfigured → `provider-unavailable` error (configure credentials/provider)
---
## `fn dashboard`
Start the web dashboard (default port `4040`, bound to `127.0.0.1`).

View File

@@ -40,6 +40,20 @@ fn task logs FN-001 --limit 50 # Limit log lines
fn task logs FN-001 --type tool # Filter by log type
```
## Research
```bash
fn research create --query "question" # Create research run
fn research create --query "question" --wait # Wait for completion
fn research list # List runs
fn research ls --status failed --limit 20 # Filter by status
fn research show RR-001 # Show one run
fn research export RR-001 --format json # Export to JSON
fn research export RR-001 --output ./run.md # Export to specific path
fn research cancel RR-001 # Cancel active run
fn research retry RR-001 # Retry failed/cancelled run
```
## Mission Management
```bash

View File

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

View File

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

View 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");
});
});

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

View File

@@ -5,6 +5,7 @@
height: 100%;
min-height: 0;
padding: var(--space-lg);
padding-bottom: calc(var(--space-lg) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.research-view__header {
@@ -95,7 +96,8 @@
flex-direction: column;
justify-content: center;
gap: var(--space-xs);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
}
.research-view__history-item:hover {
@@ -109,6 +111,10 @@
box-shadow: var(--focus-ring-strong);
}
.research-view__history-item:active {
transform: scale(0.97);
}
.research-view__history-item--active {
border-color: var(--todo);
box-shadow: var(--focus-ring);
@@ -191,12 +197,12 @@
.research-view__stat-label {
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.75rem;
font-size: 0.6875rem;
}
.research-view__stat-value {
font-family: var(--font-mono);
font-size: 1rem;
font-size: 0.8125rem;
}
.research-view__state--error {
@@ -206,6 +212,7 @@
@media (max-width: 768px) {
.research-view {
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.research-view__layout {

View File

@@ -302,7 +302,12 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</div>
<div className="research-view__history" data-testid="research-state-running">
{runs.map((run) => (
<button key={run.id} className={`research-view__history-item${selectedRunId === run.id ? " research-view__history-item--active" : ""}`} onClick={() => setSelectedRunId(run.id)}>
<button
key={run.id}
type="button"
className={`research-view__history-item${selectedRunId === run.id ? " research-view__history-item--active" : ""}`}
onClick={() => setSelectedRunId(run.id)}
>
<span className="card-id">{run.id}</span>
<span>{run.title}</span>
</button>