feat(FN-3064): add todo planning entrypoint with peer exchange shutdown imp

This merge introduces a new Todo Planning mode for task creation, wiring the entrypoint through the dashboard and CLI, accompanied by a new `PlanningModeModal` component and associated tests. It also makes peer exchange shutdown deterministic in the engine, improves rate limiting for the planning fl

Fusion-Task-Id: FN-3064
This commit is contained in:
Fusion
2026-05-01 13:43:25 -07:00
committed by gsxdsm
parent bdfbcb3039
commit 6f2632f990
18 changed files with 709 additions and 27 deletions

View File

@@ -132,7 +132,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
});
it("seeds lastModified", () => {
@@ -155,7 +155,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
});
it("does not overwrite existing config on re-init", () => {
@@ -762,7 +762,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -787,11 +787,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
db.close();
});
@@ -826,7 +826,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -867,7 +867,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -936,7 +936,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1039,7 +1039,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1113,7 +1113,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1137,7 +1137,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1241,7 +1241,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1710,7 +1710,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(57);
expect(db1.getSchemaVersion()).toBe(58);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(57);
expect(db3.getSchemaVersion()).toBe(58);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(57);
expect(db1.getSchemaVersion()).toBe(58);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(57);
expect(db2.getSchemaVersion()).toBe(58);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
});
it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(57);
expect(db.getSchemaVersion()).toBe(58);
const index = db
.prepare(

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 57;
const SCHEMA_VERSION = 58;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -2140,6 +2140,36 @@ export class Database {
});
}
// Rewrite legacy backup automation/routine commands that bake in a
// bare `fn` or `kb` binary. Those fail with "command not found" on
// hosts where the global bin was never linked. The canonical form
// (kept in sync with backup.ts) uses npx so it works zero-install.
if (version < 58) {
this.applyMigration(58, () => {
const newCommand = "npx runfusion.ai backup --create";
if (this.hasTable("automations") && this.hasColumn("automations", "command")) {
this.db
.prepare(
`UPDATE automations
SET command = ?, updatedAt = ?
WHERE name = 'Database Backup'
AND (command LIKE 'fn backup%' OR command LIKE 'kb backup%' OR command LIKE 'fusion backup%')`,
)
.run(newCommand, new Date().toISOString());
}
if (this.hasTable("routines") && this.hasColumn("routines", "command")) {
this.db
.prepare(
`UPDATE routines
SET command = ?, updatedAt = ?
WHERE name = 'Database Backup'
AND (command LIKE 'fn backup%' OR command LIKE 'kb backup%' OR command LIKE 'fusion backup%')`,
)
.run(newCommand, new Date().toISOString());
}
});
}
}
/**

View File

@@ -0,0 +1,140 @@
/**
* Resolve how to invoke the Fusion CLI from server-side code (automations,
* generated commands, docs snippets).
*
* Order of preference:
* 1. `fn` — short canonical name
* 2. `fusion` — long alias name
* 3. `npx -y runfusion.ai` — zero-install fallback that always works
*
* The npm bin name on disk varies by install path and platform; the version
* is read by spawning `<bin> --version` so we report the actually-runnable
* binary, not just the first match on PATH.
*/
import { spawn } from "node:child_process";
import { platform } from "node:os";
interface ProbeResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
/**
* Run a command with an explicit argv (no shell) and capture stdout/stderr.
* Always resolves; on spawn failure exitCode is null and stderr carries the
* error message. Used here for safe, dependency-free PATH lookups and
* version probes — do not use for general command execution.
*/
function runProbe(command: string, args: string[], timeoutMs: number): Promise<ProbeResult> {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
const timer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
child.on("error", (err) => {
clearTimeout(timer);
resolve({ exitCode: null, stdout, stderr: stderr || err.message });
});
child.on("close", (exitCode) => {
clearTimeout(timer);
resolve({ exitCode, stdout, stderr });
});
});
}
/** npm package that publishes the `fn`/`fusion` bins. Used for npx fallback. */
export const FN_NPM_PACKAGE = "runfusion.ai";
/** Recommended one-line installer URL surfaced in UI/docs. */
export const FN_INSTALL_CURL = "curl -fsSL https://runfusion.ai/install.sh | sh";
/** Recommended npm install command surfaced in UI/docs. */
export const FN_INSTALL_NPM = `npm install -g ${FN_NPM_PACKAGE}`;
/** Zero-install invocation prefix used when no global binary is present. */
export const FN_NPX_INVOCATION = `npx -y ${FN_NPM_PACKAGE}`;
/** Candidate binary names checked, in preference order. */
const CANDIDATES = ["fn", "fusion"] as const;
export type FnBinaryName = (typeof CANDIDATES)[number];
export interface FnBinaryStatus {
/** True if a working `fn` or `fusion` binary was found on PATH. */
installed: boolean;
/** Which binary name resolved, if any. */
binary?: FnBinaryName;
/** Absolute path to the resolved binary, when available. */
path?: string;
/** Version reported by `<bin> --version`, when available. */
version?: string;
/**
* Command prefix to use when scripting against the CLI. This is either
* the binary name itself (when installed) or {@link FN_NPX_INVOCATION}.
*/
invocation: string;
}
/**
* Look up an executable on PATH using the platform-appropriate command.
* Returns the first absolute path or undefined.
*/
async function whichBinary(name: string): Promise<string | undefined> {
const isWindows = platform() === "win32";
const lookup = isWindows ? "where" : "which";
const result = await runProbe(lookup, [name], 5_000);
if (result.exitCode !== 0) return undefined;
const firstLine = result.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
return firstLine || undefined;
}
/**
* Best-effort version probe. Returns undefined if the binary refuses the
* flag or produces no parseable output — the caller should treat undefined
* as "installed but version unknown" rather than "not installed".
*/
async function probeVersion(binary: string): Promise<string | undefined> {
const result = await runProbe(binary, ["--version"], 10_000);
if (result.exitCode !== 0) return undefined;
const text = (result.stdout || result.stderr).trim();
if (!text) return undefined;
// Match the first semver-ish token so we strip prefixes like "fn v0.13.0".
const match = text.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/);
return match ? match[0] : text.split(/\s+/)[0];
}
/**
* Detect whether the `fn` (or `fusion`) CLI is installed on PATH and
* return the recommended invocation prefix.
*
* Never throws — on any error it falls through to the npx fallback so
* callers can rely on `invocation` always being usable.
*/
export async function detectFnBinary(): Promise<FnBinaryStatus> {
for (const candidate of CANDIDATES) {
try {
const resolvedPath = await whichBinary(candidate);
if (!resolvedPath) continue;
const version = await probeVersion(candidate);
return {
installed: true,
binary: candidate,
path: resolvedPath,
version,
invocation: candidate,
};
} catch {
// Try the next candidate.
}
}
return {
installed: false,
invocation: FN_NPX_INVOCATION,
};
}

View File

@@ -75,6 +75,14 @@ export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";
export { runCommandAsync } from "./run-command.js";
export type { RunCommandOptions, RunCommandResult } from "./run-command.js";
export {
detectFnBinary,
FN_NPM_PACKAGE,
FN_INSTALL_NPM,
FN_INSTALL_CURL,
FN_NPX_INVOCATION,
} from "./fn-binary.js";
export type { FnBinaryStatus, FnBinaryName } from "./fn-binary.js";
export {
validateNodeOverrideChange,
type NodeOverrideValidationResult,

View File

@@ -771,7 +771,11 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<TodoView addToast={addToast} projectId={currentProject?.id} />
<TodoView
addToast={addToast}
projectId={currentProject?.id}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
/>
</Suspense>
</PageErrorBoundary>
);

View File

@@ -1296,6 +1296,47 @@ export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
return api<ClaudeCliStatus>("/providers/claude-cli/status");
}
/**
* Status snapshot for the Fusion CLI binary (`fn` / `fusion`). Used by
* Settings → General → CLI Binary and the first-launch banner.
*/
export interface FnBinaryStatus {
binary: {
installed: boolean;
binary?: "fn" | "fusion";
path?: string;
version?: string;
invocation: string;
};
expectedVersion: string;
state: "installed" | "missing" | "version-mismatch";
install: { npm: string; curl: string; package: string };
}
export interface FnBinaryInstallResult {
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
durationMs: number;
permissionsHint?: string;
}
export interface FnBinaryInstallResponse extends FnBinaryStatus {
installResult: FnBinaryInstallResult;
}
/** Read CLI binary install state. */
export function fetchFnBinaryStatus(): Promise<FnBinaryStatus> {
return api<FnBinaryStatus>("/system/fn-binary/status");
}
/** Trigger `npm install -g runfusion.ai`. Returns install log + new status. */
export function installFnBinary(): Promise<FnBinaryInstallResponse> {
return api<FnBinaryInstallResponse>("/system/fn-binary/install", { method: "POST" });
}
/** Probe the local Droid CLI binary + setting + extension state. */
export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
return api<DroidCliStatus>("/providers/droid-cli/status");

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from "react";
import {
fetchFnBinaryStatus,
installFnBinary,
type FnBinaryInstallResult,
type FnBinaryStatus,
} from "../api/legacy";
import "./CliBinaryPanel.css";
interface Props {
/**
* When true, the panel is mounted but should not auto-fetch on render.
* Used by the first-launch banner so it can show a button without
* forcing a probe before the user opts in.
*/
defer?: boolean;
}
const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" | "warn" | "err" }> = {
installed: { text: "Installed", tone: "ok" },
missing: { text: "Not installed", tone: "err" },
"version-mismatch": { text: "Version mismatch", tone: "warn" },
};
/**
* Settings panel for the `fn` / `fusion` global CLI binary.
*
* Shows current install state, a one-click install button (runs
* `npm install -g runfusion.ai` server-side), and two copy-to-clipboard
* commands so users with non-default npm setups can install themselves.
*/
export function CliBinaryPanel({ defer = false }: Props): JSX.Element {
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const [installResult, setInstallResult] = useState<FnBinaryInstallResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const next = await fetchFnBinaryStatus();
setStatus(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (defer) return;
void refresh();
}, [defer, refresh]);
const onInstall = useCallback(async () => {
setInstalling(true);
setInstallResult(null);
setError(null);
try {
const response = await installFnBinary();
setStatus({
binary: response.binary,
expectedVersion: response.expectedVersion,
state: response.state,
install: response.install,
});
setInstallResult(response.installResult);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setInstalling(false);
}
}, []);
const copy = useCallback(async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(label);
setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
} catch {
// Clipboard API unavailable — leave button silent rather than throwing.
}
}, []);
const stateMeta = status ? STATE_LABELS[status.state] : null;
return (
<div className="cli-binary-panel">
<div className="cli-binary-header">
<h4 className="settings-section-heading">CLI Binary</h4>
{stateMeta && (
<span className={`cli-binary-pill cli-binary-pill--${stateMeta.tone}`}>
{stateMeta.text}
</span>
)}
</div>
<small className="cli-binary-help">
Installing the global CLI lets you run <code>fn</code> and <code>fusion</code> from any
terminal. Automations and scripts work without it via <code>npx</code>, but a global
install is faster and more convenient.
</small>
{loading && !status && <p className="cli-binary-status-line">Checking</p>}
{status && (
<div className="cli-binary-detail">
{status.binary.installed ? (
<ul className="cli-binary-info-list">
<li>
<span>Binary:</span>
<code>{status.binary.binary}</code>
</li>
{status.binary.path && (
<li>
<span>Path:</span>
<code>{status.binary.path}</code>
</li>
)}
<li>
<span>Version:</span>
<code>{status.binary.version ?? "unknown"}</code>
<span className="cli-binary-expected">
(expected {status.expectedVersion})
</span>
</li>
</ul>
) : (
<p className="cli-binary-status-line">
Neither <code>fn</code> nor <code>fusion</code> was found on PATH.
</p>
)}
<div className="cli-binary-actions">
<button
type="button"
className="cli-binary-install-btn"
onClick={onInstall}
disabled={installing}
>
{installing
? "Installing…"
: status.binary.installed
? "Reinstall"
: "Install with npm"}
</button>
<button
type="button"
className="cli-binary-refresh-btn"
onClick={() => void refresh()}
disabled={loading || installing}
>
Refresh
</button>
</div>
<div className="cli-binary-commands">
<label>Or copy and run yourself:</label>
{[
{ label: "npm", command: status.install.npm },
{ label: "curl", command: status.install.curl },
].map(({ label, command }) => (
<div key={label} className="cli-binary-command-row">
<code>{command}</code>
<button
type="button"
onClick={() => void copy(label, command)}
className="cli-binary-copy-btn"
>
{copied === label ? "Copied" : "Copy"}
</button>
</div>
))}
</div>
</div>
)}
{installResult && (
<details className="cli-binary-install-log" open={!installResult.success}>
<summary>
{installResult.success
? `Install succeeded in ${(installResult.durationMs / 1000).toFixed(1)}s`
: `Install failed (exit ${installResult.exitCode ?? "n/a"})`}
</summary>
{installResult.permissionsHint && (
<p className="cli-binary-permissions-hint">{installResult.permissionsHint}</p>
)}
{installResult.stdout && (
<pre className="cli-binary-install-output">{installResult.stdout}</pre>
)}
{installResult.stderr && (
<pre className="cli-binary-install-output cli-binary-install-output--err">
{installResult.stderr}
</pre>
)}
</details>
)}
{error && <p className="field-error">{error}</p>}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
ListChecks,
Bot,
PlusCircle,
Lightbulb,
} from "lucide-react";
import { getErrorMessage, type Task, type TaskCreateInput, type TodoItem, type TodoList } from "@fusion/core";
import { createTask, fetchAgents } from "../api";
@@ -22,13 +23,14 @@ import "./TodoView.css";
interface TodoViewProps {
projectId?: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
onPlanningMode?: (initialPlan: string) => void;
}
function sortItems(items: TodoItem[]): TodoItem[] {
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
}
export function TodoView({ projectId, addToast }: TodoViewProps) {
export function TodoView({ projectId, addToast, onPlanningMode }: TodoViewProps) {
const {
lists,
items,
@@ -627,6 +629,17 @@ export function TodoView({ projectId, addToast }: TodoViewProps) {
<ChevronDown />
</button>
</div>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
onPlanningMode?.(item.text);
}}
aria-label={`Start planning from ${item.text}`}
data-testid={`planning-from-${item.id}`}
>
<Lightbulb />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"

View File

@@ -251,6 +251,14 @@ vi.mock("../../components/AgentsView", () => ({
AgentsView: () => <div className="agents-view">Agents view</div>,
}));
vi.mock("../../components/TodoView", () => ({
TodoView: ({ onPlanningMode }: { onPlanningMode?: (initialPlan: string) => void }) => (
<div className="todo-view" data-testid="todo-view">
<button type="button" data-testid="todo-planning-button" onClick={() => onPlanningMode?.("Seed from todo")}>Plan from todo</button>
</div>
),
}));
vi.mock("../../components/QuickChatFAB", () => ({
QuickChatFAB: () => null,
}));
@@ -1472,6 +1480,33 @@ describe("App view switching", () => {
localStorage.removeItem("kb-dashboard-view-mode");
});
it("opens planning mode when TodoView triggers planning from todo item", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "todos");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
todoView: true,
},
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("todo-planning-button"));
await waitFor(() => {
expect(screen.getByText("Planning Mode")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());
localStorage.removeItem("kb-dashboard-view-mode");
});
it("shows view toggle buttons in header including agents", async () => {
render(<App />);

View File

@@ -33,6 +33,7 @@ vi.mock("lucide-react", () => ({
ListChecks: () => <span data-testid="icon-list-checks" />,
Bot: () => <span data-testid="icon-bot" />,
PlusCircle: () => <span data-testid="icon-plus-circle" />,
Lightbulb: () => <span data-testid="icon-lightbulb" />,
}));
function createMockTodoLists(overrides: Record<string, unknown> = {}) {
@@ -407,6 +408,13 @@ describe("TodoView", () => {
expect(selectedButton.closest(".todo-list-item")).toHaveClass("todo-list-item--active");
});
it("Planning button renders for each todo item", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByTestId("planning-from-item-1")).toBeInTheDocument();
expect(screen.getByTestId("planning-from-item-2")).toBeInTheDocument();
});
it("Create Task button renders for each todo item", () => {
render(<TodoView addToast={addToast} />);
@@ -421,6 +429,16 @@ describe("TodoView", () => {
expect(screen.getByTestId("assign-agent-for-item-2")).toBeInTheDocument();
});
it("clicking Planning button calls onPlanningMode with item text", () => {
const onPlanningMode = vi.fn();
render(<TodoView addToast={addToast} onPlanningMode={onPlanningMode} />);
fireEvent.click(screen.getByTestId("planning-from-item-1"));
expect(onPlanningMode).toHaveBeenCalledWith("Buy groceries");
expect(mockCreateTask).not.toHaveBeenCalled();
});
it("clicking Create Task button calls createTask with item text", async () => {
mockCreateTask.mockResolvedValueOnce({ id: "FN-123" });
render(<TodoView addToast={addToast} projectId="project-1" />);
@@ -476,6 +494,7 @@ describe("TodoView", () => {
expect(actionsRow).toBeInTheDocument();
expect(actionsRow).toContainElement(screen.getByTestId("move-up-item-1"));
expect(actionsRow).toContainElement(screen.getByTestId("move-down-item-1"));
expect(actionsRow).toContainElement(screen.getByTestId("planning-from-item-1"));
expect(actionsRow).toContainElement(screen.getByTestId("create-task-from-item-1"));
expect(actionsRow).toContainElement(screen.getByTestId("assign-agent-for-item-1"));
expect(actionsRow).toContainElement(screen.getByTestId("edit-item-item-1"));

View File

@@ -114,6 +114,7 @@ import { registerCustomProviderRoutes } from "./routes/register-custom-provider-
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { runGitCommand } from "./routes/resolve-diff-base.js";
@@ -1465,6 +1466,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ---------- Runtime-plugin probe routes (Hermes / OpenClaw / Paperclip) ----------
registerRuntimeProviderRoutes(routeContext);
// ---------- CLI binary install / status routes ----------
registerFnBinaryRoutes(routeContext);
/**
* POST /api/ai/refine-text
* AI-powered text refinement for task descriptions.

View File

@@ -0,0 +1,173 @@
/**
* CLI binary management routes.
*
* GET /system/fn-binary/status — does the user have `fn`/`fusion` on PATH?
* POST /system/fn-binary/install — run `npm install -g runfusion.ai`
*
* Used by the Settings → General → CLI Binary panel and the first-launch
* banner. Install routes are intentionally synchronous-with-result rather
* than streaming; npm global installs are short enough that polling for
* completion isn't worth a websocket channel.
*/
import { spawn } from "node:child_process";
import {
detectFnBinary,
FN_INSTALL_CURL,
FN_INSTALL_NPM,
FN_NPM_PACKAGE,
type FnBinaryStatus,
} from "@fusion/core";
import { ApiError } from "../api-error.js";
import { getCliPackageVersion } from "../cli-package-version.js";
import type { ApiRouteRegistrar } from "./types.js";
/** Hard cap on `npm install -g` runtime. */
const INSTALL_TIMEOUT_MS = 180_000;
/** Hard cap on captured npm output to keep responses small. */
const MAX_OUTPUT_BYTES = 64 * 1024;
interface InstallResult {
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
durationMs: number;
/** Hint surfaced to the UI when EACCES is detected. */
permissionsHint?: string;
}
/**
* Compose the status payload returned by GET /system/fn-binary/status.
* Includes the expected version (read from this package's package.json),
* the canonical install commands, and a derived state for the UI.
*/
function buildStatusPayload(binary: FnBinaryStatus, expectedVersion: string) {
let state: "installed" | "missing" | "version-mismatch" = "missing";
if (binary.installed) {
state = binary.version && binary.version !== expectedVersion
? "version-mismatch"
: "installed";
}
return {
binary,
expectedVersion,
state,
install: {
npm: FN_INSTALL_NPM,
curl: FN_INSTALL_CURL,
package: FN_NPM_PACKAGE,
},
};
}
/**
* Run `npm install -g runfusion.ai`, capturing output up to MAX_OUTPUT_BYTES
* and timing out after INSTALL_TIMEOUT_MS. Always resolves; never rejects.
*/
function runNpmInstall(): Promise<InstallResult> {
const startedAt = Date.now();
const command = FN_INSTALL_NPM;
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
const child = spawn("npm", ["install", "-g", FN_NPM_PACKAGE], {
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
const timer = setTimeout(() => {
timedOut = true;
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, INSTALL_TIMEOUT_MS);
const append = (target: "stdout" | "stderr", chunk: Buffer): void => {
const text = chunk.toString("utf8");
if (target === "stdout") {
if (stdout.length < MAX_OUTPUT_BYTES) {
stdout += text.slice(0, MAX_OUTPUT_BYTES - stdout.length);
}
} else {
if (stderr.length < MAX_OUTPUT_BYTES) {
stderr += text.slice(0, MAX_OUTPUT_BYTES - stderr.length);
}
}
};
child.stdout?.on("data", (c: Buffer) => append("stdout", c));
child.stderr?.on("data", (c: Buffer) => append("stderr", c));
child.on("error", (err) => {
clearTimeout(timer);
resolve({
success: false,
exitCode: null,
stdout,
stderr: stderr || err.message,
command,
durationMs: Date.now() - startedAt,
});
});
child.on("close", (exitCode) => {
clearTimeout(timer);
const combined = `${stdout}\n${stderr}`;
const eaccesHit = /EACCES|permission denied|Operation not permitted/i.test(combined);
const success = exitCode === 0 && !timedOut;
resolve({
success,
exitCode,
stdout,
stderr: timedOut ? `${stderr}\n[install timed out after ${INSTALL_TIMEOUT_MS / 1000}s]` : stderr,
command,
durationMs: Date.now() - startedAt,
permissionsHint: !success && eaccesHit
? "npm reported a permissions error. On macOS/Linux this usually means npm's global prefix needs `sudo` or a fix to your npm prefix (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally)."
: undefined,
});
});
});
}
export const registerFnBinaryRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
/**
* GET /system/fn-binary/status
*
* Probes PATH for `fn` then `fusion`, returning install state and the
* canonical install commands. No auth — this is read-only introspection
* the dashboard banner needs before the user signs in.
*/
router.get("/system/fn-binary/status", async (_req, res) => {
try {
const binary = await detectFnBinary();
const expectedVersion = getCliPackageVersion();
res.json(buildStatusPayload(binary, expectedVersion));
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* POST /system/fn-binary/install
*
* Runs `npm install -g runfusion.ai`. Returns the install result and the
* post-install probe so the UI can refresh its state in one round trip.
*/
router.post("/system/fn-binary/install", async (_req, res) => {
try {
const installResult = await runNpmInstall();
// Re-probe even on failure — the binary may already exist from a
// previous attempt and we want the UI to reflect reality.
const binary = await detectFnBinary();
const expectedVersion = getCliPackageVersion();
const status = buildStatusPayload(binary, expectedVersion);
res.json({ ...status, installResult });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
};