@rightkit/release 0.6.16 → 0.6.20

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.
Files changed (64) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +247 -40
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +107 -9
  52. package/snapshot-check.mjs +2 -0
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar.mjs +17 -3
  58. package/suite-signing.mjs +40 -4
  59. package/swift-lock.mjs +334 -0
  60. package/test-command.mjs +13 -3
  61. package/tool-build.mjs +156 -0
  62. package/unit-gate.mjs +9 -2
  63. package/windows-identity-plan.mjs +119 -0
  64. package/windows-identity.mjs +136 -0
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // rightkit-linux-setup [--root <app>] [--dry-run]: provision a Linux host (cloud container, VM, WSL2) for `rightkit-host-gate`.
3
+ import { spawnSync } from "../background-process.mjs";
4
+ import { setup } from "../linux-setup.mjs";
5
+
6
+ const args = process.argv.slice(2);
7
+ if (args.includes("--version")) { console.log("rightkit-linux-setup (@rightkit/release)"); process.exit(0); }
8
+ if (process.platform !== "linux") { console.error("rightkit-linux-setup runs on Linux only"); process.exit(2); }
9
+ const at = args.indexOf("--root");
10
+ const run = (cmd, a, o) => spawnSync(cmd, a, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], ...o });
11
+ const fetchText = (url) => { const r = run("curl", ["-fsSL", url]); if (r.status !== 0) throw new Error(`fetch ${url} failed`); return r.stdout; };
12
+ try {
13
+ const r = setup({ root: at >= 0 ? args[at + 1] : process.cwd(), run, fetchText, dryRun: args.includes("--dry-run"), log: (m) => console.error(`[linux-setup] ${m}`) });
14
+ if (args.includes("--dry-run")) console.log(r.steps.join("\n"));
15
+ } catch (e) { console.error(`rightkit-linux-setup: ${e.message}`); process.exit(1); }
@@ -0,0 +1,184 @@
1
+ // The live place of a carried scan in the broker's queue (rightkit#1367 step 2).
2
+ //
3
+ // The broker, not the gate, decides how many builds run (Adrian, 2026-11-01), so a carried scan can wait in the broker's queue. That wait is not the
4
+ // scan's own run, so the scan's run limit is a stopwatch that does not tick while the broker holds every request of the scan QUEUED. The gate asks the
5
+ // broker (`rustright status <id>`: BROKER-CONTRACT BR7 reports live broker state in every status answer, state QUEUED until a permit is held, then RUNNING)
6
+ // about the requests the scan has made. It gives queue time no allowance of its own: how long a job may stay queued is the broker's rule (BR7 reports a queued job past
7
+ // its deadline with the holder that blocks it). The one bound the gate does add is a wall-clock ceiling (QUEUED_CEILING_FACTOR x the scan's limit) that is not a build
8
+ // count: a safety stop for a wedged broker queue (the broker's job deadlines are off by default, so nothing else would end a scan stuck QUEUED).
9
+ //
10
+ // What the client does not offer: its operation deadline (RUSTRIGHT_OPERATION_DEADLINE_MS, client.rs, BROKER-CONTRACT GB-52) is an absolute deadline from the
11
+ // client's start, so it would charge queue time to the scan. A watched scan therefore sends no operation deadline; at the end of its own run time the gate
12
+ // cancels the requests by id (`rustright cancel <id>`) and stops the process tree only after a short grace.
13
+ //
14
+ // Fail closed: a request the gate cannot read (no request id yet, a status that fails or is malformed, a state other than QUEUED) is counted as running, so
15
+ // an unreadable broker never makes a scan immortal.
16
+ import fs from "node:fs";
17
+ import { spawnBrokerClient } from "./broker-client.mjs";
18
+ import { diagnoseControlReply } from "./broker-wire.mjs";
19
+ import { readRequestRef } from "./snapshot-request.mjs";
20
+
21
+ /** How long a cancelled carried scan may take to end by itself before its process tree is stopped. */
22
+ export const CARRIED_CANCEL_GRACE_MS = 15_000;
23
+ /** How often the gate asks the broker for the state of its requests (the first read comes after 1 s, then every 5 s). */
24
+ export const ADMISSION_POLL_MS = 5_000;
25
+ const FIRST_POLL_MS = 1_000;
26
+ /** How many `rustright status` reads the gate has in flight at once, however many scans it watches. */
27
+ export const STATUS_POLL_CONCURRENCY = 2;
28
+ /**
29
+ * A watched scan is stopped when its WALL-CLOCK time (run time plus queue time) reaches its own run limit times this factor. It is a safety stop for a wedged broker queue,
30
+ * not a count: the broker decides how many builds run and in what order, and its job deadlines are off by default, so without this a scan stuck QUEUED would wait forever.
31
+ */
32
+ export const QUEUED_CEILING_FACTOR = 4;
33
+
34
+ /**
35
+ * A run stopwatch: `onExpire` fires once, when `limitMs` of RUNNING time has passed. `pause()` stops it (the scan is queued at the broker) and `resume()`
36
+ * restarts it with the time left; `stop()` ends it for good. Time spent paused is `queuedMs`.
37
+ * With `ceilingMs` (a wall-clock bound that includes the paused time) `onCeiling` fires once when it passes before the stopwatch expired or stopped, and `ceilingHit` is true.
38
+ */
39
+ export function createRunClock({ limitMs, onExpire, ceilingMs = null, onCeiling = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, now = Date.now }) {
40
+ let remaining = Math.max(0, Number(limitMs) || 0);
41
+ let handle = null; let since = 0; let pausedAt = null; let ceilingHandle = null;
42
+ let expired = false; let stopped = false; let queuedMs = 0; let ceilingHit = false;
43
+ const clearCeiling = () => { if (ceilingHandle !== null) { clearTimer(ceilingHandle); ceilingHandle = null; } };
44
+ const arm = () => {
45
+ since = now();
46
+ handle = setTimer(() => { handle = null; expired = true; clearCeiling(); onExpire(); }, remaining);
47
+ };
48
+ arm();
49
+ if (ceilingMs !== null && Number.isFinite(ceilingMs) && ceilingMs > 0) {
50
+ ceilingHandle = setTimer(() => {
51
+ ceilingHandle = null;
52
+ if (stopped || expired) return;
53
+ ceilingHit = true; expired = true;
54
+ if (handle !== null) { clearTimer(handle); handle = null; }
55
+ if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; }
56
+ onCeiling();
57
+ }, ceilingMs);
58
+ ceilingHandle?.unref?.();
59
+ }
60
+ return {
61
+ pause() { if (stopped || expired || handle === null) return; clearTimer(handle); handle = null; remaining = Math.max(0, remaining - (now() - since)); pausedAt = now(); },
62
+ resume() { if (stopped || expired || handle !== null) return; if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; } arm(); },
63
+ stop() { if (handle !== null) { clearTimer(handle); handle = null; } clearCeiling(); if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; } stopped = true; },
64
+ get expired() { return expired; },
65
+ get ceilingHit() { return ceilingHit; },
66
+ get queuedMs() { return queuedMs + (pausedAt !== null ? now() - pausedAt : 0); },
67
+ get running() { return handle !== null; },
68
+ get remainingMs() { return handle !== null ? Math.max(0, remaining - (now() - since)) : remaining; },
69
+ };
70
+ }
71
+
72
+ /** The live state word (`QUEUED`, `RUNNING`, ...) of one `rustright status` reply, or null when it is not a valid STATUS of this request. */
73
+ export function brokerStateOf(stdout, requestId) {
74
+ let doc;
75
+ try { doc = JSON.parse(String(stdout)); } catch { return null; }
76
+ const diagnosis = diagnoseControlReply(doc, requestId);
77
+ return diagnosis.kind === "live" || diagnosis.kind === "terminal" ? diagnosis.state : null;
78
+ }
79
+
80
+ /**
81
+ * The gate's reader of the broker's live state, through the RustRight client with an ASYNCHRONOUS spawn (nothing here blocks the gate's event loop):
82
+ * `state(id)` is the request's state word or null; `cancel(id)` is `rustright cancel <id>`.
83
+ */
84
+ export function createBrokerLive({ client, env = process.env, spawn, platform = process.platform }) {
85
+ const call = async (args) => {
86
+ try {
87
+ const r = await spawnBrokerClient(spawn, client, args, { encoding: "utf8", env, timeout: 30_000, maxBuffer: 1 << 24, windowsHide: true }, { platform });
88
+ return r && !r.error ? r : null;
89
+ } catch { return null; }
90
+ };
91
+ return {
92
+ async state(requestId) { const r = await call(["status", requestId]); return r ? brokerStateOf(r.stdout, requestId) : null; },
93
+ async cancel(requestId) { await call(["cancel", requestId]); },
94
+ };
95
+ }
96
+
97
+ /** The request ids the reference files name (a file not written yet, or already removed, names none). */
98
+ export function requestIdsOf(files) {
99
+ const ids = [];
100
+ for (const file of files) {
101
+ try { if (fs.existsSync(file)) ids.push(readRequestRef(file).requestId); } catch { /* not a complete reference yet */ }
102
+ }
103
+ return [...new Set(ids)];
104
+ }
105
+
106
+ /**
107
+ * ONE reader of the broker's live state per gate run, shared by every watched scan (rightkit#1367 step 2): a scan registers a member (`ids()` lists its request ids, `apply(states)` takes
108
+ * the answers) and the poller asks for each distinct id with at most `concurrency` reads in flight, the first read `firstMs` after the first member arrives and then every `intervalMs`
109
+ * while any member is registered. Passes never overlap. `pass(extra)` is one read pass (a member not registered can be passed in `extra`; tests use it). Status reads go to the client
110
+ * directly: they are not builds and are never recorded as such.
111
+ */
112
+ export function createStatusPoller({ live, intervalMs = ADMISSION_POLL_MS, firstMs = Math.min(FIRST_POLL_MS, intervalMs), concurrency = STATUS_POLL_CONCURRENCY, setTimer = setTimeout, clearTimer = clearTimeout }) {
113
+ const members = new Set();
114
+ let timer = null; let tail = Promise.resolve(); let passes = 0;
115
+ const width = Math.max(1, Math.trunc(Number(concurrency)) || 1);
116
+ async function readAll(extra) {
117
+ const snapshot = [...new Set([...members, ...extra])];
118
+ const wanted = [...new Set(snapshot.flatMap((m) => m.ids()))];
119
+ const states = new Map();
120
+ const queue = [...wanted];
121
+ await Promise.all(Array.from({ length: Math.min(width, queue.length) }, async () => {
122
+ while (queue.length) {
123
+ const id = queue.shift();
124
+ states.set(id, await Promise.resolve().then(() => live.state(id)).catch(() => null));
125
+ }
126
+ }));
127
+ for (const member of snapshot) if (members.has(member) || extra.includes(member)) member.apply(member.ids().map((id) => (states.has(id) ? states.get(id) : null)));
128
+ }
129
+ function pass(extra = []) {
130
+ passes += 1;
131
+ const run = tail.then(() => readAll(extra));
132
+ tail = run.catch(() => {}).finally(() => { passes -= 1; });
133
+ return run;
134
+ }
135
+ const schedule = (ms) => {
136
+ timer = setTimer(() => { timer = null; pass().catch(() => {}).finally(() => { if (members.size > 0 && timer === null) schedule(intervalMs); }); }, ms);
137
+ timer?.unref?.();
138
+ };
139
+ return {
140
+ pass,
141
+ /** Resolves when the passes started so far have ended. */
142
+ idle: () => tail,
143
+ /** Adds a member; returns the function that removes it. The poller stops its own timer when no member is left. */
144
+ register(member) {
145
+ members.add(member);
146
+ if (timer === null && passes === 0) schedule(firstMs);
147
+ return () => { members.delete(member); if (members.size === 0 && timer !== null) { clearTimer(timer); timer = null; } };
148
+ },
149
+ get members() { return members.size; },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Watches the requests one scan makes and keeps its `clock` honest: paused while every request the scan has is QUEUED at the broker, running otherwise.
155
+ * `refFiles()` lists the request reference files (RUSTRIGHT_REQUEST_ID_FILE, written by the client at submit) the scan's carried calls have; `live` is a
156
+ * createBrokerLive (or any object with `state(id)` and `cancel(id)`). The reads come from a shared `poller` (createStatusPoller, one per gate run); with none a private one is made
157
+ * from `live`. `poll()` is one pass (tests call it directly) and resolves to this scan's states; `start()` registers with the poller until `stop()`.
158
+ * `cancelKnown()` cancels, by id, every request seen so far.
159
+ */
160
+ export function watchBrokerAdmission({ clock, refFiles, live, poller = null, intervalMs = ADMISSION_POLL_MS, firstMs = Math.min(FIRST_POLL_MS, intervalMs), setTimer = setTimeout, clearTimer = clearTimeout }) {
161
+ let stopped = false; let unregister = null; let last = [];
162
+ const known = new Set();
163
+ const own = poller ?? createStatusPoller({ live, intervalMs, firstMs, setTimer, clearTimer });
164
+ const member = {
165
+ ids() { const ids = requestIdsOf(refFiles()); for (const id of ids) known.add(id); return ids; },
166
+ apply(states) {
167
+ last = states;
168
+ if (stopped) return;
169
+ if (states.length === 0) { clock.resume(); return; }
170
+ if (states.every((state) => state === "QUEUED")) clock.pause(); else clock.resume();
171
+ },
172
+ };
173
+ return {
174
+ async poll() { if (stopped) return null; await own.pass([member]); return last; },
175
+ done: () => own.idle(),
176
+ start() { if (!stopped && unregister === null) unregister = own.register(member); },
177
+ stop() { stopped = true; if (unregister) { unregister(); unregister = null; } },
178
+ known: () => [...known],
179
+ async cancelKnown() {
180
+ for (const id of requestIdsOf(refFiles())) known.add(id);
181
+ await Promise.allSettled([...known].map((id) => live.cancel(id)));
182
+ },
183
+ };
184
+ }
package/broker-wire.mjs CHANGED
@@ -33,11 +33,22 @@ export function overCapProblem(event) {
33
33
  return null;
34
34
  }
35
35
 
36
+ /** The broker's event types are upper-case words (`RESULT`, `JOB_EVENT`, `FRAME_OVER_CAP`, ...). */
37
+ const PROTOCOL_TYPE = /^[A-Z][A-Z0-9_]*$/;
38
+
39
+ /** Whether one line body is a broker protocol frame: a JSON object whose `type` is a protocol event type. */
40
+ function isProtocolFrame(body) {
41
+ try {
42
+ const value = JSON.parse(body);
43
+ return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.type === "string" && PROTOCOL_TYPE.test(value.type);
44
+ } catch { return false; }
45
+ }
46
+
36
47
  /**
37
48
  * 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
49
  * 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
50
  * 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.
51
+ * objects with a protocol `type` (cargo's own output, JSON included) are kept as frames and never judged.
41
52
  */
42
53
  export function drainFrames(text, limit = MAX_BROKER_LINE_BYTES) {
43
54
  const frames = []; const overCap = [];
@@ -47,8 +58,9 @@ export function drainFrames(text, limit = MAX_BROKER_LINE_BYTES) {
47
58
  if (at < 0) break;
48
59
  const line = rest.subarray(0, at + 1);
49
60
  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` };
61
+ // Only a protocol frame is judged: a JSON object whose `type` names a protocol event. A tool's own output line is never a wire frame, even when it is
62
+ // JSON: `cargo metadata` prints its whole document as one `{"packages":...}` line, megabytes on a large workspace (rightkit#1351).
63
+ if (line.length > limit && line[0] === 0x7b && isProtocolFrame(line.subarray(0, line.length - 1).toString("utf8"))) return { frames, overCap, error: `a frame of ${line.length} bytes (newline included) exceeds the ${limit}-byte limit` };
52
64
  const body = line.subarray(0, line.length - 1).toString("utf8");
53
65
  frames.push(body);
54
66
  if (body.startsWith("{")) { try { const event = JSON.parse(body); if (overCapProblem(event)) overCap.push(event); } catch { /* not a protocol frame */ } }
package/build-launch.mjs CHANGED
@@ -41,6 +41,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
41
41
  import path from "node:path";
42
42
  import { fileURLToPath } from "node:url";
43
43
  import { resolveExecutor } from "./gate-executor.mjs";
44
+ import { gateNoBroker } from "./managed-cargo.mjs";
44
45
  import { resolveExecutable } from "./admission-host.mjs";
45
46
  import { CARRIED_CARGO_MAP_ENV as CARRIED_MAP_ENV, carriedCargoMapOf, runCarriedAdapter } from "./carried-cargo.mjs";
46
47
  import { spawnSync } from "./background-process.mjs";
@@ -176,7 +177,9 @@ export function buildLaunch(command, args = [], host = {}) {
176
177
  const lookup = (cmd) => { const found = resolveExecutable(cmd, { env, hostPlatform: platform, exists: exists ?? (() => false) }); return found ? [found] : []; };
177
178
  // A snapshot stage never installs into the snapshot (the gate did): the step is a logged no-op, on every host.
178
179
  if (snapshotInstallSkip(command, args, env)) return { command: process.execPath, args: [CARRIED_ADAPTER_RUN, "--skip-install", ...args.map(String)] };
179
- const executor = resolveExecutor({ env, platform, exists, which: host.which ?? lookup, readFile: host.readFile ?? ((file) => readFileSync(file, "utf8")) });
180
+ // Inside a gate run outside the broker by Adrian's recorded exception (managed-cargo.mjs NO_BROKER_ENV, set only for that live door run's
181
+ // children), a child step (web-build's tsc, a native helper) takes the same direct route as the gate's own cargo children, not a refusal.
182
+ const executor = resolveExecutor({ env, platform, exists, which: host.which ?? lookup, readFile: host.readFile ?? ((file) => readFileSync(file, "utf8")), forceDirect: gateNoBroker(env) });
180
183
  if (executor.kind === "refuse") refuse(`${name(command)} ${args.join(" ")}: ${executor.problem}`);
181
184
  // A direct (Linux) host: the real toolchain, RustRight shim directories off PATH (`env`, to merge into the launch env); a configured
182
185
  // `rustright cargo|rustc|rustdoc …` runs that tool directly; any other `rustright` command has no direct form and is refused.
@@ -1,10 +1,12 @@
1
- // Host-wide build ownership (docs/GATE-CONTRACT.md §1 "Commit-only build", "Broker lease"; joint plan step 4, G1).
1
+ // Per-repository build ownership (docs/GATE-CONTRACT.md §1 "Commit-only build", "Broker lease"; joint plan step 4, G1; rightkit#1367).
2
2
  //
3
- // The gate acquires the host's build ownership BEFORE it syncs a snapshot and holds it until the stage completes, so no other
4
- // request can replace a snapshot while anything reads it. One owner per host, at ONE fixed location per host (`ownershipFile`:
5
- // `<managed root>/build-ownership/owner.json` on Mac & Dell, `~/.local/state/rightkit/build-ownership/owner.json` on Linux), never
6
- // derived from the snapshot root or from an environment variable: builds are one per host, and the snapshot's own sync lock only
7
- // covers the sync itself. The directory is mode 0700 and refused when group- or other-accessible (`ensureOwnershipDir`).
3
+ // The gate acquires the build ownership of the repository it is building BEFORE it syncs that repository's snapshot and holds it
4
+ // until the stage completes, so no other request can replace that snapshot while anything reads it. One owner per repository, at
5
+ // one fixed location per repository (`ownershipFile`: `<managed root>/build-ownership/<repository key>/owner.json` on Mac & Dell,
6
+ // `~/.local/state/rightkit/build-ownership/<repository key>/owner.json` on Linux), where the key is the snapshot directory's own
7
+ // key (`snapshot.repositoryOf(root).key`); never derived from the snapshot root or from an environment variable. Two repositories
8
+ // never contend: how many builds run, and in what order, is the broker's alone. The snapshot's own sync lock only covers the sync
9
+ // itself. `build-ownership/` and each repository directory are mode 0700 and refused when group- or other-accessible (`ensureOwnershipDir`).
8
10
  //
9
11
  // The owner record is written whole (create-exclusive) and names a fresh handle (the `ownership.handle` a broker request carries),
10
12
  // the gate's pid & start identity, the stage, snapshot & commit, how the gate's children are contained, and whether a broker
@@ -93,31 +95,36 @@ const sameStart = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs
93
95
  /** The directory beside the host's snapshots (or beside any injected root) that holds the owner record. */
94
96
  export const OWNERSHIP_DIRNAME = "build-ownership";
95
97
 
98
+ /** A repository key is one path segment: the snapshot directory's name (a hex digest), never a path. */
99
+ const REPOSITORY_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
100
+
96
101
  /**
97
- * Where the host's one owner record lives (GC:24 "the host's build ownership"; GC:52 "host-wide build ownership"; item 5 row 5.17):
98
- * ONE fixed location per host, independent of the snapshot root and of every environment variable. Two gates whose environments
99
- * choose different snapshot roots (XDG_STATE_HOME, a configured root) must still contend for one record, so nothing here reads
100
- * `process.env`, and nothing derives from where snapshots live.
101
- * Mac & Dell: `<managed root>/build-ownership/owner.json` (the host's fixed managed root, `/Volumes/D/.rightkit-managed`,
102
- * `D:\.rightkit-managed`).
103
- * Linux (WSL2 included): `<account home>/.local/state/rightkit/build-ownership/owner.json`, the account home taken from the
104
- * passwd database (`os.userInfo()`), never from $HOME.
105
- * `home` replaces the Linux account home and `beside` replaces the whole location (`<dirname(beside)>/build-ownership/owner.json`);
102
+ * Where a repository's owner record lives (GC:24 "the repository's build ownership"; item 5 row 5.17): ONE fixed location per
103
+ * repository, independent of the snapshot root and of every environment variable. Two gates for the same repository whose
104
+ * environments choose different snapshot roots (XDG_STATE_HOME, a configured root) must still contend for one record, so nothing
105
+ * here reads `process.env`, and nothing derives from where snapshots live. The only input besides the platform is `repository`,
106
+ * the repository key the snapshot directory uses (`snapshot.repositoryOf(root).key`); it is required.
107
+ * Mac & Dell: `<managed root>/build-ownership/<repository key>/owner.json` (the host's fixed managed root,
108
+ * `/Volumes/D/.rightkit-managed`, `D:\.rightkit-managed`).
109
+ * Linux (WSL2 included): `<account home>/.local/state/rightkit/build-ownership/<repository key>/owner.json`, the account home
110
+ * taken from the passwd database (`os.userInfo()`), never from $HOME.
111
+ * `home` replaces the Linux account home and `beside` replaces the whole location (`<dirname(beside)>/build-ownership/<key>/owner.json`);
106
112
  * both are injection points for fixtures and tests only. A production caller passes neither: the root a caller derived from the
107
113
  * environment must never be passed as `beside`, because that is the defect this row removes.
108
114
  */
109
- export function ownershipFile({ platform = process.platform, home, beside } = {}) {
115
+ export function ownershipFile({ platform = process.platform, home, beside, repository } = {}) {
116
+ if (typeof repository !== "string" || !REPOSITORY_KEY.test(repository)) throw new TypeError(`ownershipFile: repository must be the repository key (one path segment), not ${JSON.stringify(repository)}`);
110
117
  const paths = platform === "win32" ? path.win32 : path.posix;
111
118
  if (beside !== undefined) {
112
119
  if (typeof beside !== "string" || beside === "") throw new TypeError(`ownershipFile: beside must be a path (${JSON.stringify(beside)})`);
113
- return paths.join(paths.dirname(beside), OWNERSHIP_DIRNAME, "owner.json");
120
+ return paths.join(paths.dirname(beside), OWNERSHIP_DIRNAME, repository, "owner.json");
114
121
  }
115
- if (platform === "darwin" || platform === "win32") return paths.join(resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot, OWNERSHIP_DIRNAME, "owner.json");
122
+ if (platform === "darwin" || platform === "win32") return paths.join(resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot, OWNERSHIP_DIRNAME, repository, "owner.json");
116
123
  let base = home;
117
124
  if (base === undefined) {
118
- try { base = os.userInfo().homedir; } catch (e) { throw new OwnershipBusy(`build ownership: this account's home directory cannot be read (${e.message}), so the host's owner record has no location`); }
125
+ try { base = os.userInfo().homedir; } catch (e) { throw new OwnershipBusy(`build ownership: this account's home directory cannot be read (${e.message}), so the repository's owner record has no location`); }
119
126
  }
120
- return paths.join(base, ".local", "state", "rightkit", OWNERSHIP_DIRNAME, "owner.json");
127
+ return paths.join(base, ".local", "state", "rightkit", OWNERSHIP_DIRNAME, repository, "owner.json");
121
128
  }
122
129
 
123
130
  /**
@@ -140,13 +147,15 @@ function guardManagedRoot(file, { platform, managedRoot, device }) {
140
147
  }
141
148
 
142
149
  /**
143
- * Creates the ownership directory mode 0700 (one level on Mac & Dell) and refuses an existing POSIX one that grants group or other access (item 5 row 5.22):
150
+ * Creates the ownership directory mode 0700 (one level per directory on Mac & Dell) and refuses an existing POSIX one that grants group or other access (item 5 row 5.22):
144
151
  * another user's unreadable process is skipped by the nets on the premise that it cannot read this user's managed root, and a
145
152
  * directory other users could write would let them forge or remove the record. Windows has no mode bits; its directory ACL is the
146
153
  * same trust the record itself has.
147
154
  */
148
155
  export function ensureOwnershipDir(dir, { platform = process.platform } = {}) {
149
- // Mac & Dell: exactly ONE level, `build-ownership/` inside a managed root that already exists (decision D4: the gate never
156
+ // A repository directory sits inside `build-ownership/`, which gets the same mode rules (created first, then checked).
157
+ if (path.basename(path.dirname(dir)) === OWNERSHIP_DIRNAME) ensureOwnershipDir(path.dirname(dir), { platform });
158
+ // Mac & Dell: exactly ONE level per call, `build-ownership/` (then `<key>/`) inside a managed root that already exists (decision D4: the gate never
150
159
  // creates the managed root, and a recursive creation here would). Linux: the durable state directory under the account home is
151
160
  // created whole (GC:63).
152
161
  if (platform === "linux") fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -406,7 +415,7 @@ export function classifyHiddenProcesses({ procs, own = new Set(), uid, owner, re
406
415
  * The host's process facts the ownership rules rely on. Each answer is a fact or a throw; a throw means "unknown" and the
407
416
  * caller treats it as busy.
408
417
  */
409
- export function processProbe({ platform = process.platform, spawn = spawnSync, spawnHolder = spawnDetachedDefault, kill = process.kill.bind(process), readFile = fs.readFileSync, readdir = fs.readdirSync, readlink = fs.readlinkSync, exists: procExists = fs.existsSync, proc = "/proc", uid = process.getuid?.() } = {}) {
418
+ export function processProbe({ platform = process.platform, spawn = spawnSync, spawnHolder = spawnDetachedDefault, kill = process.kill.bind(process), readFile = fs.readFileSync, readdir = fs.readdirSync, readlink = fs.readlinkSync, exists: procExists = fs.existsSync, proc = "/proc", uid = process.getuid?.(), sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
410
419
  const exists = (pid) => {
411
420
  try { kill(pid, 0); return true; } catch (e) { if (e.code === "ESRCH") return false; if (e.code === "EPERM") return true; throw e; }
412
421
  };
@@ -556,6 +565,24 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
556
565
  if (startMs + START_SLACK_MS < skipBefore) return;
557
566
  throw unknown(`it started at ${new Date(startMs).toISOString()}, not before the baseline ${new Date(skipBefore).toISOString()} (the later of the last clean release's acquisition and the snapshot root's birth, else this ownership's own acquisition)`);
558
567
  };
568
+ // A process in the middle of execve is briefly non-dumpable (its new address space has no dumpable flag until the exec commits), so
569
+ // /proc/<pid>/{environ,cwd,fd,...} answer EACCES for a few milliseconds while its comm is still the parent's (`node-MainThread`)
570
+ // and its start is the fork's. Under concurrent test shards or any busy host, a scan meets such a process: it is no reader and not
571
+ // unknown, only not yet readable. So a read that would HOLD (a young, unreadable, live process of this user) is retried, bounded
572
+ // (SETTLE_TRIES x SETTLE_MS), before the hold stands; a process that stays unreadable (really non-dumpable) is held exactly as
573
+ // before, and a process that is old, gone, a zombie or another user's is judged at once, with no wait.
574
+ const SETTLE_TRIES = 20; const SETTLE_MS = 25;
575
+ /** `{ value }` of `op`, or `{ skipped: true }` when its failure is a process no reader (`unreadable` returned); throws what `unreadable` throws once the retries are spent. */
576
+ const settled = (pid, skipBefore, op) => {
577
+ for (let attempt = 0; ; attempt++) {
578
+ try { return { value: op() }; }
579
+ catch (e) {
580
+ if (!GONE.has(e.code) && !["EACCES", "EPERM"].includes(e.code)) throw e;
581
+ try { unreadable(pid, e, skipBefore); return { skipped: true }; }
582
+ catch (held) { if (attempt >= SETTLE_TRIES) throw held; sleep(SETTLE_MS); }
583
+ }
584
+ }
585
+ };
559
586
  return {
560
587
  platform,
561
588
  /** `{ alive, startedMs, group }` from `ps` (C locale). */
@@ -600,8 +627,9 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
600
627
  const needle = Buffer.from(`${token}\0`);
601
628
  const found = [];
602
629
  for (const pid of procPids()) {
603
- let environ;
604
- try { environ = readFile(`${proc}/${pid}/environ`); } catch (e) { unreadable(pid, e, skipBefore); continue; }
630
+ const got = settled(pid, skipBefore, () => readFile(`${proc}/${pid}/environ`));
631
+ if (got.skipped) continue;
632
+ const environ = got.value;
605
633
  if (Buffer.concat([environ, Buffer.from("\0")]).includes(needle)) found.push(pid);
606
634
  }
607
635
  return found;
@@ -694,7 +722,7 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
694
722
  const targetOf = (file) => { try { return readlink(file); } catch (e) { if (GONE.has(e.code)) return null; throw e; } };
695
723
  for (const pid of procPids()) {
696
724
  if (own.has(pid)) continue;
697
- try {
725
+ settled(pid, skipBefore, () => {
698
726
  for (const [link, how] of [["cwd", "working directory"], ["root", "root directory"], ["exe", "executable"]]) {
699
727
  const target = targetOf(`${proc}/${pid}/${link}`);
700
728
  if (target !== null && inside(target)) { add(pid, how); break; }
@@ -711,7 +739,7 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
711
739
  let cmdline = ""; try { cmdline = String(readFile(`${proc}/${pid}/cmdline`, "utf8")); } catch (e) { if (!GONE.has(e.code)) throw e; }
712
740
  if (named(cmdline)) add(pid, "argument");
713
741
  }
714
- } catch (e) { unreadable(pid, e, skipBefore); }
742
+ });
715
743
  }
716
744
  return found;
717
745
  }
@@ -755,7 +783,10 @@ export function spawnUnderOwnership(spawn, command, args, options = {}, { env =
755
783
  const handle = env[OWNERSHIP_ENV] ?? options?.env?.[OWNERSHIP_ENV];
756
784
  if (!options?.detached || platform === "win32" || !handle) return spawn(command, args, options);
757
785
  const merged = { ...(options.env ?? {}), ...env };
758
- const dir = childrenDir(merged[OWNERSHIP_FILE_ENV] || ownershipFile({ platform }), handle);
786
+ // The handle names a per-repository record: its file variable is required, and there is no host-wide default to fall back to.
787
+ const ownedFile = merged[OWNERSHIP_FILE_ENV];
788
+ if (!ownedFile) throw new OwnershipBusy(`build ownership: ${OWNERSHIP_ENV} is set but ${OWNERSHIP_FILE_ENV} is not, so the repository's ownership record is unknown and the detached child is not started`);
789
+ const dir = childrenDir(ownedFile, handle);
759
790
  try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } // the ownership directory exists while this gate holds it
760
791
  const intent = path.join(dir, `intent-${process.pid}-${crypto.randomUUID()}.json`);
761
792
  // The child's start lower bound is taken BEFORE it is created, so however long this process pauses before recording the
@@ -830,13 +861,13 @@ export function readRootBirth(root, bootId) {
830
861
  * reclaim, never a nested gate), before this sync's first write, its snapshots are deleted and the root is born anew, with this
831
862
  * boot's identity. Why that is sound: after a reboot no process of the old boot is alive, so the old root has no live readers; a
832
863
  * free host proves there is no current owner; and the fresh birth makes the strict rule apply only to processes that started after
833
- * it. Refused (the root stays as it was, strict) when: this process does not hold the only record, or the record is not a free-host
864
+ * it. Only this repository's own entries are deleted (see the body). Refused (the root stays as it was, strict) when: this process does not hold the only record, or the record is not a free-host
834
865
  * acquisition; this boot's identity cannot be read; the root is missing or is a link (a missing root is born by the sync); its birth
835
866
  * record is unreadable, or names THIS boot; or anything in the root is not a snapshot artifact (`<16 hex>`, `<16 hex>.lock`,
836
867
  * `.state.json`, `.staging`, or the birth record), because a root that is some other directory is never emptied.
837
868
  * Returns `{ migrated, why }`.
838
869
  */
839
- export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNERSHIP_FILE_ENV], bootId = hostBootId() } = {}) {
870
+ export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNERSHIP_FILE_ENV], bootId = hostBootId(), hooks = {} } = {}) {
840
871
  const no = (why) => ({ migrated: false, why });
841
872
  const handle = env[OWNERSHIP_ENV];
842
873
  if (!handle || !file) return no("this process holds no build ownership");
@@ -857,9 +888,45 @@ export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNER
857
888
  const birthName = path.basename(rootBirthFile(root));
858
889
  const stranger = entries.find((n) => n !== birthName && !/^[0-9a-f]{16}(\.[A-Za-z0-9._-]+)?$/.test(n));
859
890
  if (stranger !== undefined) return no(`${JSON.stringify(stranger)} in the root is not a snapshot artifact`);
860
- try { for (const n of entries) fs.rmSync(path.join(root, n), { recursive: true, force: true }); }
861
- catch (e) { return no(`the root could not be emptied (${e.code ?? e.message}); it stays without a baseline`); }
862
- return recordRootBirth(root, { bootId }) ? { migrated: true, why: `${reason}; its snapshots were deleted and it was born anew in this boot` } : no("the new birth could not be recorded");
891
+ // The root is shared by every repository, and a free host for THIS repository says nothing about another one. So this
892
+ // migration removes only THIS repository's own entries (`<key>` and `<key>.*`: the lock, the state file, the staging directory)
893
+ // and never another repository's, under any condition: a second repository can take its snapshot between any check made here
894
+ // and any delete, so no check can make a cross-repository delete safe. The root's birth is written only while no other
895
+ // repository holds or reclaims ownership (a decision, checked again right before the write); otherwise the root stays without a
896
+ // baseline and strict. Another repository's snapshots in the root are left to that repository's own migration.
897
+ const mine = path.basename(path.dirname(file));
898
+ const others = otherHolder(file);
899
+ if (others) return no(others);
900
+ hooks.afterHolderCheck?.();
901
+ const own = entries.filter((n) => n === mine || n.startsWith(`${mine}.`));
902
+ try { for (const n of own) fs.rmSync(path.join(root, n), { recursive: true, force: true }); }
903
+ catch (e) { return no(`this repository's snapshot could not be removed (${e.code ?? e.message}); the root stays without a baseline`); }
904
+ const late = otherHolder(file);
905
+ if (late) return no(`${late}; this repository's snapshot entries were removed and the root's birth was not written`);
906
+ if (fs.existsSync(rootBirthFile(root))) { try { fs.rmSync(rootBirthFile(root), { force: true }); } catch (e) { return no(`the old birth record could not be replaced (${e.code ?? e.message})`); } }
907
+ return recordRootBirth(root, { bootId }) ? { migrated: true, why: `${reason}; this repository's snapshot (${own.length} entr${own.length === 1 ? "y" : "ies"}) was deleted and the root was born anew in this boot` } : no("the new birth could not be recorded");
908
+ }
909
+
910
+ /**
911
+ * Whether anything other than the repository owning `file` holds build ownership or is reclaiming it: another repository's owner
912
+ * record or standing reclaim claim, or an older gate's host-wide `build-ownership/owner.json` (which holds EVERY repository, live or
913
+ * not). Returns the reason, or null. A listing or read failure is a reason (fail closed).
914
+ */
915
+ function otherHolder(file) {
916
+ try {
917
+ const parent = path.dirname(path.dirname(file)); const mine = path.basename(path.dirname(file));
918
+ for (const d of fs.readdirSync(parent, { withFileTypes: true })) {
919
+ if (!d.isDirectory()) {
920
+ if (d.name === "owner.json" || d.name.startsWith("owner.json.")) return `an older gate's host-wide record (${path.join(parent, d.name)}) stands`;
921
+ if (d.name.endsWith(".reclaim")) return "a reclaim claim stands under the ownership directory";
922
+ continue;
923
+ }
924
+ const names = fs.readdirSync(path.join(parent, d.name));
925
+ if (names.some((n) => n.endsWith(".reclaim"))) return `a reclaim claim stands for repository ${d.name}`;
926
+ if (d.name !== mine && names.includes("owner.json")) return `another repository (${d.name}) holds build ownership`;
927
+ }
928
+ } catch (e) { return `other repositories' ownership cannot be listed (${e.code ?? e.message})`; }
929
+ return null;
863
930
  }
864
931
 
865
932
  /** The holder's last report, or null when absent or unreadable. */
@@ -1025,6 +1092,12 @@ function coveredWorkGone(owner, { probe, lease, file, selfPid = null, waitMs = 0
1025
1092
  recorded.push({ group: entry.group, command: entry.command });
1026
1093
  let snapshot;
1027
1094
  try { snapshot = probe.groupSnapshot(entry.group); } catch (e) { throw new Error(`detached process group ${entry.group} cannot be listed (${e.message})`); }
1095
+ // The entry is written after the spawn returns, so the group's own leader (pid == group) started before the file's mtime. A
1096
+ // leader that started later is a new process that took the id: the kernel reuses a group id only once that group is empty.
1097
+ let writtenMs = NaN;
1098
+ try { writtenMs = fs.statSync(path.join(registry, name)).mtimeMs; } catch { /* unknown: judged as before */ }
1099
+ const leader = snapshot.members.find((m) => m.pid === entry.group);
1100
+ if (leader && Number.isFinite(writtenMs) && leader.startedMs > writtenMs + START_SLACK_MS) continue;
1028
1101
  const live = snapshot.members.filter((m) => m.pid !== selfPid && m.startedMs >= entry.startedMs - START_SLACK_MS).map((m) => m.pid);
1029
1102
  if (live.length) throw new Error(`detached process group ${entry.group} (${entry.command}) still has ${live.length} member(s): ${live.join(", ")}`);
1030
1103
  }
@@ -1146,12 +1219,12 @@ export function judgeOwner(observed, { file, probe, lease, host = os.hostname()
1146
1219
  }
1147
1220
 
1148
1221
  /**
1149
- * Acquires the host's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
1222
+ * Acquires a repository's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
1150
1223
  * `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(callHandle), held(), release() }`. Throws OwnershipBusy while another build
1151
- * owns the host, its liveness is unknown, or this gate cannot be contained.
1224
+ * owns this repository, its liveness is unknown, or this gate cannot be contained.
1152
1225
  */
1153
1226
  export function acquireBuildOwnership({
1154
- file = ownershipFile(),
1227
+ file,
1155
1228
  stage,
1156
1229
  snapshot,
1157
1230
  commit,
@@ -1166,9 +1239,19 @@ export function acquireBuildOwnership({
1166
1239
  } = {}) {
1167
1240
  // First, before the environment, the record or any write: the managed root exists (decision D4). `managedRoot` and `device` are
1168
1241
  // fixtures' seams; a production caller passes neither.
1242
+ if (typeof file !== "string" || file === "") throw new TypeError("acquireBuildOwnership: file is required (the path ownershipFile returns for a repository key)");
1169
1243
  guardManagedRoot(file, { platform, managedRoot, device });
1170
1244
  ensureOwnershipDir(path.dirname(file), { platform });
1171
1245
  const inherited = env[OWNERSHIP_ENV];
1246
+ // An older pinned gate on this host writes ONE host-wide record, `build-ownership/owner.json`, a plain file beside the repository
1247
+ // directories. It holds every repository: this code never joins it, reclaims it or deletes it.
1248
+ const olderFile = path.join(path.dirname(path.dirname(file)), "owner.json");
1249
+ let older = null;
1250
+ try { older = readText(olderFile); } catch (e) { throw new OwnershipBusy(`build ownership ${olderFile}: an older gate's host-wide record cannot be read (${e.code ?? e.message}); nothing runs while it may stand`); }
1251
+ if (older !== null && inherited) {
1252
+ let o = null; try { o = JSON.parse(older); } catch { o = null; }
1253
+ if (o?.handle === inherited) throw new OwnershipBusy(`build ownership ${olderFile}: ${OWNERSHIP_ENV}=${inherited} names the host-wide record of an older gate; the parent is an older gate, and a nested gate never joins it`);
1254
+ }
1172
1255
  if (inherited) {
1173
1256
  const current = readText(file);
1174
1257
  let owner = null; try { owner = JSON.parse(current ?? ""); } catch { owner = null; }
@@ -1188,6 +1271,12 @@ export function acquireBuildOwnership({
1188
1271
  }
1189
1272
  throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} is not the current owner; a nested gate never takes ownership of its own`);
1190
1273
  }
1274
+ if (older !== null) {
1275
+ // Judged exactly as the older code judged it, to word the refusal; never reclaimed from here, whatever the verdict.
1276
+ try { judgeOwner(older, { file: olderFile, probe, lease }); }
1277
+ catch (e) { if (!(e instanceof OwnershipBusy)) throw e; throw new OwnershipBusy(`${e.message}; this is an older gate's host-wide record and it holds every repository: let that gate finish or clear it (an older gate or a person), this gate does not`, e.detail ?? {}); }
1278
+ throw new OwnershipBusy(`build ownership ${olderFile}: an older gate's host-wide record stands (its owner is gone, but only an older gate or a person clears it; this gate never reclaims it), and it holds every repository; nothing ran`);
1279
+ }
1191
1280
  let me;
1192
1281
  try { me = probe.identity(process.pid); } catch (e) { throw new OwnershipBusy(`build ownership ${file}: this gate's own start identity cannot be read (${e.message})`); }
1193
1282
  if (!me.alive || !Number.isFinite(me.startedMs)) throw new OwnershipBusy(`build ownership ${file}: this gate's own start identity cannot be read`);