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,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the controller persists and reads back: run metadata, a node
|
|
3
|
+
* snapshot, a transition event, and the nested records inside them (invocations,
|
|
4
|
+
* gate results, routing, worktree, scope, usage).
|
|
5
|
+
*
|
|
6
|
+
* Validated on the way in *and* on the way out. A snapshot written by an earlier
|
|
7
|
+
* controller version, or hand-edited between runs, is refused by name rather
|
|
8
|
+
* than half-understood -- the whole point of a resumable run is that what is on
|
|
9
|
+
* disk is trustworthy or loudly not.
|
|
10
|
+
*/
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
import { MAX_SCOPE_FINDING_PATHS } from "./scope-findings.mjs";
|
|
13
|
+
import { REVIEW_MODES } from "./review-modes.mjs";
|
|
14
|
+
import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireInteger, requirePacketHash, requireString, requireTimestamp } from "./assert.mjs";
|
|
15
|
+
import { stableJson } from "../util.mjs";
|
|
16
|
+
import { isAbsolute } from "node:path";
|
|
17
|
+
import { validateCompleteSourceIdentity, validateSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
|
+
import { validateMetadata } from "./schema-version.mjs";
|
|
19
|
+
import { validateSnapshotRuntime } from "./runtime.mjs";
|
|
20
|
+
import { validateVerificationSnapshot } from "./final-verification.mjs";
|
|
21
|
+
import { validateWorkerResult } from "./worker-result.mjs";
|
|
22
|
+
|
|
23
|
+
/** @typedef {import("./index.mjs").EventRecord} EventRecord */
|
|
24
|
+
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
25
|
+
/** @typedef {import("./index.mjs").NodeSnapshot} NodeSnapshot */
|
|
26
|
+
/** @typedef {import("./index.mjs").RunMetadata} RunMetadata */
|
|
27
|
+
/** @typedef {import("./index.mjs").ValidatedNode} ValidatedNode */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hard byte ceiling for an operator's answer text, matching the bounded
|
|
31
|
+
* `Previous attempt` section a retry in place already enforces (retry.mjs).
|
|
32
|
+
*/
|
|
33
|
+
export const OPERATOR_ANSWER_MAX_BYTES = 8 * 1024;
|
|
34
|
+
|
|
35
|
+
const GATE_VERDICTS = new Set(["pass", "fail", "invalid_judge_output"]);
|
|
36
|
+
const NODE_STATUSES = new Set(["pending", "running", "done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
|
|
37
|
+
const NODE_PHASES = new Set(["waiting", "worker", "judge", "complete", "dependency", "canceled"]);
|
|
38
|
+
const GATE_RESULT_FIELDS = new Set(["verdict", "maxSeverity", "summary", "findings"]);
|
|
39
|
+
const FINDING_FIELDS = new Set(["severity", "description", "evidence"]);
|
|
40
|
+
const ERROR_FIELDS = new Set(["code", "message", "exhaustedUntil"]);
|
|
41
|
+
const USAGE_FIELDS = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
|
|
42
|
+
const MAX_ROUTING_HISTORY = 64;
|
|
43
|
+
/**
|
|
44
|
+
* @param {JsonObject} value
|
|
45
|
+
* @param {{requireSourceIdentity?: boolean}} options
|
|
46
|
+
* @returns {RunMetadata}
|
|
47
|
+
*/
|
|
48
|
+
export function validateRunMetadata(value, options = {}) {
|
|
49
|
+
assertObject(value, "run metadata");
|
|
50
|
+
rejectUnknown(value, new Set([
|
|
51
|
+
"schemaVersion", "contractVersion", "pid", "processStartToken", "startedAt", "sourceIdentity",
|
|
52
|
+
"controllerIdentity", "identityWarnings", "integrationRef", "relaunchCount", "lastRelaunchProgressAt", "attention",
|
|
53
|
+
"contractDigest", "scopeDecision", "autoRetries",
|
|
54
|
+
]), "run metadata");
|
|
55
|
+
validateMetadata(value, "run metadata");
|
|
56
|
+
requireInteger(value.pid, "run metadata.pid");
|
|
57
|
+
if (value.processStartToken !== undefined && value.processStartToken !== null) requireString(value.processStartToken, "run metadata.processStartToken");
|
|
58
|
+
requireString(value.startedAt, "run metadata.startedAt");
|
|
59
|
+
if (value.integrationRef !== undefined) requireString(value.integrationRef, "run metadata.integrationRef");
|
|
60
|
+
// The controller snapshot a run was launched from: a path plus the sha of
|
|
61
|
+
// its executable bytes, verified before the next chain node launches N+1.
|
|
62
|
+
if (value.controllerIdentity !== undefined) {
|
|
63
|
+
assertObject(value.controllerIdentity, "run metadata.controllerIdentity");
|
|
64
|
+
rejectUnknown(/** @type {JsonObject} */ (value.controllerIdentity), new Set(["path", "sha"]), "run metadata.controllerIdentity");
|
|
65
|
+
const controllerIdentity = /** @type {JsonObject} */ (value.controllerIdentity);
|
|
66
|
+
requireString(controllerIdentity.path, "run metadata.controllerIdentity.path");
|
|
67
|
+
requirePacketHash(controllerIdentity.sha, "run metadata.controllerIdentity.sha");
|
|
68
|
+
}
|
|
69
|
+
// The supervisor's no-progress relaunch guard is durable in run.json: an
|
|
70
|
+
// in-memory counter resets whenever the supervisor restarts, which is an
|
|
71
|
+
// endless dispatch storm rather than a guard.
|
|
72
|
+
if (value.relaunchCount !== undefined) nonNegativeInteger(value.relaunchCount, "run metadata.relaunchCount");
|
|
73
|
+
if (value.lastRelaunchProgressAt !== undefined && value.lastRelaunchProgressAt !== null) requireTimestamp(value.lastRelaunchProgressAt, "run metadata.lastRelaunchProgressAt");
|
|
74
|
+
if (value.attention !== undefined && value.attention !== null) {
|
|
75
|
+
assertObject(value.attention, "run metadata.attention");
|
|
76
|
+
rejectUnknown(/** @type {JsonObject} */ (value.attention), new Set(["code", "message", "at"]), "run metadata.attention");
|
|
77
|
+
const attention = /** @type {JsonObject} */ (value.attention);
|
|
78
|
+
requireString(attention.code, "run metadata.attention.code");
|
|
79
|
+
requireString(attention.message, "run metadata.attention.message");
|
|
80
|
+
requireTimestamp(attention.at, "run metadata.attention.at");
|
|
81
|
+
}
|
|
82
|
+
validateSourceIdentity(value.sourceIdentity, "run metadata.sourceIdentity", { kind: "run" });
|
|
83
|
+
if (value.identityWarnings !== undefined) {
|
|
84
|
+
if (!Array.isArray(value.identityWarnings) || value.identityWarnings.length > 8) {
|
|
85
|
+
throw new TypeError("run metadata.identityWarnings must be an array of at most 8 strings");
|
|
86
|
+
}
|
|
87
|
+
for (const [index, warning] of value.identityWarnings.entries()) {
|
|
88
|
+
if (typeof warning !== "string" || !warning.trim() || Buffer.byteLength(warning, "utf8") > 1024) {
|
|
89
|
+
throw new TypeError(`run metadata.identityWarnings[${index}] must be a string of at most 1024 bytes`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The contract the run was launched with, frozen as a digest, and the scope
|
|
94
|
+
// decision that authoring made against the then-current tree. A persisted
|
|
95
|
+
// load compares its recomputed digest against the first; both survive every
|
|
96
|
+
// metadata rewrite because they live on the fixed field list.
|
|
97
|
+
if (value.contractDigest !== undefined) requirePacketHash(value.contractDigest, "run metadata.contractDigest");
|
|
98
|
+
if (value.scopeDecision !== undefined) {
|
|
99
|
+
assertObject(value.scopeDecision, "run metadata.scopeDecision");
|
|
100
|
+
rejectUnknown(/** @type {JsonObject} */ (value.scopeDecision), new Set(["at", "base", "dirtyTreeFingerprint"]), "run metadata.scopeDecision");
|
|
101
|
+
const scopeDecision = /** @type {JsonObject} */ (value.scopeDecision);
|
|
102
|
+
requireTimestamp(scopeDecision.at, "run metadata.scopeDecision.at");
|
|
103
|
+
if (scopeDecision.base !== null) requireString(scopeDecision.base, "run metadata.scopeDecision.base");
|
|
104
|
+
if (scopeDecision.dirtyTreeFingerprint !== null) requireString(scopeDecision.dirtyTreeFingerprint, "run metadata.scopeDecision.dirtyTreeFingerprint");
|
|
105
|
+
}
|
|
106
|
+
// The one-shot automatic-retry ledger: node id to the code it retried and
|
|
107
|
+
// when. It lives in run.json so a controller restart cannot grant a second.
|
|
108
|
+
if (value.autoRetries !== undefined) {
|
|
109
|
+
assertObject(value.autoRetries, "run metadata.autoRetries");
|
|
110
|
+
for (const [nodeId, entry] of Object.entries(/** @type {JsonObject} */ (value.autoRetries))) {
|
|
111
|
+
requireId(nodeId, "run metadata.autoRetries node id");
|
|
112
|
+
assertObject(entry, `run metadata.autoRetries.${nodeId}`);
|
|
113
|
+
rejectUnknown(/** @type {JsonObject} */ (entry), new Set(["code", "at"]), `run metadata.autoRetries.${nodeId}`);
|
|
114
|
+
requireString(/** @type {JsonObject} */ (entry).code, `run metadata.autoRetries.${nodeId}.code`);
|
|
115
|
+
requireTimestamp(/** @type {JsonObject} */ (entry).at, `run metadata.autoRetries.${nodeId}.at`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (options.requireSourceIdentity) validateCompleteSourceIdentity(/** @type {JsonObject} */ (value.sourceIdentity));
|
|
119
|
+
return /** @type {RunMetadata} */ (value);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* @param {JsonObject} value
|
|
123
|
+
* @param {ValidatedNode|null} expectedNode
|
|
124
|
+
* @returns {NodeSnapshot}
|
|
125
|
+
*/
|
|
126
|
+
export function validateNodeSnapshot(value, expectedNode = null) {
|
|
127
|
+
assertObject(value, "node snapshot");
|
|
128
|
+
rejectUnknown(value, new Set([
|
|
129
|
+
"schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
|
|
130
|
+
"attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
131
|
+
"costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
|
|
132
|
+
"scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead",
|
|
133
|
+
]), "node snapshot");
|
|
134
|
+
validateMetadata(value, "node snapshot");
|
|
135
|
+
requireId(value.id, "node snapshot.id");
|
|
136
|
+
requireString(value.type, "node snapshot.type");
|
|
137
|
+
if (!NODE_STATUSES.has(/** @type {string} */ (value.status))) throw new TypeError("node snapshot.status is invalid");
|
|
138
|
+
if (!NODE_PHASES.has(/** @type {string} */ (value.phase))) throw new TypeError("node snapshot.phase is invalid");
|
|
139
|
+
nonNegativeInteger(value.attempt, "node snapshot.attempt");
|
|
140
|
+
nonNegativeInteger(value.revisions, "node snapshot.revisions");
|
|
141
|
+
if (value.judgeFailures !== undefined) nonNegativeInteger(value.judgeFailures, "node snapshot.judgeFailures");
|
|
142
|
+
// The review mode that governed the attempt's gate, recorded so a status
|
|
143
|
+
// surface can tell an advisory finding from a below-threshold blocking one.
|
|
144
|
+
if (value.review !== undefined && !REVIEW_MODES.has(/** @type {string} */ (value.review))) {
|
|
145
|
+
throw new TypeError("node snapshot.review is invalid");
|
|
146
|
+
}
|
|
147
|
+
requirePacketHash(value.packetHash, "node snapshot.packetHash");
|
|
148
|
+
validateSourceIdentity(value.sourceIdentity, "node snapshot.sourceIdentity", { kind: "node" });
|
|
149
|
+
if (value.integratedHead !== undefined && value.integratedHead !== null) boundedString(value.integratedHead, "node snapshot.integratedHead", 256);
|
|
150
|
+
const sourceIdentity = /** @type {JsonObject} */ (value.sourceIdentity);
|
|
151
|
+
requireId(sourceIdentity.contractId, "node snapshot.sourceIdentity.contractId");
|
|
152
|
+
requireId(sourceIdentity.nodeId, "node snapshot.sourceIdentity.nodeId");
|
|
153
|
+
validateSnapshotRuntime(value.runtime, "node snapshot.runtime");
|
|
154
|
+
if (!Array.isArray(value.blockedBy) || (/** @type {unknown[]} */ (value.blockedBy)).some((id) => {
|
|
155
|
+
requireId(id, "node snapshot.blockedBy item");
|
|
156
|
+
return false;
|
|
157
|
+
})) {
|
|
158
|
+
throw new TypeError("node snapshot.blockedBy must be an array of ids");
|
|
159
|
+
}
|
|
160
|
+
if (value.startedAt !== null) requireTimestamp(value.startedAt, "node snapshot.startedAt");
|
|
161
|
+
requireTimestamp(value.updatedAt, "node snapshot.updatedAt");
|
|
162
|
+
if (value.result !== null) {
|
|
163
|
+
validateWorkerResult(/** @type {Record<string, unknown>} */ (value.result));
|
|
164
|
+
}
|
|
165
|
+
validateGateResult(value.gate, "node snapshot.gate");
|
|
166
|
+
validateSnapshotError(value.error, "node snapshot.error");
|
|
167
|
+
if (value.usage !== undefined) validateUsage(value.usage, "node snapshot.usage");
|
|
168
|
+
if (value.costUsd !== undefined) nonNegativeNumber(value.costUsd, "node snapshot.costUsd");
|
|
169
|
+
if (value.routing !== undefined && value.routing !== null) validateRoutingState(value.routing, "node snapshot.routing");
|
|
170
|
+
if (value.progress !== undefined && value.progress !== null) validateProgressState(value.progress, "node snapshot.progress");
|
|
171
|
+
if (value.worktree !== undefined && value.worktree !== null) validateWorktreeState(value.worktree, "node snapshot.worktree");
|
|
172
|
+
if (value.invocations !== undefined) validateInvocations(value.invocations, "node snapshot.invocations");
|
|
173
|
+
if (value.executionOverrides !== undefined) validateExecutionOverrides(value.executionOverrides, "node snapshot.executionOverrides");
|
|
174
|
+
if (value.verification !== undefined && value.verification !== null) validateVerificationSnapshot(value.verification);
|
|
175
|
+
if (value.scope !== undefined && value.scope !== null) validateScopeSnapshot(value.scope);
|
|
176
|
+
if (value.scopeFindings !== undefined && value.scopeFindings !== null) validateScopeFindings(value.scopeFindings);
|
|
177
|
+
// The session policy a rejection decision leaves for the dispatch that will
|
|
178
|
+
// run the retry. It is persisted because the decision can hand the node back
|
|
179
|
+
// to the scheduler, whose own `startWorker` call carries no argument; without
|
|
180
|
+
// it `phaseInvocationPlan` would rediscover the prior compatible continuation
|
|
181
|
+
// from the ledger and re-read the failed transcript (phase 6).
|
|
182
|
+
if (value.sessionPolicy !== undefined && value.sessionPolicy !== null) validateSessionPolicy(value.sessionPolicy, "node snapshot.sessionPolicy");
|
|
183
|
+
// The bounded `Previous attempt` section a retry in place attaches to the
|
|
184
|
+
// re-dispatched attempt's worker and judge prompts (retry.mjs renders it).
|
|
185
|
+
if (value.previousAttempt !== undefined) {
|
|
186
|
+
if (typeof value.previousAttempt !== "string" || !value.previousAttempt.trim()) {
|
|
187
|
+
throw new TypeError("node snapshot.previousAttempt must be a non-empty string");
|
|
188
|
+
}
|
|
189
|
+
if (Buffer.byteLength(value.previousAttempt, "utf8") > 8 * 1024) throw new TypeError("node snapshot.previousAttempt exceeds 8192 bytes");
|
|
190
|
+
}
|
|
191
|
+
if (expectedNode) validateSnapshotBinding(value, expectedNode);
|
|
192
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > 128 * 1024) throw new TypeError("node snapshot exceeds 131072 bytes");
|
|
193
|
+
return /** @type {NodeSnapshot} */ (value);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* @param {JsonObject} value
|
|
197
|
+
* @returns {EventRecord}
|
|
198
|
+
*/
|
|
199
|
+
export function validateEvent(value) {
|
|
200
|
+
assertObject(value, "event");
|
|
201
|
+
rejectUnknown(value, new Set([
|
|
202
|
+
"schemaVersion", "contractVersion", "at", "node", "from", "to", "type", "phase", "attempt", "runtime",
|
|
203
|
+
"role", "status", "currentRuntime", "errorCode", "error", "verdict", "summary", "revisions", "sourceIdentity", "packetHash", "override", "recovery", "invocationId", "unexpectedPaths", "unexpectedPathCount",
|
|
204
|
+
]), "event");
|
|
205
|
+
validateMetadata(value, "event");
|
|
206
|
+
requireString(value.at, "event.at");
|
|
207
|
+
requireId(value.node, "event.node");
|
|
208
|
+
requireString(value.to, "event.to");
|
|
209
|
+
if (value.type !== undefined) boundedString(value.type, "event.type", 128);
|
|
210
|
+
if (value.role !== undefined && value.role !== "worker" && value.role !== "judge") throw new TypeError("event.role is invalid");
|
|
211
|
+
if (value.status !== undefined && !NODE_STATUSES.has(/** @type {string} */ (value.status))) throw new TypeError("event.status is invalid");
|
|
212
|
+
if (value.runtime !== undefined) requireId(value.runtime, "event.runtime");
|
|
213
|
+
if (value.currentRuntime !== undefined) requireId(value.currentRuntime, "event.currentRuntime");
|
|
214
|
+
if (value.errorCode !== undefined) boundedString(value.errorCode, "event.errorCode", 256);
|
|
215
|
+
requireString(value.packetHash, "event.packetHash");
|
|
216
|
+
validateSourceIdentity(value.sourceIdentity, "event.sourceIdentity");
|
|
217
|
+
if (value.summary !== undefined && Buffer.byteLength(/** @type {string} */ (value.summary), "utf8") > 4 * 1024) {
|
|
218
|
+
throw new TypeError("event.summary exceeds 4096 bytes");
|
|
219
|
+
}
|
|
220
|
+
if (value.unexpectedPaths !== undefined) {
|
|
221
|
+
const unexpectedPaths = /** @type {unknown[]} */ (value.unexpectedPaths);
|
|
222
|
+
if (!Array.isArray(value.unexpectedPaths) || unexpectedPaths.length > 64 || unexpectedPaths.some((path) => typeof path !== "string")) {
|
|
223
|
+
throw new TypeError("event unexpected paths are invalid");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (value.unexpectedPathCount !== undefined) nonNegativeInteger(value.unexpectedPathCount, "event.unexpectedPathCount");
|
|
227
|
+
return /** @type {EventRecord} */ (value);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* @param {unknown} value
|
|
231
|
+
* @param {string} label
|
|
232
|
+
*/
|
|
233
|
+
function validateInvocations(value, label) {
|
|
234
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
235
|
+
const invocations = /** @type {JsonObject[]} */ (value);
|
|
236
|
+
for (const [index, invocation] of invocations.entries()) {
|
|
237
|
+
assertObject(invocation, `${label}[${index}]`);
|
|
238
|
+
const allowed = new Set([
|
|
239
|
+
"id", "pid", "processGroupId", "processStartToken", "harness", "runtimeId", "phase",
|
|
240
|
+
"promptPath", "stdoutPath", "stderrPath", "startedAt", "updatedAt", "closedAt", "deadlineAt",
|
|
241
|
+
"exitCode", "signal", "status", "executable", "usage", "usageEstimated", "costUsd", "costProvenance", "snapshotPath", "revision", "cycle",
|
|
242
|
+
"runId", "campaignId", "planPhase", "role", "runtimeFingerprint", "model", "reasoning", "sandbox", "continuationId", "continuationMode",
|
|
243
|
+
"nodeId", "attempt", "workspace", "worktreeBranch", "worktreeBaseSha",
|
|
244
|
+
]);
|
|
245
|
+
rejectUnknown(invocation, allowed, `${label}[${index}]`);
|
|
246
|
+
requireString(invocation.id, `${label}[${index}].id`);
|
|
247
|
+
requireInteger(invocation.pid, `${label}[${index}].pid`);
|
|
248
|
+
if (invocation.processGroupId !== null) requireInteger(invocation.processGroupId, `${label}[${index}].processGroupId`);
|
|
249
|
+
if (invocation.processStartToken !== null) requireString(invocation.processStartToken, `${label}[${index}].processStartToken`);
|
|
250
|
+
requireString(invocation.harness, `${label}[${index}].harness`);
|
|
251
|
+
requireString(invocation.phase, `${label}[${index}].phase`);
|
|
252
|
+
requireId(invocation.runId, `${label}[${index}].runId`);
|
|
253
|
+
requireId(invocation.campaignId, `${label}[${index}].campaignId`);
|
|
254
|
+
if (invocation.nodeId !== undefined) requireId(invocation.nodeId, `${label}[${index}].nodeId`);
|
|
255
|
+
if (invocation.attempt !== undefined) nonNegativeInteger(invocation.attempt, `${label}[${index}].attempt`);
|
|
256
|
+
if (invocation.workspace !== undefined) boundedString(invocation.workspace, `${label}[${index}].workspace`, 4096);
|
|
257
|
+
if (invocation.worktreeBranch !== undefined && invocation.worktreeBranch !== null) boundedString(invocation.worktreeBranch, `${label}[${index}].worktreeBranch`, 512);
|
|
258
|
+
if (invocation.worktreeBaseSha !== undefined && invocation.worktreeBaseSha !== null) boundedString(invocation.worktreeBaseSha, `${label}[${index}].worktreeBaseSha`, 256);
|
|
259
|
+
boundedString(invocation.planPhase, `${label}[${index}].planPhase`, 128);
|
|
260
|
+
if (invocation.role !== "worker" && invocation.role !== "judge") throw new TypeError(`${label}[${index}].role is invalid`);
|
|
261
|
+
boundedString(invocation.runtimeFingerprint, `${label}[${index}].runtimeFingerprint`, 128);
|
|
262
|
+
requireString(invocation.model, `${label}[${index}].model`);
|
|
263
|
+
if (invocation.reasoning !== null) requireString(invocation.reasoning, `${label}[${index}].reasoning`);
|
|
264
|
+
if (invocation.sandbox !== null) requireString(invocation.sandbox, `${label}[${index}].sandbox`);
|
|
265
|
+
if (invocation.continuationId !== null) boundedString(invocation.continuationId, `${label}[${index}].continuationId`, 512);
|
|
266
|
+
if (!['fresh', 'reuse', 'rotate'].includes(/** @type {string} */ (invocation.continuationMode))) {
|
|
267
|
+
throw new TypeError(`${label}[${index}].continuationMode is invalid`);
|
|
268
|
+
}
|
|
269
|
+
if (invocation.revision !== undefined) nonNegativeInteger(invocation.revision, `${label}[${index}].revision`);
|
|
270
|
+
if (invocation.cycle !== undefined) nonNegativeInteger(invocation.cycle, `${label}[${index}].cycle`);
|
|
271
|
+
for (const key of ["promptPath", "stdoutPath", "stderrPath", "executable"]) {
|
|
272
|
+
if (invocation[key] !== null) requireString(invocation[key], `${label}[${index}].${key}`);
|
|
273
|
+
}
|
|
274
|
+
if (invocation.snapshotPath !== undefined) requireString(invocation.snapshotPath, `${label}[${index}].snapshotPath`);
|
|
275
|
+
for (const key of ["startedAt", "updatedAt", "deadlineAt"]) requireTimestamp(invocation[key], `${label}[${index}].${key}`);
|
|
276
|
+
if (invocation.closedAt !== null) requireTimestamp(invocation.closedAt, `${label}[${index}].closedAt`);
|
|
277
|
+
if (!Number.isInteger(invocation.exitCode) && invocation.exitCode !== null) throw new TypeError(`${label}[${index}].exitCode must be an integer or null`);
|
|
278
|
+
if (invocation.signal !== null) requireString(invocation.signal, `${label}[${index}].signal`);
|
|
279
|
+
if (! ["active", "closed", "terminated"].includes(/** @type {string} */ (invocation.status))) throw new TypeError(`${label}[${index}].status is invalid`);
|
|
280
|
+
if (invocation.usage !== undefined) validateInvocationUsage(invocation.usage, `${label}[${index}].usage`);
|
|
281
|
+
if (invocation.usageEstimated !== undefined && typeof invocation.usageEstimated !== "boolean") {
|
|
282
|
+
throw new TypeError(`${label}[${index}].usageEstimated must be a boolean`);
|
|
283
|
+
}
|
|
284
|
+
if (invocation.costUsd !== undefined && invocation.costUsd !== null) nonNegativeNumber(invocation.costUsd, `${label}[${index}].costUsd`);
|
|
285
|
+
// Provenance is only ever explicit when it is `priced`; a number costUsd
|
|
286
|
+
// with no field is a harness-reported `provider`, and no costUsd is
|
|
287
|
+
// `unknown`. Encoding either of those in the field would let a persisted
|
|
288
|
+
// record claim a provenance it never earned.
|
|
289
|
+
if (invocation.costProvenance !== undefined && invocation.costProvenance !== "priced") {
|
|
290
|
+
throw new TypeError(`${label}[${index}].costProvenance must be "priced" when present`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* @param {unknown} value
|
|
296
|
+
* @param {string} label
|
|
297
|
+
*/
|
|
298
|
+
function validateInvocationUsage(value, label) {
|
|
299
|
+
assertObject(value, label);
|
|
300
|
+
rejectUnknown(value, USAGE_FIELDS, label);
|
|
301
|
+
for (const key of USAGE_FIELDS) {
|
|
302
|
+
if (value[key] !== null) nonNegativeInteger(value[key], `${label}.${key}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* @param {unknown} value
|
|
307
|
+
* @param {string} label
|
|
308
|
+
*/
|
|
309
|
+
function validateExecutionOverrides(value, label) {
|
|
310
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
311
|
+
const overrides = /** @type {JsonObject[]} */ (value);
|
|
312
|
+
for (const [index, override] of overrides.entries()) {
|
|
313
|
+
assertObject(override, `${label}[${index}]`);
|
|
314
|
+
rejectUnknown(override, new Set(["kind", "at", "reason", "timeoutSec", "decision", "invocationId", "phase", "result", "usage", "costUsd", "text"]), `${label}[${index}]`);
|
|
315
|
+
requireString(override.kind, `${label}[${index}].kind`);
|
|
316
|
+
requireTimestamp(override.at, `${label}[${index}].at`);
|
|
317
|
+
requireString(override.reason, `${label}[${index}].reason`);
|
|
318
|
+
if (override.timeoutSec !== undefined) positiveNumber(override.timeoutSec, `${label}[${index}].timeoutSec`);
|
|
319
|
+
if (override.decision !== undefined) requireString(override.decision, `${label}[${index}].decision`);
|
|
320
|
+
if (override.invocationId !== undefined) requireString(override.invocationId, `${label}[${index}].invocationId`);
|
|
321
|
+
if (override.phase !== undefined) requireString(override.phase, `${label}[${index}].phase`);
|
|
322
|
+
if (override.result !== undefined && override.result !== null) requireString(override.result, `${label}[${index}].result`);
|
|
323
|
+
if (override.usage !== undefined) validateInvocationUsage(override.usage, `${label}[${index}].usage`);
|
|
324
|
+
if (override.costUsd !== undefined && override.costUsd !== null) nonNegativeNumber(override.costUsd, `${label}[${index}].costUsd`);
|
|
325
|
+
if (override.text !== undefined) boundedString(override.text, `${label}[${index}].text`, OPERATOR_ANSWER_MAX_BYTES);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* @param {unknown} value
|
|
330
|
+
* @param {string} label
|
|
331
|
+
*/
|
|
332
|
+
function validateGateResult(value, label) {
|
|
333
|
+
if (value === null) return;
|
|
334
|
+
assertObject(value, label);
|
|
335
|
+
rejectUnknown(value, GATE_RESULT_FIELDS, label);
|
|
336
|
+
const verdict = value.verdict;
|
|
337
|
+
const maxSeverity = value.maxSeverity;
|
|
338
|
+
if (!GATE_VERDICTS.has(/** @type {string} */ (verdict))) throw new TypeError(`${label}.verdict is invalid`);
|
|
339
|
+
if (!["none", "minor", "major", "critical"].includes(/** @type {string} */ (maxSeverity))) {
|
|
340
|
+
throw new TypeError(`${label}.maxSeverity is invalid`);
|
|
341
|
+
}
|
|
342
|
+
// An invalid verdict is the record that the judge produced nothing usable:
|
|
343
|
+
// it carries no finding the node could be rejected with.
|
|
344
|
+
if (verdict === "invalid_judge_output" && (maxSeverity !== "none" || /** @type {unknown[]} */ (value.findings).length > 0)) {
|
|
345
|
+
throw new TypeError(`${label} with verdict invalid_judge_output records no findings`);
|
|
346
|
+
}
|
|
347
|
+
if (typeof value.summary !== "string") throw new TypeError(`${label}.summary must be a string`);
|
|
348
|
+
if (Buffer.byteLength(value.summary, "utf8") > 4 * 1024) throw new TypeError(`${label}.summary exceeds 4096 bytes`);
|
|
349
|
+
if (!Array.isArray(value.findings)) throw new TypeError(`${label}.findings must be an array`);
|
|
350
|
+
if (value.findings.length > 32) throw new TypeError(`${label}.findings must have at most 32 items`);
|
|
351
|
+
const rank = { none: 0, minor: 1, major: 2, critical: 3 };
|
|
352
|
+
let actualMax = "none";
|
|
353
|
+
const findings = /** @type {JsonObject[]} */ (value.findings);
|
|
354
|
+
for (const [index, finding] of findings.entries()) {
|
|
355
|
+
assertObject(finding, `${label}.findings[${index}]`);
|
|
356
|
+
rejectUnknown(finding, FINDING_FIELDS, `${label}.findings[${index}]`);
|
|
357
|
+
const severity = finding.severity;
|
|
358
|
+
if (typeof severity !== "string" || !["minor", "major", "critical"].includes(severity)) {
|
|
359
|
+
throw new TypeError(`${label}.findings[${index}].severity is invalid`);
|
|
360
|
+
}
|
|
361
|
+
if (typeof finding.description !== "string") {
|
|
362
|
+
throw new TypeError(`${label}.findings[${index}].description must be a string`);
|
|
363
|
+
}
|
|
364
|
+
if (typeof finding.evidence !== "string") {
|
|
365
|
+
throw new TypeError(`${label}.findings[${index}].evidence must be a string`);
|
|
366
|
+
}
|
|
367
|
+
if (Buffer.byteLength(finding.description, "utf8") > 2 * 1024 || Buffer.byteLength(finding.evidence, "utf8") > 4 * 1024) {
|
|
368
|
+
throw new TypeError(`${label}.findings[${index}] exceeds evidence limits`);
|
|
369
|
+
}
|
|
370
|
+
const severityKey = /** @type {"minor"|"major"|"critical"} */ (severity);
|
|
371
|
+
if (rank[severityKey] > rank[/** @type {keyof typeof rank} */ (actualMax)]) actualMax = severityKey;
|
|
372
|
+
}
|
|
373
|
+
if (actualMax !== maxSeverity) throw new TypeError(`${label}.maxSeverity does not match findings`);
|
|
374
|
+
// An invalid verdict is not a pass, yet it records no severity either: only a
|
|
375
|
+
// real arbitrated verdict is held to the pass/none pairing.
|
|
376
|
+
if (verdict !== "invalid_judge_output" && (verdict === "pass") !== (maxSeverity === "none")) {
|
|
377
|
+
throw new TypeError(`${label}.verdict and maxSeverity are inconsistent`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* @param {unknown} value
|
|
382
|
+
* @param {string} label
|
|
383
|
+
*/
|
|
384
|
+
function validateSnapshotError(value, label) {
|
|
385
|
+
if (value === null) return;
|
|
386
|
+
assertObject(value, label);
|
|
387
|
+
rejectUnknown(value, ERROR_FIELDS, label);
|
|
388
|
+
requireString(value.code, `${label}.code`);
|
|
389
|
+
requireString(value.message, `${label}.message`);
|
|
390
|
+
if (value.exhaustedUntil !== undefined && value.exhaustedUntil !== null) requireTimestamp(value.exhaustedUntil, `${label}.exhaustedUntil`);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The one persisted session policy: `forceFresh` starts the next dispatch in a
|
|
394
|
+
* provider session of its own instead of a rediscovered continuation. Only
|
|
395
|
+
* `true` is meaningful; a false or absent flag is the ordinary reuse path.
|
|
396
|
+
*
|
|
397
|
+
* @param {unknown} value
|
|
398
|
+
* @param {string} label
|
|
399
|
+
*/
|
|
400
|
+
function validateSessionPolicy(value, label) {
|
|
401
|
+
assertObject(value, label);
|
|
402
|
+
rejectUnknown(value, new Set(["forceFresh"]), label);
|
|
403
|
+
if (value.forceFresh !== undefined && typeof value.forceFresh !== "boolean") {
|
|
404
|
+
throw new TypeError(`${label}.forceFresh must be a boolean`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* @param {unknown} value
|
|
409
|
+
* @param {string} label
|
|
410
|
+
*/
|
|
411
|
+
function validateUsage(value, label) {
|
|
412
|
+
assertObject(value, label);
|
|
413
|
+
rejectUnknown(value, USAGE_FIELDS, label);
|
|
414
|
+
for (const key of USAGE_FIELDS) nonNegativeInteger(value[key], `${label}.${key}`);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The durable evidence of one tier-exhaustion generation: which role it
|
|
418
|
+
* belongs to and which candidates were tried, each with the reset instant the
|
|
419
|
+
* provider announced (null when it announced none).
|
|
420
|
+
*
|
|
421
|
+
* @param {unknown} value
|
|
422
|
+
* @param {string} label
|
|
423
|
+
*/
|
|
424
|
+
function validateTierExhaustion(value, label) {
|
|
425
|
+
assertObject(value, label);
|
|
426
|
+
rejectUnknown(value, new Set(["role", "candidates"]), label);
|
|
427
|
+
if (value.role !== "worker" && value.role !== "judge") throw new TypeError(`${label}.role is invalid`);
|
|
428
|
+
if (!Array.isArray(value.candidates)) throw new TypeError(`${label}.candidates must be an array`);
|
|
429
|
+
for (const [index, candidate] of value.candidates.entries()) {
|
|
430
|
+
assertObject(candidate, `${label}.candidates[${index}]`);
|
|
431
|
+
rejectUnknown(candidate, new Set(["runtimeId", "exhaustedUntil"]), `${label}.candidates[${index}]`);
|
|
432
|
+
requireId(candidate.runtimeId, `${label}.candidates[${index}].runtimeId`);
|
|
433
|
+
if (candidate.exhaustedUntil !== null) requireTimestamp(candidate.exhaustedUntil, `${label}.candidates[${index}].exhaustedUntil`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* @param {unknown} value
|
|
438
|
+
* @param {string} label
|
|
439
|
+
*/
|
|
440
|
+
function validateRoutingState(value, label) {
|
|
441
|
+
assertObject(value, label);
|
|
442
|
+
rejectUnknown(value, new Set(["history", "currentOverride", "assignments", "availability", "tierExhaustion", "tierExhaustionCycle"]), label);
|
|
443
|
+
if (!Array.isArray(value.history) || value.history.length > MAX_ROUTING_HISTORY) {
|
|
444
|
+
throw new TypeError(`${label}.history must be an array with at most ${MAX_ROUTING_HISTORY} items`);
|
|
445
|
+
}
|
|
446
|
+
for (const [index, entry] of value.history.entries()) {
|
|
447
|
+
validateRoutingEntry(entry, `${label}.history[${index}]`, false);
|
|
448
|
+
}
|
|
449
|
+
if (value.currentOverride !== null) validateRoutingEntry(value.currentOverride, `${label}.currentOverride`, true);
|
|
450
|
+
if (value.tierExhaustion !== undefined) validateTierExhaustion(value.tierExhaustion, `${label}.tierExhaustion`);
|
|
451
|
+
if (value.tierExhaustionCycle !== undefined) nonNegativeInteger(value.tierExhaustionCycle, `${label}.tierExhaustionCycle`);
|
|
452
|
+
if (value.assignments !== undefined) {
|
|
453
|
+
assertObject(value.assignments, `${label}.assignments`);
|
|
454
|
+
rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge"]), `${label}.assignments`);
|
|
455
|
+
requireId(value.assignments.worker, `${label}.assignments.worker`);
|
|
456
|
+
requireId(value.assignments.judge, `${label}.assignments.judge`);
|
|
457
|
+
for (const key of ["composedWorker", "composedJudge"]) {
|
|
458
|
+
if (value.assignments[key] !== undefined && typeof value.assignments[key] !== "boolean") throw new TypeError(`${label}.assignments.${key} must be boolean`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (value.availability !== undefined) {
|
|
462
|
+
assertObject(value.availability, `${label}.availability`);
|
|
463
|
+
for (const [id, availability] of Object.entries(value.availability)) {
|
|
464
|
+
requireId(id, `${label}.availability runtime`);
|
|
465
|
+
assertObject(availability, `${label}.availability.${id}`);
|
|
466
|
+
rejectUnknown(availability, new Set(["available", "exhaustedUntil", "reason"]), `${label}.availability.${id}`);
|
|
467
|
+
if (typeof availability.available !== "boolean") throw new TypeError(`${label}.availability.${id}.available must be boolean`);
|
|
468
|
+
if (availability.exhaustedUntil !== undefined && availability.exhaustedUntil !== null) requireTimestamp(availability.exhaustedUntil, `${label}.availability.${id}.exhaustedUntil`);
|
|
469
|
+
requireString(availability.reason, `${label}.availability.${id}.reason`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* @param {unknown} value
|
|
475
|
+
* @param {string} label
|
|
476
|
+
* @param {boolean} override
|
|
477
|
+
*/
|
|
478
|
+
function validateRoutingEntry(value, label, override) {
|
|
479
|
+
assertObject(value, label);
|
|
480
|
+
const fields = override
|
|
481
|
+
? new Set(["at", "role", "runtime", "nextRuntime", "rule", "ruleIndex", "revision", "hop", "reason", "backoffSec", "backoffUntil", "usage", "costUsd", "costProvenance"])
|
|
482
|
+
: new Set(["at", "role", "runtime", "nextRuntime", "rule", "ruleIndex", "revision", "hop", "status", "errorCode", "backoffSec", "backoffUntil", "usage", "costUsd", "costProvenance"]);
|
|
483
|
+
rejectUnknown(value, fields, label);
|
|
484
|
+
requireTimestamp(value.at, `${label}.at`);
|
|
485
|
+
if (value.role !== "worker" && value.role !== "judge") throw new TypeError(`${label}.role is invalid`);
|
|
486
|
+
requireId(value.runtime, `${label}.runtime`);
|
|
487
|
+
if (value.nextRuntime !== undefined) requireId(value.nextRuntime, `${label}.nextRuntime`);
|
|
488
|
+
if (value.rule !== undefined) nonNegativeInteger(value.rule, `${label}.rule`);
|
|
489
|
+
if (value.ruleIndex !== undefined) nonNegativeInteger(value.ruleIndex, `${label}.ruleIndex`);
|
|
490
|
+
if (value.revision !== undefined) nonNegativeInteger(value.revision, `${label}.revision`);
|
|
491
|
+
if (value.hop !== undefined) nonNegativeInteger(value.hop, `${label}.hop`);
|
|
492
|
+
if (override) {
|
|
493
|
+
boundedString(value.reason, `${label}.reason`, 2 * 1024);
|
|
494
|
+
} else {
|
|
495
|
+
if (value.status !== undefined && !NODE_STATUSES.has(/** @type {string} */ (value.status))) {
|
|
496
|
+
throw new TypeError(`${label}.status is invalid`);
|
|
497
|
+
}
|
|
498
|
+
if (value.errorCode !== undefined) boundedString(value.errorCode, `${label}.errorCode`, 256);
|
|
499
|
+
}
|
|
500
|
+
if (value.backoffSec !== undefined) nonNegativeNumber(value.backoffSec, `${label}.backoffSec`);
|
|
501
|
+
if (value.backoffUntil !== undefined) requireTimestamp(value.backoffUntil, `${label}.backoffUntil`);
|
|
502
|
+
if (value.usage !== undefined) validateInvocationUsage(value.usage, `${label}.usage`);
|
|
503
|
+
if (value.costUsd !== undefined && value.costUsd !== null) nonNegativeNumber(value.costUsd, `${label}.costUsd`);
|
|
504
|
+
// Provenance is explicit only when it is `priced`; a provider-reported
|
|
505
|
+
// number leaves the field absent in a routing entry, exactly as it does on
|
|
506
|
+
// an invocation.
|
|
507
|
+
if (value.costProvenance !== undefined && value.costProvenance !== "priced") {
|
|
508
|
+
throw new TypeError(`${label}.costProvenance must be "priced" when present`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* @param {unknown} value
|
|
513
|
+
* @param {string} label
|
|
514
|
+
*/
|
|
515
|
+
function validateProgressState(value, label) {
|
|
516
|
+
assertObject(value, label);
|
|
517
|
+
rejectUnknown(value, new Set(["revision", "heartbeatCount", "dryHeartbeatCount", "progressSignature", "lastHeartbeatAt", "lastProgressAt", "nextCheckAt"]), label);
|
|
518
|
+
if (value.revision !== undefined) nonNegativeInteger(value.revision, `${label}.revision`);
|
|
519
|
+
const heartbeatCount = nonNegativeInteger(value.heartbeatCount, `${label}.heartbeatCount`);
|
|
520
|
+
const dryHeartbeatCount = nonNegativeInteger(value.dryHeartbeatCount, `${label}.dryHeartbeatCount`);
|
|
521
|
+
if (dryHeartbeatCount > heartbeatCount) throw new TypeError(`${label}.dryHeartbeatCount cannot exceed heartbeatCount`);
|
|
522
|
+
if (heartbeatCount > 1_000_000 || dryHeartbeatCount > 1_000_000) throw new TypeError(`${label} heartbeat counts are out of bounds`);
|
|
523
|
+
if (value.lastHeartbeatAt !== null) requireTimestamp(value.lastHeartbeatAt, `${label}.lastHeartbeatAt`);
|
|
524
|
+
if (value.lastProgressAt !== null) requireTimestamp(value.lastProgressAt, `${label}.lastProgressAt`);
|
|
525
|
+
if (value.progressSignature !== undefined && value.progressSignature !== null) boundedString(value.progressSignature, `${label}.progressSignature`, 256);
|
|
526
|
+
if (value.nextCheckAt !== undefined && value.nextCheckAt !== null) requireTimestamp(value.nextCheckAt, `${label}.nextCheckAt`);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* @param {unknown} value
|
|
530
|
+
* @param {string} label
|
|
531
|
+
*/
|
|
532
|
+
function validateWorktreeState(value, label) {
|
|
533
|
+
assertObject(value, label);
|
|
534
|
+
rejectUnknown(value, new Set(["status", "path", "branch", "commit", "baseSha", "sealedSha", "sealError", "previousAttempt"]), label);
|
|
535
|
+
if (!["unassigned", "provisioning", "ready", "failed", "removed"].includes(/** @type {string} */ (value.status))) {
|
|
536
|
+
throw new TypeError(`${label}.status is invalid`);
|
|
537
|
+
}
|
|
538
|
+
for (const [key, maxBytes] of /** @type {[string, number][]} */ ([['path', 4096], ['branch', 512], ['commit', 256], ['baseSha', 256], ['sealedSha', 256], ['sealError', 1024]])) {
|
|
539
|
+
if (value[key] !== undefined && value[key] !== null) boundedString(value[key], `${label}.${key}`, maxBytes);
|
|
540
|
+
}
|
|
541
|
+
if (value.previousAttempt !== undefined && value.previousAttempt !== null) positiveInteger(value.previousAttempt, `${label}.previousAttempt`);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* @param {JsonObject} value
|
|
545
|
+
* @param {ValidatedNode} node
|
|
546
|
+
*/
|
|
547
|
+
function validateSnapshotBinding(value, node) {
|
|
548
|
+
if (value.id !== node.id) throw new TypeError(`node snapshot.id does not match contract node ${node.id}`);
|
|
549
|
+
if (value.type !== node.type) throw new TypeError(`node snapshot.type does not match contract node ${node.id}`);
|
|
550
|
+
if (value.packetHash !== node.packetHash) throw new TypeError(`node snapshot.packetHash does not match contract node ${node.id}`);
|
|
551
|
+
if (stableJson(value.sourceIdentity) !== stableJson(node.sourceIdentity)) {
|
|
552
|
+
throw new TypeError(`node snapshot.sourceIdentity does not match contract node ${node.id}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* @param {unknown} value
|
|
557
|
+
*/
|
|
558
|
+
function validateScopeSnapshot(value) {
|
|
559
|
+
assertObject(value, "node snapshot.scope");
|
|
560
|
+
rejectUnknown(value, new Set([
|
|
561
|
+
"changedPaths", "unexpectedPaths", "changedPathCount", "unexpectedPathCount", "truncated", "boundary",
|
|
562
|
+
]), "node snapshot.scope");
|
|
563
|
+
for (const key of ["changedPaths", "unexpectedPaths"]) {
|
|
564
|
+
const paths = /** @type {unknown[]} */ (value[key]);
|
|
565
|
+
if (!Array.isArray(value[key]) || paths.length > 64 || paths.some((path) => typeof path !== "string")) {
|
|
566
|
+
throw new TypeError(`node snapshot.scope.${key} is invalid`);
|
|
567
|
+
}
|
|
568
|
+
if (paths.some((path) => Buffer.byteLength(/** @type {string} */ (path), "utf8") > 1024)) {
|
|
569
|
+
throw new TypeError(`node snapshot.scope.${key} contains an oversized path`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const key of ["changedPathCount", "unexpectedPathCount"]) {
|
|
573
|
+
if (value[key] !== undefined) nonNegativeInteger(value[key], `node snapshot.scope.${key}`);
|
|
574
|
+
}
|
|
575
|
+
if (value.truncated !== undefined && typeof value.truncated !== "boolean") throw new TypeError("node snapshot.scope.truncated is invalid");
|
|
576
|
+
if (value.boundary !== undefined && value.boundary !== null) validateScopeBoundarySnapshot(value.boundary, "node snapshot.scope.boundary");
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* An advisory finding recorded when a completed attempt's controller
|
|
580
|
+
* verification passed despite unexpected workspace writes (TECH-SPEC lean,
|
|
581
|
+
* rule 1). Never a terminal state.
|
|
582
|
+
*
|
|
583
|
+
* @param {unknown} value
|
|
584
|
+
*/
|
|
585
|
+
function validateScopeFindings(value) {
|
|
586
|
+
assertObject(value, "node snapshot.scopeFindings");
|
|
587
|
+
rejectUnknown(value, new Set(["unexpectedPaths"]), "node snapshot.scopeFindings");
|
|
588
|
+
const paths = /** @type {unknown[]} */ (value.unexpectedPaths);
|
|
589
|
+
if (!Array.isArray(value.unexpectedPaths) || paths.length > MAX_SCOPE_FINDING_PATHS || paths.some((path) => typeof path !== "string")) {
|
|
590
|
+
throw new TypeError("node snapshot.scopeFindings.unexpectedPaths is invalid");
|
|
591
|
+
}
|
|
592
|
+
if (paths.some((path) => Buffer.byteLength(/** @type {string} */ (path), "utf8") > 1024)) {
|
|
593
|
+
throw new TypeError("node snapshot.scopeFindings.unexpectedPaths contains an oversized path");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* @param {unknown} value
|
|
598
|
+
* @param {string} label
|
|
599
|
+
*/
|
|
600
|
+
function validateScopeBoundarySnapshot(value, label) {
|
|
601
|
+
assertObject(value, label);
|
|
602
|
+
rejectUnknown(value, new Set(["schemaVersion", "files", "roots", "fileRoots", "fileOrigins", "rootOrigins"]), label);
|
|
603
|
+
if (value.schemaVersion !== 1 || !Array.isArray(value.files) || !Array.isArray(value.roots) || !Array.isArray(value.fileOrigins) || !Array.isArray(value.rootOrigins)) {
|
|
604
|
+
throw new TypeError(`${label} is malformed`);
|
|
605
|
+
}
|
|
606
|
+
// Declared roots that named a regular file at capture time: they authorize
|
|
607
|
+
// exactly that path, never the paths beneath it.
|
|
608
|
+
const fileRoots = value.fileRoots ?? [];
|
|
609
|
+
if (!Array.isArray(fileRoots)) throw new TypeError(`${label}.fileRoots is invalid`);
|
|
610
|
+
const total = value.files.length + value.roots.length + fileRoots.length + value.fileOrigins.length + value.rootOrigins.length;
|
|
611
|
+
if (total > 4096) throw new TypeError(`${label} is too large`);
|
|
612
|
+
/** @param {unknown} path @param {string} pathLabel */
|
|
613
|
+
const validPath = (path, pathLabel) => {
|
|
614
|
+
if (typeof path !== "string" || path.length === 0 || isAbsolute(path) || /^[A-Za-z]:[\\/]/u.test(path) || /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(path)) {
|
|
615
|
+
throw new TypeError(`${pathLabel} contains an invalid path`);
|
|
616
|
+
}
|
|
617
|
+
if (path === "." || /[\\/]$/u.test(path) || Buffer.byteLength(path, "utf8") > 1024) {
|
|
618
|
+
throw new TypeError(`${pathLabel} contains an invalid path`);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
for (const [kind, paths] of [["files", value.files], ["roots", value.roots], ["fileRoots", fileRoots]]) {
|
|
622
|
+
for (const path of /** @type {unknown[]} */ (paths)) validPath(path, `${label}.${kind}`);
|
|
623
|
+
}
|
|
624
|
+
for (const path of /** @type {unknown[]} */ (fileRoots)) {
|
|
625
|
+
if (!/** @type {unknown[]} */ (value.roots).includes(path)) throw new TypeError(`${label}.fileRoots must be declared roots`);
|
|
626
|
+
}
|
|
627
|
+
for (const [kind, origins] of [["fileOrigins", value.fileOrigins], ["rootOrigins", value.rootOrigins]]) {
|
|
628
|
+
for (const [index, origin] of /** @type {unknown[]} */ (origins).entries()) {
|
|
629
|
+
assertObject(origin, `${label}.${kind}[${index}]`);
|
|
630
|
+
rejectUnknown(origin, new Set(["literal", "paths"]), `${label}.${kind}[${index}]`);
|
|
631
|
+
validPath(origin.literal, `${label}.${kind}[${index}].literal`);
|
|
632
|
+
if (!Array.isArray(origin.paths) || origin.paths.length === 0 || origin.paths.length > 4096) {
|
|
633
|
+
throw new TypeError(`${label}.${kind}[${index}].paths is invalid`);
|
|
634
|
+
}
|
|
635
|
+
for (const path of /** @type {unknown[]} */ (origin.paths)) validPath(path, `${label}.${kind}[${index}].paths`);
|
|
636
|
+
if (!origin.paths.includes(origin.literal)) throw new TypeError(`${label}.${kind}[${index}] must include its literal path`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|