Files
fusion/packages/core/src/fn-binary.ts
gsxdsm 478f226a54 test: green full-suite CI after main drift (#2229)
## 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 -->
2026-07-16 16:01:29 -07:00

306 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}