## Summary Restores green **Full Suite (non-blocking)** runs on `main`. Recent main merges left i18n key parity, schema baseline bookkeeping (0011→0012), heartbeat tool inventory (FN-8058 `fn_task_logs_read`), and merger whitespace-classification mocks (execFile `git diff -p -w :2: :3:`) out of date, so all four test shards failed. ## Root causes observed on main - **Shard 4 / `@fusion/i18n`**: missing `skipConfirmationDialogs*` + `reviewBudgetExhausted` in non-en locales; orphan `awaitingApprovalPlanReviewReplanCap` - **Shard 3 / `@fusion/core`**: `SCHEMA_BASELINE_VERSION` advanced to `0012` while tests still equated it with `OWNER_PROJECT_ID_SPLIT_VERSION` (`0011`) and omitted `0012` from applied-migration lists - **Shards 1–2 / `@fusion/engine`**: tool count/snapshot drift for `fn_task_logs_read`; merger tests still mocked `git diff-tree` for trivial classification after the execFile `:2:`/`:3:` cutover; mock provider `updateTask` arity drift ## Changes - Locale catalogs: add missing keys, drop orphan key - Schema applier tests: immutable 0011 identity + baseline 0012 lists - Heartbeat + gating snapshots: include `fn_task_logs_read` - Merger unit mocks: recognize `git diff -p -w :2:path :3:path` - Mock provider: accept optional third `updateTask` arg ## Test plan - [x] `pnpm --filter @fusion/i18n exec vitest run` — 23/23 - [x] `pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/schema-applier.test.ts` (immutable + automation upgrade) — pass - [x] `pnpm --filter @fusion/core exec vitest run` project-identity + satellite-fusiondir — pass - [x] Engine suites from failed CI shards (file-scoped, hermes/openclaw/paperclip/grok, reliability post-finalize/mission, heartbeat, gating, merger recovery/prompt, mock-provider, etc.) — pass - [ ] Full Suite workflow green on merge to main <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Improved project data isolation across backend operations. - Added safer optional toast handling when UI components render outside the full application shell. - Added support for reading task logs during agent heartbeat sessions. - **Bug Fixes** - Prevented runtime probes from hanging and avoided scanning large binary files. - Improved path handling for workspaces with missing descendants. - Corrected task retry state resets and GitHub import/issue-close behavior. - **Style** - Improved chat, terminal, and settings spacing. - Added clearer accessibility labeling for the auto-merge control. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
/**
|
||
* 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. Prefer
|
||
* reading the installed package manifest from the resolved binary path so we
|
||
* don't execute older buggy global installs just to discover their version.
|
||
*/
|
||
|
||
import { spawn } from "node:child_process";
|
||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||
import { platform, tmpdir } from "node:os";
|
||
import { posix, win32 } from "node:path";
|
||
|
||
/*
|
||
FNXC:FnBinaryProbe 2026-07-15-10:05:
|
||
Native/bundled `fn` installs (e.g. ~/.local/share/fusion/fn) are multi‑MB binaries.
|
||
resolveShimTargets must never readFileSync the whole file then regex‑scan it — that hung detectFnBinary for ~77s on a real host and timed out the default vitest suite.
|
||
Only open small text shims (shebang / cmd/ps1 wrappers), capped to a few KB.
|
||
*/
|
||
|
||
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 = "";
|
||
let settled = false;
|
||
const finish = (result: ProbeResult) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
resolve(result);
|
||
};
|
||
// Run probes from the OS temp directory so a buggy CLI version (older
|
||
// `runfusion.ai` releases initialise an engine — and a fresh
|
||
// `.fusion/<project>/.fusion/` tree — even on `--version`) cannot leave
|
||
// artefacts under whichever project happens to be the parent's cwd.
|
||
const child = spawn(command, args, {
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
shell: false,
|
||
cwd: tmpdir(),
|
||
});
|
||
/*
|
||
FNXC:FnBinaryProbe 2026-07-15-10:05:
|
||
Always settle the probe on timeout even if SIGKILL does not promptly emit close —
|
||
otherwise detectFnBinary can hang past vitest's default 15s and leave orphan children.
|
||
*/
|
||
const timer = setTimeout(() => {
|
||
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
||
finish({ exitCode: null, stdout, stderr: stderr || "probe timed out" });
|
||
}, 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) => {
|
||
finish({ exitCode: null, stdout, stderr: stderr || err.message });
|
||
});
|
||
child.on("close", (exitCode) => {
|
||
finish({ 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;
|
||
const FUSION_PACKAGE_NAMES = new Set(["runfusion.ai", "@runfusion/fusion"]);
|
||
|
||
type PathApi = Pick<typeof posix, "dirname" | "resolve" | "sep">;
|
||
|
||
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;
|
||
}
|
||
|
||
function getPathApi(pathValue: string): PathApi {
|
||
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.includes("\\")
|
||
? win32
|
||
: posix;
|
||
}
|
||
|
||
function readPackageVersionFromPath(startPath: string): string | undefined {
|
||
const pathApi = getPathApi(startPath);
|
||
let dir = pathApi.dirname(startPath);
|
||
|
||
for (let i = 0; i < 8; i += 1) {
|
||
const packageJsonPath = pathApi.resolve(dir, "package.json");
|
||
if (existsSync(packageJsonPath)) {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||
name?: string;
|
||
version?: string;
|
||
};
|
||
if (
|
||
typeof parsed.name === "string"
|
||
&& typeof parsed.version === "string"
|
||
&& FUSION_PACKAGE_NAMES.has(parsed.name)
|
||
) {
|
||
return parsed.version;
|
||
}
|
||
} catch {
|
||
// Ignore malformed manifests and keep walking upward.
|
||
}
|
||
}
|
||
|
||
const parent = pathApi.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/** Max bytes to read when sniffing npm/cmd/ps1 shims for package paths. */
|
||
const SHIM_READ_MAX_BYTES = 16_384;
|
||
|
||
function isLikelyTextShim(resolvedPath: string, size: number): boolean {
|
||
/*
|
||
FNXC:FnBinaryProbe 2026-07-15-10:05:
|
||
Windows npm shims are .cmd/.ps1 text; POSIX shims are small scripts with a shebang.
|
||
Skip Mach-O/ELF/PE binaries and any file larger than SHIM_READ_MAX_BYTES before reading.
|
||
*/
|
||
if (size <= 0 || size > SHIM_READ_MAX_BYTES) return false;
|
||
const lower = resolvedPath.toLowerCase();
|
||
if (lower.endsWith(".cmd") || lower.endsWith(".bat") || lower.endsWith(".ps1")) {
|
||
return true;
|
||
}
|
||
try {
|
||
// Read only a tiny head — never the whole path before classification.
|
||
const fdContents = readFileSync(resolvedPath, { encoding: "utf-8", flag: "r" });
|
||
const head = fdContents.slice(0, 64);
|
||
// Shebang scripts only; refuse binary garbage that would thrash the regex.
|
||
return head.startsWith("#!") || /^@?ECHO\s/i.test(head) || head.includes("node_modules");
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function resolveShimTargets(resolvedPath: string): string[] {
|
||
let size = 0;
|
||
try {
|
||
const st = statSync(resolvedPath);
|
||
if (!st.isFile()) return [];
|
||
size = st.size;
|
||
} catch {
|
||
return [];
|
||
}
|
||
if (!isLikelyTextShim(resolvedPath, size)) return [];
|
||
|
||
const pathApi = getPathApi(resolvedPath);
|
||
const basedir = pathApi.dirname(resolvedPath);
|
||
let contents: string;
|
||
|
||
try {
|
||
// Size already gated by SHIM_READ_MAX_BYTES; still slice as a hard ceiling.
|
||
contents = readFileSync(resolvedPath, { encoding: "utf-8", flag: "r" }).slice(0, SHIM_READ_MAX_BYTES);
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const targets = new Set<string>();
|
||
const pattern = /([^\r\n"'`]*node_modules[\\/](?:runfusion\.ai|@runfusion[\\/](?:fusion))[^\r\n"'`]*(?:\.js|package\.json))/gi;
|
||
|
||
for (const match of contents.matchAll(pattern)) {
|
||
const raw = match[1];
|
||
if (!raw) continue;
|
||
|
||
const trimmed = raw.trim().replace(/^['"]|['"]$/g, "");
|
||
const normalized = trimmed
|
||
.replace(/^%~?dp0%?/i, "")
|
||
.replace(/^\$basedir/i, "")
|
||
.replace(/^\$PSScriptRoot/i, "")
|
||
.replace(/^[\\/]+/, "")
|
||
.replace(/[\\/]/g, pathApi.sep);
|
||
|
||
targets.add(pathApi.resolve(basedir, normalized));
|
||
}
|
||
|
||
return Array.from(targets);
|
||
}
|
||
|
||
function readVersionFromResolvedBinaryPath(resolvedPath: string): string | undefined {
|
||
const candidatePaths = new Set<string>([resolvedPath]);
|
||
|
||
try {
|
||
candidatePaths.add(realpathSync(resolvedPath));
|
||
} catch {
|
||
// Fall back to the original resolved path.
|
||
}
|
||
|
||
// FNXC:CliBinaryProbe 2026-07-15-14:45: Check the resolved
|
||
// executable and its realpath before scanning shim contents. Dashboard
|
||
// status polling calls this path; parsing a normal JavaScript entrypoint as
|
||
// an npm shim can trap the server event loop in the shim-target regex.
|
||
for (const candidatePath of candidatePaths) {
|
||
const version = readPackageVersionFromPath(candidatePath);
|
||
if (version) return version;
|
||
}
|
||
|
||
// A package manifest was not adjacent to either direct path, so this may be
|
||
// an npm-generated Windows shim whose target is the only useful clue.
|
||
for (const shimTarget of resolveShimTargets(resolvedPath)) {
|
||
const version = readPackageVersionFromPath(shimTarget);
|
||
if (version) return version;
|
||
}
|
||
|
||
return 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 = readVersionFromResolvedBinaryPath(resolvedPath) ?? 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,
|
||
};
|
||
}
|