@webpieces/rules-config 0.4.686 → 0.4.688

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.
@@ -0,0 +1,508 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BuildsLog = exports.RunningBuild = exports.BuildTicket = exports.MAX_ROW_BYTES = exports.BUILDS_LOG_GENERATIONS = exports.MAX_BUILDS_LOG_BYTES = exports.BUILD_DONE_FAIL = exports.BUILD_DONE_SUCCESS = exports.BUILD_START = exports.BUILDS_LOCK_FILE = exports.BUILDS_LOG_FILE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const crypto = tslib_1.__importStar(require("crypto"));
7
+ const fs = tslib_1.__importStar(require("fs"));
8
+ const os = tslib_1.__importStar(require("os"));
9
+ const path = tslib_1.__importStar(require("path"));
10
+ const inversify_1 = require("inversify");
11
+ const state_dir_1 = require("./state-dir");
12
+ const home_config_1 = require("./home-config");
13
+ const to_error_1 = require("./to-error");
14
+ /**
15
+ * `~/.webpieces/builds.log` — the MACHINE-WIDE, append-only ledger of every build this box has started.
16
+ *
17
+ * ─── WHY THIS ONE FILE LIVES OUTSIDE THE REPO ─────────────────────────────────────────────────────────
18
+ * `no-machine-global-state.spec.ts` records the standing rule: webpieces writes state under
19
+ * `{repo}/.webpieces` and nowhere else. This is the ONE carve-out, and the argument is written out in
20
+ * `decisions/0006-the-build-ledger-is-machine-global.md`. In short:
21
+ *
22
+ * • The FACT is machine-scoped. "How many builds are burning this box's CPU right now" is not a
23
+ * property of any repo; it is a property of the machine. A per-repo ledger cannot answer it — every
24
+ * linked worktree has its OWN `.webpieces/`, so it would be blind to the sibling worktree it is
25
+ * actually contending with, never mind the four other repos on the disk.
26
+ * • It is NOT A CACHE. The retired `PrBodyStore` that the no-machine-global rule was written for was a
27
+ * local copy of a fact GitHub owned, so it could be stale, missing, or on the wrong computer. There
28
+ * is no remote copy of this. The file IS the fact.
29
+ * • Its key is an ABSOLUTE LOCAL PATH, which is stable precisely because it never leaves the machine —
30
+ * the instability that killed `PrBodyStore`'s `<host>/<owner>/<repo>` key cannot arise here.
31
+ *
32
+ * ─── WHY IT IS SAFE TO WRITE CONCURRENTLY ─────────────────────────────────────────────────────────────
33
+ * Rows are deliberately kept under `MAX_ROW_BYTES` (512, macOS `PIPE_BUF`). A single `O_APPEND`
34
+ * `write(2)` at or below that size is indivisible, so two builds appending at the same instant cannot
35
+ * interleave halves of a line. The lock is therefore belt-and-braces for the APPEND and genuinely
36
+ * load-bearing for ROTATION, where a rename-and-reopen really does race.
37
+ *
38
+ * ─── IT MAY NEVER FAIL A BUILD ────────────────────────────────────────────────────────────────────────
39
+ * Every method here is best-effort and swallows its own errors. A build must never die because a log
40
+ * file was busy, unwritable, or on a full disk. Lock acquisition retries and then gives up and appends
41
+ * anyway — which the row-size invariant above makes safe.
42
+ */
43
+ exports.BUILDS_LOG_FILE = 'builds.log';
44
+ exports.BUILDS_LOCK_FILE = 'builds.log.lock';
45
+ /** START, and the two terminal kinds. `DONE-` is the greppable prefix that pairs with a START. */
46
+ exports.BUILD_START = 'START';
47
+ exports.BUILD_DONE_SUCCESS = 'DONE-SUCCESS';
48
+ exports.BUILD_DONE_FAIL = 'DONE-FAIL';
49
+ /** Rotate at 1 MB, keeping five generations (`.1` … `.5`); the old `.5` is dropped. */
50
+ exports.MAX_BUILDS_LOG_BYTES = 1024 * 1024;
51
+ exports.BUILDS_LOG_GENERATIONS = 5;
52
+ /**
53
+ * macOS `PIPE_BUF`. A row at or under this size is written by ONE indivisible `write(2)`, which is what
54
+ * makes a lost lock a non-event rather than a corrupted file. Long paths are clipped to hold the line
55
+ * under it — see `clip`.
56
+ */
57
+ exports.MAX_ROW_BYTES = 512;
58
+ /** How long a build may hold the lock before another writer stops waiting and appends anyway. */
59
+ const LOCK_RETRY_MS = 50;
60
+ const LOCK_TIMEOUT_MS = 2000;
61
+ /**
62
+ * The handle a START row hands back, and the ONLY thing `finish()` accepts. Data-only (a class, per
63
+ * CLAUDE.md), carrying exactly the fields the DONE row needs to pair itself with its START: the uuid,
64
+ * the caller, the repo, and when it began (so `took=` is computed from one clock, not two).
65
+ */
66
+ class BuildTicket {
67
+ id;
68
+ by;
69
+ repo;
70
+ startedMs;
71
+ constructor(id, by, repo, startedMs) {
72
+ this.id = id;
73
+ this.by = by;
74
+ this.repo = repo;
75
+ this.startedMs = startedMs;
76
+ }
77
+ }
78
+ exports.BuildTicket = BuildTicket;
79
+ /**
80
+ * One build that is STILL RUNNING — a START row with no matching `DONE-`, whose pid is still alive.
81
+ * Data-only. This is what the refusal message renders, so it carries the three things a reader needs to
82
+ * recognise the build in question: where it is, which tree, and how old it is.
83
+ */
84
+ class RunningBuild {
85
+ id;
86
+ by;
87
+ repo;
88
+ tree;
89
+ cwd;
90
+ branch;
91
+ pid;
92
+ startedMs;
93
+ // eslint-disable-next-line @typescript-eslint/max-params
94
+ constructor(id, by, repo, tree, cwd, branch, pid, startedMs) {
95
+ this.id = id;
96
+ this.by = by;
97
+ this.repo = repo;
98
+ this.tree = tree;
99
+ this.cwd = cwd;
100
+ this.branch = branch;
101
+ this.pid = pid;
102
+ this.startedMs = startedMs;
103
+ }
104
+ }
105
+ exports.RunningBuild = RunningBuild;
106
+ /**
107
+ * The ledger. See the file docblock for why it is machine-global and why every operation swallows its
108
+ * own errors.
109
+ *
110
+ * `homeDir` is a parameter on every public method, defaulted to `os.homedir()`, for exactly the reason
111
+ * `HomeConfigService.configPath` takes one: a spec must be able to exercise the real code against a temp
112
+ * directory and must never touch the developer's actual `~/.webpieces`.
113
+ */
114
+ let BuildsLog = class BuildsLog {
115
+ dotDir;
116
+ constructor(dotDir) {
117
+ this.dotDir = dotDir;
118
+ }
119
+ /** `~/.webpieces/builds.log`. */
120
+ logPath(homeDir = os.homedir()) {
121
+ return path.join(homeDir, home_config_1.HOME_CONFIG_DIR, exports.BUILDS_LOG_FILE);
122
+ }
123
+ /** `~/.webpieces/builds.log.lock` — `{"pid":N,"started":<epochMs>}`. */
124
+ lockPath(homeDir = os.homedir()) {
125
+ return path.join(homeDir, home_config_1.HOME_CONFIG_DIR, exports.BUILDS_LOCK_FILE);
126
+ }
127
+ /** `~/.webpieces/builds.log.<n>` — generation `n`, 1 being the most recent. */
128
+ rotatedPath(generation, homeDir = os.homedir()) {
129
+ return `${this.logPath(homeDir)}.${String(generation)}`;
130
+ }
131
+ /**
132
+ * Record that a build is starting, and hand back the ticket its DONE row will need.
133
+ *
134
+ * `by` is the CALLER — `BuildGateOptions.stage`, i.e. `build` | `review` | `finish`. There is no
135
+ * second "caller" concept anywhere: the stage id already is one, and a second spelling of it would
136
+ * be the shim CLAUDE.md rejects.
137
+ *
138
+ * Returns a ticket even when the append failed. A build whose START row never landed still has to be
139
+ * able to call `finish()`; the alternative is a nullable return that every call site must branch on
140
+ * for a logging failure that is, by policy, not an error.
141
+ */
142
+ start(by, startDir, homeDir = os.homedir()) {
143
+ const ticket = new BuildTicket(crypto.randomUUID(), by, this.dotDir.primaryRoot(startDir), Date.now());
144
+ this.append(this.startRow(ticket, startDir), homeDir);
145
+ return ticket;
146
+ }
147
+ /**
148
+ * Record that the build behind `ticket` has ended. `exitCode` 0 writes `DONE-SUCCESS`; anything else
149
+ * writes `DONE-FAIL` carrying the code, so `grep DONE-FAIL` lists every red build on the machine.
150
+ */
151
+ finish(ticket, exitCode, homeDir = os.homedir()) {
152
+ this.append(this.doneRow(ticket, exitCode), homeDir);
153
+ }
154
+ /**
155
+ * Every build that is still live: a `START` with no matching `DONE-` row, whose pid is still alive.
156
+ *
157
+ * The pid filter is not an optimisation, it is what keeps the ledger from wedging the machine. A
158
+ * build killed with SIGKILL — an agent cancelled mid-run, a terminal closed — writes no DONE row, so
159
+ * without the liveness test its START would count forever and the fourth build would be refused for
160
+ * the rest of the machine's life. The uuid answers "which build"; the pid answers "is it still real".
161
+ *
162
+ * Only the CURRENT generation is read. A rotated-away START is by definition at least 1 MB of rows
163
+ * old and is not a build anyone is contending with.
164
+ */
165
+ running(homeDir = os.homedir()) {
166
+ const lines = this.readLines(this.logPath(homeDir));
167
+ const done = new Set();
168
+ for (const line of lines) {
169
+ if (line.startsWith(`${exports.BUILD_DONE_SUCCESS}\t`) || line.startsWith(`${exports.BUILD_DONE_FAIL}\t`)) {
170
+ done.add(this.field(line, 'id'));
171
+ }
172
+ }
173
+ const live = [];
174
+ for (const line of lines) {
175
+ if (!line.startsWith(`${exports.BUILD_START}\t`))
176
+ continue;
177
+ const build = this.toRunningBuild(line);
178
+ if (build === null || done.has(build.id))
179
+ continue;
180
+ if (!this.isAlive(build.pid))
181
+ continue;
182
+ live.push(build);
183
+ }
184
+ return live;
185
+ }
186
+ /**
187
+ * The `@webpieces` release ACTUALLY EXECUTING — found by walking UP from this module's own directory
188
+ * to the nearest enclosing `node_modules/@webpieces/<pkg>/package.json`. `''` when this code is
189
+ * running from source rather than from an installed package (which is the state in this repo's own
190
+ * specs, and a perfectly ordinary answer).
191
+ *
192
+ * ─── WHY THIS IS NOT `WebpiecesVersions.readInstalled(root)` ──────────────────────────────────────
193
+ * They answer DIFFERENT QUESTIONS and merging them would break the older one. `readInstalled` joins
194
+ * `<root>/node_modules/@webpieces/...` at a FIXED tree root ON PURPOSE: its whole job is to detect
195
+ * DRIFT between what a tree PINS and what some other tree pins, and a walk-up would silently resolve
196
+ * a worktree with no install of its own to the primary clone's copy — hiding exactly the skew that
197
+ * guard exists to catch. This question is the opposite one: "whichever copy is running, name it", and
198
+ * for that the walk-up is the only correct answer. Do not fold them together.
199
+ */
200
+ executingVersion() {
201
+ let dir = __dirname;
202
+ for (let hops = 0; hops < 40; hops += 1) {
203
+ const version = this.versionOfEnclosingPackage(dir);
204
+ if (version !== '')
205
+ return version;
206
+ const parent = path.dirname(dir);
207
+ if (parent === dir)
208
+ return '';
209
+ dir = parent;
210
+ }
211
+ return '';
212
+ }
213
+ // `<dir>` is `node_modules/@webpieces/<pkg>/...`? Then that package's version, else ''.
214
+ versionOfEnclosingPackage(dir) {
215
+ const parent = path.dirname(dir);
216
+ const grandparent = path.dirname(parent);
217
+ if (path.basename(parent) !== '@webpieces' || path.basename(grandparent) !== 'node_modules')
218
+ return '';
219
+ const text = this.readTextOrEmpty(path.join(dir, 'package.json'));
220
+ const match = /"version"\s*:\s*"([^"]+)"/.exec(text);
221
+ return match === null ? '' : match[1];
222
+ }
223
+ // ─── ROW RENDERING ────────────────────────────────────────────────────────────────────────────────
224
+ startRow(ticket, startDir) {
225
+ return [
226
+ exports.BUILD_START,
227
+ `id=${ticket.id}`,
228
+ `t=${new Date(ticket.startedMs).toISOString()}`,
229
+ `ms=${String(ticket.startedMs)}`,
230
+ `by=${ticket.by}`,
231
+ `repo=${this.clip(ticket.repo)}`,
232
+ `tree=${this.dotDir.worktreeName(startDir) || 'primary'}`,
233
+ `cwd=${this.clip(startDir)}`,
234
+ `branch=${this.gitBranch(startDir)}`,
235
+ `pid=${String(process.pid)}`,
236
+ `wp=${this.executingVersion()}`,
237
+ ].join('\t');
238
+ }
239
+ doneRow(ticket, exitCode) {
240
+ const now = Date.now();
241
+ const fields = [
242
+ exitCode === 0 ? exports.BUILD_DONE_SUCCESS : exports.BUILD_DONE_FAIL,
243
+ `id=${ticket.id}`,
244
+ `t=${new Date(now).toISOString()}`,
245
+ `ms=${String(now)}`,
246
+ `by=${ticket.by}`,
247
+ `repo=${this.clip(ticket.repo)}`,
248
+ `took=${String(now - ticket.startedMs)}`,
249
+ ];
250
+ if (exitCode !== 0)
251
+ fields.push(`exit=${String(exitCode)}`);
252
+ fields.push(`pid=${String(process.pid)}`);
253
+ return fields.join('\t');
254
+ }
255
+ /**
256
+ * Hold a long path down to `max` characters by keeping its TAIL, which is the half that identifies
257
+ * the tree; a clipped value is marked with a leading `…` so nobody mistakes it for a real path.
258
+ *
259
+ * This is what keeps a row under `MAX_ROW_BYTES` — see the file docblock. `append` re-checks the
260
+ * assembled line as a backstop, because three clipped fields plus a long branch name can still add up.
261
+ */
262
+ clip(value, max = 120) {
263
+ const oneLine = value.replace(/[\t\n\r]/g, ' ');
264
+ return oneLine.length <= max ? oneLine : `…${oneLine.slice(oneLine.length - max)}`;
265
+ }
266
+ // ─── ROW PARSING ──────────────────────────────────────────────────────────────────────────────────
267
+ /** The value of `<name>=` on a TSV row, or '' when the row does not carry it. */
268
+ field(line, name) {
269
+ for (const part of line.split('\t')) {
270
+ if (part.startsWith(`${name}=`))
271
+ return part.slice(name.length + 1);
272
+ }
273
+ return '';
274
+ }
275
+ // A START row as a RunningBuild, or null when it is missing the two fields that make it usable.
276
+ toRunningBuild(line) {
277
+ const id = this.field(line, 'id');
278
+ const pid = Number.parseInt(this.field(line, 'pid'), 10);
279
+ if (id === '' || !Number.isInteger(pid) || pid <= 0)
280
+ return null;
281
+ const startedMs = Number.parseInt(this.field(line, 'ms'), 10);
282
+ return new RunningBuild(id, this.field(line, 'by'), this.field(line, 'repo'), this.field(line, 'tree'), this.field(line, 'cwd'), this.field(line, 'branch'), pid, Number.isInteger(startedMs) ? startedMs : 0);
283
+ }
284
+ /**
285
+ * Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH
286
+ * is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).
287
+ * Same test, same reasoning, as `AgentWorktreeLockReader.isRunning`, including the accepted
288
+ * imprecision of pid reuse: being wrong in the "still running" direction costs one extra refusal,
289
+ * being wrong the other way lets a fourth build start.
290
+ */
291
+ isAlive(pid) {
292
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
293
+ try {
294
+ process.kill(pid, 0);
295
+ return true;
296
+ }
297
+ catch (err) {
298
+ const error = (0, to_error_1.toError)(err);
299
+ return error.code !== 'ESRCH';
300
+ }
301
+ }
302
+ // ─── APPEND, LOCK, ROTATE ─────────────────────────────────────────────────────────────────────────
303
+ /**
304
+ * Append one row, best-effort. Takes the lock so rotation cannot race, and appends ANYWAY when the
305
+ * lock cannot be had within `LOCK_TIMEOUT_MS` — the row is under `PIPE_BUF`, so an unlocked
306
+ * `O_APPEND` write is still indivisible, and a build must never die because a log file was busy.
307
+ */
308
+ append(row, homeDir) {
309
+ const line = `${this.truncateToRowLimit(row)}\n`;
310
+ const held = this.tryAcquireLock(homeDir);
311
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
312
+ try {
313
+ this.ensureDir(this.logPath(homeDir));
314
+ if (held)
315
+ this.rotateIfLarge(homeDir);
316
+ fs.appendFileSync(this.logPath(homeDir), line);
317
+ }
318
+ catch (err) {
319
+ const error = (0, to_error_1.toError)(err);
320
+ void error; // logging may never fail a build — see the file docblock
321
+ }
322
+ finally {
323
+ if (held)
324
+ this.releaseLock(homeDir);
325
+ }
326
+ }
327
+ // The backstop for the PIPE_BUF invariant, measured in BYTES rather than characters because a path
328
+ // may hold multi-byte characters. Truncating a row loses fields off the end, which is strictly better
329
+ // than a torn line that breaks every row after it.
330
+ truncateToRowLimit(row) {
331
+ const bytes = Buffer.from(row, 'utf8');
332
+ // -1 for the newline `append` adds.
333
+ if (bytes.length <= exports.MAX_ROW_BYTES - 1)
334
+ return row;
335
+ return bytes.subarray(0, exports.MAX_ROW_BYTES - 1).toString('utf8');
336
+ }
337
+ /**
338
+ * Take the ledger lock, retrying every `LOCK_RETRY_MS` until `LOCK_TIMEOUT_MS`. False means "carry on
339
+ * without it" — never an error, and never a reason to skip the append.
340
+ *
341
+ * The mechanism is `MainSyncStatusService.tryAcquireMainSyncLock`'s, proven and deliberately copied
342
+ * rather than re-invented: an `wx` (O_CREAT|O_EXCL) create so exactly one of N racers wins, a payload
343
+ * carrying pid + started so a dead holder is identifiable, stale reclaim gated on pid liveness, and a
344
+ * re-read afterwards to confirm the entry on disk is OURS (a simultaneous reclaimer could have
345
+ * unlinked ours and written its own between the two calls).
346
+ */
347
+ tryAcquireLock(homeDir) {
348
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
349
+ this.ensureDir(this.lockPath(homeDir));
350
+ // Rendered rather than JSON.stringify'd off an anonymous object — two fields, both numbers, and
351
+ // the file's whole contract is `{"pid":N,"started":M}`.
352
+ const payload = `{"pid":${String(process.pid)},"started":${String(Date.now())}}\n`;
353
+ for (;;) {
354
+ if (this.createExclusive(this.lockPath(homeDir), payload))
355
+ return true;
356
+ if (!this.isHolderAlive(homeDir)) {
357
+ this.unlinkQuietly(this.lockPath(homeDir));
358
+ if (this.createExclusive(this.lockPath(homeDir), payload) && this.holderIsUs(homeDir))
359
+ return true;
360
+ }
361
+ if (Date.now() >= deadline)
362
+ return false;
363
+ this.sleep(LOCK_RETRY_MS);
364
+ }
365
+ }
366
+ releaseLock(homeDir) {
367
+ if (!this.holderIsUs(homeDir))
368
+ return;
369
+ this.unlinkQuietly(this.lockPath(homeDir));
370
+ }
371
+ // O_CREAT|O_EXCL write: true when THIS call created the file, false when it already existed.
372
+ createExclusive(file, payload) {
373
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
374
+ try {
375
+ fs.writeFileSync(file, payload, { flag: 'wx' });
376
+ return true;
377
+ }
378
+ catch (err) {
379
+ const error = (0, to_error_1.toError)(err);
380
+ void error;
381
+ return false;
382
+ }
383
+ }
384
+ // The pid recorded in the lock file, or 0 when there is no readable lock.
385
+ lockHolderPid(homeDir) {
386
+ const text = this.readTextOrEmpty(this.lockPath(homeDir));
387
+ if (text === '')
388
+ return 0;
389
+ const match = /"pid"\s*:\s*(\d+)/.exec(text);
390
+ return match === null ? 0 : Number.parseInt(match[1], 10);
391
+ }
392
+ // An unreadable or pid-less lock file counts as DEAD: it is a corpse from a crashed writer, and
393
+ // leaving it forever would mean every future append silently skips rotation.
394
+ isHolderAlive(homeDir) {
395
+ const pid = this.lockHolderPid(homeDir);
396
+ return pid > 0 && this.isAlive(pid);
397
+ }
398
+ holderIsUs(homeDir) {
399
+ return this.lockHolderPid(homeDir) === process.pid;
400
+ }
401
+ /**
402
+ * `.4→.5, .3→.4, … .log→.1`, dropping the old `.5`. Runs INSIDE the lock, which is the one place the
403
+ * lock is genuinely load-bearing: a rename-and-reopen really does race, and a writer that opened the
404
+ * old inode mid-shift would append into a file nobody reads again.
405
+ */
406
+ rotateIfLarge(homeDir) {
407
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
408
+ try {
409
+ if (!fs.existsSync(this.logPath(homeDir)))
410
+ return;
411
+ if (fs.statSync(this.logPath(homeDir)).size < exports.MAX_BUILDS_LOG_BYTES)
412
+ return;
413
+ this.unlinkQuietly(this.rotatedPath(exports.BUILDS_LOG_GENERATIONS, homeDir));
414
+ for (let gen = exports.BUILDS_LOG_GENERATIONS - 1; gen >= 1; gen -= 1) {
415
+ this.renameQuietly(this.rotatedPath(gen, homeDir), this.rotatedPath(gen + 1, homeDir));
416
+ }
417
+ this.renameQuietly(this.logPath(homeDir), this.rotatedPath(1, homeDir));
418
+ }
419
+ catch (err) {
420
+ const error = (0, to_error_1.toError)(err);
421
+ void error;
422
+ }
423
+ }
424
+ // ─── FILESYSTEM PRIMITIVES, ALL SWALLOWING ────────────────────────────────────────────────────────
425
+ ensureDir(file) {
426
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
427
+ try {
428
+ fs.mkdirSync(path.dirname(file), { recursive: true });
429
+ }
430
+ catch (err) {
431
+ const error = (0, to_error_1.toError)(err);
432
+ void error;
433
+ }
434
+ }
435
+ unlinkQuietly(file) {
436
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
437
+ try {
438
+ if (fs.existsSync(file))
439
+ fs.unlinkSync(file);
440
+ }
441
+ catch (err) {
442
+ const error = (0, to_error_1.toError)(err);
443
+ void error;
444
+ }
445
+ }
446
+ renameQuietly(from, to) {
447
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
448
+ try {
449
+ if (fs.existsSync(from))
450
+ fs.renameSync(from, to);
451
+ }
452
+ catch (err) {
453
+ const error = (0, to_error_1.toError)(err);
454
+ void error;
455
+ }
456
+ }
457
+ readTextOrEmpty(file) {
458
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
459
+ try {
460
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
461
+ }
462
+ catch (err) {
463
+ const error = (0, to_error_1.toError)(err);
464
+ void error;
465
+ return '';
466
+ }
467
+ }
468
+ readLines(file) {
469
+ const text = this.readTextOrEmpty(file);
470
+ if (text === '')
471
+ return [];
472
+ return text.split('\n').filter((line) => line.trim() !== '');
473
+ }
474
+ // A blocking sleep, because the lock retry sits on a synchronous append path that must not become
475
+ // async — `finish()` is called from a `finally` and an async logger there could outlive the process.
476
+ sleep(ms) {
477
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
478
+ try {
479
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
480
+ }
481
+ catch (err) {
482
+ const error = (0, to_error_1.toError)(err);
483
+ void error;
484
+ }
485
+ }
486
+ // The checked-out branch in `startDir`, or '' when git cannot say. spawnSync does not throw on a
487
+ // non-zero exit, so "not a repo" arrives as a status, not an exception.
488
+ gitBranch(startDir) {
489
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
490
+ try {
491
+ const result = (0, child_process_1.spawnSync)('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: startDir, encoding: 'utf8' });
492
+ if (result.status !== 0 || typeof result.stdout !== 'string')
493
+ return '';
494
+ return result.stdout.trim();
495
+ }
496
+ catch (err) {
497
+ const error = (0, to_error_1.toError)(err);
498
+ void error;
499
+ return '';
500
+ }
501
+ }
502
+ };
503
+ exports.BuildsLog = BuildsLog;
504
+ exports.BuildsLog = BuildsLog = tslib_1.__decorate([
505
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
506
+ tslib_1.__metadata("design:paramtypes", [state_dir_1.DotWebpieces])
507
+ ], BuildsLog);
508
+ //# sourceMappingURL=builds-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builds-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/builds-log.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,uDAAiC;AACjC,+CAAyB;AACzB,+CAAyB;AACzB,mDAA6B;AAC7B,yCAA2D;AAE3D,2CAA2C;AAC3C,+CAAgD;AAChD,yCAAqC;AAErC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACU,QAAA,eAAe,GAAG,YAAY,CAAC;AAC/B,QAAA,gBAAgB,GAAG,iBAAiB,CAAC;AAElD,kGAAkG;AACrF,QAAA,WAAW,GAAG,OAAO,CAAC;AACtB,QAAA,kBAAkB,GAAG,cAAc,CAAC;AACpC,QAAA,eAAe,GAAG,WAAW,CAAC;AAE3C,uFAAuF;AAC1E,QAAA,oBAAoB,GAAG,IAAI,GAAG,IAAI,CAAC;AACnC,QAAA,sBAAsB,GAAG,CAAC,CAAC;AAExC;;;;GAIG;AACU,QAAA,aAAa,GAAG,GAAG,CAAC;AAEjC,iGAAiG;AACjG,MAAM,aAAa,GAAG,EAAE,CAAC;AACzB,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B;;;;GAIG;AACH,MAAa,WAAW;IACpB,EAAE,CAAS;IACX,EAAE,CAAS;IACX,IAAI,CAAS;IACb,SAAS,CAAS;IAElB,YAAY,EAAU,EAAE,EAAU,EAAE,IAAY,EAAE,SAAiB;QAC/D,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;CACJ;AAZD,kCAYC;AAED;;;;GAIG;AACH,MAAa,YAAY;IACrB,EAAE,CAAS;IACX,EAAE,CAAS;IACX,IAAI,CAAS;IACb,IAAI,CAAS;IACb,GAAG,CAAS;IACZ,MAAM,CAAS;IACf,GAAG,CAAS;IACZ,SAAS,CAAS;IAElB,yDAAyD;IACzD,YACI,EAAU,EAAE,EAAU,EAAE,IAAY,EAAE,IAAY,EAClD,GAAW,EAAE,MAAc,EAAE,GAAW,EAAE,SAAiB;QAE3D,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;CACJ;AAxBD,oCAwBC;AAED;;;;;;;GAOG;AAEI,IAAM,SAAS,GAAf,MAAM,SAAS;IACW;IAA7B,YAA6B,MAAoB;QAApB,WAAM,GAAN,MAAM,CAAc;IAAG,CAAC;IAErD,iCAAiC;IACjC,OAAO,CAAC,UAAkB,EAAE,CAAC,OAAO,EAAE;QAClC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,6BAAe,EAAE,uBAAe,CAAC,CAAC;IAChE,CAAC;IAED,wEAAwE;IACxE,QAAQ,CAAC,UAAkB,EAAE,CAAC,OAAO,EAAE;QACnC,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,6BAAe,EAAE,wBAAgB,CAAC,CAAC;IACjE,CAAC;IAED,+EAA+E;IAC/E,WAAW,CAAC,UAAkB,EAAE,UAAkB,EAAE,CAAC,OAAO,EAAE;QAC1D,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;IAC5D,CAAC;IAED;;;;;;;;;;OAUG;IACH,KAAK,CAAC,EAAU,EAAE,QAAgB,EAAE,UAAkB,EAAE,CAAC,OAAO,EAAE;QAC9D,MAAM,MAAM,GAAG,IAAI,WAAW,CAC1B,MAAM,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAC5E,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;QACtD,OAAO,MAAM,CAAC;IAClB,CAAC;IAED;;;OAGG;IACH,MAAM,CAAC,MAAmB,EAAE,QAAgB,EAAE,UAAkB,EAAE,CAAC,OAAO,EAAE;QACxE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC;IAED;;;;;;;;;;OAUG;IACH,OAAO,CAAC,UAAkB,EAAE,CAAC,OAAO,EAAE;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;QACpD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACvB,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,0BAAkB,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,uBAAe,IAAI,CAAC,EAAE,CAAC;gBACxF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;YACrC,CAAC;QACL,CAAC;QACD,MAAM,IAAI,GAAmB,EAAE,CAAC;QAChC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACvB,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,mBAAW,IAAI,CAAC;gBAAE,SAAS;YACnD,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YACxC,IAAI,KAAK,KAAK,IAAI,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBAAE,SAAS;YACnD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC;gBAAE,SAAS;YACvC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,gBAAgB;QACZ,IAAI,GAAG,GAAG,SAAS,CAAC;QACpB,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC;YACtC,MAAM,OAAO,GAAG,IAAI,CAAC,yBAAyB,CAAC,GAAG,CAAC,CAAC;YACpD,IAAI,OAAO,KAAK,EAAE;gBAAE,OAAO,OAAO,CAAC;YACnC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACjC,IAAI,MAAM,KAAK,GAAG;gBAAE,OAAO,EAAE,CAAC;YAC9B,GAAG,GAAG,MAAM,CAAC;QACjB,CAAC;QACD,OAAO,EAAE,CAAC;IACd,CAAC;IAED,wFAAwF;IAChF,yBAAyB,CAAC,GAAW;QACzC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACzC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,YAAY,IAAI,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,KAAK,cAAc;YAAE,OAAO,EAAE,CAAC;QACvG,MAAM,IAAI,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC;QAClE,MAAM,KAAK,GAAG,2BAA2B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACrD,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1C,CAAC;IAED,qGAAqG;IAE7F,QAAQ,CAAC,MAAmB,EAAE,QAAgB;QAClD,OAAO;YACH,mBAAW;YACX,MAAM,MAAM,CAAC,EAAE,EAAE;YACjB,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,EAAE;YAC/C,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE;YAChC,MAAM,MAAM,CAAC,EAAE,EAAE;YACjB,QAAQ,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE;YAChC,QAAQ,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,SAAS,EAAE;YACzD,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE;YAC5B,UAAU,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE;YACpC,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;YAC5B,MAAM,IAAI,CAAC,gBAAgB,EAAE,EAAE;SAClC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAEO,OAAO,CAAC,MAAmB,EAAE,QAAgB;QACjD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,MAAM,GAAG;YACX,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,0BAAkB,CAAC,CAAC,CAAC,uBAAe;YACrD,MAAM,MAAM,CAAC,EAAE,EAAE;YACjB,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,EAAE;YAClC,MAAM,MAAM,CAAC,GAAG,CAAC,EAAE;YACnB,MAAM,MAAM,CAAC,EAAE,EAAE;YACjB,QAAQ,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE;YAChC,QAAQ,MAAM,CAAC,GAAG,GAAG,MAAM,CAAC,SAAS,CAAC,EAAE;SAC3C,CAAC;QACF,IAAI,QAAQ,KAAK,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,QAAQ,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QAC5D,MAAM,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC1C,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC;IAED;;;;;;OAMG;IACK,IAAI,CAAC,KAAa,EAAE,GAAG,GAAG,GAAG;QACjC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;QAChD,OAAO,OAAO,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,GAAG,CAAC,EAAE,CAAC;IACvF,CAAC;IAED,qGAAqG;IAErG,iFAAiF;IACzE,KAAK,CAAC,IAAY,EAAE,IAAY;QACpC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAClC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACxE,CAAC;QACD,OAAO,EAAE,CAAC;IACd,CAAC;IAED,gGAAgG;IACxF,cAAc,CAAC,IAAY;QAC/B,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAClC,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;QACzD,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACjE,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;QAC9D,OAAO,IAAI,YAAY,CACnB,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,EAC9E,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,EAAE,GAAG,EACxD,MAAM,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAC9C,CAAC;IACN,CAAC;IAED;;;;;;OAMG;IACK,OAAO,CAAC,GAAW;QACvB,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;YACrB,OAAO,IAAI,CAAC;QAChB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,OAAQ,KAA+B,CAAC,IAAI,KAAK,OAAO,CAAC;QAC7D,CAAC;IACL,CAAC;IAED,qGAAqG;IAErG;;;;OAIG;IACK,MAAM,CAAC,GAAW,EAAE,OAAe;QACvC,MAAM,IAAI,GAAG,GAAG,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,IAAI,CAAC;QACjD,MAAM,IAAI,GAAG,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;QAC1C,8DAA8D;QAC9D,IAAI,CAAC;YACD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;YACtC,IAAI,IAAI;gBAAE,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;YACtC,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,CAAC;QACnD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC,CAAE,yDAAyD;QAC1E,CAAC;gBAAS,CAAC;YACP,IAAI,IAAI;gBAAE,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QACxC,CAAC;IACL,CAAC;IAED,mGAAmG;IACnG,sGAAsG;IACtG,mDAAmD;IAC3C,kBAAkB,CAAC,GAAW;QAClC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACvC,oCAAoC;QACpC,IAAI,KAAK,CAAC,MAAM,IAAI,qBAAa,GAAG,CAAC;YAAE,OAAO,GAAG,CAAC;QAClD,OAAO,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,qBAAa,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACjE,CAAC;IAED;;;;;;;;;OASG;IACK,cAAc,CAAC,OAAe;QAClC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,eAAe,CAAC;QAC9C,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QACvC,gGAAgG;QAChG,wDAAwD;QACxD,MAAM,OAAO,GAAG,UAAU,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC;QACnF,SAAS,CAAC;YACN,IAAI,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;gBAAE,OAAO,IAAI,CAAC;YACvE,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/B,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;gBAC3C,IAAI,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;oBAAE,OAAO,IAAI,CAAC;YACvG,CAAC;YACD,IAAI,IAAI,CAAC,GAAG,EAAE,IAAI,QAAQ;gBAAE,OAAO,KAAK,CAAC;YACzC,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC9B,CAAC;IACL,CAAC;IAEO,WAAW,CAAC,OAAe;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO;QACtC,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/C,CAAC;IAED,6FAA6F;IACrF,eAAe,CAAC,IAAY,EAAE,OAAe;QACjD,8DAA8D;QAC9D,IAAI,CAAC;YACD,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAChD,OAAO,IAAI,CAAC;QAChB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,KAAK,CAAC;QACjB,CAAC;IACL,CAAC;IAED,0EAA0E;IAClE,aAAa,CAAC,OAAe;QACjC,MAAM,IAAI,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QAC1D,IAAI,IAAI,KAAK,EAAE;YAAE,OAAO,CAAC,CAAC;QAC1B,MAAM,KAAK,GAAG,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC9D,CAAC;IAED,gGAAgG;IAChG,6EAA6E;IACrE,aAAa,CAAC,OAAe;QACjC,MAAM,GAAG,GAAG,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACxC,OAAO,GAAG,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACxC,CAAC;IAEO,UAAU,CAAC,OAAe;QAC9B,OAAO,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,GAAG,CAAC;IACvD,CAAC;IAED;;;;OAIG;IACK,aAAa,CAAC,OAAe;QACjC,8DAA8D;QAC9D,IAAI,CAAC;YACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;gBAAE,OAAO;YAClD,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,GAAG,4BAAoB;gBAAE,OAAO;YAC3E,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,8BAAsB,EAAE,OAAO,CAAC,CAAC,CAAC;YACtE,KAAK,IAAI,GAAG,GAAG,8BAAsB,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;gBAC5D,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;YAC3F,CAAC;YACD,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;QAC5E,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;QACf,CAAC;IACL,CAAC;IAED,qGAAqG;IAE7F,SAAS,CAAC,IAAY;QAC1B,8DAA8D;QAC9D,IAAI,CAAC;YACD,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC1D,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;QACf,CAAC;IACL,CAAC;IAEO,aAAa,CAAC,IAAY;QAC9B,8DAA8D;QAC9D,IAAI,CAAC;YACD,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QACjD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;QACf,CAAC;IACL,CAAC;IAEO,aAAa,CAAC,IAAY,EAAE,EAAU;QAC1C,8DAA8D;QAC9D,IAAI,CAAC;YACD,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;QACf,CAAC;IACL,CAAC;IAEO,eAAe,CAAC,IAAY;QAChC,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACpE,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,EAAE,CAAC;QACd,CAAC;IACL,CAAC;IAEO,SAAS,CAAC,IAAY;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,IAAI,KAAK,EAAE;YAAE,OAAO,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAY,EAAW,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAClF,CAAC;IAED,kGAAkG;IAClG,qGAAqG;IAC7F,KAAK,CAAC,EAAU;QACpB,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QACrE,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;QACf,CAAC;IACL,CAAC;IAED,iGAAiG;IACjG,wEAAwE;IAChE,SAAS,CAAC,QAAgB;QAC9B,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,WAAW,EAAE,cAAc,EAAE,MAAM,CAAC,EACjE,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;YACzC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;gBAAE,OAAO,EAAE,CAAC;YACxE,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAChC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,EAAE,CAAC;QACd,CAAC;IACL,CAAC;CACJ,CAAA;AAvYY,8BAAS;oBAAT,SAAS;IADrB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAEA,wBAAY;GADxC,SAAS,CAuYrB","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as crypto from 'crypto';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { DotWebpieces } from './state-dir';\nimport { HOME_CONFIG_DIR } from './home-config';\nimport { toError } from './to-error';\n\n/**\n * `~/.webpieces/builds.log` — the MACHINE-WIDE, append-only ledger of every build this box has started.\n *\n * ─── WHY THIS ONE FILE LIVES OUTSIDE THE REPO ─────────────────────────────────────────────────────────\n * `no-machine-global-state.spec.ts` records the standing rule: webpieces writes state under\n * `{repo}/.webpieces` and nowhere else. This is the ONE carve-out, and the argument is written out in\n * `decisions/0006-the-build-ledger-is-machine-global.md`. In short:\n *\n * • The FACT is machine-scoped. \"How many builds are burning this box's CPU right now\" is not a\n * property of any repo; it is a property of the machine. A per-repo ledger cannot answer it — every\n * linked worktree has its OWN `.webpieces/`, so it would be blind to the sibling worktree it is\n * actually contending with, never mind the four other repos on the disk.\n * • It is NOT A CACHE. The retired `PrBodyStore` that the no-machine-global rule was written for was a\n * local copy of a fact GitHub owned, so it could be stale, missing, or on the wrong computer. There\n * is no remote copy of this. The file IS the fact.\n * • Its key is an ABSOLUTE LOCAL PATH, which is stable precisely because it never leaves the machine —\n * the instability that killed `PrBodyStore`'s `<host>/<owner>/<repo>` key cannot arise here.\n *\n * ─── WHY IT IS SAFE TO WRITE CONCURRENTLY ─────────────────────────────────────────────────────────────\n * Rows are deliberately kept under `MAX_ROW_BYTES` (512, macOS `PIPE_BUF`). A single `O_APPEND`\n * `write(2)` at or below that size is indivisible, so two builds appending at the same instant cannot\n * interleave halves of a line. The lock is therefore belt-and-braces for the APPEND and genuinely\n * load-bearing for ROTATION, where a rename-and-reopen really does race.\n *\n * ─── IT MAY NEVER FAIL A BUILD ────────────────────────────────────────────────────────────────────────\n * Every method here is best-effort and swallows its own errors. A build must never die because a log\n * file was busy, unwritable, or on a full disk. Lock acquisition retries and then gives up and appends\n * anyway — which the row-size invariant above makes safe.\n */\nexport const BUILDS_LOG_FILE = 'builds.log';\nexport const BUILDS_LOCK_FILE = 'builds.log.lock';\n\n/** START, and the two terminal kinds. `DONE-` is the greppable prefix that pairs with a START. */\nexport const BUILD_START = 'START';\nexport const BUILD_DONE_SUCCESS = 'DONE-SUCCESS';\nexport const BUILD_DONE_FAIL = 'DONE-FAIL';\n\n/** Rotate at 1 MB, keeping five generations (`.1` … `.5`); the old `.5` is dropped. */\nexport const MAX_BUILDS_LOG_BYTES = 1024 * 1024;\nexport const BUILDS_LOG_GENERATIONS = 5;\n\n/**\n * macOS `PIPE_BUF`. A row at or under this size is written by ONE indivisible `write(2)`, which is what\n * makes a lost lock a non-event rather than a corrupted file. Long paths are clipped to hold the line\n * under it — see `clip`.\n */\nexport const MAX_ROW_BYTES = 512;\n\n/** How long a build may hold the lock before another writer stops waiting and appends anyway. */\nconst LOCK_RETRY_MS = 50;\nconst LOCK_TIMEOUT_MS = 2000;\n\n/**\n * The handle a START row hands back, and the ONLY thing `finish()` accepts. Data-only (a class, per\n * CLAUDE.md), carrying exactly the fields the DONE row needs to pair itself with its START: the uuid,\n * the caller, the repo, and when it began (so `took=` is computed from one clock, not two).\n */\nexport class BuildTicket {\n id: string;\n by: string;\n repo: string;\n startedMs: number;\n\n constructor(id: string, by: string, repo: string, startedMs: number) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * One build that is STILL RUNNING — a START row with no matching `DONE-`, whose pid is still alive.\n * Data-only. This is what the refusal message renders, so it carries the three things a reader needs to\n * recognise the build in question: where it is, which tree, and how old it is.\n */\nexport class RunningBuild {\n id: string;\n by: string;\n repo: string;\n tree: string;\n cwd: string;\n branch: string;\n pid: number;\n startedMs: number;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n id: string, by: string, repo: string, tree: string,\n cwd: string, branch: string, pid: number, startedMs: number,\n ) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.tree = tree;\n this.cwd = cwd;\n this.branch = branch;\n this.pid = pid;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * The ledger. See the file docblock for why it is machine-global and why every operation swallows its\n * own errors.\n *\n * `homeDir` is a parameter on every public method, defaulted to `os.homedir()`, for exactly the reason\n * `HomeConfigService.configPath` takes one: a spec must be able to exercise the real code against a temp\n * directory and must never touch the developer's actual `~/.webpieces`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildsLog {\n constructor(private readonly dotDir: DotWebpieces) {}\n\n /** `~/.webpieces/builds.log`. */\n logPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOG_FILE);\n }\n\n /** `~/.webpieces/builds.log.lock` — `{\"pid\":N,\"started\":<epochMs>}`. */\n lockPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOCK_FILE);\n }\n\n /** `~/.webpieces/builds.log.<n>` — generation `n`, 1 being the most recent. */\n rotatedPath(generation: number, homeDir: string = os.homedir()): string {\n return `${this.logPath(homeDir)}.${String(generation)}`;\n }\n\n /**\n * Record that a build is starting, and hand back the ticket its DONE row will need.\n *\n * `by` is the CALLER — `BuildGateOptions.stage`, i.e. `build` | `review` | `finish`. There is no\n * second \"caller\" concept anywhere: the stage id already is one, and a second spelling of it would\n * be the shim CLAUDE.md rejects.\n *\n * Returns a ticket even when the append failed. A build whose START row never landed still has to be\n * able to call `finish()`; the alternative is a nullable return that every call site must branch on\n * for a logging failure that is, by policy, not an error.\n */\n start(by: string, startDir: string, homeDir: string = os.homedir()): BuildTicket {\n const ticket = new BuildTicket(\n crypto.randomUUID(), by, this.dotDir.primaryRoot(startDir), Date.now());\n this.append(this.startRow(ticket, startDir), homeDir);\n return ticket;\n }\n\n /**\n * Record that the build behind `ticket` has ended. `exitCode` 0 writes `DONE-SUCCESS`; anything else\n * writes `DONE-FAIL` carrying the code, so `grep DONE-FAIL` lists every red build on the machine.\n */\n finish(ticket: BuildTicket, exitCode: number, homeDir: string = os.homedir()): void {\n this.append(this.doneRow(ticket, exitCode), homeDir);\n }\n\n /**\n * Every build that is still live: a `START` with no matching `DONE-` row, whose pid is still alive.\n *\n * The pid filter is not an optimisation, it is what keeps the ledger from wedging the machine. A\n * build killed with SIGKILL — an agent cancelled mid-run, a terminal closed — writes no DONE row, so\n * without the liveness test its START would count forever and the fourth build would be refused for\n * the rest of the machine's life. The uuid answers \"which build\"; the pid answers \"is it still real\".\n *\n * Only the CURRENT generation is read. A rotated-away START is by definition at least 1 MB of rows\n * old and is not a build anyone is contending with.\n */\n running(homeDir: string = os.homedir()): RunningBuild[] {\n const lines = this.readLines(this.logPath(homeDir));\n const done = new Set<string>();\n for (const line of lines) {\n if (line.startsWith(`${BUILD_DONE_SUCCESS}\\t`) || line.startsWith(`${BUILD_DONE_FAIL}\\t`)) {\n done.add(this.field(line, 'id'));\n }\n }\n const live: RunningBuild[] = [];\n for (const line of lines) {\n if (!line.startsWith(`${BUILD_START}\\t`)) continue;\n const build = this.toRunningBuild(line);\n if (build === null || done.has(build.id)) continue;\n if (!this.isAlive(build.pid)) continue;\n live.push(build);\n }\n return live;\n }\n\n /**\n * The `@webpieces` release ACTUALLY EXECUTING — found by walking UP from this module's own directory\n * to the nearest enclosing `node_modules/@webpieces/<pkg>/package.json`. `''` when this code is\n * running from source rather than from an installed package (which is the state in this repo's own\n * specs, and a perfectly ordinary answer).\n *\n * ─── WHY THIS IS NOT `WebpiecesVersions.readInstalled(root)` ──────────────────────────────────────\n * They answer DIFFERENT QUESTIONS and merging them would break the older one. `readInstalled` joins\n * `<root>/node_modules/@webpieces/...` at a FIXED tree root ON PURPOSE: its whole job is to detect\n * DRIFT between what a tree PINS and what some other tree pins, and a walk-up would silently resolve\n * a worktree with no install of its own to the primary clone's copy — hiding exactly the skew that\n * guard exists to catch. This question is the opposite one: \"whichever copy is running, name it\", and\n * for that the walk-up is the only correct answer. Do not fold them together.\n */\n executingVersion(): string {\n let dir = __dirname;\n for (let hops = 0; hops < 40; hops += 1) {\n const version = this.versionOfEnclosingPackage(dir);\n if (version !== '') return version;\n const parent = path.dirname(dir);\n if (parent === dir) return '';\n dir = parent;\n }\n return '';\n }\n\n // `<dir>` is `node_modules/@webpieces/<pkg>/...`? Then that package's version, else ''.\n private versionOfEnclosingPackage(dir: string): string {\n const parent = path.dirname(dir);\n const grandparent = path.dirname(parent);\n if (path.basename(parent) !== '@webpieces' || path.basename(grandparent) !== 'node_modules') return '';\n const text = this.readTextOrEmpty(path.join(dir, 'package.json'));\n const match = /\"version\"\\s*:\\s*\"([^\"]+)\"/.exec(text);\n return match === null ? '' : match[1];\n }\n\n // ─── ROW RENDERING ────────────────────────────────────────────────────────────────────────────────\n\n private startRow(ticket: BuildTicket, startDir: string): string {\n return [\n BUILD_START,\n `id=${ticket.id}`,\n `t=${new Date(ticket.startedMs).toISOString()}`,\n `ms=${String(ticket.startedMs)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `tree=${this.dotDir.worktreeName(startDir) || 'primary'}`,\n `cwd=${this.clip(startDir)}`,\n `branch=${this.gitBranch(startDir)}`,\n `pid=${String(process.pid)}`,\n `wp=${this.executingVersion()}`,\n ].join('\\t');\n }\n\n private doneRow(ticket: BuildTicket, exitCode: number): string {\n const now = Date.now();\n const fields = [\n exitCode === 0 ? BUILD_DONE_SUCCESS : BUILD_DONE_FAIL,\n `id=${ticket.id}`,\n `t=${new Date(now).toISOString()}`,\n `ms=${String(now)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `took=${String(now - ticket.startedMs)}`,\n ];\n if (exitCode !== 0) fields.push(`exit=${String(exitCode)}`);\n fields.push(`pid=${String(process.pid)}`);\n return fields.join('\\t');\n }\n\n /**\n * Hold a long path down to `max` characters by keeping its TAIL, which is the half that identifies\n * the tree; a clipped value is marked with a leading `…` so nobody mistakes it for a real path.\n *\n * This is what keeps a row under `MAX_ROW_BYTES` — see the file docblock. `append` re-checks the\n * assembled line as a backstop, because three clipped fields plus a long branch name can still add up.\n */\n private clip(value: string, max = 120): string {\n const oneLine = value.replace(/[\\t\\n\\r]/g, ' ');\n return oneLine.length <= max ? oneLine : `…${oneLine.slice(oneLine.length - max)}`;\n }\n\n // ─── ROW PARSING ──────────────────────────────────────────────────────────────────────────────────\n\n /** The value of `<name>=` on a TSV row, or '' when the row does not carry it. */\n private field(line: string, name: string): string {\n for (const part of line.split('\\t')) {\n if (part.startsWith(`${name}=`)) return part.slice(name.length + 1);\n }\n return '';\n }\n\n // A START row as a RunningBuild, or null when it is missing the two fields that make it usable.\n private toRunningBuild(line: string): RunningBuild | null {\n const id = this.field(line, 'id');\n const pid = Number.parseInt(this.field(line, 'pid'), 10);\n if (id === '' || !Number.isInteger(pid) || pid <= 0) return null;\n const startedMs = Number.parseInt(this.field(line, 'ms'), 10);\n return new RunningBuild(\n id, this.field(line, 'by'), this.field(line, 'repo'), this.field(line, 'tree'),\n this.field(line, 'cwd'), this.field(line, 'branch'), pid,\n Number.isInteger(startedMs) ? startedMs : 0,\n );\n }\n\n /**\n * Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH\n * is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).\n * Same test, same reasoning, as `AgentWorktreeLockReader.isRunning`, including the accepted\n * imprecision of pid reuse: being wrong in the \"still running\" direction costs one extra refusal,\n * being wrong the other way lets a fourth build start.\n */\n private isAlive(pid: number): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n }\n\n // ─── APPEND, LOCK, ROTATE ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Append one row, best-effort. Takes the lock so rotation cannot race, and appends ANYWAY when the\n * lock cannot be had within `LOCK_TIMEOUT_MS` — the row is under `PIPE_BUF`, so an unlocked\n * `O_APPEND` write is still indivisible, and a build must never die because a log file was busy.\n */\n private append(row: string, homeDir: string): void {\n const line = `${this.truncateToRowLimit(row)}\\n`;\n const held = this.tryAcquireLock(homeDir);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n this.ensureDir(this.logPath(homeDir));\n if (held) this.rotateIfLarge(homeDir);\n fs.appendFileSync(this.logPath(homeDir), line);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // logging may never fail a build — see the file docblock\n } finally {\n if (held) this.releaseLock(homeDir);\n }\n }\n\n // The backstop for the PIPE_BUF invariant, measured in BYTES rather than characters because a path\n // may hold multi-byte characters. Truncating a row loses fields off the end, which is strictly better\n // than a torn line that breaks every row after it.\n private truncateToRowLimit(row: string): string {\n const bytes = Buffer.from(row, 'utf8');\n // -1 for the newline `append` adds.\n if (bytes.length <= MAX_ROW_BYTES - 1) return row;\n return bytes.subarray(0, MAX_ROW_BYTES - 1).toString('utf8');\n }\n\n /**\n * Take the ledger lock, retrying every `LOCK_RETRY_MS` until `LOCK_TIMEOUT_MS`. False means \"carry on\n * without it\" — never an error, and never a reason to skip the append.\n *\n * The mechanism is `MainSyncStatusService.tryAcquireMainSyncLock`'s, proven and deliberately copied\n * rather than re-invented: an `wx` (O_CREAT|O_EXCL) create so exactly one of N racers wins, a payload\n * carrying pid + started so a dead holder is identifiable, stale reclaim gated on pid liveness, and a\n * re-read afterwards to confirm the entry on disk is OURS (a simultaneous reclaimer could have\n * unlinked ours and written its own between the two calls).\n */\n private tryAcquireLock(homeDir: string): boolean {\n const deadline = Date.now() + LOCK_TIMEOUT_MS;\n this.ensureDir(this.lockPath(homeDir));\n // Rendered rather than JSON.stringify'd off an anonymous object — two fields, both numbers, and\n // the file's whole contract is `{\"pid\":N,\"started\":M}`.\n const payload = `{\"pid\":${String(process.pid)},\"started\":${String(Date.now())}}\\n`;\n for (;;) {\n if (this.createExclusive(this.lockPath(homeDir), payload)) return true;\n if (!this.isHolderAlive(homeDir)) {\n this.unlinkQuietly(this.lockPath(homeDir));\n if (this.createExclusive(this.lockPath(homeDir), payload) && this.holderIsUs(homeDir)) return true;\n }\n if (Date.now() >= deadline) return false;\n this.sleep(LOCK_RETRY_MS);\n }\n }\n\n private releaseLock(homeDir: string): void {\n if (!this.holderIsUs(homeDir)) return;\n this.unlinkQuietly(this.lockPath(homeDir));\n }\n\n // O_CREAT|O_EXCL write: true when THIS call created the file, false when it already existed.\n private createExclusive(file: string, payload: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(file, payload, { flag: 'wx' });\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // The pid recorded in the lock file, or 0 when there is no readable lock.\n private lockHolderPid(homeDir: string): number {\n const text = this.readTextOrEmpty(this.lockPath(homeDir));\n if (text === '') return 0;\n const match = /\"pid\"\\s*:\\s*(\\d+)/.exec(text);\n return match === null ? 0 : Number.parseInt(match[1], 10);\n }\n\n // An unreadable or pid-less lock file counts as DEAD: it is a corpse from a crashed writer, and\n // leaving it forever would mean every future append silently skips rotation.\n private isHolderAlive(homeDir: string): boolean {\n const pid = this.lockHolderPid(homeDir);\n return pid > 0 && this.isAlive(pid);\n }\n\n private holderIsUs(homeDir: string): boolean {\n return this.lockHolderPid(homeDir) === process.pid;\n }\n\n /**\n * `.4→.5, .3→.4, … .log→.1`, dropping the old `.5`. Runs INSIDE the lock, which is the one place the\n * lock is genuinely load-bearing: a rename-and-reopen really does race, and a writer that opened the\n * old inode mid-shift would append into a file nobody reads again.\n */\n private rotateIfLarge(homeDir: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(this.logPath(homeDir))) return;\n if (fs.statSync(this.logPath(homeDir)).size < MAX_BUILDS_LOG_BYTES) return;\n this.unlinkQuietly(this.rotatedPath(BUILDS_LOG_GENERATIONS, homeDir));\n for (let gen = BUILDS_LOG_GENERATIONS - 1; gen >= 1; gen -= 1) {\n this.renameQuietly(this.rotatedPath(gen, homeDir), this.rotatedPath(gen + 1, homeDir));\n }\n this.renameQuietly(this.logPath(homeDir), this.rotatedPath(1, homeDir));\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // ─── FILESYSTEM PRIMITIVES, ALL SWALLOWING ────────────────────────────────────────────────────────\n\n private ensureDir(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private unlinkQuietly(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(file)) fs.unlinkSync(file);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private renameQuietly(from: string, to: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(from)) fs.renameSync(from, to);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private readTextOrEmpty(file: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n\n private readLines(file: string): string[] {\n const text = this.readTextOrEmpty(file);\n if (text === '') return [];\n return text.split('\\n').filter((line: string): boolean => line.trim() !== '');\n }\n\n // A blocking sleep, because the lock retry sits on a synchronous append path that must not become\n // async — `finish()` is called from a `finally` and an async logger there could outlive the process.\n private sleep(ms: number): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // The checked-out branch in `startDir`, or '' when git cannot say. spawnSync does not throw on a\n // non-zero exit, so \"not a repo\" arrives as a status, not an exception.\n private gitBranch(startDir: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],\n { cwd: startDir, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') return '';\n return result.stdout.trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n}\n"]}
package/src/cli-args.d.ts CHANGED
@@ -20,8 +20,11 @@ export declare class CliFlag {
20
20
  * Usage descriptor for a `wp-*` bin. Data-only (classes-over-interfaces): a command name, its one-line
21
21
  * summary, and the flags it accepts. `CliArgs.classify` turns it into the `--help` / unknown-arg message.
22
22
  *
23
- * `flags` defaults to [] — the no-argument case stays a two-arg construction, which is what eight of the
24
- * nine `wp-*` bins are. A flag a command does not DECLARE here is still rejected with exit 2: that guard is
23
+ * `flags` defaults to [] — the no-argument case stays a two-arg construction, which is what MOST `wp-*`
24
+ * bins are. (Deliberately not a count: the last one written down went stale the next time a bin grew a
25
+ * flag, which is exactly the drift the corollary in CLAUDE.md is about.)
26
+ *
27
+ * A flag a command does not DECLARE here is still rejected with exit 2: that guard is
25
28
  * the reason this class exists (`wp-start-upsert-pr --help` once launched a squash-merge), and making it
26
29
  * flag-aware must not soften it.
27
30
  */
package/src/cli-args.js CHANGED
@@ -31,8 +31,11 @@ exports.CliFlag = CliFlag;
31
31
  * Usage descriptor for a `wp-*` bin. Data-only (classes-over-interfaces): a command name, its one-line
32
32
  * summary, and the flags it accepts. `CliArgs.classify` turns it into the `--help` / unknown-arg message.
33
33
  *
34
- * `flags` defaults to [] — the no-argument case stays a two-arg construction, which is what eight of the
35
- * nine `wp-*` bins are. A flag a command does not DECLARE here is still rejected with exit 2: that guard is
34
+ * `flags` defaults to [] — the no-argument case stays a two-arg construction, which is what MOST `wp-*`
35
+ * bins are. (Deliberately not a count: the last one written down went stale the next time a bin grew a
36
+ * flag, which is exactly the drift the corollary in CLAUDE.md is about.)
37
+ *
38
+ * A flag a command does not DECLARE here is still rejected with exit 2: that guard is
36
39
  * the reason this class exists (`wp-start-upsert-pr --help` once launched a squash-merge), and making it
37
40
  * flag-aware must not soften it.
38
41
  */
@@ -95,7 +98,7 @@ class CliScan {
95
98
  /** Argument guard for the no-argument `wp-*` bins. */
96
99
  let CliArgs = class CliArgs {
97
100
  // The help/usage block shown for `--help` and appended to an unknown-arg error. A command with no
98
- // declared flags says so outright, because "takes no arguments" is the whole usage for eight of nine.
101
+ // declared flags says so outright, because "takes no arguments" is the whole usage for most of them.
99
102
  usageText(usage) {
100
103
  const head = `${usage.command} — ${usage.summary}\n\n`;
101
104
  if (usage.flags.length === 0) {