2026-04-29 18:25:21 -04:00
|
|
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
2026-04-07 20:19:26 +02:00
|
|
|
import { constants } from 'node:fs';
|
|
|
|
|
import { access, stat } from 'node:fs/promises';
|
|
|
|
|
import path from 'node:path';
|
2026-04-27 17:19:08 -04:00
|
|
|
import { spawn, spawnSync, type ChildProcess, type StdioOptions } from 'node:child_process';
|
|
|
|
|
import { Readable } from 'node:stream';
|
|
|
|
|
import { pipeline } from 'node:stream/promises';
|
refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) (#1494)
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0)
Extend the workspace with packages/* and move the kernel behind an
enforced public API: packages/kernel with nine consumer-earned subpath
exports (errors, device, snapshot, contracts, collections, rect,
redaction, daemon-error, bounds — the last absorbed from utils as Rect
vocabulary). Every kernel import repo-wide becomes the
@agent-device/kernel/<sub> specifier; kernel tests move to
src/__tests__/kernel/ and exercise the package surface. The root
declares the package in devDependencies (workspace:*), tsdown bundles
it (noExternal) so the published artifact and its runtime dependency
manifest are unchanged.
Gate rewiring in the same change, per the W0 brief:
- R1 kernel-sink retires (physically subsumed); new R11
package-boundaries guards no-root-back-imports, relative tunnelling
past exports maps, undeclared workspace deps, and non-exported
subpaths, with runtime resolution pins via import.meta.resolve.
- resolveImportEdges and mutation ownership follow workspace
specifiers through exports maps, keeping R4 cycle checks, depgraph,
and derived test ownership connected across the seam (kernel-errors
still owns 495 tests). listSourceFiles includes packages/*/src.
- kernel becomes an unranked zone; mutation registry, stryker mutate
globs, and the mutation-affected workflow path filter move to
packages/kernel/src/errors.ts.
- check:affected gains packages/ ownership (manifests fail open);
vitest and coverage include packages/*/src; fallow ignores
packages/** (its resolver cannot follow workspace specifiers).
- The affected-selector CI job installs dependencies: its closure now
crosses workspace specifiers, and the R8 relative exception is
unsafe for production src files (Node ESM does not realpath, so dual
specifier/relative loads would instantiate modules twice). The R8
zero-dep set is pinned empty with that rationale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b
Review findings on #1494, all five:
1. contracts-schema-public.test.ts reads the kernel source at its
packages/ path (fs access invisible to the codemod and typecheck).
2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules
symlink resolves @agent-device/* back to the real repo, so mutants
in the sandbox never load and vitest.related finds no tests.
vitest.mutation.config.ts now aliases each EXPORTED specifier to
its source (derived from exports maps, never a wildcard), keeping
resolution inside the mutated tree. Validated: kernel-errors module
runs end to end (dry run 3,984 tests, mutants killed, exit 0).
3. Layering/depgraph resolve workspace specifiers through the
exports-derived map (workspaceSpecifierTargets) instead of
reconstructing paths, so '.'-facade packages resolve; the
positional fallback remains only for map-less fixtures (P0 pin).
4. Per-package project references implemented: packages/kernel is
composite (emitDeclarationOnly -> dist-types, gitignored), the root
references it, and typecheck becomes tsc -b — probed to catch type
errors on both sides under TypeScript 7 native.
5. R11's relative-route exception now requires membership in an actual
R8 zero-dep job closure (zeroDepClosureFiles walks entries), not
mere scripts/ placement — closing the dual-instantiation bypass.
Also from review discussion: daemon-error moves out of the kernel
package to src/client/ — its consumers (cli, client facade) rehydrate
wire DaemonErrors client-side; the daemon only produces them. Kernel
drops to 8 exported subpaths before any of them ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* refactor: one exports-map reader for mutation alias and ownership
Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The
manifest-reading logic already exists as workspaceSpecifierTargets in
scripts/layering/package-boundaries.ts, so both the Stryker sandbox
alias table and the mutation ownership walker now consume it instead
of carrying near-clones. Behavior unchanged; mutation suite 45/45 and
changed-code fallow green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* fix: composite kernel without a root references edge
FreeRange runs plain `tsc -p tsconfig.json`, and a root `references`
entry makes non-build-mode TypeScript demand the referenced project's
built declarations (TS6305) — a standing "build first" tax on every
plain -p consumer (fr, editors). Keep the per-package composite
project and build it in typecheck (`tsc -b packages/kernel` before the
root and examples/sdk passes), but drop the root references edge: root
consumption resolves through exports to source, identical to runtime
and to the bundler. Probed: plain -p green with no prebuilt output;
kernel-side type errors still caught by its own build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
* fix: R11 uses the layering parser; mutation config is a fallow entry
Review blockers on #1494:
- R11's private single-quote regex could miss a double-quoted or
re-export route into packages/*/src. specifierSites now delegates to
the layering model's parseImports (both quote styles, side-effect
imports, re-exports, dynamic imports), with direct regressions for
each formerly-invisible form.
- vitest.mutation.config.ts becomes a declared fallow entry instead of
a tolerated unused-file finding: the full-repo audit now reports it
reachable (unused files 2 -> 1; the remainder predates this PR).
FreeRange clean-checkout evidence: with packages/kernel/dist-types and
every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings
on this head — the TS6305 topology died with the root references edge
in the previous commit; check:freerange has no build precondition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 12:12:46 +02:00
|
|
|
import { AppError } from '@agent-device/kernel/errors';
|
2026-07-02 14:05:47 +02:00
|
|
|
import { emitDiagnostic, getDiagnosticsMeta, updateDiagnosticsScope } from './diagnostics.ts';
|
|
|
|
|
import { parseBooleanLiteral } from './source-value.ts';
|
2026-01-30 19:41:00 +01:00
|
|
|
|
|
|
|
|
export type ExecResult = {
|
|
|
|
|
stdout: string;
|
|
|
|
|
stderr: string;
|
|
|
|
|
exitCode: number;
|
|
|
|
|
stdoutBuffer?: Buffer;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-29 18:25:21 -04:00
|
|
|
export type ExecOptions = {
|
2026-01-30 19:41:00 +01:00
|
|
|
cwd?: string;
|
|
|
|
|
env?: NodeJS.ProcessEnv;
|
|
|
|
|
allowFailure?: boolean;
|
|
|
|
|
binaryStdout?: boolean;
|
2026-02-02 15:12:23 +01:00
|
|
|
stdin?: string | Buffer;
|
2026-02-12 17:17:56 +01:00
|
|
|
timeoutMs?: number;
|
2026-02-20 17:18:37 +01:00
|
|
|
detached?: boolean;
|
2026-04-27 17:19:08 -04:00
|
|
|
signal?: AbortSignal;
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
/** Max stdout/stderr bytes for synchronous runs (default Node ~1MB). */
|
|
|
|
|
maxBuffer?: number;
|
2026-01-30 19:41:00 +01:00
|
|
|
};
|
|
|
|
|
|
2026-02-20 19:44:58 +01:00
|
|
|
type ExecStreamOptions = ExecOptions & {
|
2026-01-30 19:41:00 +01:00
|
|
|
onStdoutChunk?: (chunk: string) => void;
|
|
|
|
|
onStderrChunk?: (chunk: string) => void;
|
2026-02-20 17:18:37 +01:00
|
|
|
onSpawn?: (child: ReturnType<typeof spawn>) => void;
|
2026-01-30 19:41:00 +01:00
|
|
|
};
|
|
|
|
|
|
2026-02-02 21:33:45 +01:00
|
|
|
export type ExecBackgroundResult = {
|
|
|
|
|
child: ReturnType<typeof spawn>;
|
|
|
|
|
wait: Promise<ExecResult>;
|
|
|
|
|
};
|
|
|
|
|
|
refactor: P3 polish — Interactor type safety, error response builder, OXC (#316)
* refactor: polish improvements from codebase review (P3)
- Export Interactor type and add `satisfies Interactor` to both return
objects in getInteractor() for compile-time completeness checking
- Create errorResponse/successResponse builder in handlers/response.ts
and adopt it in 5 representative handler files (find, record-trace,
interaction-press, session-deploy, snapshot-wait)
- Add ESLint with typescript-eslint, fix unused imports and trivial
lint issues, configure rules to warn for stylistic patterns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: complete P3 items — consolidate runner files, document macOS exports, standardize dispatch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address P3 review feedback — remove invalid ESLint rules, dead code, unused export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: replace eslint and prettier with oxc
* chore: tighten oxc config and upgrade typescript
* refactor: extract ios runner contract helpers
* docs: tighten agent guidance and add check scripts
* fix: resolve post-rebase validation issues
* fix: remove shell interpolation from metro launcher
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:43:24 +02:00
|
|
|
type ExecDetachedOptions = ExecOptions & {
|
|
|
|
|
stdio?: StdioOptions;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-12 17:32:56 +02:00
|
|
|
export type ExecDetachedExit = {
|
|
|
|
|
pid: number;
|
|
|
|
|
exitCode?: number;
|
|
|
|
|
signal?: NodeJS.Signals;
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ExecDetachedProcess = {
|
|
|
|
|
pid: number;
|
|
|
|
|
exited: Promise<ExecDetachedExit>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 13:05:25 +02:00
|
|
|
export type ExecBackgroundOptions = ExecOptions & {
|
|
|
|
|
/**
|
|
|
|
|
* Capture stdout/stderr into the wait result when the child has piped stdio.
|
|
|
|
|
* Set false when the caller owns, ignores, or forwards the streams.
|
|
|
|
|
*/
|
|
|
|
|
captureOutput?: boolean;
|
|
|
|
|
stdio?: StdioOptions;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-07 20:19:26 +02:00
|
|
|
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
|
|
|
const WINDOWS_PATH_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
|
2026-07-02 14:05:47 +02:00
|
|
|
const EXEC_DIAGNOSTIC_ARG_LIMIT = 6;
|
2026-04-29 18:25:21 -04:00
|
|
|
export type CommandExecutorOverride = (
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecOptions,
|
|
|
|
|
) => Promise<ExecResult> | undefined;
|
|
|
|
|
|
|
|
|
|
const commandExecutorOverrideScope = new AsyncLocalStorage<CommandExecutorOverride | undefined>();
|
|
|
|
|
|
|
|
|
|
export async function withCommandExecutorOverride<T>(
|
|
|
|
|
override: CommandExecutorOverride | undefined,
|
|
|
|
|
fn: () => Promise<T>,
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
if (!override) return await fn();
|
|
|
|
|
return await commandExecutorOverrideScope.run(override, fn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Used by local executors to bypass the active override for intentional host command execution.
|
|
|
|
|
export async function withoutCommandExecutorOverride<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
|
|
return await commandExecutorOverrideScope.run(undefined, fn);
|
|
|
|
|
}
|
2026-04-07 20:19:26 +02:00
|
|
|
|
2026-01-30 19:41:00 +01:00
|
|
|
export async function runCmd(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecOptions = {},
|
2026-04-27 17:19:08 -04:00
|
|
|
): Promise<ExecResult> {
|
2026-04-29 18:25:21 -04:00
|
|
|
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
|
|
|
if (overrideResult) return coerceExecResult(await overrideResult);
|
2026-04-27 17:19:08 -04:00
|
|
|
return await runSpawnedCommand(cmd, args, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runCmdStreaming(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecStreamOptions = {},
|
|
|
|
|
): Promise<ExecResult> {
|
2026-04-29 18:25:21 -04:00
|
|
|
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
|
|
|
if (overrideResult) return coerceExecResult(await overrideResult);
|
2026-04-27 17:19:08 -04:00
|
|
|
return await runSpawnedCommand(cmd, args, options);
|
|
|
|
|
}
|
|
|
|
|
|
refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
|
|
|
/**
|
|
|
|
|
* Normalize an exec result produced outside this module. Tool providers,
|
|
|
|
|
* executor overrides, and SDK-supplied adb executors are plain callbacks whose
|
|
|
|
|
* results cross an unchecked boundary; coercing once here lets downstream code
|
|
|
|
|
* trust the ExecResult types instead of re-wrapping fields defensively at every
|
|
|
|
|
* use. A non-number exitCode coerces to 1 — the same failure branch such a
|
|
|
|
|
* result already landed in at every `exitCode !== 0` guard.
|
|
|
|
|
*/
|
|
|
|
|
export function coerceExecResult<T extends Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode'>>(
|
|
|
|
|
result: T,
|
|
|
|
|
): T {
|
|
|
|
|
const stdout = typeof result.stdout === 'string' ? result.stdout : String(result.stdout ?? '');
|
|
|
|
|
const stderr = typeof result.stderr === 'string' ? result.stderr : String(result.stderr ?? '');
|
|
|
|
|
const exitCode = typeof result.exitCode === 'number' ? result.exitCode : 1;
|
|
|
|
|
if (stdout === result.stdout && stderr === result.stderr && exitCode === result.exitCode) {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
return { ...result, stdout, stderr, exitCode };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:19:08 -04:00
|
|
|
function runSpawnedCommand(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecStreamOptions = {},
|
2026-01-30 19:41:00 +01:00
|
|
|
): Promise<ExecResult> {
|
2026-04-07 20:19:26 +02:00
|
|
|
const executable = normalizeExecutableCommand(cmd);
|
2026-07-02 14:05:47 +02:00
|
|
|
const execTrace = createExecTraceContext();
|
2026-01-30 19:41:00 +01:00
|
|
|
return new Promise((resolve, reject) => {
|
2026-04-07 20:19:26 +02:00
|
|
|
const child = spawn(executable, args, {
|
2026-01-30 19:41:00 +01:00
|
|
|
cwd: options.cwd,
|
|
|
|
|
env: options.env,
|
2026-02-02 15:12:23 +01:00
|
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
2026-02-20 17:18:37 +01:00
|
|
|
detached: options.detached,
|
2026-05-12 19:15:11 +02:00
|
|
|
windowsHide: true,
|
2026-04-07 20:19:26 +02:00
|
|
|
shell: false,
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
2026-04-27 17:19:08 -04:00
|
|
|
options.onSpawn?.(child);
|
2026-01-30 19:41:00 +01:00
|
|
|
|
|
|
|
|
let stdout = '';
|
2026-04-27 17:19:08 -04:00
|
|
|
const stdoutChunks: Buffer[] | undefined = options.binaryStdout ? [] : undefined;
|
2026-01-30 19:41:00 +01:00
|
|
|
let stderr = '';
|
2026-02-12 17:17:56 +01:00
|
|
|
let didTimeout = false;
|
|
|
|
|
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
|
|
|
const timeoutHandle = timeoutMs
|
|
|
|
|
? setTimeout(() => {
|
2026-03-17 18:18:53 +01:00
|
|
|
didTimeout = true;
|
2026-04-27 17:19:08 -04:00
|
|
|
killProcessTree(child, options.detached);
|
2026-03-17 18:18:53 +01:00
|
|
|
}, timeoutMs)
|
2026-02-12 17:17:56 +01:00
|
|
|
: null;
|
2026-05-13 13:05:25 +02:00
|
|
|
const abort = watchCommandAbort(child, options);
|
2026-01-30 19:41:00 +01:00
|
|
|
|
|
|
|
|
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
|
|
|
|
|
child.stderr.setEncoding('utf8');
|
|
|
|
|
|
2026-04-27 17:19:08 -04:00
|
|
|
void writeChildStdin(child, options.stdin).catch((err: unknown) => {
|
2026-05-13 13:05:25 +02:00
|
|
|
if (abort.didAbort || didTimeout) return;
|
2026-04-27 17:19:08 -04:00
|
|
|
if (isEpipeError(err)) return;
|
|
|
|
|
reject(createStdinError(executable, cmd, args, err));
|
|
|
|
|
killProcessTree(child, options.detached);
|
|
|
|
|
});
|
2026-02-02 15:12:23 +01:00
|
|
|
|
2026-01-30 19:41:00 +01:00
|
|
|
child.stdout.on('data', (chunk) => {
|
|
|
|
|
if (options.binaryStdout) {
|
2026-04-27 17:19:08 -04:00
|
|
|
stdoutChunks?.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
|
|
|
return;
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
2026-04-27 17:19:08 -04:00
|
|
|
const text = String(chunk);
|
|
|
|
|
stdout += text;
|
|
|
|
|
options.onStdoutChunk?.(text);
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.stderr.on('data', (chunk) => {
|
2026-04-27 17:19:08 -04:00
|
|
|
const text = String(chunk);
|
|
|
|
|
stderr += text;
|
|
|
|
|
options.onStderrChunk?.(text);
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('error', (err) => {
|
2026-02-12 17:17:56 +01:00
|
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
2026-05-13 13:05:25 +02:00
|
|
|
abort.dispose();
|
2026-07-02 14:05:47 +02:00
|
|
|
execTrace.emitForegroundCompletion(cmd, args);
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
reject(spawnRejectionError(abort, executable, cmd, args, err));
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('close', (code) => {
|
2026-02-12 17:17:56 +01:00
|
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
2026-05-13 13:05:25 +02:00
|
|
|
abort.dispose();
|
2026-07-02 14:05:47 +02:00
|
|
|
execTrace.emitForegroundCompletion(cmd, args);
|
2026-01-30 19:41:00 +01:00
|
|
|
const exitCode = code ?? 1;
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
if (!abort.didAbort && didTimeout && timeoutMs) {
|
2026-04-27 17:19:08 -04:00
|
|
|
reject(createTimeoutError(executable, cmd, args, timeoutMs, exitCode, stdout, stderr));
|
2026-02-12 17:17:56 +01:00
|
|
|
return;
|
|
|
|
|
}
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
const failure = commandCloseFailure(
|
|
|
|
|
abort,
|
|
|
|
|
executable,
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
exitCode,
|
|
|
|
|
options.allowFailure,
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
);
|
|
|
|
|
if (failure) {
|
|
|
|
|
reject(failure);
|
2026-01-30 19:41:00 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-27 17:19:08 -04:00
|
|
|
resolve({
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
exitCode,
|
|
|
|
|
stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : undefined,
|
|
|
|
|
});
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function whichCmd(cmd: string): Promise<boolean> {
|
2026-04-11 21:36:54 +02:00
|
|
|
const candidate = normalizeExecutableLookup(cmd);
|
2026-04-07 20:19:26 +02:00
|
|
|
if (!candidate) return false;
|
|
|
|
|
|
|
|
|
|
if (path.isAbsolute(candidate)) {
|
2026-04-11 21:36:54 +02:00
|
|
|
return isExecutablePath(candidate);
|
2026-04-07 20:19:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pathValue = process.env.PATH;
|
|
|
|
|
if (!pathValue) return false;
|
|
|
|
|
const pathExtensions = resolvePathExtensions();
|
|
|
|
|
for (const directory of pathValue.split(path.delimiter)) {
|
|
|
|
|
const trimmedDirectory = directory.trim();
|
|
|
|
|
if (!trimmedDirectory) continue;
|
|
|
|
|
for (const entry of resolveExecutableCandidates(candidate, pathExtensions)) {
|
2026-04-11 21:36:54 +02:00
|
|
|
if (await isExecutablePath(path.join(trimmedDirectory, entry))) {
|
2026-04-07 20:19:26 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
2026-04-07 20:19:26 +02:00
|
|
|
|
|
|
|
|
return false;
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 21:36:54 +02:00
|
|
|
export async function resolveExecutableOverridePath(
|
|
|
|
|
rawPath: string | undefined,
|
|
|
|
|
envName: string,
|
|
|
|
|
): Promise<string | undefined> {
|
|
|
|
|
const candidate = normalizeOverridePath(rawPath, envName, 'executable');
|
|
|
|
|
if (!candidate) return undefined;
|
|
|
|
|
if (!(await isExecutablePath(candidate))) {
|
|
|
|
|
throw new AppError(
|
|
|
|
|
'TOOL_MISSING',
|
|
|
|
|
`${envName} points to a missing or non-executable file: ${candidate}`,
|
|
|
|
|
{ envName, path: candidate },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function resolveFileOverridePath(
|
|
|
|
|
rawPath: string | undefined,
|
|
|
|
|
envName: string,
|
|
|
|
|
): Promise<string | undefined> {
|
|
|
|
|
const candidate = normalizeOverridePath(rawPath, envName, 'file');
|
|
|
|
|
if (!candidate) return undefined;
|
|
|
|
|
if (!(await isFilePath(candidate))) {
|
|
|
|
|
throw new AppError(
|
|
|
|
|
'TOOL_MISSING',
|
|
|
|
|
`${envName} points to a missing or non-file path: ${candidate}`,
|
|
|
|
|
{ envName, path: candidate },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 19:41:00 +01:00
|
|
|
export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {}): ExecResult {
|
2026-04-07 20:19:26 +02:00
|
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
|
|
|
const result = spawnSync(executable, args, {
|
2026-01-30 19:41:00 +01:00
|
|
|
cwd: options.cwd,
|
|
|
|
|
env: options.env,
|
2026-02-02 15:12:23 +01:00
|
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
2026-01-30 19:41:00 +01:00
|
|
|
encoding: options.binaryStdout ? undefined : 'utf8',
|
2026-02-02 15:12:23 +01:00
|
|
|
input: options.stdin,
|
2026-02-12 17:17:56 +01:00
|
|
|
timeout: normalizeTimeoutMs(options.timeoutMs),
|
2026-05-12 19:15:11 +02:00
|
|
|
windowsHide: true,
|
2026-04-07 20:19:26 +02:00
|
|
|
shell: false,
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
...(options.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}),
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result.error) {
|
|
|
|
|
const code = (result.error as NodeJS.ErrnoException).code;
|
2026-02-12 17:17:56 +01:00
|
|
|
if (code === 'ETIMEDOUT') {
|
2026-03-17 18:18:53 +01:00
|
|
|
throw new AppError(
|
|
|
|
|
'COMMAND_FAILED',
|
2026-04-07 20:19:26 +02:00
|
|
|
`${executable} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`,
|
2026-03-17 18:18:53 +01:00
|
|
|
{
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
|
|
|
|
|
},
|
|
|
|
|
result.error,
|
|
|
|
|
);
|
2026-02-12 17:17:56 +01:00
|
|
|
}
|
2026-01-30 19:41:00 +01:00
|
|
|
if (code === 'ENOENT') {
|
2026-04-27 17:19:08 -04:00
|
|
|
throw createMissingToolError(executable, cmd, result.error);
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
2026-04-27 17:19:08 -04:00
|
|
|
throw createCommandFailedError(executable, cmd, args, result.error);
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stdoutBuffer = options.binaryStdout
|
|
|
|
|
? Buffer.isBuffer(result.stdout)
|
|
|
|
|
? result.stdout
|
|
|
|
|
: Buffer.from(result.stdout ?? '')
|
|
|
|
|
: undefined;
|
|
|
|
|
const stdout = options.binaryStdout
|
|
|
|
|
? ''
|
|
|
|
|
: typeof result.stdout === 'string'
|
|
|
|
|
? result.stdout
|
|
|
|
|
: (result.stdout ?? '').toString();
|
|
|
|
|
const stderr =
|
|
|
|
|
typeof result.stderr === 'string' ? result.stderr : (result.stderr ?? '').toString();
|
|
|
|
|
const exitCode = result.status ?? 1;
|
|
|
|
|
|
|
|
|
|
if (exitCode !== 0 && !options.allowFailure) {
|
2026-04-27 17:19:08 -04:00
|
|
|
throw createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { stdout, stderr, exitCode, stdoutBuffer };
|
|
|
|
|
}
|
|
|
|
|
|
refactor: P3 polish — Interactor type safety, error response builder, OXC (#316)
* refactor: polish improvements from codebase review (P3)
- Export Interactor type and add `satisfies Interactor` to both return
objects in getInteractor() for compile-time completeness checking
- Create errorResponse/successResponse builder in handlers/response.ts
and adopt it in 5 representative handler files (find, record-trace,
interaction-press, session-deploy, snapshot-wait)
- Add ESLint with typescript-eslint, fix unused imports and trivial
lint issues, configure rules to warn for stylistic patterns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: complete P3 items — consolidate runner files, document macOS exports, standardize dispatch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address P3 review feedback — remove invalid ESLint rules, dead code, unused export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: replace eslint and prettier with oxc
* chore: tighten oxc config and upgrade typescript
* refactor: extract ios runner contract helpers
* docs: tighten agent guidance and add check scripts
* fix: resolve post-rebase validation issues
* fix: remove shell interpolation from metro launcher
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:43:24 +02:00
|
|
|
export function runCmdDetached(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecDetachedOptions = {},
|
|
|
|
|
): number {
|
2026-06-12 17:32:56 +02:00
|
|
|
return runCmdDetachedMonitored(cmd, args, options).pid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function runCmdDetachedMonitored(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
options: ExecDetachedOptions = {},
|
|
|
|
|
): ExecDetachedProcess {
|
2026-04-07 20:19:26 +02:00
|
|
|
const executable = normalizeExecutableCommand(cmd);
|
|
|
|
|
const child = spawn(executable, args, {
|
2026-01-30 19:41:00 +01:00
|
|
|
cwd: options.cwd,
|
|
|
|
|
env: options.env,
|
refactor: P3 polish — Interactor type safety, error response builder, OXC (#316)
* refactor: polish improvements from codebase review (P3)
- Export Interactor type and add `satisfies Interactor` to both return
objects in getInteractor() for compile-time completeness checking
- Create errorResponse/successResponse builder in handlers/response.ts
and adopt it in 5 representative handler files (find, record-trace,
interaction-press, session-deploy, snapshot-wait)
- Add ESLint with typescript-eslint, fix unused imports and trivial
lint issues, configure rules to warn for stylistic patterns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: complete P3 items — consolidate runner files, document macOS exports, standardize dispatch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address P3 review feedback — remove invalid ESLint rules, dead code, unused export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: replace eslint and prettier with oxc
* chore: tighten oxc config and upgrade typescript
* refactor: extract ios runner contract helpers
* docs: tighten agent guidance and add check scripts
* fix: resolve post-rebase validation issues
* fix: remove shell interpolation from metro launcher
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:43:24 +02:00
|
|
|
stdio: options.stdio ?? 'ignore',
|
2026-01-30 19:41:00 +01:00
|
|
|
detached: true,
|
2026-05-12 19:15:11 +02:00
|
|
|
windowsHide: true,
|
2026-04-07 20:19:26 +02:00
|
|
|
shell: false,
|
2026-01-30 19:41:00 +01:00
|
|
|
});
|
2026-06-12 17:32:56 +02:00
|
|
|
const pid = child.pid ?? 0;
|
|
|
|
|
const exited = new Promise<ExecDetachedExit>((resolve) => {
|
|
|
|
|
child.once('error', (err) => {
|
|
|
|
|
resolve({ pid, error: err.message });
|
|
|
|
|
});
|
|
|
|
|
child.once('exit', (code, signal) => {
|
|
|
|
|
resolve({
|
|
|
|
|
pid,
|
|
|
|
|
...(typeof code === 'number' ? { exitCode: code } : {}),
|
|
|
|
|
...(signal ? { signal } : {}),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-30 19:41:00 +01:00
|
|
|
child.unref();
|
2026-06-12 17:32:56 +02:00
|
|
|
return { pid, exited };
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 21:33:45 +01:00
|
|
|
export function runCmdBackground(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
2026-05-13 13:05:25 +02:00
|
|
|
options: ExecBackgroundOptions = {},
|
2026-02-02 21:33:45 +01:00
|
|
|
): ExecBackgroundResult {
|
2026-04-07 20:19:26 +02:00
|
|
|
const executable = normalizeExecutableCommand(cmd);
|
2026-07-02 14:05:47 +02:00
|
|
|
const execTrace = createExecTraceContext();
|
2026-04-07 20:19:26 +02:00
|
|
|
const child = spawn(executable, args, {
|
2026-02-02 21:33:45 +01:00
|
|
|
cwd: options.cwd,
|
|
|
|
|
env: options.env,
|
2026-05-13 13:05:25 +02:00
|
|
|
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
2026-02-20 17:18:37 +01:00
|
|
|
detached: options.detached,
|
2026-05-12 19:15:11 +02:00
|
|
|
windowsHide: true,
|
2026-04-07 20:19:26 +02:00
|
|
|
shell: false,
|
2026-02-02 21:33:45 +01:00
|
|
|
});
|
2026-07-02 14:05:47 +02:00
|
|
|
execTrace.emitBackgroundSpawn(cmd, args);
|
2026-02-02 21:33:45 +01:00
|
|
|
|
|
|
|
|
let stdout = '';
|
|
|
|
|
let stderr = '';
|
2026-05-13 13:05:25 +02:00
|
|
|
const captureOutput = options.captureOutput ?? true;
|
|
|
|
|
const abort = watchCommandAbort(child, options);
|
2026-02-02 21:33:45 +01:00
|
|
|
|
2026-05-13 13:05:25 +02:00
|
|
|
if (captureOutput) {
|
|
|
|
|
child.stdout?.setEncoding('utf8');
|
|
|
|
|
child.stderr?.setEncoding('utf8');
|
2026-02-02 21:33:45 +01:00
|
|
|
|
2026-05-13 13:05:25 +02:00
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
|
|
|
stdout += chunk;
|
|
|
|
|
});
|
|
|
|
|
child.stderr?.on('data', (chunk) => {
|
|
|
|
|
stderr += chunk;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 21:33:45 +01:00
|
|
|
|
|
|
|
|
const wait = new Promise<ExecResult>((resolve, reject) => {
|
|
|
|
|
child.on('error', (err) => {
|
2026-05-13 13:05:25 +02:00
|
|
|
abort.dispose();
|
2026-07-02 14:05:47 +02:00
|
|
|
execTrace.emitBackgroundCompletion(cmd, args, 'error');
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
reject(spawnRejectionError(abort, executable, cmd, args, err));
|
2026-02-02 21:33:45 +01:00
|
|
|
});
|
|
|
|
|
child.on('close', (code) => {
|
2026-05-13 13:05:25 +02:00
|
|
|
abort.dispose();
|
2026-07-02 14:05:47 +02:00
|
|
|
execTrace.emitBackgroundCompletion(cmd, args, 'exit');
|
2026-02-02 21:33:45 +01:00
|
|
|
const exitCode = code ?? 1;
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
const failure = commandCloseFailure(
|
|
|
|
|
abort,
|
|
|
|
|
executable,
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
exitCode,
|
|
|
|
|
options.allowFailure,
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
);
|
|
|
|
|
if (failure) {
|
|
|
|
|
reject(failure);
|
2026-02-02 21:33:45 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
resolve({ stdout, stderr, exitCode });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { child, wait };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:05:47 +02:00
|
|
|
type ExecTraceContext = {
|
|
|
|
|
emitBackgroundCompletion: (cmd: string, args: string[], event: 'error' | 'exit') => void;
|
|
|
|
|
emitBackgroundSpawn: (cmd: string, args: string[]) => void;
|
|
|
|
|
emitForegroundCompletion: (cmd: string, args: string[]) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function createExecTraceContext(): ExecTraceContext {
|
|
|
|
|
const diagnosticsMeta = getDiagnosticsMeta();
|
|
|
|
|
const diagnosticsDebugEnabled = diagnosticsMeta.debug === true;
|
|
|
|
|
const envTraceEnabled = parseBooleanLiteral(process.env.AGENT_DEVICE_EXEC_TRACE ?? '') === true;
|
|
|
|
|
if (!diagnosticsDebugEnabled && !envTraceEnabled) {
|
|
|
|
|
return createDisabledExecTraceContext();
|
|
|
|
|
}
|
|
|
|
|
if (envTraceEnabled && diagnosticsMeta.flushOnSuccess !== true) {
|
|
|
|
|
updateDiagnosticsScope({ flushOnSuccess: true });
|
|
|
|
|
}
|
|
|
|
|
const startedAtMs = Date.now();
|
|
|
|
|
let completionEmitted = false;
|
|
|
|
|
return {
|
|
|
|
|
emitForegroundCompletion: (cmd, args) => {
|
|
|
|
|
if (completionEmitted) return;
|
|
|
|
|
completionEmitted = true;
|
|
|
|
|
emitExecCommandDiagnostic({
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
startedAtMs,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
emitBackgroundSpawn: (cmd, args) => {
|
|
|
|
|
emitExecCommandDiagnostic({
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
data: { event: 'spawn' },
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
emitBackgroundCompletion: (cmd, args, event) => {
|
|
|
|
|
if (completionEmitted) return;
|
|
|
|
|
completionEmitted = true;
|
|
|
|
|
emitExecCommandDiagnostic({
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
startedAtMs,
|
|
|
|
|
data: { event },
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createDisabledExecTraceContext(): ExecTraceContext {
|
|
|
|
|
return {
|
|
|
|
|
emitForegroundCompletion: () => {},
|
|
|
|
|
emitBackgroundSpawn: () => {},
|
|
|
|
|
emitBackgroundCompletion: () => {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitExecCommandDiagnostic(params: {
|
|
|
|
|
cmd: string;
|
|
|
|
|
args: string[];
|
|
|
|
|
startedAtMs?: number;
|
|
|
|
|
data?: Record<string, unknown>;
|
|
|
|
|
}): void {
|
|
|
|
|
const argsPrefix = params.args.slice(0, EXEC_DIAGNOSTIC_ARG_LIMIT);
|
|
|
|
|
emitDiagnostic({
|
|
|
|
|
level: 'debug',
|
|
|
|
|
phase: 'exec_command',
|
|
|
|
|
durationMs:
|
|
|
|
|
params.startedAtMs === undefined ? undefined : Math.max(0, Date.now() - params.startedAtMs),
|
|
|
|
|
data: {
|
|
|
|
|
command: params.cmd,
|
|
|
|
|
argsPrefix,
|
|
|
|
|
...(params.args.length > argsPrefix.length
|
|
|
|
|
? { omittedArgCount: params.args.length - argsPrefix.length }
|
|
|
|
|
: {}),
|
|
|
|
|
...(params.data ?? {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:19:26 +02:00
|
|
|
function normalizeExecutableCommand(cmd: string): string {
|
2026-04-11 21:36:54 +02:00
|
|
|
const candidate = normalizeExecutableLookup(cmd);
|
2026-04-07 20:19:26 +02:00
|
|
|
if (!candidate) {
|
|
|
|
|
throw new AppError('INVALID_ARGS', `Invalid executable command: ${JSON.stringify(cmd)}`, {
|
|
|
|
|
cmd,
|
2026-04-11 21:36:54 +02:00
|
|
|
hint: 'Use a bare command name from PATH or an absolute executable path.',
|
2026-04-07 20:19:26 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:19:08 -04:00
|
|
|
function createSpawnError(executable: string, cmd: string, args: string[], err: Error): AppError {
|
|
|
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
|
|
|
if (code === 'ENOENT') {
|
|
|
|
|
return createMissingToolError(executable, cmd, err);
|
|
|
|
|
}
|
|
|
|
|
return createCommandFailedError(executable, cmd, args, err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMissingToolError(executable: string, cmd: string, cause: Error): AppError {
|
|
|
|
|
return new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, cause);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createCommandFailedError(
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
cause: Error,
|
|
|
|
|
): AppError {
|
|
|
|
|
return new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, cause);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createStdinError(
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
cause: unknown,
|
|
|
|
|
): AppError {
|
|
|
|
|
return new AppError(
|
|
|
|
|
'COMMAND_FAILED',
|
|
|
|
|
`Failed to write stdin for ${executable}`,
|
|
|
|
|
{ cmd, args },
|
|
|
|
|
cause instanceof Error ? cause : undefined,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createCommandCanceledError(executable: string, cmd: string, args: string[]): AppError {
|
|
|
|
|
return new AppError('COMMAND_FAILED', 'request canceled', {
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
executable,
|
|
|
|
|
reason: 'request_canceled',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createTimeoutError(
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
timeoutMs: number,
|
|
|
|
|
exitCode: number,
|
|
|
|
|
stdout: string,
|
|
|
|
|
stderr: string,
|
|
|
|
|
): AppError {
|
refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
|
|
|
// exec-guard-allow: deliberately no processExitError — "timed out after Nms"
|
|
|
|
|
// beats whatever partial stderr the killed process left behind.
|
2026-04-27 17:19:08 -04:00
|
|
|
return new AppError('COMMAND_FAILED', `${executable} timed out after ${timeoutMs}ms`, {
|
|
|
|
|
cmd,
|
|
|
|
|
args,
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
exitCode,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createExitError(
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
exitCode: number,
|
|
|
|
|
stdout: string,
|
|
|
|
|
stderr: string,
|
|
|
|
|
): AppError {
|
fix: surface stderr excerpts from hand-rolled exec failure wraps (#1072)
* fix: surface stderr excerpts from hand-rolled exec failure wraps
normalizeError only replaces a generic COMMAND_FAILED message with the
first meaningful stderr line when details.processExitError is true, but
only createExitError in utils/exec.ts set that flag. Hand-rolled wrap
sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr,
exitCode}) after an allowFailure run) missed it, so users saw messages
like "xcrun exited with code 22" with a generic hint instead of the
actual stderr excerpt.
Add execFailureDetails(result, extra?) to utils/exec.ts returning the
stdout/stderr/exitCode spread plus processExitError: true (extras spread
after the base keys so call sites can keep truncation or String()
normalization), and convert ~42 exit-guarded wrap sites across the apple
platform (apps launch/terminate paths, devicectl, simulator boot, perf,
perf-xctrace, runner modules, dsym/symbolication, macOS host provider)
and android/web (app-helpers, logcat, devices, perf, settings,
snapshot helpers, app-lifecycle, device-input-state, multitouch-helper,
agent-browser provider).
Sites reachable at exit 0 (parse/stdout checks), failure aggregations,
timeout/forced-kill errors, and sites that already build richer messages
(macos helper JSON, runner-contract early-exit, maestro run-script) are
deliberately left unflagged so stderr enrichment never replaces a more
specific message.
* fix: append stderr excerpts to curated wrap messages instead of replacing them
Spreading processExitError to curated call sites made normalizeError
replace specific messages like 'uiautomator dump did not return XML'
with the bare stderr line, losing the operation context (caught by the
provider-failures integration test). Keep wholesale replacement only for
the information-free generic '<tool> exited with code N' wraps; curated
messages now keep their specific reason and gain the stderr excerpt as a
': <excerpt>' suffix (skipped when already present).
Separate commit rather than amend so the semantics change to
maybeEnrichCommandFailedMessage stays individually reviewable on the PR.
2026-07-04 10:25:03 +02:00
|
|
|
return new AppError(
|
|
|
|
|
'COMMAND_FAILED',
|
|
|
|
|
`${executable} exited with code ${exitCode}`,
|
|
|
|
|
execFailureDetails({ stdout, stderr, exitCode }, { cmd, args }),
|
|
|
|
|
);
|
refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:
- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
allowFailure result and throws the curated COMMAND_FAILED (flag set via
execFailureDetails) itself. Result-side by design — tool providers and
executor overrides return results without throwing, so an ExecOptions
knob interpreted at spawn time would silently not fire on those paths.
~30 pure guard-and-throw sites across apple/android/web converted; sites
with tolerance branches, cleanup, or exit-0 reachability keep their
explicit shape.
- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
AppError('COMMAND_FAILED', ...) details literal rebuilds the
stdout/stderr/exitCode trio inline without the helper; intentional
holdouts opt out with a documented exec-guard-allow comment. The guard
immediately found three wrap sites the July audit missed (runtime-hints
run-as probe, device-ready not_ready branch, perf export table) — now
converted.
- coerceExecResult at the provider boundaries (executor overrides, apple
tool provider scope, android adb provider scope): SDK-supplied callbacks
cross an unchecked boundary; coercing once there replaces the per-site
String(result.stdout ?? '') defensiveness, which is removed.
- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
simctl:/error:) before rendering; skip/strip lists stay in the kernel
deliberately (platforms cannot hook normalizeError) with a comment
marking the registry escape hatch if they grow.
- AppErrorDetails exported and documented in kernel/errors.ts: the magic
keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
stdout/stderr/exitCode) now carry types and doc comments.
- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
app-install.ts reuses it instead of duplicating the wrap + hint logic
(its failure hint now falls back to the devicectl default hint).
* fix: reconcile exec hardening with the adb failure classifier
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:
- Android call sites keep main's androidAdbResultError form — it composes
execFailureDetails with the classified adb hint, which is strictly
richer than a plain requireExecSuccess conversion for adb invocations.
requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
enrichment pass (exec/pull/install), so the existing WeakSet memo also
prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
the perf artifact-tail clone carries fallow-ignore markers on both
platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Guard an exec result that was obtained with `allowFailure: true`: throw a
|
|
|
|
|
* curated COMMAND_FAILED (with `processExitError` set, so normalizeError
|
|
|
|
|
* appends the stderr excerpt) on non-zero exit, and pass the result through
|
|
|
|
|
* otherwise. This is the standard shape for "run tool, fail with a specific
|
|
|
|
|
* message" call sites — it works under every executor (local spawn, tool
|
|
|
|
|
* providers, command overrides) because it checks the returned result rather
|
|
|
|
|
* than relying on the spawn layer to throw. `extra` accepts a function so
|
|
|
|
|
* failure-only work (hint classification) is not paid on the success path.
|
|
|
|
|
*/
|
|
|
|
|
export function requireExecSuccess(
|
|
|
|
|
result: ExecResult,
|
|
|
|
|
message: string,
|
|
|
|
|
extra?: Record<string, unknown> | ((result: ExecResult) => Record<string, unknown>),
|
|
|
|
|
): ExecResult {
|
|
|
|
|
if (result.exitCode === 0) return result;
|
|
|
|
|
throw new AppError(
|
|
|
|
|
'COMMAND_FAILED',
|
|
|
|
|
message,
|
|
|
|
|
execFailureDetails(result, typeof extra === 'function' ? extra(result) : extra),
|
|
|
|
|
);
|
fix: surface stderr excerpts from hand-rolled exec failure wraps (#1072)
* fix: surface stderr excerpts from hand-rolled exec failure wraps
normalizeError only replaces a generic COMMAND_FAILED message with the
first meaningful stderr line when details.processExitError is true, but
only createExitError in utils/exec.ts set that flag. Hand-rolled wrap
sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr,
exitCode}) after an allowFailure run) missed it, so users saw messages
like "xcrun exited with code 22" with a generic hint instead of the
actual stderr excerpt.
Add execFailureDetails(result, extra?) to utils/exec.ts returning the
stdout/stderr/exitCode spread plus processExitError: true (extras spread
after the base keys so call sites can keep truncation or String()
normalization), and convert ~42 exit-guarded wrap sites across the apple
platform (apps launch/terminate paths, devicectl, simulator boot, perf,
perf-xctrace, runner modules, dsym/symbolication, macOS host provider)
and android/web (app-helpers, logcat, devices, perf, settings,
snapshot helpers, app-lifecycle, device-input-state, multitouch-helper,
agent-browser provider).
Sites reachable at exit 0 (parse/stdout checks), failure aggregations,
timeout/forced-kill errors, and sites that already build richer messages
(macos helper JSON, runner-contract early-exit, maestro run-script) are
deliberately left unflagged so stderr enrichment never replaces a more
specific message.
* fix: append stderr excerpts to curated wrap messages instead of replacing them
Spreading processExitError to curated call sites made normalizeError
replace specific messages like 'uiautomator dump did not return XML'
with the bare stderr line, losing the operation context (caught by the
provider-failures integration test). Keep wholesale replacement only for
the information-free generic '<tool> exited with code N' wraps; curated
messages now keep their specific reason and gain the stderr excerpt as a
': <excerpt>' suffix (skipped when already present).
Separate commit rather than amend so the semantics change to
maybeEnrichCommandFailedMessage stays individually reviewable on the PR.
2026-07-04 10:25:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* COMMAND_FAILED details for a non-zero exec result. `processExitError: true`
|
|
|
|
|
* lets normalizeError surface the first meaningful stderr line as the user-facing
|
|
|
|
|
* message instead of the generic wrap message.
|
|
|
|
|
*/
|
|
|
|
|
export function execFailureDetails(
|
|
|
|
|
result: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode'>,
|
|
|
|
|
extra?: Record<string, unknown>,
|
|
|
|
|
): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
stdout: result.stdout,
|
|
|
|
|
stderr: result.stderr,
|
|
|
|
|
exitCode: result.exitCode,
|
2026-04-27 17:19:08 -04:00
|
|
|
processExitError: true,
|
fix: surface stderr excerpts from hand-rolled exec failure wraps (#1072)
* fix: surface stderr excerpts from hand-rolled exec failure wraps
normalizeError only replaces a generic COMMAND_FAILED message with the
first meaningful stderr line when details.processExitError is true, but
only createExitError in utils/exec.ts set that flag. Hand-rolled wrap
sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr,
exitCode}) after an allowFailure run) missed it, so users saw messages
like "xcrun exited with code 22" with a generic hint instead of the
actual stderr excerpt.
Add execFailureDetails(result, extra?) to utils/exec.ts returning the
stdout/stderr/exitCode spread plus processExitError: true (extras spread
after the base keys so call sites can keep truncation or String()
normalization), and convert ~42 exit-guarded wrap sites across the apple
platform (apps launch/terminate paths, devicectl, simulator boot, perf,
perf-xctrace, runner modules, dsym/symbolication, macOS host provider)
and android/web (app-helpers, logcat, devices, perf, settings,
snapshot helpers, app-lifecycle, device-input-state, multitouch-helper,
agent-browser provider).
Sites reachable at exit 0 (parse/stdout checks), failure aggregations,
timeout/forced-kill errors, and sites that already build richer messages
(macos helper JSON, runner-contract early-exit, maestro run-script) are
deliberately left unflagged so stderr enrichment never replaces a more
specific message.
* fix: append stderr excerpts to curated wrap messages instead of replacing them
Spreading processExitError to curated call sites made normalizeError
replace specific messages like 'uiautomator dump did not return XML'
with the bare stderr line, losing the operation context (caught by the
provider-failures integration test). Keep wholesale replacement only for
the information-free generic '<tool> exited with code N' wraps; curated
messages now keep their specific reason and gain the stderr excerpt as a
': <excerpt>' suffix (skipped when already present).
Separate commit rather than amend so the semantics change to
maybeEnrichCommandFailedMessage stays individually reviewable on the PR.
2026-07-04 10:25:03 +02:00
|
|
|
...extra,
|
|
|
|
|
};
|
2026-04-27 17:19:08 -04:00
|
|
|
}
|
|
|
|
|
|
feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
|
|
|
type CommandAbort = { readonly didAbort: boolean };
|
|
|
|
|
|
|
|
|
|
// Error to reject a spawned child's `error` event with: canceled if we aborted, else a spawn error.
|
|
|
|
|
function spawnRejectionError(
|
|
|
|
|
abort: CommandAbort,
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
err: Error,
|
|
|
|
|
): AppError {
|
|
|
|
|
return abort.didAbort
|
|
|
|
|
? createCommandCanceledError(executable, cmd, args)
|
|
|
|
|
: createSpawnError(executable, cmd, args, err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Failure (if any) for a spawned child's `close` event: canceled if we aborted, an exit error on
|
|
|
|
|
// a non-zero code unless allowed, otherwise null (the command resolves successfully).
|
|
|
|
|
function commandCloseFailure(
|
|
|
|
|
abort: CommandAbort,
|
|
|
|
|
executable: string,
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
exitCode: number,
|
|
|
|
|
allowFailure: boolean | undefined,
|
|
|
|
|
stdout: string,
|
|
|
|
|
stderr: string,
|
|
|
|
|
): AppError | null {
|
|
|
|
|
if (abort.didAbort) return createCommandCanceledError(executable, cmd, args);
|
|
|
|
|
if (exitCode !== 0 && !allowFailure) {
|
|
|
|
|
return createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 21:36:54 +02:00
|
|
|
function normalizeOverridePath(
|
|
|
|
|
rawPath: string | undefined,
|
|
|
|
|
envName: string,
|
|
|
|
|
kind: 'executable' | 'file',
|
|
|
|
|
): string | undefined {
|
|
|
|
|
const candidate = rawPath?.trim();
|
|
|
|
|
if (!candidate) return undefined;
|
|
|
|
|
if (!path.isAbsolute(candidate) || candidate.includes('\0')) {
|
|
|
|
|
throw new AppError(
|
|
|
|
|
'INVALID_ARGS',
|
|
|
|
|
`${envName} must be an absolute ${kind} path, not ${JSON.stringify(rawPath)}`,
|
|
|
|
|
{ envName, path: rawPath },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeExecutableLookup(cmd: string): string | null {
|
2026-04-07 20:19:26 +02:00
|
|
|
const candidate = cmd.trim();
|
|
|
|
|
if (!candidate || candidate.includes('\0')) return null;
|
|
|
|
|
if (path.isAbsolute(candidate)) return candidate;
|
|
|
|
|
if (candidate.includes('/') || candidate.includes('\\')) {
|
2026-04-11 21:36:54 +02:00
|
|
|
return null;
|
2026-04-07 20:19:26 +02:00
|
|
|
}
|
|
|
|
|
return BARE_COMMAND_RE.test(candidate) ? candidate : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolvePathExtensions(): string[] {
|
|
|
|
|
if (process.platform !== 'win32') return [''];
|
|
|
|
|
const rawPathExt = process.env.PATHEXT;
|
|
|
|
|
if (!rawPathExt) return WINDOWS_PATH_EXTENSIONS;
|
|
|
|
|
const extensions = rawPathExt
|
|
|
|
|
.split(';')
|
|
|
|
|
.map((value) => value.trim().toLowerCase())
|
|
|
|
|
.filter((value) => value.length > 0);
|
|
|
|
|
return extensions.length > 0 ? extensions : WINDOWS_PATH_EXTENSIONS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveExecutableCandidates(cmd: string, pathExtensions: string[]): string[] {
|
|
|
|
|
if (process.platform !== 'win32') return [cmd];
|
|
|
|
|
const lowered = cmd.toLowerCase();
|
|
|
|
|
if (pathExtensions.some((extension) => lowered.endsWith(extension))) {
|
|
|
|
|
return [cmd];
|
|
|
|
|
}
|
|
|
|
|
return pathExtensions.map((extension) => `${cmd}${extension}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 21:36:54 +02:00
|
|
|
export async function isExecutablePath(filePath: string): Promise<boolean> {
|
2026-04-07 20:19:26 +02:00
|
|
|
try {
|
2026-04-11 21:36:54 +02:00
|
|
|
if (!(await isFilePath(filePath))) return false;
|
2026-04-07 20:19:26 +02:00
|
|
|
await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
2026-01-30 19:41:00 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-12 17:17:56 +01:00
|
|
|
|
2026-04-11 21:36:54 +02:00
|
|
|
async function isFilePath(filePath: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
const fileStat = await stat(filePath);
|
|
|
|
|
return fileStat.isFile();
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 17:17:56 +01:00
|
|
|
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
|
|
|
if (!Number.isFinite(value)) return undefined;
|
|
|
|
|
const timeout = Math.floor(value as number);
|
|
|
|
|
if (timeout <= 0) return undefined;
|
|
|
|
|
return timeout;
|
|
|
|
|
}
|
2026-04-27 17:19:08 -04:00
|
|
|
|
2026-05-13 13:05:25 +02:00
|
|
|
function watchCommandAbort(
|
|
|
|
|
child: ChildProcess,
|
|
|
|
|
options: Pick<ExecOptions, 'detached' | 'signal'>,
|
|
|
|
|
): { readonly didAbort: boolean; dispose: () => void } {
|
|
|
|
|
let didAbort = false;
|
|
|
|
|
const onAbort = () => {
|
|
|
|
|
didAbort = true;
|
|
|
|
|
killProcessTree(child, options.detached);
|
|
|
|
|
};
|
|
|
|
|
if (options.signal?.aborted) {
|
|
|
|
|
onAbort();
|
|
|
|
|
} else {
|
|
|
|
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
get didAbort() {
|
|
|
|
|
return didAbort;
|
|
|
|
|
},
|
|
|
|
|
dispose: () => {
|
|
|
|
|
options.signal?.removeEventListener('abort', onAbort);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:19:08 -04:00
|
|
|
function killProcessTree(child: ChildProcess, detached: boolean | undefined): void {
|
|
|
|
|
if (detached && child.pid && process.platform !== 'win32') {
|
|
|
|
|
try {
|
|
|
|
|
process.kill(-child.pid, 'SIGKILL');
|
|
|
|
|
return;
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
child.kill('SIGKILL');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeChildStdin(
|
|
|
|
|
child: ChildProcess,
|
|
|
|
|
stdin: string | Buffer | undefined,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!child.stdin) return;
|
|
|
|
|
if (stdin === undefined) {
|
|
|
|
|
child.stdin?.end();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await pipeline(Readable.from([stdin]), child.stdin);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isEpipeError(error: unknown): boolean {
|
|
|
|
|
return (
|
|
|
|
|
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EPIPE'
|
|
|
|
|
);
|
|
|
|
|
}
|