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,