faberun 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
package/src/engine/resume.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { acquire as acquireLock } from "../run/lock.mjs";
|
|
|
18
18
|
import { applyInvalidWorkerResult, assertRunMutable, handleProviderExhaustion } from "./lifecycle.mjs";
|
|
19
19
|
import { applyJudgeResult } from "./review.mjs";
|
|
20
20
|
import { assertSourceUnchanged, captureRunIdentity, recordedBaseRef, statesFingerprint } from "./run-identity.mjs";
|
|
21
|
-
import { attemptWorkspace,
|
|
21
|
+
import { attemptWorkspace, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
|
|
22
|
+
import { attemptWorktreePath } from "../run/paths.mjs";
|
|
22
23
|
import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
|
|
23
24
|
import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
|
|
24
25
|
import { closePersistedInvocation, recoverOrphan, recoveryFromOverride } from "./recover.mjs";
|
|
@@ -302,7 +303,10 @@ export async function resumeRun(runDirPath, options = {}) {
|
|
|
302
303
|
await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
|
|
303
304
|
continue;
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
+
// Same discriminator as lifecycle.mjs: the artifact is owed only by
|
|
307
|
+
// the repo-reading discovery packet (empty readFiles), never by one
|
|
308
|
+
// closed to its listed read files, which delivers through `output`.
|
|
309
|
+
if (node.taskPacket.mode === "discovery" && node.taskPacket.readFiles.length === 0 && parsedWorkerResult.status === "done") {
|
|
306
310
|
try {
|
|
307
311
|
parseDiscoveryResult(parsedWorkerResult, attemptWorkspace(state) ?? contract.cwd);
|
|
308
312
|
} catch (error) {
|
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
import { stableJson } from "../util.mjs";
|
|
24
24
|
import { validateRunMetadata } from "../contract/snapshot.mjs";
|
|
25
25
|
import { contractDigest } from "../contract/index.mjs";
|
|
26
|
+
import { RUNS_DIR_NAME, runDirectory } from "../run/paths.mjs";
|
|
26
27
|
|
|
27
28
|
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
28
29
|
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
@@ -88,7 +89,7 @@ export function createRunMetadata(lock, sourceIdentity, resume = {}, integration
|
|
|
88
89
|
*/
|
|
89
90
|
function runDirFor(sourceIdentity) {
|
|
90
91
|
if (!sourceIdentity.cwd || !sourceIdentity.contractId) return null;
|
|
91
|
-
return
|
|
92
|
+
return runDirectory(sourceIdentity.cwd, sourceIdentity.contractId);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/**
|
|
@@ -319,7 +320,13 @@ function walkControllerFiles(root) {
|
|
|
319
320
|
/** @type {string[]} */
|
|
320
321
|
const found = [];
|
|
321
322
|
for (const entry of readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
322
|
-
|
|
323
|
+
// The controller identity hashes the executable snapshot, not the tree it
|
|
324
|
+
// is running in: `node_modules` is a dependency install, `.git` is
|
|
325
|
+
// repository metadata, and `RUNS_DIR_NAME` is the runner's own scratch
|
|
326
|
+
// state written while a run is in flight. None of the three is a change
|
|
327
|
+
// to the controller's source, so the sha must never move because the
|
|
328
|
+
// runner wrote to its own state -- only because tracked source did.
|
|
329
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === RUNS_DIR_NAME) continue;
|
|
323
330
|
const path = join(root, entry.name);
|
|
324
331
|
if (entry.isDirectory()) found.push(...walkControllerFiles(path));
|
|
325
332
|
else if (entry.isFile()) found.push(path);
|
package/src/engine/scheduler.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
4
4
|
import { JUDGE_SCHEMA, PARKED, SETTLED, retryPrompt } from "./prompts.mjs";
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
} from "../run/store.mjs";
|
|
19
19
|
import {
|
|
20
20
|
acquire as acquireLock,
|
|
21
|
+
lockStale,
|
|
21
22
|
LockLostError,
|
|
22
23
|
processStartToken,
|
|
24
|
+
readLock,
|
|
23
25
|
} from "../run/lock.mjs";
|
|
24
26
|
import { registerRun, resolveCampaign } from "../campaign/index.mjs";
|
|
25
27
|
import { createRunRef, runRefName } from "../repo/worktree.mjs";
|
|
@@ -46,6 +48,7 @@ import { startJudge, startWorker } from "./dispatch.mjs";
|
|
|
46
48
|
import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
|
|
47
49
|
import { blockDependents, runtimeAssignments } from "./assignment.mjs";
|
|
48
50
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from "./supervise.mjs";
|
|
51
|
+
import { runDirectory, runsRoot } from "../run/paths.mjs";
|
|
49
52
|
|
|
50
53
|
/** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
|
|
51
54
|
|
|
@@ -179,6 +182,25 @@ function renderFingerprint(states) {
|
|
|
179
182
|
return `${statesFingerprint(states)}#${progress}`;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Move a cancelled run's directory out of the way of its own id, once, so
|
|
187
|
+
* the launch that needs the id back can create it fresh. Never called for
|
|
188
|
+
* any other reason a run directory might already exist -- this is the one
|
|
189
|
+
* occupant a launch is allowed to displace on its own.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} runDir
|
|
192
|
+
* @returns {void}
|
|
193
|
+
*/
|
|
194
|
+
function archiveCanceledRunDir(runDir) {
|
|
195
|
+
let archivedPath = `${runDir}.canceled-${Date.now()}`;
|
|
196
|
+
let suffix = 0;
|
|
197
|
+
while (existsSync(archivedPath)) {
|
|
198
|
+
suffix += 1;
|
|
199
|
+
archivedPath = `${runDir}.canceled-${Date.now()}-${suffix}`;
|
|
200
|
+
}
|
|
201
|
+
renameSync(runDir, archivedPath);
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
/**
|
|
183
205
|
* @param {string} contractPath
|
|
184
206
|
* @param {{detachedBootstrap?: boolean, baseRef?: string}} [options]
|
|
@@ -192,9 +214,21 @@ function renderFingerprint(states) {
|
|
|
192
214
|
export async function runContract(contractPath, options = {}) {
|
|
193
215
|
const absoluteContractPath = resolve(contractPath);
|
|
194
216
|
const contract = validateContractForLaunch(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, { baseRef: options.baseRef });
|
|
195
|
-
const runDir =
|
|
196
|
-
if (existsSync(runDir))
|
|
197
|
-
|
|
217
|
+
const runDir = runDirectory(contract.cwd, contract.id);
|
|
218
|
+
if (existsSync(runDir)) {
|
|
219
|
+
// A cancelled run is the one prior occupant of this id a fresh launch may
|
|
220
|
+
// move aside on its own: `cancel` already released the git names (the run
|
|
221
|
+
// ref, every node's attempt branch) this launch needs back, and the
|
|
222
|
+
// cancel-request marker plus a stale controller lock is what proves no
|
|
223
|
+
// process can still be writing into it. Anything else at this path --
|
|
224
|
+
// still running, or settled without ever being cancelled -- keeps
|
|
225
|
+
// refusing exactly as before; the operator's evidence is never silently
|
|
226
|
+
// claimed.
|
|
227
|
+
const wasCanceled = existsSync(join(runDir, "cancel.request.json")) && lockStale(readLock(runDir));
|
|
228
|
+
if (!wasCanceled) throw new Error(`run already exists: ${runDir}`);
|
|
229
|
+
archiveCanceledRunDir(runDir);
|
|
230
|
+
}
|
|
231
|
+
mkdirSync(runsRoot(contract.cwd), { recursive: true });
|
|
198
232
|
try {
|
|
199
233
|
mkdirSync(runDir);
|
|
200
234
|
} catch (error) {
|
|
@@ -208,7 +242,7 @@ export async function runContract(contractPath, options = {}) {
|
|
|
208
242
|
const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
|
|
209
243
|
const integrationRef = createRunRef(contract.cwd, contract.id, sourceIdentity.gitHead);
|
|
210
244
|
lock.assert();
|
|
211
|
-
const runsDir =
|
|
245
|
+
const runsDir = runsRoot(contract.cwd);
|
|
212
246
|
const campaign = resolveCampaign(runsDir, contract.campaignId);
|
|
213
247
|
mkdirSync(join(runDir, "nodes"), { recursive: true });
|
|
214
248
|
mkdirSync(join(runDir, "logs"), { recursive: true });
|
|
@@ -281,7 +315,7 @@ export async function runContract(contractPath, options = {}) {
|
|
|
281
315
|
export async function driveRun(contract, runDir, states, campaign, lock, sourceIdentity, resume = {}, options = {}) {
|
|
282
316
|
lock.assert();
|
|
283
317
|
assertEnvironmentReady(contract, runDir, sourceIdentity);
|
|
284
|
-
const runsDir =
|
|
318
|
+
const runsDir = runsRoot(contract.cwd);
|
|
285
319
|
const bootstrapNonce = bootstrapNonceForProcess();
|
|
286
320
|
// Only the CLI entry can answer this: a nonce inherited by evals/run.mjs or
|
|
287
321
|
// by a test must not make the controller wait for an acknowledgement nobody
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge branch of `finalizeClosedJobs`, and the workspace comparison its
|
|
3
|
+
* fail-closed write check depends on. Split out of `lifecycle.mjs` for the
|
|
4
|
+
* same reason `engine/settle.mjs` was: `lifecycle.mjs` and
|
|
5
|
+
* `test/engine/judge.test.mjs` both sit on the 800-line ceiling
|
|
6
|
+
* `test/repo/source-shape.test.mjs` enforces.
|
|
7
|
+
*
|
|
8
|
+
* `settleJudgeRound` runs only after `lifecycle.mjs` has already called
|
|
9
|
+
* `judgeWorkspaceWriteViolation` and found nothing: the check has to run
|
|
10
|
+
* before any branch here (a failed provider, a bounded re-dispatch, or a
|
|
11
|
+
* verdict) can adopt or launder a judge's own write, so it cannot live inside
|
|
12
|
+
* this function without reintroducing the escape it closes.
|
|
13
|
+
*
|
|
14
|
+
* `clearTierExhaustion` and `handleProviderExhaustion` are `lifecycle.mjs`'s
|
|
15
|
+
* own -- importing them back from there would recreate the exact
|
|
16
|
+
* `lifecycle.mjs` <-> `review.mjs` cycle `test/repo/source-shape.test.mjs`
|
|
17
|
+
* once caught and `AGENTS.md` records as fixed, so the caller passes them in
|
|
18
|
+
* instead.
|
|
19
|
+
*/
|
|
20
|
+
import { judgeReaskOutstanding } from "./judge-gate.mjs";
|
|
21
|
+
import { judgeVerdictEvidence } from "../contract/review-modes.mjs";
|
|
22
|
+
import {
|
|
23
|
+
JUDGE_MAX_FAILURES,
|
|
24
|
+
applyJudgeProtocolFailure,
|
|
25
|
+
applyJudgeResult,
|
|
26
|
+
applyJudgeRound,
|
|
27
|
+
settleUnavailableJudge,
|
|
28
|
+
} from "./review.mjs";
|
|
29
|
+
import { networkTransition } from "./backoff.mjs";
|
|
30
|
+
import { startJudge } from "./dispatch.mjs";
|
|
31
|
+
import { writeNode } from "./state.mjs";
|
|
32
|
+
import { compareWorkspaceSnapshot } from "../repo/workspace.mjs";
|
|
33
|
+
import { errorCode, errorMessage, excerpt } from "../util.mjs";
|
|
34
|
+
|
|
35
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
36
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
37
|
+
/** @typedef {import("./process.mjs").Job} Job */
|
|
38
|
+
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
39
|
+
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
40
|
+
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
41
|
+
/** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a closed judge job wrote into its own workspace, or a workspace
|
|
45
|
+
* comparison error that means the same thing: either is a violation, never
|
|
46
|
+
* silence. `compareWorkspaceSnapshot` throws `snapshot_ignore_changed` when
|
|
47
|
+
* the judge edits an ignore source, `snapshot_symlink_escape` when it creates
|
|
48
|
+
* a symlink out of the tree, and `snapshot_too_large` when it adds enough
|
|
49
|
+
* entries -- all ordinary judge writes that must fail closed exactly like the
|
|
50
|
+
* worker path (`engine/scope.mjs`'s `checkWorkerScope`), never vanish into a
|
|
51
|
+
* "no writes" result. `job.scopeBaseline` is null only when the capture at
|
|
52
|
+
* dispatch time itself failed -- `startJudge` degrades to unchecked rather
|
|
53
|
+
* than refuse to run the judge at all -- and that is the one case with
|
|
54
|
+
* nothing to compare, not a violation.
|
|
55
|
+
*
|
|
56
|
+
* @param {Job} job
|
|
57
|
+
* @returns {{message: string}|null}
|
|
58
|
+
*/
|
|
59
|
+
export function judgeWorkspaceWriteViolation(job) {
|
|
60
|
+
const baseline = job.scopeBaseline;
|
|
61
|
+
if (!baseline) return null;
|
|
62
|
+
try {
|
|
63
|
+
const { unexpectedPaths } = compareWorkspaceSnapshot(/** @type {import("../repo/workspace.mjs").WorkspaceSnapshot} */ (baseline), job.cwd);
|
|
64
|
+
if (!unexpectedPaths.length) return null;
|
|
65
|
+
return { message: excerpt(`judge wrote into its own workspace (${unexpectedPaths.length}): ${unexpectedPaths.slice(0, 8).join(", ")}`) ?? "judge wrote into its own workspace" };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { message: excerpt(`judge workspace comparison failed (${errorCode(error) ?? "scope_snapshot_invalid"}): ${errorMessage(error)}`) ?? "judge workspace comparison failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Settle a closed judge invocation whose write check already passed: a
|
|
73
|
+
* provider that failed outright gets one bounded re-dispatch, then review-mode
|
|
74
|
+
* settlement; anything else that is not exactly one usable verdict (none at
|
|
75
|
+
* all, several of them, an unparseable one, a stream cut off before its
|
|
76
|
+
* terminal envelope, or a phase killed on its wall clock) takes the same
|
|
77
|
+
* bounded re-ask; a clean verdict is applied.
|
|
78
|
+
*
|
|
79
|
+
* @param {ValidatedContract} contract
|
|
80
|
+
* @param {Job} job
|
|
81
|
+
* @param {NodeSnapshot} state
|
|
82
|
+
* @param {string} runDir
|
|
83
|
+
* @param {Map<string, Job>} running
|
|
84
|
+
* @param {LockHandle} lock
|
|
85
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
86
|
+
* @param {string} campaignPath
|
|
87
|
+
* @param {PricedEnvelope} envelope
|
|
88
|
+
* @param {{clearTierExhaustion: (state: NodeSnapshot) => void, handleProviderExhaustion: typeof import("./lifecycle.mjs").handleProviderExhaustion}} hooks
|
|
89
|
+
* `lifecycle.mjs`'s own tier-exhaustion clear and provider-exhaustion router, passed in rather than imported back.
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
export async function settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion }) {
|
|
93
|
+
const node = job.node;
|
|
94
|
+
// A judge provider that failed outright (its turn died, its tool host was
|
|
95
|
+
// gone) is a provider failure, never a verdict: the gate cannot adopt a
|
|
96
|
+
// result the judge could not ground in inspection. Re-dispatch the judge
|
|
97
|
+
// once on the same routing, then settle by review mode so a judge failure
|
|
98
|
+
// is surfaced, never silently settled. A stream that never reached its
|
|
99
|
+
// terminal envelope is a protocol defect instead and takes the bounded
|
|
100
|
+
// re-ask below.
|
|
101
|
+
if (envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
|
|
102
|
+
// A judge that lost its socket is not an unavailable judge. It buys the
|
|
103
|
+
// same bounded network waits a worker does, on the runtime it already
|
|
104
|
+
// warmed, and spends none of the one re-dispatch counted below.
|
|
105
|
+
const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
|
|
106
|
+
if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
|
|
107
|
+
clearTierExhaustion(state);
|
|
108
|
+
// The provider died on the bounded re-ask itself, so the one permitted
|
|
109
|
+
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
110
|
+
// third judge invocation behind a fresh failure count.
|
|
111
|
+
if (judgeReaskOutstanding(state)) {
|
|
112
|
+
await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
state.judgeFailures = (state.judgeFailures ?? 0) + 1;
|
|
116
|
+
if (state.judgeFailures < JUDGE_MAX_FAILURES) {
|
|
117
|
+
writeNode(runDir, state, lock);
|
|
118
|
+
await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
119
|
+
contract, node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await settleUnavailableJudge(contract, node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Whatever else this invocation produced, it is not exactly one usable
|
|
126
|
+
// verdict: no verdict at all, several of them in separate agent messages,
|
|
127
|
+
// an unparseable one, a stream cut off before its terminal envelope, or a
|
|
128
|
+
// phase killed on its wall clock. One bounded re-ask, then the review mode
|
|
129
|
+
// decides — advisory completes, blocking enters attention with the work
|
|
130
|
+
// preserved so a retry in place can re-judge it.
|
|
131
|
+
const evidence = judgeVerdictEvidence(envelope);
|
|
132
|
+
if (!evidence.ok) {
|
|
133
|
+
const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
|
|
134
|
+
if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
|
|
135
|
+
clearTierExhaustion(state);
|
|
136
|
+
await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, evidence.reason);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
clearTierExhaustion(state);
|
|
140
|
+
await applyJudgeResult(contract, node, state, evidence.result, runDir, lock, running, states, campaignPath);
|
|
141
|
+
}
|
package/src/engine/settle.mjs
CHANGED
|
@@ -22,10 +22,10 @@ import { alreadyNotified, notifyQueueFor } from "./notify-queue.mjs";
|
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
attemptWorkspace,
|
|
25
|
-
attemptWorktreePath,
|
|
26
25
|
removeWorktree,
|
|
27
26
|
sealAttempt,
|
|
28
27
|
} from "../repo/worktree.mjs";
|
|
28
|
+
import { attemptWorktreePath } from "../run/paths.mjs";
|
|
29
29
|
import { basename } from "node:path";
|
|
30
30
|
import { boundedUtf8, errorCode, errorMessage } from "../util.mjs";
|
|
31
31
|
import { campaignIdOf } from "../campaign/record.mjs";
|
|
@@ -498,6 +498,50 @@ export function isVerdictCandidate(text) {
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* A judge's final message can carry its whole verdict on one line with no
|
|
503
|
+
* newline before the JSON: `Confirmed. Now write the verdict JSON.{"verdict":
|
|
504
|
+
* "fail", ...}`, measured 2026-09-17 at 5662 characters. A line-boundary-only
|
|
505
|
+
* suffix scan never finds it, extractJson returns null, isVerdictCandidate
|
|
506
|
+
* returns false, and a correct, evidenced verdict is discarded as
|
|
507
|
+
* `judge_unavailable`. The candidate starts tried here are every `{` and `[`
|
|
508
|
+
* offset, not just line starts, so a JSON object glued directly to prose is
|
|
509
|
+
* still reachable.
|
|
510
|
+
*/
|
|
511
|
+
const SUFFIX_CANDIDATE_LIMIT = 200;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The last parseable suffix of `trimmed`, trying every `{`/`[` offset from the
|
|
515
|
+
* end backwards. Bounded to the last `SUFFIX_CANDIDATE_LIMIT` offsets: without
|
|
516
|
+
* a cap this is one `JSON.parse` per bracket character, quadratic on a
|
|
517
|
+
* pathological message built mostly of brackets. 200 is comfortably above the
|
|
518
|
+
* bracket count of any real verdict object (the measured 5662-character
|
|
519
|
+
* message resolves on one of its first few tries, since its outermost `{` is
|
|
520
|
+
* also its first) and keeps every call to at most 200 parses regardless of
|
|
521
|
+
* message length.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} trimmed
|
|
524
|
+
* @returns {string|null}
|
|
525
|
+
*/
|
|
526
|
+
function suffixJsonCandidate(trimmed) {
|
|
527
|
+
const offsets = [];
|
|
528
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
529
|
+
const char = trimmed[index];
|
|
530
|
+
if (char === "{" || char === "[") offsets.push(index);
|
|
531
|
+
}
|
|
532
|
+
const floor = Math.max(0, offsets.length - SUFFIX_CANDIDATE_LIMIT);
|
|
533
|
+
for (let index = offsets.length - 1; index >= floor; index -= 1) {
|
|
534
|
+
const candidate = trimmed.slice(offsets[index]);
|
|
535
|
+
try {
|
|
536
|
+
JSON.parse(candidate);
|
|
537
|
+
return candidate;
|
|
538
|
+
} catch {
|
|
539
|
+
// This suffix is not JSON; keep trying earlier bracket offsets.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
501
545
|
/**
|
|
502
546
|
* Extract a JSON value from a provider response that may carry prose, taking
|
|
503
547
|
* the last parseable suffix or fenced JSON block.
|
|
@@ -514,16 +558,8 @@ export function extractJson(value) {
|
|
|
514
558
|
} catch {
|
|
515
559
|
// Not JSON as a whole: fall through to the suffix and fenced-block scans below.
|
|
516
560
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
const candidate = lines.slice(index).join("\n").trim();
|
|
520
|
-
try {
|
|
521
|
-
JSON.parse(candidate);
|
|
522
|
-
return candidate;
|
|
523
|
-
} catch {
|
|
524
|
-
// This suffix is not JSON; keep trying earlier line boundaries.
|
|
525
|
-
}
|
|
526
|
-
}
|
|
561
|
+
const suffix = suffixJsonCandidate(trimmed);
|
|
562
|
+
if (suffix !== null) return suffix;
|
|
527
563
|
const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
|
|
528
564
|
for (const block of blocks.reverse()) {
|
|
529
565
|
const candidate = block[1].trim();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { RUNS_DIR_NAME } from "../../run/paths.mjs";
|
|
4
5
|
|
|
5
6
|
const ENVELOPE_STATUSES = new Set(["done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
|
|
6
7
|
const LINE_KEYS = new Set(["envelope", "files", "delayMs", "exitCode", "stdoutRaw"]);
|
|
@@ -13,7 +14,15 @@ const ENVELOPE_KEYS = new Set([...ENVELOPE_REQUIRED_KEYS, ...ENVELOPE_OPTIONAL_K
|
|
|
13
14
|
const USAGE_KEYS = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
|
|
14
15
|
const FILE_KEYS = new Set(["path", "content"]);
|
|
15
16
|
const ERROR_KEYS = new Set(["code", "message", "resetAt"]);
|
|
16
|
-
|
|
17
|
+
// A recorded file write is refused, not merely ignored, if it targets any of
|
|
18
|
+
// these roots: `.git` is repository metadata, `RUNS_DIR_NAME` is the runner's
|
|
19
|
+
// own scratch tree, `node_modules` is a dependency install, and `.claude` and
|
|
20
|
+
// `.codex` are agent runtimes' own scratch state. A replayed recording that
|
|
21
|
+
// named one of these would be indistinguishable from a live worker escaping
|
|
22
|
+
// its workspace into the runner's own bookkeeping, so replay comparison
|
|
23
|
+
// treats all five the same way live execution does: never a legitimate
|
|
24
|
+
// worker write.
|
|
25
|
+
const METADATA_ROOTS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".claude", ".codex"]);
|
|
17
26
|
const PREFLIGHT_TOKEN = "FABERUN_PREFLIGHT_OK";
|
|
18
27
|
|
|
19
28
|
const PREFLIGHT_ENVELOPE = Object.freeze({
|
package/src/host/home.mjs
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One module owns every path the installer, the self-updater and the banner
|
|
5
5
|
* must agree on: `versions/<v>/`, the `current` symlink, `config.json`,
|
|
6
|
-
* `update-check.json` and `
|
|
7
|
-
* and the updater each spelled the layout out, they would drift
|
|
8
|
-
* `current` pointed at a directory one of them did not mean.
|
|
6
|
+
* `update-check.json`, `tmp/` and `projects/`. install.sh is owned by another
|
|
7
|
+
* node; if it and the updater each spelled the layout out, they would drift
|
|
8
|
+
* until `current` pointed at a directory one of them did not mean.
|
|
9
|
+
* `projects/` holds the project registry; `host/projects.mjs` owns what goes
|
|
10
|
+
* inside it, since this module owns where things sit, not what they mean.
|
|
9
11
|
*/
|
|
10
12
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
11
13
|
import { homedir } from "node:os";
|
|
@@ -50,6 +52,11 @@ export function tmpDir(home) {
|
|
|
50
52
|
return join(home, "tmp");
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/** @param {string} home @returns {string} */
|
|
56
|
+
export function projectsDir(home) {
|
|
57
|
+
return join(home, "projects");
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
/**
|
|
54
61
|
* The version directory an entry path was launched from, or null when the path
|
|
55
62
|
* does not name a location inside `versions/`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three primitives whose implementation differs on Windows, in one module
|
|
3
|
+
* because the callers that need them — the updater repointing `current`, the
|
|
4
|
+
* skill registration linking a directory, the doctor and the banner asking
|
|
5
|
+
* whether a binary is there — must all get the same answer.
|
|
6
|
+
*
|
|
7
|
+
* A directory link is a junction on Windows. A directory *symlink* there needs
|
|
8
|
+
* Developer Mode or an elevated shell, so `symlinkSync` on a stock machine
|
|
9
|
+
* fails with EPERM; a junction needs no privilege, resolves through
|
|
10
|
+
* `realpathSync` — which is how `installedVersionDir` identifies the running
|
|
11
|
+
* version — and reports `isSymbolicLink()` true, so every reader already
|
|
12
|
+
* written against the POSIX layout keeps working.
|
|
13
|
+
*
|
|
14
|
+
* `tar` is the one in System32 when it is there (bsdtar, shipped with Windows
|
|
15
|
+
* since 10 1803). Git for Windows puts a GNU tar on PATH that reads the `C:` in
|
|
16
|
+
* an absolute path as a remote host and fails with `Cannot connect to C:`, and
|
|
17
|
+
* whichever of the two comes first on PATH is not something this tool gets to
|
|
18
|
+
* choose. install.ps1 resolves it the same way for the same reason.
|
|
19
|
+
*
|
|
20
|
+
* A command name is a name plus `PATHEXT` on Windows. `node` on PATH is
|
|
21
|
+
* `node.exe` and there is no extensionless file beside it, so a lookup that
|
|
22
|
+
* only joins the bare name reports the running interpreter as missing.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
25
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Point `linkPath` at `target`, replacing whatever link is already there. A
|
|
29
|
+
* relative `target` is relative to the link's own directory, which is the form
|
|
30
|
+
* the install layout is written in (`current -> versions/<v>`).
|
|
31
|
+
*
|
|
32
|
+
* On POSIX the swap is atomic: a sibling temporary link then a rename, so a
|
|
33
|
+
* concurrent reader sees either the old target or the new one, never a missing
|
|
34
|
+
* link. On Windows it cannot be — renaming a directory over an existing
|
|
35
|
+
* junction fails with EPERM — so the link is removed and remade, and a reader
|
|
36
|
+
* in the moment between the two finds nothing there.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} linkPath
|
|
39
|
+
* @param {string} target
|
|
40
|
+
* @returns {void}
|
|
41
|
+
*/
|
|
42
|
+
export function linkDirectory(linkPath, target) {
|
|
43
|
+
if (process.platform === "win32") {
|
|
44
|
+
// A junction stores an absolute target; a relative one would be recorded
|
|
45
|
+
// against the process cwd rather than the link's directory.
|
|
46
|
+
const absolute = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
|
|
47
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
48
|
+
symlinkSync(absolute, linkPath, "junction");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const temporary = `${linkPath}.tmp`;
|
|
52
|
+
rmSync(temporary, { force: true });
|
|
53
|
+
symlinkSync(target, temporary);
|
|
54
|
+
renameSync(temporary, linkPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The `tar` to spawn on this machine.
|
|
59
|
+
*
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function tarExecutable() {
|
|
63
|
+
if (process.platform !== "win32") return "tar";
|
|
64
|
+
const system = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
|
|
65
|
+
return existsSync(system) ? system : "tar";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The first directory on PATH that holds `name`, or null. Exported because the
|
|
70
|
+
* doctor, the banner and the skill registration all count the same binaries,
|
|
71
|
+
* rather than each keeping a copy of the lookup that can disagree.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} name
|
|
74
|
+
* @returns {string|null}
|
|
75
|
+
*/
|
|
76
|
+
export function findExecutable(name) {
|
|
77
|
+
const candidates = executableCandidates(name);
|
|
78
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
79
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
80
|
+
}
|
|
81
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
82
|
+
if (!dir) continue;
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
const path = join(dir, candidate);
|
|
85
|
+
if (existsSync(path)) return path;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The file names a command name can have on this platform: itself on POSIX,
|
|
93
|
+
* itself plus every `PATHEXT` spelling on Windows.
|
|
94
|
+
*
|
|
95
|
+
* PATHEXT comes first and the bare name last, because that is the order
|
|
96
|
+
* Windows itself runs them in: `node` on PATH is `node.exe`, a CLI npm
|
|
97
|
+
* installed is `<name>.cmd`, and the extensionless file npm leaves beside it is
|
|
98
|
+
* a shell script no Windows process can execute. Measured 2026-09-18 on Windows
|
|
99
|
+
* 11: without the extensions `doctor` reported `binary node · not found on
|
|
100
|
+
* PATH` from a process that node itself was running.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} name
|
|
103
|
+
* @returns {string[]}
|
|
104
|
+
*/
|
|
105
|
+
function executableCandidates(name) {
|
|
106
|
+
if (process.platform !== "win32") return [name];
|
|
107
|
+
// Lowercased so the reported path reads like the file on disk: PATHEXT is
|
|
108
|
+
// upper case by convention, the file system is not case sensitive.
|
|
109
|
+
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
|
|
110
|
+
return [...extensions.map((extension) => `${name}${extension}`), name];
|
|
111
|
+
}
|
package/src/host/preflight.mjs
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { existsSync, readFileSync, statfsSync } from "node:fs";
|
|
19
|
-
import {
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
20
|
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
|
|
21
21
|
import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
|
|
22
|
+
import { pricingSeedAge } from "../engine/pricing-seed.mjs";
|
|
22
23
|
import { validateContract } from "../contract/index.mjs";
|
|
23
24
|
import { sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
24
25
|
import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runtime-discovery.mjs";
|
|
@@ -26,7 +27,9 @@ import { errorMessage } from "../util.mjs";
|
|
|
26
27
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
27
28
|
import { routeRuntime } from "../contract/runtime.mjs";
|
|
28
29
|
import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
|
|
30
|
+
import { findExecutable } from "./platform.mjs";
|
|
29
31
|
import { colorLevel, statusToken } from "../cli/brand.mjs";
|
|
32
|
+
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
30
33
|
|
|
31
34
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
32
35
|
/** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
@@ -254,6 +257,22 @@ export function declaredVerificationCommands(contract) {
|
|
|
254
257
|
return [...commands.values()];
|
|
255
258
|
}
|
|
256
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Environment names removed before spawning a timing candidate. The probe
|
|
262
|
+
* only measures how long a command takes; a subtraction of a named few, not
|
|
263
|
+
* an allowlist, so PATH, HOME and every ordinary variable the command needs
|
|
264
|
+
* to run at all still pass through unchanged.
|
|
265
|
+
*/
|
|
266
|
+
const SIDE_EFFECT_ENV_KEYS = [
|
|
267
|
+
NOTIFY_BIN_ENV, // a measurement must not notify a human
|
|
268
|
+
"FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
|
|
269
|
+
"FABERUN_CLAUDE_BIN", // same, for the claude harness
|
|
270
|
+
"FABERUN_AGY_BIN", // same, for the agy harness
|
|
271
|
+
"FABERUN_DSH_BIN", // same, for the dsh harness
|
|
272
|
+
"FABERUN_ZCODE_BIN", // same, for the zcode harness
|
|
273
|
+
"FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
|
|
274
|
+
];
|
|
275
|
+
|
|
257
276
|
/**
|
|
258
277
|
* Run every declared verification command once and report what it actually
|
|
259
278
|
* costs against the timeout the contract gives it.
|
|
@@ -275,6 +294,8 @@ export function declaredVerificationCommands(contract) {
|
|
|
275
294
|
export function timeVerificationCommands(contract, probes = {}) {
|
|
276
295
|
const now = probes.now ?? (() => Date.now());
|
|
277
296
|
const run = probes.run ?? spawnSync;
|
|
297
|
+
const env = { ...process.env };
|
|
298
|
+
for (const key of SIDE_EFFECT_ENV_KEYS) delete env[key];
|
|
278
299
|
return declaredVerificationCommands(contract).map((command) => {
|
|
279
300
|
const label = command.argv.join(" ");
|
|
280
301
|
const name = `verification timing · ${label}`;
|
|
@@ -288,6 +309,7 @@ export function timeVerificationCommands(contract, probes = {}) {
|
|
|
288
309
|
timeout: ceilingSec * 1_000,
|
|
289
310
|
stdio: "ignore",
|
|
290
311
|
encoding: "utf8",
|
|
312
|
+
env,
|
|
291
313
|
});
|
|
292
314
|
const seconds = (now() - startedAt) / 1_000;
|
|
293
315
|
const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
|
|
@@ -391,6 +413,17 @@ export async function doctorCommand(contractPath, values) {
|
|
|
391
413
|
checks.push({ name: `binary ${binary}`, ok: found !== null, detail: found ?? "not found on PATH" });
|
|
392
414
|
}
|
|
393
415
|
checks.push({ name: "runner schema", ok: true, detail: `protocol ${PROTOCOL_SCHEMA_VERSION} · runner ${CONTRACT_VERSION}` });
|
|
416
|
+
// A stale seed still prices better than no seed, so this check informs and
|
|
417
|
+
// never fails: a fact beside the runner-schema line, not a gate like the
|
|
418
|
+
// advisory checks below it, which exist because they can genuinely fail.
|
|
419
|
+
const seedAge = pricingSeedAge();
|
|
420
|
+
checks.push({
|
|
421
|
+
name: "pricing seed",
|
|
422
|
+
ok: true,
|
|
423
|
+
detail: seedAge.stale
|
|
424
|
+
? `models.dev rates vendored ${seedAge.fetchedAt} · ${seedAge.ageDays} days old; re-vendor src/engine/pricing-seed.json`
|
|
425
|
+
: `models.dev rates vendored ${seedAge.fetchedAt}`,
|
|
426
|
+
});
|
|
394
427
|
/** @type {Set<string>} */
|
|
395
428
|
let usedHarnesses = new Set();
|
|
396
429
|
/** @type {Set<string>} */
|
|
@@ -486,12 +519,20 @@ function isGitWorkTree(repoDir) {
|
|
|
486
519
|
}
|
|
487
520
|
|
|
488
521
|
/**
|
|
522
|
+
* Whether the runs directory name is ignored in `repoDir`.
|
|
523
|
+
*
|
|
524
|
+
* Not dead cleanup after run state moved under the faberun home: an attempt
|
|
525
|
+
* worktree is still a git working tree, and R3 keeps the worker's result
|
|
526
|
+
* sidecar inside it, under `.runs/`. The ignore line is what keeps that
|
|
527
|
+
* sidecar out of the worktree's scope diff, so this check -- and the
|
|
528
|
+
* `.gitignore` line `cli/init.mjs` writes -- survive the move.
|
|
529
|
+
*
|
|
489
530
|
* @param {string} repoDir
|
|
490
531
|
* @returns {boolean}
|
|
491
532
|
*/
|
|
492
533
|
function isRunsIgnored(repoDir) {
|
|
493
534
|
try {
|
|
494
|
-
const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q",
|
|
535
|
+
const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q", RUNS_DIR_NAME], { stdio: ["ignore", "ignore", "ignore"] });
|
|
495
536
|
if (result.status === 0) return true;
|
|
496
537
|
} catch {
|
|
497
538
|
// A git that cannot run leaves the check-ignore answer unknown; fall through to reading .gitignore directly.
|
|
@@ -503,22 +544,3 @@ function isRunsIgnored(repoDir) {
|
|
|
503
544
|
return false;
|
|
504
545
|
}
|
|
505
546
|
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* The first directory on PATH that holds `name`, or null. Exported because the
|
|
509
|
-
* banner counts the harness binaries with the same lookup the doctor uses,
|
|
510
|
-
* rather than a second copy that can disagree.
|
|
511
|
-
*
|
|
512
|
-
* @param {string} name
|
|
513
|
-
* @returns {string|null}
|
|
514
|
-
*/
|
|
515
|
-
export function findExecutable(name) {
|
|
516
|
-
if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
|
|
517
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
518
|
-
if (!dir) continue;
|
|
519
|
-
const candidate = join(dir, name);
|
|
520
|
-
if (existsSync(candidate)) return candidate;
|
|
521
|
-
}
|
|
522
|
-
return null;
|
|
523
|
-
}
|
|
524
|
-
|