@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1

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 (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +19 -4
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
package/ledger.mjs ADDED
@@ -0,0 +1,186 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { VERIFIED_STATUSES } from './digest.mjs';
10
+ import { ROOT } from './root.mjs';
11
+ import { HISTORY_REL, readHistory } from './tree.mjs';
12
+ import { formatMs } from './summary.mjs';
13
+
14
+ export const LEDGER_REL = '.turbo/ticket-verify-steps.jsonl';
15
+
16
+ export const LEDGER = join(ROOT, LEDGER_REL);
17
+
18
+ export const LEDGER_MAX = 200;
19
+
20
+ // One line per e2e spec per run, kept apart from the step ledger.
21
+ export const SPEC_LEDGER = join(ROOT, '.turbo', 'ticket-verify-specs.jsonl');
22
+
23
+ export const SPEC_LEDGER_MAX = 2000;
24
+
25
+ export const HISTORY = join(ROOT, HISTORY_REL);
26
+
27
+ export const HISTORY_MAX = 50;
28
+
29
+ /** { treeHash, ranAt } (exactly these keys) of a green receipt, else null. */
30
+ export function historyEntry(receipt) {
31
+ if (receipt?.ok !== true) return null;
32
+ if (receipt.oversize === true) return null;
33
+ // prettier-ignore
34
+ if (!(receipt.results ?? []).every((r) => VERIFIED_STATUSES.includes(r.status))) return null;
35
+ if (typeof receipt.treeHash !== 'string') return null;
36
+ return { treeHash: receipt.treeHash, ranAt: receipt.ranAt };
37
+ }
38
+
39
+ const writeLines = (path, entries) => {
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ writeFileSync(
42
+ `${path}.tmp`,
43
+ entries.map((e) => `${JSON.stringify(e)}\n`).join(''),
44
+ );
45
+ renameSync(`${path}.tmp`, path);
46
+ };
47
+
48
+ /** No history file yet and receiptPath holds a receipt historyEntry accepts: write that one line. */
49
+ export function seedHistory(historyPath, receiptPath) {
50
+ if (existsSync(historyPath)) return false;
51
+ let receipt;
52
+ try {
53
+ receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
54
+ } catch {
55
+ return false;
56
+ }
57
+ const entry = historyEntry(receipt);
58
+ if (!entry) return false;
59
+ writeLines(historyPath, [entry]);
60
+ return true;
61
+ }
62
+
63
+ /** historyEntry(receipt) null: no write. Else keep the newest HISTORY_MAX lines. */
64
+ export function appendHistory(historyPath, receipt) {
65
+ const entry = historyEntry(receipt);
66
+ if (!entry) return false;
67
+ const kept = [...readHistory(historyPath), entry].slice(-HISTORY_MAX);
68
+ writeLines(historyPath, kept);
69
+ return true;
70
+ }
71
+
72
+ /** Lines of the step ledger in file order. Missing file -> []; a blank, non-JSON or non-object line is skipped. */
73
+ export function readLedger(path = LEDGER) {
74
+ if (!existsSync(path)) return [];
75
+ const out = [];
76
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
77
+ if (raw.trim() === '') continue;
78
+ let line;
79
+ try {
80
+ line = JSON.parse(raw);
81
+ } catch {
82
+ continue; // a corrupt line is skipped, never fatal
83
+ }
84
+ if (line && typeof line === 'object' && !Array.isArray(line))
85
+ out.push(line);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** The newest ledger line matching id, cmd and digest. */
91
+ export function newestLedgerLine(ledger, { id, cmd, digest }) {
92
+ return ledger.findLast(
93
+ (l) => l.id === id && l.cmd === cmd && l.digest === digest,
94
+ );
95
+ }
96
+
97
+ /**
98
+ * One ledger line per pass or fail result (a timeout is a fail) whose inputs
99
+ * are still what the plan digested: `digestNow(id, cmd)` is the digest of the
100
+ * tree after the steps ran. A pass is never filed under inputs it did not see.
101
+ */
102
+ export function ledgerLines(results, { ranAt, digestNow }) {
103
+ return results
104
+ .filter(
105
+ (r) =>
106
+ (r.status === 'pass' || r.status === 'fail') &&
107
+ typeof r.digest === 'string' &&
108
+ digestNow(r.id, r.cmd) === r.digest,
109
+ )
110
+ .map((r) => ({
111
+ id: r.id,
112
+ cmd: r.cmd,
113
+ digest: r.digest,
114
+ status: r.status,
115
+ ranAt,
116
+ summary: r.summary,
117
+ ms: r.ms,
118
+ ...(r.mode ? { mode: r.mode } : {}),
119
+ }));
120
+ }
121
+
122
+ /** Append `lines` to the ledger at `path`, keeping the newest LEDGER_MAX. */
123
+ export function appendLedger(path, lines) {
124
+ writeLines(path, [...readLedger(path), ...lines].slice(-LEDGER_MAX));
125
+ }
126
+
127
+ /** Append `lines` to the spec ledger at `path`, keeping the newest SPEC_LEDGER_MAX. */
128
+ export function appendSpecLedger(path, lines) {
129
+ writeLines(path, [...readLedger(path), ...lines].slice(-SPEC_LEDGER_MAX));
130
+ }
131
+
132
+ /** One spec-ledger line per pass or fail entry whose digest still holds after the run. */
133
+ export function specLedgerLines(specs, { ranAt, mode, ms, digestNow }) {
134
+ return specs
135
+ .filter(
136
+ (s) =>
137
+ (s.status === 'pass' || s.status === 'fail') &&
138
+ typeof s.digest === 'string' &&
139
+ digestNow(s.file) === s.digest,
140
+ )
141
+ .map((s) => ({
142
+ id: 'e2e-spec',
143
+ file: s.file,
144
+ digest: s.digest,
145
+ status: s.status,
146
+ ranAt,
147
+ mode,
148
+ ms: s.ms ?? ms,
149
+ }));
150
+ }
151
+
152
+ /** D10: median of the newest 5 full e2e passes, with the largest specs of the newest when it is over budget. */
153
+ export function fullMedianLine(ledger, specLedger) {
154
+ const runs = ledger
155
+ .filter(
156
+ (l) =>
157
+ l.id === 'e2e' &&
158
+ l.status === 'pass' &&
159
+ typeof l.ms === 'number' &&
160
+ (l.mode ?? 'full') === 'full',
161
+ )
162
+ .slice(-5);
163
+ if (runs.length === 0) return null;
164
+ const sorted = runs.map((l) => l.ms).sort((a, b) => a - b);
165
+ const mid = sorted.length >> 1;
166
+ const median =
167
+ sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
168
+ const warn = median > FULL_MEDIAN_WARN_MS;
169
+ let line = `e2e full median: ${formatMs(median)} over the last ${runs.length} full runs`;
170
+ if (warn) {
171
+ const newest = runs.at(-1).ranAt;
172
+ const top = specLedger
173
+ .filter(
174
+ (l) =>
175
+ l.id === 'e2e-spec' && l.ranAt === newest && typeof l.ms === 'number',
176
+ )
177
+ .sort((a, b) => b.ms - a.ms)
178
+ .slice(0, 5)
179
+ .map((l) => `${l.file} ${formatMs(l.ms)}`);
180
+ line += `\nWARNING: over the 20 min budget (D10)${top.length ? `; largest specs: ${top.join(', ')}` : ''}`;
181
+ }
182
+ return { line, warn };
183
+ }
184
+
185
+ // D10: a full-suite median above this prints a warning.
186
+ export const FULL_MEDIAN_WARN_MS = 20 * 60 * 1000;
package/lock.d.mts ADDED
@@ -0,0 +1,72 @@
1
+ // Types for lock.mjs, the stack lock shared by the gate, the nightly and the profiler.
2
+ export interface LockHolder {
3
+ owner: string;
4
+ pid: number;
5
+ id: string;
6
+ startedAt: string;
7
+ cwd: string;
8
+ nested?: boolean;
9
+ [key: string]: unknown;
10
+ }
11
+ export interface LockOptions {
12
+ now?: () => number;
13
+ alive?: (pid: number) => boolean;
14
+ env?: NodeJS.ProcessEnv;
15
+ }
16
+ export type TakeResult =
17
+ | { ok: true; holder: LockHolder; nested?: boolean }
18
+ | { ok: false; holder: LockHolder };
19
+ export interface GateLock {
20
+ acquire(): { ok: boolean; [key: string]: unknown };
21
+ release(): void;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ export const OWNERS: string[];
26
+ export const STALE_EMPTY_MS: number;
27
+ export const WAIT_S: number;
28
+ export const POLL_S: number;
29
+ export const PRINT_S: number;
30
+
31
+ export function mainCheckout(cwd?: string): string;
32
+ export function lockPath(
33
+ cwd?: string,
34
+ opts?: { env?: NodeJS.ProcessEnv },
35
+ ): string;
36
+ export function isAlive(pid: number): boolean;
37
+ export function readLock(
38
+ path: string,
39
+ opts?: { now?: () => number },
40
+ ): LockHolder | null;
41
+ export function readReleased(path: string): Record<string, unknown> | null;
42
+ export function reapStale(path: string, opts?: LockOptions): boolean;
43
+ export function tryTake(
44
+ path: string,
45
+ opts: LockOptions & { owner: string; cwd?: string; pid?: number },
46
+ ): TakeResult;
47
+ export function release(
48
+ path: string,
49
+ holder: LockHolder,
50
+ opts?: { now?: () => number },
51
+ ): boolean;
52
+ export function heldByOther(path: string, opts?: LockOptions): LockHolder | null;
53
+ export function holderLine(holder: LockHolder): string;
54
+ export function refuseIfHeld(path?: string, opts?: LockOptions): void;
55
+ export function takeOrExit(
56
+ owner: string,
57
+ opts?: {
58
+ path?: string;
59
+ exit?: (code: number) => unknown;
60
+ log?: (line: string) => void;
61
+ },
62
+ ): LockHolder;
63
+ export function sleepSync(ms: number): void;
64
+ export function gateLock(opts?: {
65
+ path?: string;
66
+ env?: NodeJS.ProcessEnv;
67
+ now?: () => number;
68
+ sleep?: (ms: number) => void;
69
+ log?: (line: string) => void;
70
+ alive?: (pid: number) => boolean;
71
+ commands?: Record<string, unknown>;
72
+ }): GateLock;
package/lock.mjs ADDED
@@ -0,0 +1,329 @@
1
+ // One lock for the single local Supabase stack .
2
+ //
3
+ // The stack (db, edge runtime, :3000) holds one database that e2e resets and
4
+ // seeds. Two runs at once corrupt each other, so every run that resets it takes
5
+ // this lock: verify:ticket, mutate:check, profile:web and the nightly e2e.
6
+ //
7
+ // The lock is a directory `<main checkout>/.turbo/stack.lock` made by one
8
+ // atomic mkdir, with `owner.json` inside. A holder that dies leaves a lock
9
+ // naming a dead pid; the next taker removes it (takeover) and records
10
+ // `lastReleasedAt`, so nobody needs a signal handler (contract L-R1).
11
+ // Node built-ins only; no build step.
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+ import {
15
+ mkdirSync,
16
+ readFileSync,
17
+ realpathSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { dirname, join, resolve } from 'node:path';
24
+ import { DEFAULT_COMMANDS } from './config.mjs';
25
+ import { bothNames, envName } from './env-name.mjs';
26
+ import { ROOT } from './root.mjs';
27
+
28
+ export const OWNERS = [
29
+ 'verify:ticket',
30
+ 'mutate:check',
31
+ 'profile:web',
32
+ 'nightly',
33
+ ];
34
+ export const STALE_EMPTY_MS = 10_000;
35
+ export const WAIT_S = 2700,
36
+ POLL_S = 5,
37
+ PRINT_S = 60;
38
+
39
+ /** The main checkout, also from inside a linked worktree. */
40
+ export function mainCheckout(cwd = ROOT) {
41
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
42
+ cwd,
43
+ encoding: 'utf8',
44
+ }).trim();
45
+ return realpathSync(dirname(resolve(cwd, common)));
46
+ }
47
+
48
+ export function lockPath(cwd = ROOT, { env = process.env } = {}) {
49
+ return (
50
+ envName(env, 'STACK_LOCK_PATH') ?? join(mainCheckout(cwd), '.turbo', 'stack.lock')
51
+ );
52
+ }
53
+
54
+ export function isAlive(pid) {
55
+ try {
56
+ process.kill(pid, 0);
57
+ return true;
58
+ } catch (e) {
59
+ return e.code === 'EPERM';
60
+ }
61
+ }
62
+
63
+ const readJson = (file) => {
64
+ try {
65
+ return JSON.parse(readFileSync(file, 'utf8'));
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ export function readLock(path, { now = Date.now } = {}) {
72
+ let st;
73
+ try {
74
+ st = statSync(path);
75
+ } catch {
76
+ return null;
77
+ }
78
+ void now;
79
+ const o = readJson(join(path, 'owner.json'));
80
+ if (o && typeof o.id === 'string' && Number.isInteger(o.pid)) return o;
81
+ return {
82
+ owner: 'unknown',
83
+ pid: 0,
84
+ id: '',
85
+ startedAt: new Date(st.mtimeMs).toISOString(),
86
+ cwd: '',
87
+ };
88
+ }
89
+
90
+ export function readReleased(path) {
91
+ return readJson(`${path}.released.json`);
92
+ }
93
+
94
+ function writeReleased(path, { owner, pid, stale, now = Date.now }) {
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ writeFileSync(
97
+ `${path}.released.json`,
98
+ `${JSON.stringify({ v: 1, lastReleasedAt: new Date(now()).toISOString(), owner, pid, stale })}\n`,
99
+ );
100
+ }
101
+
102
+ const emptyAndOld = (holder, now) =>
103
+ holder.owner === 'unknown' &&
104
+ now() - Date.parse(holder.startedAt) > STALE_EMPTY_MS;
105
+
106
+ export function reapStale(path, { now = Date.now, alive = isAlive } = {}) {
107
+ const holder = readLock(path, { now });
108
+ // An empty lock dir has no pid to test: it is stale only once it is old.
109
+ if (holder?.owner === 'unknown')
110
+ return emptyAndOld(holder, now) && reap(path, holder, now);
111
+ if (holder === null || alive(holder.pid)) return false;
112
+ return reap(path, holder, now);
113
+ }
114
+
115
+ // Reapers are serialized by an atomic mkdir of `${path}.reaping`, and the lock
116
+ // is re-read inside it: between readLock and the rename another taker may have
117
+ // reaped the dead holder and taken the lock, and a bare rename would take that
118
+ // live lock away from it.
119
+ function reap(path, holder, now) {
120
+ const guard = `${path}.reaping`;
121
+ try {
122
+ mkdirSync(guard);
123
+ } catch (e) {
124
+ if (e.code !== 'EEXIST') throw e;
125
+ // A reaper that died inside the guard must not block reaping forever.
126
+ try {
127
+ if (now() - statSync(guard).mtimeMs > STALE_EMPTY_MS)
128
+ rmSync(guard, { recursive: true, force: true });
129
+ } catch {
130
+ // already gone
131
+ }
132
+ return false;
133
+ }
134
+ try {
135
+ const onDisk = readLock(path, { now });
136
+ if (onDisk === null || onDisk.id !== holder.id) return false;
137
+ if (onDisk.id === '' && !emptyAndOld(onDisk, now)) return false;
138
+ const gone = `${path}.stale-${process.pid}-${now()}`;
139
+ renameSync(path, gone);
140
+ rmSync(gone, { recursive: true, force: true });
141
+ writeReleased(path, {
142
+ owner: holder.owner,
143
+ pid: holder.pid,
144
+ stale: true,
145
+ now,
146
+ });
147
+ return true;
148
+ } catch {
149
+ return false; // lost a race on the rename
150
+ } finally {
151
+ rmSync(guard, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ export function tryTake(
156
+ path,
157
+ {
158
+ owner,
159
+ cwd = process.cwd(),
160
+ pid = process.pid,
161
+ now = Date.now,
162
+ alive = isAlive,
163
+ env = process.env,
164
+ },
165
+ ) {
166
+ if (!OWNERS.includes(owner))
167
+ throw new Error(`unknown stack lock owner: ${owner}`);
168
+ reapStale(path, { now, alive });
169
+ const current = readLock(path, { now });
170
+ if (
171
+ current !== null &&
172
+ current.id !== '' &&
173
+ envName(env, 'STACK_LOCK_OWNER') === current.id &&
174
+ alive(current.pid)
175
+ )
176
+ return { ok: true, holder: { ...current, nested: true }, nested: true };
177
+ mkdirSync(dirname(path), { recursive: true });
178
+ try {
179
+ mkdirSync(path);
180
+ } catch (e) {
181
+ if (e.code === 'EEXIST')
182
+ return { ok: false, holder: readLock(path, { now }) };
183
+ throw e;
184
+ }
185
+ const startedAtMs = now();
186
+ const holder = {
187
+ v: 1,
188
+ id: `${owner}:${pid}:${startedAtMs}`,
189
+ owner,
190
+ pid,
191
+ cwd,
192
+ startedAt: new Date(startedAtMs).toISOString(),
193
+ };
194
+ writeFileSync(join(path, 'owner.json'), `${JSON.stringify(holder)}\n`);
195
+ return { ok: true, holder };
196
+ }
197
+
198
+ export function release(path, holder, { now = Date.now } = {}) {
199
+ if (holder.nested === true) return false;
200
+ const onDisk = readLock(path, { now });
201
+ if (onDisk?.id !== holder.id) return false;
202
+ rmSync(path, { recursive: true, force: true });
203
+ // prettier-ignore
204
+ writeReleased(path, { owner: holder.owner, pid: holder.pid, stale: false, now });
205
+ return true;
206
+ }
207
+
208
+ /** The live holder that is not the caller's own tree, else null. */
209
+ export function heldByOther(
210
+ path,
211
+ { env = process.env, alive = isAlive, now = Date.now } = {},
212
+ ) {
213
+ const holder = readLock(path, { now });
214
+ if (holder === null) return null;
215
+ if (holder.owner === 'unknown')
216
+ return emptyAndOld(holder, now) ? null : holder;
217
+ if (envName(env, 'STACK_LOCK_OWNER') === holder.id) return null;
218
+ return alive(holder.pid) ? holder : null;
219
+ }
220
+
221
+ export function holderLine(holder) {
222
+ return `${holder.owner} running since ${holder.startedAt} (pid ${holder.pid}, ${holder.cwd})`;
223
+ }
224
+
225
+ export function refuseIfHeld(path = lockPath(), opts) {
226
+ const h = heldByOther(path, opts);
227
+ if (h !== null)
228
+ throw new Error(
229
+ `stack lock held: ${holderLine(h)}. Wait for it, or stop it; TICKET_GATE_STACK_LOCK_OWNER lets the holder's own runs pass.`,
230
+ );
231
+ }
232
+
233
+ /** Take the lock or exit 1 naming the holder; release on exit. */
234
+ export function takeOrExit(
235
+ owner,
236
+ { path = lockPath(), exit = process.exit, log = console.error } = {},
237
+ ) {
238
+ const got = tryTake(path, { owner });
239
+ if (!got.ok) {
240
+ log(`${owner}: stack lock held: ${holderLine(got.holder)}`);
241
+ exit(1);
242
+ return got.holder;
243
+ }
244
+ Object.assign(process.env, bothNames('STACK_LOCK_OWNER', got.holder.id));
245
+ process.on('exit', () => release(path, got.holder));
246
+ return got.holder;
247
+ }
248
+
249
+ /** Blocks the calling thread; no signal handler, so Ctrl-C kills a waiting gate at once (L-R1). */
250
+ export function sleepSync(ms) {
251
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
252
+ }
253
+
254
+ const envS = (env, name, dflt) => {
255
+ const n = Number.parseInt(envName(env, name) ?? '', 10);
256
+ return Number.isInteger(n) && n >= 0 ? n : dflt;
257
+ };
258
+
259
+ /**
260
+ * The stack lock around the stack steps. `acquire` waits for any
261
+ * holder up to TICKET_GATE_STACK_LOCK_WAIT_S (45 min), then fails the preflight step with
262
+ * a summary naming the holder. `release` is safe to call twice.
263
+ */
264
+ export function gateLock({
265
+ path = lockPath(),
266
+ env = process.env,
267
+ now = Date.now,
268
+ sleep = sleepSync,
269
+ log = console.log,
270
+ alive = isAlive,
271
+ commands: given,
272
+ } = {}) {
273
+ const commands = { ...DEFAULT_COMMANDS, ...given };
274
+ let held = null;
275
+ const waiter = join(`${path}.waiters`, `${process.pid}.json`);
276
+ const dropWaiter = () => rmSync(waiter, { force: true });
277
+ return {
278
+ acquire() {
279
+ const waitS = envS(env, 'STACK_LOCK_WAIT_S', WAIT_S);
280
+ const pollS = envS(env, 'STACK_LOCK_POLL_S', POLL_S);
281
+ const printS = envS(env, 'STACK_LOCK_PRINT_S', PRINT_S);
282
+ const take = () =>
283
+ tryTake(path, { owner: 'verify:ticket', now, alive, env });
284
+ const taken = (got) => {
285
+ dropWaiter();
286
+ held = got.holder;
287
+ Object.assign(env, bothNames('STACK_LOCK_OWNER', got.holder.id));
288
+ return { ok: true };
289
+ };
290
+ let got = take();
291
+ if (got.ok) return taken(got);
292
+ const started = now();
293
+ let lastPrint = -Infinity;
294
+ mkdirSync(dirname(waiter), { recursive: true });
295
+ writeFileSync(
296
+ waiter,
297
+ `${JSON.stringify({ v: 1, owner: 'verify:ticket', pid: process.pid, cwd: process.cwd(), since: new Date(started).toISOString() })}\n`,
298
+ );
299
+ const fail = (holder) => {
300
+ dropWaiter();
301
+ const min = Math.round((now() - started) / 60000);
302
+ const who = holder ? holderLine(holder) : 'unknown holder';
303
+ return {
304
+ ok: false,
305
+ summary: `stack lock: ${who}; waited ${min} min. Wait for it to finish${commands.nightlyStatus ? ` (${commands.nightlyStatus} shows the nightly)` : ''}, then rerun ${commands.verify}.`,
306
+ };
307
+ };
308
+ for (;;) {
309
+ const { holder } = got;
310
+ if (now() - started >= waitS * 1000) return fail(holder);
311
+ if (now() - lastPrint >= printS * 1000) {
312
+ lastPrint = now();
313
+ const min = Math.floor((now() - started) / 60000);
314
+ log(
315
+ `stack lock held by ${holderLine(holder ?? {})}; waiting (${min} of ${waitS / 60} min)`,
316
+ );
317
+ }
318
+ sleep(pollS * 1000);
319
+ got = take();
320
+ if (got.ok) return taken(got);
321
+ }
322
+ },
323
+ release() {
324
+ dropWaiter();
325
+ if (held) release(path, held, { now });
326
+ held = null;
327
+ },
328
+ };
329
+ }