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:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(57);
|
||||
expect(db.getSchemaVersion()).toBe(58);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
140
packages/core/src/fn-binary.ts
Normal file
140
packages/core/src/fn-binary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
204
packages/dashboard/app/components/CliBinaryPanel.tsx
Normal file
204
packages/dashboard/app/components/CliBinaryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
173
packages/dashboard/src/routes/register-fn-binary-routes.ts
Normal file
173
packages/dashboard/src/routes/register-fn-binary-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user