@rightkit/release 0.6.13 → 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/rightkit-gate.mjs +1193 -337
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +112 -26
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +161 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/pipeline-receipts.mjs +302 -302
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +15 -11
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/broker-wire.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The wire limits of the RustRight broker protocol and the gate's handling of what crosses them (GB-53; BROKER-CONTRACT section 14).
|
|
2
|
+
//
|
|
3
|
+
// Both limits are 1 MiB, NEWLINE INCLUDED, in ONE unit: MAX_REQUEST_BYTES (a request line) and MAX_BROKER_LINE_BYTES (a response line). The longest body is
|
|
4
|
+
// therefore 1 MiB - 1 bytes. A request longer than that is refused by the client before it connects (`request exceeds input cap`) and by the broker with
|
|
5
|
+
// RESULT REJECTED (exit 125), so no job exists. The broker never sends a response line over the limit: a non-terminal event that would be longer is
|
|
6
|
+
// replaced by {"type":"FRAME_OVER_CAP","for":<type>,"bytes":<n>,"limit":1048576}; a terminal RESULT that would be longer by {"type":"RESULT",...,"status":"FAILED",
|
|
7
|
+
// "code":"RK_RESPONSE_OVER_CAP",...}. A consumer that captures output treats FRAME_OVER_CAP as TRUNCATION: it refuses (or verifies the complete output log named by the
|
|
8
|
+
// receipt's diagnosticsPath); it never reads a result that lost its middle as complete. The receipt and the evidence files stay readable.
|
|
9
|
+
export const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
10
|
+
export const MAX_BROKER_LINE_BYTES = 1024 * 1024;
|
|
11
|
+
export const FRAME_OVER_CAP = "FRAME_OVER_CAP";
|
|
12
|
+
export const RESPONSE_OVER_CAP_CODE = "RK_RESPONSE_OVER_CAP";
|
|
13
|
+
export const REQUEST_OVER_CAP_CODE = "RK_REQUEST_OVER_CAP";
|
|
14
|
+
/**
|
|
15
|
+
* What the gate reserves for the request line beside a carrier's own bytes (the client's envelope: command, argv, environment names, protocol, runtime, ids,
|
|
16
|
+
* the managed-environment record). A deliberately generous bound, so a request the gate lets through is never one the client would refuse for size.
|
|
17
|
+
*/
|
|
18
|
+
export const REQUEST_ENVELOPE_RESERVE_BYTES = 64 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Whether a line of `bodyBytes` bytes (without its newline) fits a limit: the newline is counted, so the longest body is limit - 1. */
|
|
21
|
+
export const lineFits = (bodyBytes, limit = MAX_BROKER_LINE_BYTES) => Number.isInteger(bodyBytes) && bodyBytes >= 0 && bodyBytes + 1 <= limit;
|
|
22
|
+
|
|
23
|
+
/** A refusal reason when a request whose carrier is `carrierBytes` long, plus the envelope reserve, cannot fit one request line; else null. */
|
|
24
|
+
export function requestOverCap(carrierBytes, { reserve = REQUEST_ENVELOPE_RESERVE_BYTES } = {}) {
|
|
25
|
+
return lineFits(carrierBytes + reserve) ? null : `the request is ${carrierBytes} bytes of declared inputs and identity plus a ${reserve}-byte envelope, more than the ${MAX_REQUEST_BYTES}-byte request line (the newline counts); the client refuses it before it connects and no job would exist`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Why one decoded response event is a truncation or an over-cap outcome (`FRAME_OVER_CAP`, a terminal `RK_RESPONSE_OVER_CAP`), or null. */
|
|
29
|
+
export function overCapProblem(event) {
|
|
30
|
+
if (!event || typeof event !== "object") return null;
|
|
31
|
+
if (event.type === FRAME_OVER_CAP) return { code: "frame-over-cap", message: `the broker replaced a ${event.for ?? "response"} frame of ${event.bytes ?? "?"} bytes with ${FRAME_OVER_CAP} (limit ${event.limit ?? MAX_BROKER_LINE_BYTES}): the captured output lost that frame, so it is truncated and refused; the complete output log named by the receipt's diagnosticsPath is the evidence` };
|
|
32
|
+
if (event.code === RESPONSE_OVER_CAP_CODE) return { code: "response-over-cap", message: `the broker's terminal result exceeded the ${MAX_BROKER_LINE_BYTES}-byte line limit and was replaced by ${RESPONSE_OVER_CAP_CODE} (original status ${event.originalStatus ?? "unknown"}, ${event.bytes ?? "?"} bytes): a typed failure, never a success; the receipt and evidence files stay readable on the host` };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses captured client output as RustRight's client reads the wire: every complete newline-terminated frame is drained and checked against the limit FIRST, and
|
|
38
|
+
* only then is the one incomplete frame still waiting for its newline bounded (a frame already at the limit without a newline cannot fit it). A limit-sized frame
|
|
39
|
+
* followed by the start of the next one in the same read is valid. Returns `{ frames: [text], overCap: [event], error: string | null }`; lines that are not JSON
|
|
40
|
+
* objects (cargo's own output) are kept as frames and never judged.
|
|
41
|
+
*/
|
|
42
|
+
export function drainFrames(text, limit = MAX_BROKER_LINE_BYTES) {
|
|
43
|
+
const frames = []; const overCap = [];
|
|
44
|
+
let rest = Buffer.from(String(text), "utf8");
|
|
45
|
+
for (;;) {
|
|
46
|
+
const at = rest.indexOf(0x0a);
|
|
47
|
+
if (at < 0) break;
|
|
48
|
+
const line = rest.subarray(0, at + 1);
|
|
49
|
+
rest = rest.subarray(at + 1);
|
|
50
|
+
const protocolLike = line[0] === 0x7b; // `{`: a protocol frame; a tool's own long output line is not a wire frame and is never judged here
|
|
51
|
+
if (protocolLike && line.length > limit) return { frames, overCap, error: `a frame of ${line.length} bytes (newline included) exceeds the ${limit}-byte limit` };
|
|
52
|
+
const body = line.subarray(0, line.length - 1).toString("utf8");
|
|
53
|
+
frames.push(body);
|
|
54
|
+
if (body.startsWith("{")) { try { const event = JSON.parse(body); if (overCapProblem(event)) overCap.push(event); } catch { /* not a protocol frame */ } }
|
|
55
|
+
}
|
|
56
|
+
if (rest[0] === 0x7b && rest.length >= limit) return { frames, overCap, error: `an incomplete frame of ${rest.length} bytes has no newline within the ${limit}-byte limit` };
|
|
57
|
+
if (rest.length > 0) frames.push(rest.toString("utf8"));
|
|
58
|
+
return { frames, overCap, error: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- GB-84: the typed diagnosis of one STATUS or RESULT reply (rustright_protocol::diagnose_control_reply, vector control-status-vectors.v1.json) ----
|
|
62
|
+
const TERMINAL_STATES = new Set(["SUCCEEDED", "FAILED", "CANCELLED", "TIMED_OUT", "REJECTED"]);
|
|
63
|
+
const REFUSAL_CLASSES = Object.freeze({ RK_ADMISSION_PAUSED: "paused", RK_JOB_SUSPENDED: "suspended", RK_JOB_NOT_FOUND: "not-found", RK_ACTIVATION_PENDING: "activation-pending", RK_CUTOVER_DRAIN_PENDING: "cutover-drain" });
|
|
64
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
+
|
|
66
|
+
// ---- the typed refusals of BROKER-CONTRACT section 16: each its own diagnosis, and whether waiting clears it ----
|
|
67
|
+
// RK_BROKER_BUSY GB-52 every pipe instance stayed busy: the connection never opened, no byte was sent (a wait, not an outage) retryable
|
|
68
|
+
// RK_RECOVERY_PENDING GB-78 restart recovery of the workspace is still running after the bounded wait: nothing was admitted retryable
|
|
69
|
+
// RK_STORAGE_BUDGET_UNAVAILABLE GB-39 the storage budget could not be settled within bounds.budgetAdmissionWaitMs: the root was not admitted retryable
|
|
70
|
+
// RK_ADAPTER_PIN_REQUIRED GB-51 a Gradle wrapper without a pinned distributionSha256Sum: START failed before any spawn not retryable (the commit must pin it)
|
|
71
|
+
// RK_OPERATION_DEADLINE GB-52 the client's operation deadline passed: it cancelled by id and watched for the terminal state not retryable (never resubmitted)
|
|
72
|
+
// A retryable refusal is a wait the gate makes within its own deadline (each retry a new carrier and sub-handle, a refused submission being a settled, spawned:false member); it is never
|
|
73
|
+
// success and never a generic failure. `refusal` is the class name a diagnosis carries.
|
|
74
|
+
export const TYPED_REFUSALS = Object.freeze({
|
|
75
|
+
RK_BROKER_BUSY: Object.freeze({ refusal: "broker-busy", retryable: true }),
|
|
76
|
+
RK_RECOVERY_PENDING: Object.freeze({ refusal: "recovery-pending", retryable: true }),
|
|
77
|
+
RK_STORAGE_BUDGET_UNAVAILABLE: Object.freeze({ refusal: "storage-budget-unavailable", retryable: true }),
|
|
78
|
+
RK_ADAPTER_PIN_REQUIRED: Object.freeze({ refusal: "adapter-pin-required", retryable: false }),
|
|
79
|
+
RK_OPERATION_DEADLINE: Object.freeze({ refusal: "operation-deadline", retryable: false }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The typed refusal one reply names, or null: the event's (or its receipt's) `code`, else a message that STARTS with the code (`RK_RECOVERY_PENDING: restart recovery ...`, which is how the
|
|
84
|
+
* broker words them). A code merely mentioned inside other text is not a refusal. `{ code, refusal, retryable }`.
|
|
85
|
+
*/
|
|
86
|
+
export function typedRefusalOf(...sources) {
|
|
87
|
+
for (const source of sources) {
|
|
88
|
+
if (!isObject(source)) continue;
|
|
89
|
+
const named = typeof source.code === "string" && Object.hasOwn(TYPED_REFUSALS, source.code) ? source.code : null;
|
|
90
|
+
const prefixed = named === null ? ["error", "message"].map((key) => (typeof source[key] === "string" ? /^\s*(?:error:\s*)?(RK_[A-Z0-9_]+)\b/.exec(source[key])?.[1] : null)).find((code) => code && Object.hasOwn(TYPED_REFUSALS, code)) ?? null : null;
|
|
91
|
+
const code = named ?? prefixed;
|
|
92
|
+
if (code !== null) return { code, ...TYPED_REFUSALS[code] };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a client concludes from one STATUS or RESULT line about a request it knows by id. Only `terminal` is a build outcome, and only after the receipt's own signed
|
|
99
|
+
* validation; every other kind keeps the broker's code, state and identity instead of collapsing into a generic receipt failure, and a known request is reconciled by
|
|
100
|
+
* its durable id, never resubmitted because a diagnosis improved:
|
|
101
|
+
* { kind: "live", id, state } non-terminal with an absent or null receipt (pending; never a result, never green)
|
|
102
|
+
* { kind: "terminal", id, state, receipt } a terminal state with a receipt object whose id is the request's
|
|
103
|
+
* { kind: "unavailable", id, reason } the front gate's STATUS with state UNAVAILABLE and its diagnostic code
|
|
104
|
+
* { kind: "refused", code, refusal, message } a RESULT with status REJECTED: paused, suspended, not-found, activation-pending, cutover-drain, other
|
|
105
|
+
* { kind: "malformed", message } not a typed object, another request's id or receipt, no state, a terminal state without a receipt, a receipt that is not an object
|
|
106
|
+
*/
|
|
107
|
+
export function diagnoseControlReply(event, expectedId = null) {
|
|
108
|
+
const malformed = (message) => ({ kind: "malformed", message });
|
|
109
|
+
if (!isObject(event) || typeof event.type !== "string") return malformed("the reply is not a typed object");
|
|
110
|
+
const id = typeof event.id === "string" ? event.id : null;
|
|
111
|
+
if (expectedId !== null && id !== null && expectedId !== id) return malformed("the reply names another request");
|
|
112
|
+
const receiptOf = (state) => {
|
|
113
|
+
const receipt = event.receipt;
|
|
114
|
+
if (isObject(receipt)) {
|
|
115
|
+
const receiptId = typeof receipt.id === "string" ? receipt.id : null;
|
|
116
|
+
const want = expectedId ?? id;
|
|
117
|
+
if (want !== null && receiptId !== null && want !== receiptId) return malformed("the receipt is for another request");
|
|
118
|
+
if (want !== null && receiptId === null) return malformed("the receipt names no request");
|
|
119
|
+
// A root the broker refused before it ran (a storage budget that could not be settled, a recovery still running, a wrapper without a pinned distribution) ends FAILED with its code:
|
|
120
|
+
// it is the typed refusal, never a receipt to validate and never a build outcome. Only a failed or rejected state is read so; a SUCCEEDED receipt is never turned into a refusal.
|
|
121
|
+
const typed = state === "FAILED" || state === "REJECTED" ? typedRefusalOf(receipt, { error: event.error }) : null;
|
|
122
|
+
if (typed) return { kind: "refused", id, state, code: typed.code, refusal: typed.refusal, retryable: typed.retryable, message: typeof receipt.error === "string" ? receipt.error : typeof event.error === "string" ? event.error : "" };
|
|
123
|
+
return { kind: "terminal", id, state, receipt };
|
|
124
|
+
}
|
|
125
|
+
if (receipt === undefined || receipt === null) return TERMINAL_STATES.has(state) ? malformed("a finished job's reply carries no receipt") : { kind: "live", id, state };
|
|
126
|
+
return malformed("the receipt is not an object");
|
|
127
|
+
};
|
|
128
|
+
if (event.type === "STATUS") {
|
|
129
|
+
if (typeof event.state !== "string" || event.state === "") return malformed("the status carries no state");
|
|
130
|
+
if (event.state === "UNAVAILABLE") {
|
|
131
|
+
const reason = (isObject(event.diagnostic) && typeof event.diagnostic.code === "string" ? event.diagnostic.code : null) ?? (typeof event.code === "string" ? event.code : null) ?? "unavailable";
|
|
132
|
+
return { kind: "unavailable", id, reason };
|
|
133
|
+
}
|
|
134
|
+
return receiptOf(event.state);
|
|
135
|
+
}
|
|
136
|
+
if (event.type === "RESULT") {
|
|
137
|
+
if (event.status === "REJECTED") {
|
|
138
|
+
const code = typeof event.code === "string" ? event.code : null;
|
|
139
|
+
const typed = typedRefusalOf(event);
|
|
140
|
+
if (typed) return { kind: "refused", code: typed.code, refusal: typed.refusal, retryable: typed.retryable, message: typeof event.error === "string" ? event.error : "" };
|
|
141
|
+
return { kind: "refused", code, refusal: (code !== null && Object.hasOwn(REFUSAL_CLASSES, code) ? REFUSAL_CLASSES[code] : "other"), retryable: false, message: typeof event.error === "string" ? event.error : "" };
|
|
142
|
+
}
|
|
143
|
+
return typeof event.status === "string" && TERMINAL_STATES.has(event.status) ? receiptOf(event.status) : malformed("the result carries no terminal status");
|
|
144
|
+
}
|
|
145
|
+
return malformed("the reply is not a STATUS or RESULT");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- GB-54: pause and resume in the typed protocol (BROKER-CONTRACT section 14) ----
|
|
149
|
+
// `PAUSE_ADMISSION` and `RESUME_ADMISSION` are variants of RustRight's `BrokerRequest` and `ADMISSION` a variant of `BrokerEvent`, defined once in rustright-protocol
|
|
150
|
+
// and answered by the broker's front gate (authentication unchanged, whether or not the job service is up). Wire:
|
|
151
|
+
// {"type":"PAUSE_ADMISSION","protocol","runtime","reason"?:string,"holder"?:{"pid":u32,"startTimeMs"?:u64}}
|
|
152
|
+
// {"type":"RESUME_ADMISSION","protocol","runtime"}
|
|
153
|
+
// reply {"type":"ADMISSION","paused":bool,"reason"?,"holder"?,"persistError"?}
|
|
154
|
+
// Compatibility: unknown optional fields are ignored; a `reason` or `holder` of the wrong shape is refused (it used to be dropped silently, leaving a pause without its holder).
|
|
155
|
+
export const PAUSE_ADMISSION = "PAUSE_ADMISSION";
|
|
156
|
+
export const RESUME_ADMISSION = "RESUME_ADMISSION";
|
|
157
|
+
export const ADMISSION_EVENT = "ADMISSION";
|
|
158
|
+
export const ADMISSION_PAUSED_CODE = "RK_ADMISSION_PAUSED";
|
|
159
|
+
const isU32 = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
|
|
160
|
+
const isUint = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
161
|
+
|
|
162
|
+
const holderProblem = (holder) => {
|
|
163
|
+
if (!isObject(holder) || !isU32(holder.pid)) return "holder must be {pid: u32, startTimeMs?: u64}";
|
|
164
|
+
if (holder.startTimeMs !== undefined && !isUint(holder.startTimeMs)) return "holder.startTimeMs must be a non-negative integer";
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Parses one admission control request as RustRight's canonical codec does: `{ ok: true, request }` (known fields only) or `{ ok: false, message }`; nothing changes on a refusal. */
|
|
169
|
+
export function parseAdmissionRequest(value) {
|
|
170
|
+
if (!isObject(value) || (value.type !== PAUSE_ADMISSION && value.type !== RESUME_ADMISSION)) return { ok: false, message: "not a PAUSE_ADMISSION or RESUME_ADMISSION request" };
|
|
171
|
+
if (typeof value.protocol !== "string" || value.protocol === "") return { ok: false, message: "protocol is required" };
|
|
172
|
+
if (!isObject(value.runtime)) return { ok: false, message: "runtime identity is required" };
|
|
173
|
+
const request = { type: value.type, protocol: value.protocol, runtime: value.runtime };
|
|
174
|
+
if (value.type === PAUSE_ADMISSION) {
|
|
175
|
+
if (value.reason !== undefined && value.reason !== null) { if (typeof value.reason !== "string") return { ok: false, message: "reason must be a string" }; request.reason = value.reason; }
|
|
176
|
+
if (value.holder !== undefined && value.holder !== null) { const problem = holderProblem(value.holder); if (problem) return { ok: false, message: problem }; request.holder = { pid: value.holder.pid, ...(value.holder.startTimeMs !== undefined ? { startTimeMs: value.holder.startTimeMs } : {}) }; }
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, request };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The wire request a gate-side caller builds: the same function the parser judges, so a request that is built is a request that parses. */
|
|
182
|
+
export function admissionRequest(type, { protocol, runtime, reason, holder } = {}) {
|
|
183
|
+
const parsed = parseAdmissionRequest({ type, protocol, runtime, ...(type === PAUSE_ADMISSION ? { reason, holder } : {}) });
|
|
184
|
+
if (!parsed.ok) throw Object.assign(new Error(`${type}: ${parsed.message}`), { code: "RK_ADMISSION_REQUEST_INVALID" });
|
|
185
|
+
return parsed.request;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The `ADMISSION` reply: `{ ok: true, paused, reason?, holder?, persistError? }` or `{ ok: false, message }`. `persistError` means the pause holds for this run but could not be written to survive a restart. */
|
|
189
|
+
export function parseAdmissionEvent(event) {
|
|
190
|
+
if (!isObject(event) || event.type !== ADMISSION_EVENT) return { ok: false, message: "not an ADMISSION event" };
|
|
191
|
+
if (typeof event.paused !== "boolean") return { ok: false, message: "ADMISSION carries no boolean paused" };
|
|
192
|
+
if (event.holder !== undefined && holderProblem(event.holder)) return { ok: false, message: holderProblem(event.holder) };
|
|
193
|
+
for (const name of ["reason", "persistError"]) if (event[name] !== undefined && typeof event[name] !== "string") return { ok: false, message: `${name} must be a string` };
|
|
194
|
+
return { ok: true, paused: event.paused, ...(event.reason !== undefined ? { reason: event.reason } : {}), ...(event.holder !== undefined ? { holder: event.holder } : {}), ...(event.persistError !== undefined ? { persistError: event.persistError } : {}) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The typed refusal the client's captured output carries, if it carries one: a `RESULT` with status `REJECTED` (diagnoseControlReply), for example `RK_ADMISSION_PAUSED`
|
|
199
|
+
* (exit 125: admission is paused and NOTHING was spawned). `{ code, refusal, message }` or null. Never a build outcome.
|
|
200
|
+
*/
|
|
201
|
+
export function refusalInCapturedOutput(text) {
|
|
202
|
+
for (const frame of drainFrames(String(text ?? "")).frames) {
|
|
203
|
+
if (!frame.startsWith("{")) continue;
|
|
204
|
+
let event; try { event = JSON.parse(frame); } catch { continue; }
|
|
205
|
+
const diagnosis = diagnoseControlReply(event);
|
|
206
|
+
if (diagnosis.kind === "refused") return { code: diagnosis.code, refusal: diagnosis.refusal, retryable: diagnosis.retryable === true, message: diagnosis.message };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- GB-52: an end-to-end operation deadline and explicit cancellation for carried calls (BROKER-CONTRACT section 16) ----
|
|
212
|
+
// RustRight's client bounds one submit-and-watch operation with RUSTRIGHT_OPERATION_DEADLINE_MS (absent or 0: none; a user-set test cap or stall limit is never replaced
|
|
213
|
+
// by a universal duration). Past it the client does not abandon or resubmit the job: it sends CANCEL by id and polls STATUS for the terminal state within
|
|
214
|
+
// RUSTRIGHT_CANCEL_WAIT_MS (default 30000), failing with `operation deadline of N ms passed; request <id>: cancelConfirmed: <bool>, terminal state: <state | not observed>`.
|
|
215
|
+
// An unobserved state keeps ownership unknown. `RK_BROKER_BUSY` (every pipe instance busy: a wait) is distinct from `RK_BROKER_UNAVAILABLE`.
|
|
216
|
+
export const OPERATION_DEADLINE_ENV = "RUSTRIGHT_OPERATION_DEADLINE_MS";
|
|
217
|
+
export const CANCEL_WAIT_ENV = "RUSTRIGHT_CANCEL_WAIT_MS";
|
|
218
|
+
export const DEFAULT_CANCEL_WAIT_MS = 30_000;
|
|
219
|
+
/** The gate's remaining stage budget, as an absolute epoch-ms instant a stage sets for its children (unset: no budget). */
|
|
220
|
+
export const STAGE_DEADLINE_AT_ENV = "RK_GATE_STAGE_DEADLINE_AT";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The environment delta that gives one carried call its operation deadline: the caller's own RUSTRIGHT_OPERATION_DEADLINE_MS is never replaced (a user-set cap wins);
|
|
224
|
+
* otherwise `deadlineMs` (a scan's or stage's own limit) or the time left to `deadlineAt` (STAGE_DEADLINE_AT_ENV in `env`), at least 1 ms. `{}` when there is no budget.
|
|
225
|
+
*/
|
|
226
|
+
export function operationDeadlineEnv({ env = {}, deadlineMs = null, deadlineAt = null, now = Date.now() } = {}) {
|
|
227
|
+
const set = Object.entries(env).find(([key]) => key.toUpperCase() === OPERATION_DEADLINE_ENV);
|
|
228
|
+
if (set && String(set[1]).trim() !== "") return {};
|
|
229
|
+
const at = deadlineAt ?? (Number.isFinite(Number(env[STAGE_DEADLINE_AT_ENV])) && Number(env[STAGE_DEADLINE_AT_ENV]) > 0 ? Number(env[STAGE_DEADLINE_AT_ENV]) : null);
|
|
230
|
+
const budget = deadlineMs ?? (at !== null ? at - now : null);
|
|
231
|
+
if (!Number.isFinite(budget) || budget === null) return {};
|
|
232
|
+
return { [OPERATION_DEADLINE_ENV]: String(Math.max(1, Math.trunc(budget))) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The outer guard on the client process: the deadline, the cancel wait and a margin, so the client's own cancel and terminal-state wait finish before the gate kills it. */
|
|
236
|
+
export function clientGuardMs(env, { margin = 15_000 } = {}) {
|
|
237
|
+
const deadline = Number(env?.[OPERATION_DEADLINE_ENV]);
|
|
238
|
+
if (!Number.isFinite(deadline) || deadline <= 0) return null;
|
|
239
|
+
const wait = Number(env?.[CANCEL_WAIT_ENV]);
|
|
240
|
+
return deadline + (Number.isFinite(wait) && wait >= 0 ? wait : DEFAULT_CANCEL_WAIT_MS) + margin;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** What the client reported when its operation deadline passed, or null: `{ code, deadlineMs, requestId, cancelConfirmed, terminalState }`; busy and unavailable are named apart. */
|
|
244
|
+
export function operationOutcomeInOutput(text) {
|
|
245
|
+
const t = String(text ?? "");
|
|
246
|
+
const deadline = /operation deadline of (\d+) ms passed; request ([0-9A-Za-z._-]+): cancelConfirmed: (true|false), terminal state: (not observed|[A-Z_]+)/.exec(t);
|
|
247
|
+
if (deadline) return { code: "operation-deadline", retryable: false, deadlineMs: Number(deadline[1]), requestId: deadline[2], cancelConfirmed: deadline[3] === "true", terminalState: deadline[4].trim().startsWith("not observed") ? null : deadline[4].trim() };
|
|
248
|
+
if (/RK_OPERATION_DEADLINE/.test(t)) return { code: "operation-deadline", retryable: false, deadlineMs: null, requestId: null, cancelConfirmed: null, terminalState: null };
|
|
249
|
+
if (/RK_BROKER_BUSY/.test(t)) return { code: "broker-busy", retryable: true };
|
|
250
|
+
if (/RK_BROKER_UNAVAILABLE/.test(t)) return { code: "broker-unavailable", retryable: false };
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- retrying what waiting clears (GB-52, GB-78, GB-39) ----
|
|
255
|
+
/** The outcome codes (snapshot-broker.mjs) of the refusals that clear on their own: the gate waits and retries these within its deadline. */
|
|
256
|
+
export const RETRYABLE_OUTCOME_CODES = Object.freeze(new Set(["broker-busy", "broker-refused-recovery-pending", "broker-refused-storage-budget-unavailable"]));
|
|
257
|
+
/** How long a retrying call keeps waiting when it was given no deadline of its own (recovery's bounded wait is 120 s; the budget gate's 600 s is the broker's own). */
|
|
258
|
+
export const DEFAULT_RETRY_WINDOW_MS = 180_000;
|
|
259
|
+
/** The pause before retry number `attempt` (1 for the first): 250 ms doubling to 5 s. */
|
|
260
|
+
export const retryPauseMs = (attempt) => Math.min(5000, 250 * 2 ** Math.max(0, attempt - 1));
|
|
261
|
+
/** A synchronous sleep (the carried call is synchronous): never busy-waits. */
|
|
262
|
+
export function sleepSync(ms) { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|