faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proving a resumed run is the same run.
|
|
3
|
+
*
|
|
4
|
+
* A run is pinned to a git head, a dirty-tree fingerprint, a hash per task
|
|
5
|
+
* packet, the agent-guidance files in force and the harness versions observed.
|
|
6
|
+
* `assertSourceUnchanged` is where a resume against a moved head, an edited
|
|
7
|
+
* packet or a different harness build is refused: continuing there would mean
|
|
8
|
+
* finishing work nobody approved, on evidence that no longer holds.
|
|
9
|
+
*
|
|
10
|
+
* `probeRuntimeVersionStable` retries because a cold CLI sometimes reports no
|
|
11
|
+
* version on its first call, and a missing version is indistinguishable from a
|
|
12
|
+
* changed one.
|
|
13
|
+
*/
|
|
14
|
+
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, probeRuntime } from "../harnesses/index.mjs";
|
|
15
|
+
import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
|
|
16
|
+
import { blockingChecks, environmentPreflight, reachableRuntimes } from "../host/preflight.mjs";
|
|
17
|
+
import { captureSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
|
+
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
21
|
+
import { join, relative, sep } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { stableJson } from "../util.mjs";
|
|
24
|
+
import { validateRunMetadata } from "../contract/snapshot.mjs";
|
|
25
|
+
import { contractDigest } from "../contract/index.mjs";
|
|
26
|
+
|
|
27
|
+
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
28
|
+
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
29
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
30
|
+
/** @typedef {import("../contract/index.mjs").RunMetadata} RunMetadata */
|
|
31
|
+
/** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
|
|
32
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
33
|
+
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
34
|
+
/** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {LockHandle} lock
|
|
38
|
+
* @param {SourceIdentity} sourceIdentity
|
|
39
|
+
* @param {{identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, controllerIdentity?: import("../contract/index.mjs").ControllerIdentity}} [resume]
|
|
40
|
+
* @param {string} [integrationRef]
|
|
41
|
+
* @returns {RunMetadata}
|
|
42
|
+
*/
|
|
43
|
+
export function createRunMetadata(lock, sourceIdentity, resume = {}, integrationRef = undefined) {
|
|
44
|
+
const current = lock.current;
|
|
45
|
+
// Both frozen records are carried forward from the run that already exists.
|
|
46
|
+
// `driveRun` rewrites run.json on every controller start, so recomputing
|
|
47
|
+
// them from the current tree would let a resume or a heartbeat relaunch
|
|
48
|
+
// reinterpret history against a tree that has since drifted.
|
|
49
|
+
const stored = readStoredRunRecords(sourceIdentity);
|
|
50
|
+
const digest = stored.contractDigest ?? readContractDigest(sourceIdentity);
|
|
51
|
+
const controllerIdentity = stored.controllerIdentity ?? resume.controllerIdentity ?? defaultControllerIdentity();
|
|
52
|
+
const scopeDecision = stored.scopeDecision ?? {
|
|
53
|
+
at: current.startedAt,
|
|
54
|
+
base: sourceIdentity.gitHead ?? null,
|
|
55
|
+
dirtyTreeFingerprint: sourceIdentity.dirtyTreeFingerprint ?? null,
|
|
56
|
+
};
|
|
57
|
+
const metadata = {
|
|
58
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
59
|
+
contractVersion: CONTRACT_VERSION,
|
|
60
|
+
pid: current.pid,
|
|
61
|
+
processStartToken: current.processStartToken,
|
|
62
|
+
startedAt: current.startedAt,
|
|
63
|
+
sourceIdentity,
|
|
64
|
+
controllerIdentity,
|
|
65
|
+
...(integrationRef ? { integrationRef } : {}),
|
|
66
|
+
...(resume.identityWarnings?.length ? { identityWarnings: resume.identityWarnings } : {}),
|
|
67
|
+
// The supervisor's relaunch guard and its durable attention record are
|
|
68
|
+
// carried through a controller restart by the controller itself; a field
|
|
69
|
+
// not named here is dropped by the fixed field list above.
|
|
70
|
+
...(resume.relaunchCount !== undefined ? { relaunchCount: resume.relaunchCount } : {}),
|
|
71
|
+
...(resume.lastRelaunchProgressAt !== undefined ? { lastRelaunchProgressAt: resume.lastRelaunchProgressAt } : {}),
|
|
72
|
+
...(resume.attention !== undefined ? { attention: resume.attention } : {}),
|
|
73
|
+
...(digest !== null ? { contractDigest: digest } : {}),
|
|
74
|
+
// The one-shot auto-retry ledger is durable across a controller restart
|
|
75
|
+
// for the same reason: an in-memory flag would grant a fresh retry.
|
|
76
|
+
...(stored.autoRetries !== undefined ? { autoRetries: stored.autoRetries } : {}),
|
|
77
|
+
scopeDecision,
|
|
78
|
+
};
|
|
79
|
+
return validateRunMetadata(metadata);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The run directory the source identity names: the run lives at
|
|
84
|
+
* `<cwd>/.runs/<contractId>`, which is derivable from the identity alone.
|
|
85
|
+
*
|
|
86
|
+
* @param {SourceIdentity} sourceIdentity
|
|
87
|
+
* @returns {string|null}
|
|
88
|
+
*/
|
|
89
|
+
function runDirFor(sourceIdentity) {
|
|
90
|
+
if (!sourceIdentity.cwd || !sourceIdentity.contractId) return null;
|
|
91
|
+
return join(sourceIdentity.cwd, ".runs", sourceIdentity.contractId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The records a prior run.json already froze, when one exists. On creation
|
|
96
|
+
* this is empty; on every later metadata rewrite it is the source of truth.
|
|
97
|
+
*
|
|
98
|
+
* @param {SourceIdentity} sourceIdentity
|
|
99
|
+
* @returns {{contractDigest?: string, scopeDecision?: import("../contract/index.mjs").ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>, controllerIdentity?: import("../contract/index.mjs").ControllerIdentity}}
|
|
100
|
+
*/
|
|
101
|
+
function readStoredRunRecords(sourceIdentity) {
|
|
102
|
+
const runDir = runDirFor(sourceIdentity);
|
|
103
|
+
if (!runDir) return {};
|
|
104
|
+
/** @type {Record<string, unknown>} */
|
|
105
|
+
let record;
|
|
106
|
+
try {
|
|
107
|
+
record = JSON.parse(readFileSync(join(runDir, "run.json"), "utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...(record.contractDigest !== undefined ? { contractDigest: /** @type {string} */ (record.contractDigest) } : {}),
|
|
113
|
+
...(record.scopeDecision !== undefined ? { scopeDecision: /** @type {import("../contract/index.mjs").ScopeDecision} */ (record.scopeDecision) } : {}),
|
|
114
|
+
...(record.autoRetries !== undefined ? { autoRetries: /** @type {Record<string, {code: string, at: string}>} */ (record.autoRetries) } : {}),
|
|
115
|
+
...(record.controllerIdentity !== undefined ? { controllerIdentity: /** @type {import("../contract/index.mjs").ControllerIdentity} */ (record.controllerIdentity) } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The digest of the contract stored in a run directory, computed over the
|
|
121
|
+
* stored bytes. The controller writes this file and records this digest at
|
|
122
|
+
* creation; a later reader must read the same file for the same answer,
|
|
123
|
+
* because the in-memory validated contract carries `undefined` fields the
|
|
124
|
+
* stored JSON dropped, so hashing the two objects disagrees.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} runDir
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function storedContractDigest(runDir) {
|
|
130
|
+
const raw = JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8"));
|
|
131
|
+
return contractDigest(raw);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The digest of the stored contract.json, computed at creation and only then:
|
|
136
|
+
* the contract bytes are frozen at launch, and a persisted load compares
|
|
137
|
+
* against this record rather than against the mutated tree.
|
|
138
|
+
*
|
|
139
|
+
* @param {SourceIdentity} sourceIdentity
|
|
140
|
+
* @returns {string|null}
|
|
141
|
+
*/
|
|
142
|
+
function readContractDigest(sourceIdentity) {
|
|
143
|
+
const runDir = runDirFor(sourceIdentity);
|
|
144
|
+
if (!runDir) return null;
|
|
145
|
+
return storedContractDigest(runDir);
|
|
146
|
+
}
|
|
147
|
+
const HARNESS_PROBE_RETRIES = 2;
|
|
148
|
+
const HARNESS_PROBE_RETRY_BACKOFF_MS = 250;
|
|
149
|
+
/**
|
|
150
|
+
* Probe a runtime version, retrying transient unavailability so a loaded host
|
|
151
|
+
* is never misread as a changed harness. Only a concrete version or final
|
|
152
|
+
* unavailability leaves this function.
|
|
153
|
+
*
|
|
154
|
+
* @param {import("../harnesses/index.mjs").HarnessRuntime & {capabilities?: unknown}} runtime
|
|
155
|
+
* @param {string} cwd
|
|
156
|
+
* @returns {Promise<string|null>}
|
|
157
|
+
*/
|
|
158
|
+
export async function probeRuntimeVersionStable(runtime, cwd) {
|
|
159
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
160
|
+
const result = await probeRuntime(runtime, { cwd, timeoutSec: 5 });
|
|
161
|
+
if (result.version !== null || attempt >= HARNESS_PROBE_RETRIES) return result.version ?? null;
|
|
162
|
+
await new Promise((resolveRetry) => setTimeout(resolveRetry, HARNESS_PROBE_RETRY_BACKOFF_MS * 2 ** attempt));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The base ref a *creation* was launched with, set by the CLI entry before it
|
|
167
|
+
* calls `runContract`. It is a module rather than an option because
|
|
168
|
+
* `runContract` reads the contract itself and threads no base ref; a resume
|
|
169
|
+
* never sets it, so the identity a resume captures is the checkout's HEAD as
|
|
170
|
+
* before.
|
|
171
|
+
*
|
|
172
|
+
* @type {string|null}
|
|
173
|
+
*/
|
|
174
|
+
let pendingLaunchBaseRef = null;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string|undefined|null} baseRef
|
|
178
|
+
* @returns {void}
|
|
179
|
+
*/
|
|
180
|
+
export function setLaunchBaseRef(baseRef) {
|
|
181
|
+
pendingLaunchBaseRef = baseRef ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Capture the run's source identity, including one version-only probe per
|
|
186
|
+
* distinct routed runtime (a local binary call, no model tokens) so a later
|
|
187
|
+
* resume can refuse a harness that was upgraded or broke mid-campaign.
|
|
188
|
+
*
|
|
189
|
+
* @param {ValidatedContract} contract
|
|
190
|
+
* @param {Map<string, import("../repo/workspace.mjs").WorkspaceScopeBoundary>} scopeBoundaries
|
|
191
|
+
* @param {string} [baseRef] the ref the run is cut from; defaults to the CLI's `--base-ref`
|
|
192
|
+
* @returns {Promise<SourceIdentity>}
|
|
193
|
+
*/
|
|
194
|
+
export async function captureRunIdentity(contract, scopeBoundaries, baseRef = pendingLaunchBaseRef ?? undefined) {
|
|
195
|
+
const runtimes = reachableRuntimes(contract);
|
|
196
|
+
const versionsPromise = Promise.all([...runtimes.entries()].map(async ([id, { runtime }]) => {
|
|
197
|
+
return [id, await probeRuntimeVersionStable(runtime, contract.cwd)];
|
|
198
|
+
}));
|
|
199
|
+
const ignorePaths = [...new Set([...scopeBoundaries.values()].flatMap((boundary) => boundary.files))];
|
|
200
|
+
const ignoreRoots = [...new Set([...scopeBoundaries.values()].flatMap((boundary) => boundary.roots))];
|
|
201
|
+
const identity = captureSourceIdentity(contract, {}, { ignorePaths, ignoreRoots, ...(baseRef ? { baseRef } : {}) });
|
|
202
|
+
const versions = await versionsPromise;
|
|
203
|
+
return { ...identity, harnessVersions: Object.fromEntries(versions) };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Compare the recorded source identity with the current one. A HEAD that
|
|
207
|
+
* descends from the recorded one is accepted and recorded — workers and the
|
|
208
|
+
* orchestrator commit between attempts, so a retry in place expects the branch
|
|
209
|
+
* to have moved on — while a non-descendant HEAD is still drift. A dirty-tree
|
|
210
|
+
* fingerprint mismatch is only a warning: the fingerprint covers the whole
|
|
211
|
+
* tree, so any committed work between attempts changes it.
|
|
212
|
+
*
|
|
213
|
+
* @param {SourceIdentity|undefined} expected
|
|
214
|
+
* @param {SourceIdentity|undefined} actual
|
|
215
|
+
* @returns {{warnings: string[]}} warnings to surface in status
|
|
216
|
+
*/
|
|
217
|
+
export function assertSourceUnchanged(expected, actual) {
|
|
218
|
+
const fields = ["cwd", "gitHead", "dirtyTreeFingerprint", "packetHashes", "harnessVersions"];
|
|
219
|
+
/** @type {string[]} */
|
|
220
|
+
const warnings = [];
|
|
221
|
+
for (const field of fields) {
|
|
222
|
+
const expectedRecord = /** @type {Record<string, unknown>|undefined} */ (expected);
|
|
223
|
+
const actualRecord = /** @type {Record<string, unknown>|undefined} */ (actual);
|
|
224
|
+
if (expectedRecord?.[field] === undefined) throw new Error(`source identity is incomplete; resume refused`);
|
|
225
|
+
if (field === "harnessVersions") {
|
|
226
|
+
const expectedVersions = /** @type {Record<string, string|null>} */ (expectedRecord?.[field] ?? {});
|
|
227
|
+
const actualVersions = /** @type {Record<string, string|null>} */ (actualRecord?.[field] ?? {});
|
|
228
|
+
const ids = new Set([...Object.keys(expectedVersions), ...Object.keys(actualVersions)]);
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
const expectedVersion = expectedVersions[id] ?? null;
|
|
231
|
+
const actualVersion = actualVersions[id] ?? null;
|
|
232
|
+
if (expectedVersion === actualVersion) continue;
|
|
233
|
+
if (expectedVersion === null || actualVersion === null) {
|
|
234
|
+
throw new Error(`harness probe unavailable for ${id}; resume refused`);
|
|
235
|
+
}
|
|
236
|
+
throw new Error("source drift detected in harnessVersions; resume refused");
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (field === "gitHead" && stableJson(expectedRecord?.gitHead ?? null) !== stableJson(actualRecord?.gitHead ?? null)) {
|
|
241
|
+
const expectedHead = typeof expectedRecord?.gitHead === "string" ? expectedRecord.gitHead : null;
|
|
242
|
+
const actualHead = typeof actualRecord?.gitHead === "string" ? actualRecord.gitHead : null;
|
|
243
|
+
if (expectedHead && actualHead && isDescendantHead(expected?.cwd, expectedHead, actualHead)) continue;
|
|
244
|
+
throw new Error(`source drift detected in gitHead; resume refused`);
|
|
245
|
+
}
|
|
246
|
+
if (field === "dirtyTreeFingerprint" && stableJson(expectedRecord?.[field] ?? null) !== stableJson(actualRecord?.[field] ?? null)) {
|
|
247
|
+
warnings.push("source tree fingerprint changed since the run started; work committed between attempts is expected and the run continues on the current tree");
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (stableJson(expectedRecord?.[field] ?? null) !== stableJson(actualRecord?.[field] ?? null)) {
|
|
251
|
+
throw new Error(`source drift detected in ${field}; resume refused`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { warnings };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Whether `head` is a descendant of `recorded` (or the same commit).
|
|
258
|
+
*
|
|
259
|
+
* @param {string|undefined} cwd
|
|
260
|
+
* @param {string} recorded
|
|
261
|
+
* @param {string} head
|
|
262
|
+
* @returns {boolean}
|
|
263
|
+
*/
|
|
264
|
+
export function isDescendantHead(cwd, recorded, head) {
|
|
265
|
+
if (!cwd) return false;
|
|
266
|
+
const result = boundedGitSync(["-C", cwd, "merge-base", "--is-ancestor", recorded, head], { encoding: "utf8" });
|
|
267
|
+
return result.status === 0;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The controller snapshot a freshly created run is pinned to when the caller
|
|
271
|
+
* names none: the source tree of the controller that is running now.
|
|
272
|
+
*
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
export function controllerSnapshotPath() {
|
|
276
|
+
return fileURLToPath(new URL("../", import.meta.url));
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Hash a controller executable snapshot: a single file, or every file below a
|
|
280
|
+
* directory, in sorted relative-path order, so two reads of an unchanged
|
|
281
|
+
* snapshot always agree and any edit changes the sha.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} snapshotPath
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
function hashControllerSnapshot(snapshotPath) {
|
|
287
|
+
const hash = createHash("sha256");
|
|
288
|
+
if (lstatSync(snapshotPath).isDirectory()) {
|
|
289
|
+
for (const file of walkControllerFiles(snapshotPath)) {
|
|
290
|
+
hash.update(relative(snapshotPath, file).split(sep).join("/"));
|
|
291
|
+
hash.update("\0");
|
|
292
|
+
hash.update(readFileSync(file));
|
|
293
|
+
hash.update("\0");
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
hash.update(readFileSync(snapshotPath));
|
|
297
|
+
}
|
|
298
|
+
return hash.digest("hex");
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* @param {string} root
|
|
302
|
+
* @returns {string[]}
|
|
303
|
+
*/
|
|
304
|
+
function walkControllerFiles(root) {
|
|
305
|
+
/** @type {string[]} */
|
|
306
|
+
const found = [];
|
|
307
|
+
for (const entry of readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
308
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".runs") continue;
|
|
309
|
+
const path = join(root, entry.name);
|
|
310
|
+
if (entry.isDirectory()) found.push(...walkControllerFiles(path));
|
|
311
|
+
else if (entry.isFile()) found.push(path);
|
|
312
|
+
}
|
|
313
|
+
return found;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The `{path, sha}` identity of a controller snapshot. `path` is the file or
|
|
317
|
+
* directory a launch would execute from; `sha` is its content digest.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} snapshotPath
|
|
320
|
+
* @returns {import("../contract/index.mjs").ControllerIdentity}
|
|
321
|
+
*/
|
|
322
|
+
export function controllerSnapshotIdentity(snapshotPath) {
|
|
323
|
+
return { path: snapshotPath, sha: hashControllerSnapshot(snapshotPath) };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* @returns {import("../contract/index.mjs").ControllerIdentity}
|
|
327
|
+
*/
|
|
328
|
+
export function defaultControllerIdentity() {
|
|
329
|
+
return controllerSnapshotIdentity(controllerSnapshotPath());
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The check the next chain node runs before launching N+1: the executable
|
|
333
|
+
* snapshot at the recorded path must still hash to the recorded sha. A path
|
|
334
|
+
* plus sha is not enough on its own because the path can be rewritten under a
|
|
335
|
+
* recorded sha.
|
|
336
|
+
*
|
|
337
|
+
* @param {import("../contract/index.mjs").ControllerIdentity} identity
|
|
338
|
+
* @param {string} [snapshotPath]
|
|
339
|
+
* @returns {import("../contract/index.mjs").ControllerIdentity}
|
|
340
|
+
*/
|
|
341
|
+
export function verifyControllerIdentity(identity, snapshotPath = identity.path) {
|
|
342
|
+
let actual;
|
|
343
|
+
try {
|
|
344
|
+
actual = controllerSnapshotIdentity(snapshotPath);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
347
|
+
throw Object.assign(new Error(`controller snapshot at ${identity.path} is unreadable: ${message}`), { code: "controller_snapshot_missing" });
|
|
348
|
+
}
|
|
349
|
+
if (actual.sha !== identity.sha) {
|
|
350
|
+
throw Object.assign(
|
|
351
|
+
new Error(`controller snapshot at ${identity.path} does not match its recorded sha ${identity.sha}; refresh the snapshot as a declared human boundary`),
|
|
352
|
+
{ code: "controller_snapshot_changed" },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return actual;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
export function statesFingerprint(states) {
|
|
362
|
+
return [...states.values()].map((state) => `${state.id}:${state.status}:${state.phase}:${state.attempt ?? 0}:${state.revisions ?? 0}`).join("|");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* @param {ValidatedContract} contract
|
|
366
|
+
* @returns {ValidatedContract}
|
|
367
|
+
*/
|
|
368
|
+
export function serializableContract(contract) {
|
|
369
|
+
const { warnings, ...rest } = contract;
|
|
370
|
+
return {
|
|
371
|
+
...rest,
|
|
372
|
+
warnings,
|
|
373
|
+
nodes: contract.nodes.map((node) => {
|
|
374
|
+
const copy = /** @type {Record<string, unknown>} */ ({ ...node });
|
|
375
|
+
delete copy.prompt;
|
|
376
|
+
delete copy.promptFile;
|
|
377
|
+
delete copy.taskPacketFile;
|
|
378
|
+
return /** @type {ValidatedNode} */ (copy);
|
|
379
|
+
}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The dispatch gate: no node starts until the host can carry the run. The
|
|
384
|
+
* report is written as run evidence either way, and a blocking failure leaves
|
|
385
|
+
* the materialized run untouched — the operator fixes the host and resumes,
|
|
386
|
+
* so a run is never silently restarted and already-paid nodes are not redone.
|
|
387
|
+
*
|
|
388
|
+
* @param {ValidatedContract} contract
|
|
389
|
+
* @param {string} runDir
|
|
390
|
+
* @param {SourceIdentity|undefined} sourceIdentity
|
|
391
|
+
*/
|
|
392
|
+
export function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
393
|
+
const report = environmentPreflight({
|
|
394
|
+
cwd: contract.cwd,
|
|
395
|
+
runtimes: reachableRuntimes(contract),
|
|
396
|
+
harnessVersions: sourceIdentity?.harnessVersions ?? {},
|
|
397
|
+
});
|
|
398
|
+
const evidence = {
|
|
399
|
+
schemaVersion: report.schemaVersion,
|
|
400
|
+
contractVersion: CONTRACT_VERSION,
|
|
401
|
+
at: new Date().toISOString(),
|
|
402
|
+
contractId: contract.id,
|
|
403
|
+
ok: report.ok,
|
|
404
|
+
checks: report.checks,
|
|
405
|
+
};
|
|
406
|
+
writeJsonAtomic(join(runDir, "env-preflight.json"), evidence);
|
|
407
|
+
if (report.ok) return;
|
|
408
|
+
appendJsonl(join(runDir, "events.jsonl"), { type: "run.env-preflight-failed", ...evidence });
|
|
409
|
+
const blocking = blockingChecks(report).map((check) => `${check.name}: ${check.detail}`).join(" · ");
|
|
410
|
+
throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
|
|
411
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { normalizeProviderAvailability, probeRuntime } from "../harnesses/index.mjs";
|
|
2
|
+
|
|
3
|
+
// Availability normalization belongs to the adapter registry, which is where
|
|
4
|
+
// each provider's own exhaustion, balance, and authentication wording is
|
|
5
|
+
// already classified. Re-exported here so discovery callers keep one import
|
|
6
|
+
// site; a second copy of these two functions is how they drift apart.
|
|
7
|
+
export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/index.mjs";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
10
|
+
/** @typedef {{harness?: string, model?: string, vendor: string, tier?: number|string, costRank?: number, [key: string]: unknown}} RuntimeLike */
|
|
11
|
+
/** @typedef {{runtimes: Record<string, RuntimeLike>, runtimeDefaults?: {worker?: string, judge?: string}, nodes?: {id: string, runtime?: string, gate: {enabled: boolean, runtime?: string}}[]}} RuntimeContract */
|
|
12
|
+
/** @typedef {{available: boolean, exhaustedUntil: string|null, reason: string}} RuntimeAvailability */
|
|
13
|
+
/** @typedef {{harness: string, model: string, vendor: string, tier: number, costRank: number, config?: Record<string, unknown>}} DiscoveryRuntime */
|
|
14
|
+
/** @typedef {{id: string, runtime: RuntimeLike, order: number}} RuntimeCandidate */
|
|
15
|
+
/** @typedef {import("../host/config.mjs").UserConfig} UserConfig */
|
|
16
|
+
/** @typedef {{config?: UserConfig|null, onWarning?: (message: string) => void}} ComposeOptions */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Candidates used when a contract omits its runtime catalogue. The catalogue
|
|
20
|
+
* only names harnesses; availability still comes from the installed binary.
|
|
21
|
+
*
|
|
22
|
+
* Every id is `<harness>-<model>`, saying out loud what the fields already
|
|
23
|
+
* say: `harness` is the harness that runs the turn, `model` is what that
|
|
24
|
+
* harness asks, and the two vary independently — DeepSeek answers through the
|
|
25
|
+
* `dsh` harness, GLM through `zcode`. An id naming only one half (the bare
|
|
26
|
+
* `glm` this catalogue used to carry, which was at once a model family, a
|
|
27
|
+
* vendor, and an adapter name) hides which harness a recorded run used. The
|
|
28
|
+
* separator is a dash because `contract.mjs` admits no `:` in an id.
|
|
29
|
+
*
|
|
30
|
+
* Declaration order is the tie-break `composeAssignments` applies inside a
|
|
31
|
+
* tier, so the cheap harnesses lead: the first available tier-1 entry works
|
|
32
|
+
* and the strongest available entry of another vendor judges.
|
|
33
|
+
*
|
|
34
|
+
* @type {Readonly<Record<string, DiscoveryRuntime>>}
|
|
35
|
+
*/
|
|
36
|
+
export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
37
|
+
// `dsh` defaults no vendor and no provider route, so both are declared here
|
|
38
|
+
// or nothing can build a command from this entry.
|
|
39
|
+
"dsh-deepseek": {
|
|
40
|
+
harness: "dsh",
|
|
41
|
+
model: "deepseek-flash",
|
|
42
|
+
vendor: "deepseek",
|
|
43
|
+
config: { provider: "deepseek-official", "api_key.env_key": "DEEPSEEK_API_KEY" },
|
|
44
|
+
tier: 1,
|
|
45
|
+
costRank: 1,
|
|
46
|
+
},
|
|
47
|
+
"zcode-glm": {
|
|
48
|
+
harness: "zcode",
|
|
49
|
+
model: "glm-5.3",
|
|
50
|
+
vendor: "zhipu",
|
|
51
|
+
config: { "auth_token.env_key": "ZAI_API_KEY" },
|
|
52
|
+
tier: 1,
|
|
53
|
+
costRank: 1,
|
|
54
|
+
},
|
|
55
|
+
"agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
|
|
56
|
+
"codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
|
|
57
|
+
"claude-sonnet": { harness: "claude", model: "claude-sonnet-5", vendor: "anthropic", tier: 2, costRank: 2 },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Discover runtime binaries without sending a model prompt. Tests can pass
|
|
62
|
+
* recorded responses so no network call is needed.
|
|
63
|
+
*
|
|
64
|
+
* @param {Record<string, import("../harnesses/index.mjs").HarnessRuntime>} runtimes
|
|
65
|
+
* @param {{cwd?: string, responses?: Record<string, unknown>, exitCodes?: Record<string, number|null>, signals?: Record<string, string|null>}} [options]
|
|
66
|
+
* @returns {Promise<Record<string, RuntimeAvailability>>}
|
|
67
|
+
*/
|
|
68
|
+
export async function discoverRuntimes(runtimes, options = {}) {
|
|
69
|
+
const entries = await Promise.all(Object.entries(runtimes).map(async ([id, runtime]) => {
|
|
70
|
+
const response = options.responses?.[id];
|
|
71
|
+
if (response !== undefined) {
|
|
72
|
+
return [id, normalizeProviderAvailability(runtime, response, options.exitCodes?.[id] ?? 0, options.signals?.[id] ?? null)];
|
|
73
|
+
}
|
|
74
|
+
const probe = await probeRuntime(runtime, { cwd: options.cwd });
|
|
75
|
+
return [id, probe.availability ?? (probe.ok
|
|
76
|
+
? { available: true, exhaustedUntil: null, reason: "ready" }
|
|
77
|
+
: { available: false, exhaustedUntil: null, reason: probe.detail ?? "provider_unavailable" })];
|
|
78
|
+
}));
|
|
79
|
+
return Object.fromEntries(entries);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The available candidates in declaration order — the unit both
|
|
84
|
+
* `composeAssignments` and `setup`'s defaults select from.
|
|
85
|
+
*
|
|
86
|
+
* @param {Record<string, RuntimeLike>} runtimes
|
|
87
|
+
* @param {Record<string, RuntimeAvailability>} [availability]
|
|
88
|
+
* @returns {RuntimeCandidate[]}
|
|
89
|
+
*/
|
|
90
|
+
export function availableCandidates(runtimes, availability = {}) {
|
|
91
|
+
return Object.entries(runtimes)
|
|
92
|
+
.filter(([id]) => isAvailable(availability[id]))
|
|
93
|
+
.map(([id, runtime], order) => ({ id, runtime, order }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compose only omitted assignments. Explicit node and default declarations are
|
|
98
|
+
* copied exactly; callers persist the returned pair in run state.
|
|
99
|
+
*
|
|
100
|
+
* An optional `options.config` narrows the candidate set to the harnesses the
|
|
101
|
+
* operator enabled and prefers its named worker and judge. An empty narrowed
|
|
102
|
+
* set falls back to the unrestricted candidates and is reported through
|
|
103
|
+
* `options.onWarning`; config never overrides an explicit node, gate or
|
|
104
|
+
* runtime-default declaration, and the cross-vendor judge rule still applies.
|
|
105
|
+
*
|
|
106
|
+
* @param {RuntimeContract} contract
|
|
107
|
+
* @param {Record<string, RuntimeAvailability>} availability
|
|
108
|
+
* @param {ComposeOptions} [options]
|
|
109
|
+
* @returns {Record<string, {worker: string, judge: string}>}
|
|
110
|
+
*/
|
|
111
|
+
export function composeAssignments(contract, availability = {}, options = {}) {
|
|
112
|
+
const allCandidates = availableCandidates(contract.runtimes, availability);
|
|
113
|
+
const config = options.config ?? null;
|
|
114
|
+
let candidates = allCandidates;
|
|
115
|
+
if (config && Array.isArray(config.harnesses)) {
|
|
116
|
+
const enabled = new Set(config.harnesses);
|
|
117
|
+
const restricted = allCandidates.filter(({ runtime }) => typeof runtime.harness === "string" && enabled.has(runtime.harness));
|
|
118
|
+
if (restricted.length) candidates = restricted;
|
|
119
|
+
else if (allCandidates.length) options.onWarning?.(`config harnesses · none of ${config.harnesses.join(", ")} is available · using all available runtimes`);
|
|
120
|
+
}
|
|
121
|
+
/** @type {Record<string, {worker: string, judge: string}>} */
|
|
122
|
+
const assignments = {};
|
|
123
|
+
for (const node of contract.nodes ?? []) {
|
|
124
|
+
const workerOmitted = node.runtime === undefined && contract.runtimeDefaults?.worker === undefined;
|
|
125
|
+
const preferredWorker = workerOmitted ? candidateById(candidates, config?.worker) : undefined;
|
|
126
|
+
const worker = node.runtime ?? contract.runtimeDefaults?.worker ?? preferredWorker?.id ?? cheapest(candidates)?.id;
|
|
127
|
+
if (!worker || !contract.runtimes[worker]) throw new Error(`runtime_assignment_worker_unavailable: no available worker runtime for node ${node.id}`);
|
|
128
|
+
const workerRuntime = contract.runtimes[worker];
|
|
129
|
+
const judgeOmitted = node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined;
|
|
130
|
+
const preferredJudge = judgeOmitted ? candidateById(candidates, config?.judge) : undefined;
|
|
131
|
+
const judge = node.gate.runtime ?? contract.runtimeDefaults?.judge
|
|
132
|
+
?? (preferredJudge && preferredJudge.runtime.vendor !== workerRuntime.vendor ? preferredJudge.id : undefined)
|
|
133
|
+
?? strongest(candidates, workerRuntime.vendor)?.id;
|
|
134
|
+
if (node.gate.enabled && (!judge || !contract.runtimes[judge])) {
|
|
135
|
+
throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
|
|
136
|
+
}
|
|
137
|
+
const judgeRuntime = judge ? contract.runtimes[judge] : undefined;
|
|
138
|
+
if (node.gate.enabled && workerRuntime && judgeRuntime && judgeRuntime.vendor === workerRuntime.vendor) {
|
|
139
|
+
throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
|
|
140
|
+
}
|
|
141
|
+
assignments[node.id] = { worker, judge: judge ?? worker };
|
|
142
|
+
}
|
|
143
|
+
return assignments;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {RuntimeCandidate[]} candidates
|
|
148
|
+
* @param {string|undefined} id
|
|
149
|
+
* @returns {RuntimeCandidate|undefined}
|
|
150
|
+
*/
|
|
151
|
+
function candidateById(candidates, id) {
|
|
152
|
+
return id === undefined ? undefined : candidates.find((candidate) => candidate.id === id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Select an unattempted, available runtime in the current tier. Judge
|
|
157
|
+
* candidates remain admissible only when their vendor differs from the worker
|
|
158
|
+
* runtime that actually ran the node.
|
|
159
|
+
*
|
|
160
|
+
* @param {RuntimeContract} contract
|
|
161
|
+
* @param {{assignments?: {worker?: string, judge?: string}, availability?: Record<string, RuntimeAvailability>}} stateRouting
|
|
162
|
+
* @param {"worker"|"judge"} role
|
|
163
|
+
* @param {string} current
|
|
164
|
+
* @param {Iterable<string>} attempted
|
|
165
|
+
* @returns {string|null}
|
|
166
|
+
*/
|
|
167
|
+
export function nextSameTierRuntime(contract, stateRouting, role, current, attempted) {
|
|
168
|
+
const currentRuntime = contract.runtimes[current];
|
|
169
|
+
if (!currentRuntime) return null;
|
|
170
|
+
const workerId = stateRouting.assignments?.worker;
|
|
171
|
+
const workerVendor = workerId ? contract.runtimes[workerId]?.vendor : null;
|
|
172
|
+
const used = new Set(attempted);
|
|
173
|
+
return Object.entries(contract.runtimes)
|
|
174
|
+
.filter(([id, runtime]) => id !== current && !used.has(id) && sameTier(runtime, currentRuntime))
|
|
175
|
+
.filter(([id]) => isAvailable(stateRouting.availability?.[id]))
|
|
176
|
+
.filter(([, runtime]) => role !== "judge" || runtime.vendor !== workerVendor)
|
|
177
|
+
.sort((left, right) => runtimeOrder(left[1]) - runtimeOrder(right[1]))
|
|
178
|
+
.map(([id]) => id)
|
|
179
|
+
.at(0) ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {RuntimeLike} left @param {RuntimeLike} right @returns {boolean} */
|
|
183
|
+
function sameTier(left, right) {
|
|
184
|
+
return left.tier !== undefined || right.tier !== undefined
|
|
185
|
+
? left.tier === right.tier
|
|
186
|
+
: (left.costRank ?? Number.MAX_SAFE_INTEGER) === (right.costRank ?? Number.MAX_SAFE_INTEGER);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {RuntimeLike} runtime @returns {number} */
|
|
190
|
+
function runtimeOrder(runtime) {
|
|
191
|
+
return runtime.costRank ?? Number.MAX_SAFE_INTEGER;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The cheapest available candidate: lowest tier, then lowest cost rank, then
|
|
196
|
+
* declaration order. Exported so `setup` suggests the same default rather than
|
|
197
|
+
* keeping a second ranking in step with this one.
|
|
198
|
+
*
|
|
199
|
+
* @param {RuntimeCandidate[]} candidates
|
|
200
|
+
* @returns {RuntimeCandidate|null}
|
|
201
|
+
*/
|
|
202
|
+
export function cheapest(candidates) {
|
|
203
|
+
return [...candidates].sort((left, right) => tierOrder(left.runtime) - tierOrder(right.runtime)
|
|
204
|
+
|| runtimeOrder(left.runtime) - runtimeOrder(right.runtime)
|
|
205
|
+
|| left.order - right.order).at(0) ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The strongest available candidate of a vendor other than `vendor`: highest
|
|
210
|
+
* tier, then highest cost rank, then declaration order. Exported alongside
|
|
211
|
+
* `cheapest` for `setup`'s cross-vendor judge default.
|
|
212
|
+
*
|
|
213
|
+
* @param {RuntimeCandidate[]} candidates
|
|
214
|
+
* @param {string} vendor
|
|
215
|
+
* @returns {RuntimeCandidate|null}
|
|
216
|
+
*/
|
|
217
|
+
export function strongest(candidates, vendor) {
|
|
218
|
+
return [...candidates].filter(({ runtime }) => runtime.vendor !== vendor)
|
|
219
|
+
.sort((left, right) => tierOrder(right.runtime) - tierOrder(left.runtime)
|
|
220
|
+
|| runtimeOrder(right.runtime) - runtimeOrder(left.runtime)
|
|
221
|
+
|| left.order - right.order).at(0) ?? null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {RuntimeLike} runtime @returns {number} */
|
|
225
|
+
function tierOrder(runtime) {
|
|
226
|
+
return typeof runtime.tier === "number" ? runtime.tier : runtime.costRank ?? Number.MAX_SAFE_INTEGER;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {RuntimeAvailability|undefined} availability @returns {boolean} */
|
|
230
|
+
function isAvailable(availability) {
|
|
231
|
+
if (!availability) return false;
|
|
232
|
+
if (availability.available === true) return !availability.exhaustedUntil || Date.parse(availability.exhaustedUntil) <= Date.now();
|
|
233
|
+
return Boolean(availability.exhaustedUntil && Date.parse(availability.exhaustedUntil) <= Date.now());
|
|
234
|
+
}
|
|
235
|
+
|