staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Where observations live on disk.
3
+ *
4
+ * One line of JSON per fact, in files keyed by which build was running and which journey was
5
+ * walked. JSONL rather than one big JSON document for three reasons: a capture can be written
6
+ * as it happens instead of held in memory, a torn file loses its last line rather than all of
7
+ * them, and `grep` works on it, which matters more than it sounds when a run reports something
8
+ * strange at three in the morning.
9
+ *
10
+ * .staysfixed/v2/
11
+ * builds/<build>/build.json what we know about the build
12
+ * builds/<build>/<journey>/<capture>.jsonl one run of one journey
13
+ * references.json which build each product calls 'working'
14
+ *
15
+ * WHAT THIS COSTS ON DISK, honestly, because the design brief noticed nobody had costed it:
16
+ * one observation line runs about 150 bytes. A CLI journey makes maybe 300 of them — 45 KB, a
17
+ * rounding error. A full desktop sweep makes around 20,000 — about 3 MB a capture. A check
18
+ * runs the new build twice, so 6 MB a check; ten checks a day is 60 MB, and about 22 GB a
19
+ * year. That is NOT "small and kept forever". So the rule is: captures against a REFERENCE
20
+ * build are kept forever, because they are what everything is compared against; captures
21
+ * against working builds are pruned to the newest few per journey by `pruneBuild`. Build
22
+ * ARTIFACTS are never kept here at all — that is the marker system's job, and it keeps them
23
+ * only at markers for exactly this reason.
24
+ *
25
+ * Two safety properties this file owes the rest of the tool:
26
+ * - A file being written is never visible half-written. Everything lands as `.part` and is
27
+ * renamed into place, and a rename is atomic on every filesystem we run on.
28
+ * - A capture that was killed mid-write is readable anyway. The reader takes whole lines and
29
+ * stops at the first torn one, and says `complete: false` rather than pretending.
30
+ */
31
+
32
+ import fs from 'node:fs';
33
+ import fsp from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import crypto from 'node:crypto';
36
+ import { safeName } from '../core/paths.js';
37
+ import { StaysFixedError } from '../core/errors.js';
38
+ import { sortObservations } from './observation.js';
39
+
40
+ /**
41
+ * @typedef {import('./types.js').Store} Store
42
+ * @typedef {import('./types.js').Capture} Capture
43
+ * @typedef {import('./types.js').CaptureRef} CaptureRef
44
+ * @typedef {import('./types.js').CaptureRun} CaptureRun
45
+ * @typedef {import('./types.js').Observation} Observation
46
+ * @typedef {import('./types.js').BuildFingerprint} BuildFingerprint
47
+ * @typedef {import('./types.js').BuildRecord} BuildRecord
48
+ * @typedef {import('./types.js').ReferencePointer} ReferencePointer
49
+ * @typedef {import('./types.js').Coverage} Coverage
50
+ * @typedef {import('./types.js').JourneySource} JourneySource
51
+ */
52
+
53
+ /** The format written into every header line, so a future reader can tell what it is holding. */
54
+ const FORMAT = 2;
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Opening the store
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * @param {{root?: string, dir?: string}} [opts] `dir` overrides the whole location; `root` is
62
+ * the project folder and the usual way in.
63
+ * @returns {Store}
64
+ */
65
+ export function openStore(opts = {}) {
66
+ const root = path.resolve(opts.root ?? process.cwd());
67
+ const dir = opts.dir ? path.resolve(opts.dir) : path.join(root, '.staysfixed', 'v2');
68
+ return {
69
+ root,
70
+ dir,
71
+ buildsDir: path.join(dir, 'builds'),
72
+ referencesFile: path.join(dir, 'references.json'),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * @param {Store} store
78
+ * @param {string} buildId
79
+ * @returns {string}
80
+ */
81
+ function buildDir(store, buildId) {
82
+ return path.join(store.buildsDir, safeName(buildId));
83
+ }
84
+
85
+ /**
86
+ * A sortable capture id: when it ran, and which of the two runs it was.
87
+ * @param {CaptureRun} run
88
+ * @param {Date} [now]
89
+ * @returns {string}
90
+ */
91
+ export function newCaptureId(run, now = new Date()) {
92
+ /**
93
+ * @param {number} n
94
+ * @returns {string}
95
+ */
96
+ const p = (n) => String(n).padStart(2, '0');
97
+ const stamp =
98
+ `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-` +
99
+ `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
100
+ // Two captures inside one second is normal for fast CLI journeys, so the id carries a few
101
+ // random characters as well. Without them the second one would silently overwrite the first.
102
+ return `${stamp}-${run}-${crypto.randomBytes(3).toString('hex')}`;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Writing
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Write a file so nobody can ever read it half-finished.
111
+ * @param {string} file
112
+ * @param {string} text
113
+ */
114
+ async function writeAtomic(file, text) {
115
+ await fsp.mkdir(path.dirname(file), { recursive: true });
116
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.part`;
117
+ await fsp.writeFile(temp, text);
118
+ await fsp.rename(temp, file);
119
+ }
120
+
121
+ /**
122
+ * Record what we know about a build, and merge it with whatever we knew before.
123
+ *
124
+ * Called every time a capture is saved, so `lastSeenAt` and the journey list stay true without
125
+ * anybody having to remember to update them.
126
+ *
127
+ * @param {Store} store
128
+ * @param {BuildFingerprint} fingerprint
129
+ * @param {{journey?: string, at?: string, captures?: number}} [opts]
130
+ * @returns {Promise<BuildRecord>}
131
+ */
132
+ export async function saveBuild(store, fingerprint, opts = {}) {
133
+ if (!fingerprint?.id) throw new StaysFixedError('A build needs an id before its observations can be stored.');
134
+ if (!fingerprint.product) throw new StaysFixedError(`Build ${fingerprint.id} does not say which product it is of.`);
135
+
136
+ const at = opts.at ?? new Date().toISOString();
137
+ const existing = await loadBuild(store, fingerprint.id);
138
+ const journeys = new Set(existing?.journeys ?? []);
139
+ if (opts.journey) journeys.add(opts.journey);
140
+
141
+ /** @type {BuildRecord} */
142
+ const record = {
143
+ fingerprint: { ...existing?.fingerprint, ...fingerprint },
144
+ firstSeenAt: existing?.firstSeenAt ?? at,
145
+ lastSeenAt: at,
146
+ captures: opts.captures ?? existing?.captures ?? 0,
147
+ journeys: [...journeys].sort(),
148
+ };
149
+ await writeAtomic(path.join(buildDir(store, fingerprint.id), 'build.json'), JSON.stringify(record, null, 2) + '\n');
150
+ return record;
151
+ }
152
+
153
+ /**
154
+ * Store one finished capture.
155
+ *
156
+ * @param {Store} store
157
+ * @param {Capture} capture
158
+ * @returns {Promise<CaptureRef>}
159
+ */
160
+ export async function saveCapture(store, capture) {
161
+ const ref = refFor(store, capture.build.id, capture.journey, capture.id);
162
+ const lines = [JSON.stringify(headerOf(capture))];
163
+ for (const o of sortObservations(capture.observations)) lines.push(JSON.stringify(o));
164
+ lines.push(JSON.stringify(endOf(capture, capture.observations.length)));
165
+ await writeAtomic(ref.file, lines.join('\n') + '\n');
166
+ await bumpBuild(store, capture);
167
+ return ref;
168
+ }
169
+
170
+ /**
171
+ * Store a capture as it happens, rather than holding every observation in memory.
172
+ *
173
+ * The file only appears under its real name when `close` is called, so a run that dies halfway
174
+ * leaves a `.part` file that no reader will ever mistake for a capture. `sweepIncomplete`
175
+ * clears those up.
176
+ *
177
+ * @param {Store} store
178
+ * @param {{build: BuildFingerprint, journey: string, run: CaptureRun, id?: string, source?: JourneySource, startedAt?: string, rules?: string}} opts
179
+ * @returns {Promise<{ref: CaptureRef, append: (o: Observation) => Promise<void>, close: (end?: {durationMs?: number, coverage?: Coverage, note?: string}) => Promise<CaptureRef>, abandon: () => Promise<void>}>}
180
+ */
181
+ export async function openCaptureWriter(store, opts) {
182
+ const id = opts.id ?? newCaptureId(opts.run);
183
+ const startedAt = opts.startedAt ?? new Date().toISOString();
184
+ const ref = refFor(store, opts.build.id, opts.journey, id);
185
+ const temp = `${ref.file}.part`;
186
+
187
+ await fsp.mkdir(path.dirname(ref.file), { recursive: true });
188
+ const handle = await fsp.open(temp, 'w');
189
+ let count = 0;
190
+ const started = Date.now();
191
+
192
+ /** @type {Capture} */
193
+ const shell = {
194
+ id,
195
+ journey: opts.journey,
196
+ build: opts.build,
197
+ run: opts.run,
198
+ startedAt,
199
+ durationMs: 0,
200
+ observations: [],
201
+ };
202
+ if (opts.source) shell.source = opts.source;
203
+ if (opts.rules) shell.rules = opts.rules;
204
+ await handle.write(JSON.stringify(headerOf(shell)) + '\n');
205
+
206
+ return {
207
+ ref,
208
+ async append(o) {
209
+ count++;
210
+ await handle.write(JSON.stringify(o) + '\n');
211
+ },
212
+ async close(end = {}) {
213
+ const finished = { ...shell, durationMs: end.durationMs ?? Date.now() - started };
214
+ if (end.coverage) finished.coverage = end.coverage;
215
+ if (end.note) finished.note = end.note;
216
+ await handle.write(JSON.stringify(endOf(finished, count)) + '\n');
217
+ await handle.close();
218
+ await fsp.rename(temp, ref.file);
219
+ await bumpBuild(store, finished);
220
+ return ref;
221
+ },
222
+ async abandon() {
223
+ await handle.close();
224
+ await fsp.rm(temp, { force: true });
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * @param {Store} store
231
+ * @param {Capture} capture
232
+ */
233
+ async function bumpBuild(store, capture) {
234
+ // Counted from what is actually on disk rather than incremented, so a pruned build reports
235
+ // the truth instead of a number that only ever goes up.
236
+ const captures = (await listCaptures(store, { buildId: capture.build.id })).length;
237
+ await saveBuild(store, capture.build, { journey: capture.journey, captures });
238
+ }
239
+
240
+ /**
241
+ * @param {Capture} capture
242
+ * @returns {Record<string, unknown>}
243
+ */
244
+ function headerOf(capture) {
245
+ return {
246
+ kind: 'capture',
247
+ format: FORMAT,
248
+ id: capture.id,
249
+ journey: capture.journey,
250
+ source: capture.source,
251
+ build: capture.build,
252
+ run: capture.run,
253
+ startedAt: capture.startedAt,
254
+ rules: capture.rules,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * @param {Capture} capture
260
+ * @param {number} count
261
+ * @returns {Record<string, unknown>}
262
+ */
263
+ function endOf(capture, count) {
264
+ return {
265
+ kind: 'end',
266
+ count,
267
+ durationMs: capture.durationMs,
268
+ coverage: capture.coverage,
269
+ note: capture.note,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * @param {Store} store
275
+ * @param {string} buildId
276
+ * @param {string} journey
277
+ * @param {string} captureId
278
+ * @returns {CaptureRef}
279
+ */
280
+ function refFor(store, buildId, journey, captureId) {
281
+ return {
282
+ buildId,
283
+ journey,
284
+ captureId,
285
+ file: path.join(buildDir(store, buildId), safeName(journey), `${safeName(captureId)}.jsonl`),
286
+ };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Reading
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Read a stored capture back.
295
+ *
296
+ * Torn files are the point of this function. A run killed mid-write, a disk that filled, a
297
+ * laptop that died — all of them leave a file whose last line is half a JSON object. That line
298
+ * is dropped, `complete` comes back false, and the caller can decide. What must never happen
299
+ * is a silent parse failure that reads as "this journey observed nothing", because that is
300
+ * indistinguishable from "everything vanished", which is the loudest finding the tool has.
301
+ *
302
+ * @param {Store} store
303
+ * @param {CaptureRef|{buildId: string, journey: string, captureId: string}|string} where
304
+ * A ref, the three parts, or an absolute path to the file.
305
+ * @returns {Promise<Capture|null>}
306
+ */
307
+ export async function loadCapture(store, where) {
308
+ const file = typeof where === 'string'
309
+ ? where
310
+ : 'file' in where && where.file
311
+ ? where.file
312
+ : refFor(store, where.buildId, where.journey, where.captureId).file;
313
+
314
+ /** @type {string} */
315
+ let raw;
316
+ try {
317
+ raw = await fsp.readFile(file, 'utf8');
318
+ } catch {
319
+ return null;
320
+ }
321
+
322
+ const lines = raw.split('\n');
323
+ /** @type {Record<string, any>|null} */
324
+ let header = null;
325
+ /** @type {Observation[]} */
326
+ const observations = [];
327
+ /** @type {Record<string, any>|null} */
328
+ let end = null;
329
+ let unreadable = 0;
330
+
331
+ for (const line of lines) {
332
+ if (line.trim() === '') continue;
333
+ /** @type {any} */
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(line);
337
+ } catch {
338
+ unreadable++;
339
+ continue;
340
+ }
341
+ if (parsed?.kind === 'capture') {
342
+ header = parsed;
343
+ continue;
344
+ }
345
+ if (parsed?.kind === 'end') {
346
+ end = parsed;
347
+ continue;
348
+ }
349
+ if (typeof parsed?.path === 'string' && typeof parsed?.channel === 'string') {
350
+ observations.push(/** @type {Observation} */ (parsed));
351
+ } else {
352
+ unreadable++;
353
+ }
354
+ }
355
+
356
+ if (!header) {
357
+ throw new StaysFixedError(`${file} does not start with a capture header, so it is not a Stays Fixed capture file.`, {
358
+ hint: 'Delete it and run the check again — a capture is evidence, never a promise, and it can always be retaken.',
359
+ });
360
+ }
361
+
362
+ const complete = Boolean(end) && unreadable === 0 && (end?.count === undefined || end.count === observations.length);
363
+
364
+ /** @type {Capture} */
365
+ const capture = {
366
+ id: header.id,
367
+ journey: header.journey,
368
+ build: header.build,
369
+ run: header.run ?? 'single',
370
+ startedAt: header.startedAt,
371
+ durationMs: end?.durationMs ?? 0,
372
+ observations,
373
+ complete,
374
+ };
375
+ if (header.source) capture.source = header.source;
376
+ if (header.rules) capture.rules = header.rules;
377
+ if (end?.coverage) capture.coverage = end.coverage;
378
+
379
+ const notes = [];
380
+ if (end?.note) notes.push(end.note);
381
+ if (!end) notes.push('This capture was never finished — the run stopped while it was being written, so some of what the journey saw is missing.');
382
+ if (unreadable > 0) notes.push(`${unreadable} line${unreadable === 1 ? '' : 's'} in this file could not be read and ${unreadable === 1 ? 'was' : 'were'} skipped.`);
383
+ if (end?.count !== undefined && end.count !== observations.length) {
384
+ notes.push(`This file says it holds ${end.count} observations and ${observations.length} could be read.`);
385
+ }
386
+ if (notes.length > 0) capture.note = notes.join(' ');
387
+
388
+ return capture;
389
+ }
390
+
391
+ /**
392
+ * Every stored capture for a build, newest last. `.part` files are never listed — a capture
393
+ * still being written is not a capture.
394
+ *
395
+ * @param {Store} store
396
+ * @param {{buildId: string, journey?: string}} opts
397
+ * @returns {Promise<CaptureRef[]>}
398
+ */
399
+ export async function listCaptures(store, opts) {
400
+ const dir = buildDir(store, opts.buildId);
401
+ const journeys = opts.journey ? [safeName(opts.journey)] : await subdirs(dir);
402
+ /** @type {CaptureRef[]} */
403
+ const out = [];
404
+ for (const journeyDir of journeys) {
405
+ const full = path.join(dir, journeyDir);
406
+ for (const name of await entries(full)) {
407
+ if (!name.endsWith('.jsonl')) continue;
408
+ out.push({
409
+ buildId: opts.buildId,
410
+ journey: opts.journey ?? journeyDir,
411
+ captureId: name.slice(0, -'.jsonl'.length),
412
+ file: path.join(full, name),
413
+ });
414
+ }
415
+ }
416
+ // Capture ids start with a sortable timestamp, so plain string order is time order.
417
+ return out.sort((a, b) => (a.captureId < b.captureId ? -1 : a.captureId > b.captureId ? 1 : 0));
418
+ }
419
+
420
+ /**
421
+ * The most recent capture of one journey against one build.
422
+ * @param {Store} store
423
+ * @param {{buildId: string, journey: string, run?: CaptureRun}} opts
424
+ * @returns {Promise<Capture|null>}
425
+ */
426
+ export async function latestCapture(store, opts) {
427
+ const refs = await listCaptures(store, { buildId: opts.buildId, journey: opts.journey });
428
+ for (let i = refs.length - 1; i >= 0; i--) {
429
+ /** @type {Capture|null} */
430
+ let capture = null;
431
+ try {
432
+ capture = await loadCapture(store, refs[i]);
433
+ } catch {
434
+ // One file nobody can read must never take the whole reference with it. Asking for
435
+ // a named capture that turns out not to be one is an error and stays one; scanning
436
+ // for the newest usable record steps over it and keeps looking. Otherwise a single
437
+ // interrupted run leaves the next check with "nothing to compare against", which
438
+ // reads as a pass and lets a release through.
439
+ continue;
440
+ }
441
+ if (!capture) continue;
442
+ if (opts.run && capture.run !== opts.run) continue;
443
+ return capture;
444
+ }
445
+ return null;
446
+ }
447
+
448
+ /**
449
+ * @param {Store} store
450
+ * @param {string} buildId
451
+ * @returns {Promise<BuildRecord|null>}
452
+ */
453
+ export async function loadBuild(store, buildId) {
454
+ try {
455
+ const raw = await fsp.readFile(path.join(buildDir(store, buildId), 'build.json'), 'utf8');
456
+ return /** @type {BuildRecord} */ (JSON.parse(raw));
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Every build the store knows about, newest first.
464
+ *
465
+ * @param {Store} store
466
+ * @param {{product?: string}} [opts]
467
+ * @returns {Promise<BuildRecord[]>}
468
+ */
469
+ export async function listBuilds(store, opts = {}) {
470
+ const references = await loadReferences(store);
471
+ /** @type {BuildRecord[]} */
472
+ const out = [];
473
+ for (const dirName of await subdirs(store.buildsDir)) {
474
+ /** @type {BuildRecord|null} */
475
+ let record = null;
476
+ try {
477
+ const raw = await fsp.readFile(path.join(store.buildsDir, dirName, 'build.json'), 'utf8');
478
+ record = /** @type {BuildRecord} */ (JSON.parse(raw));
479
+ } catch {
480
+ // A build folder with no readable record is not worth failing a run over. It happens
481
+ // when a write was interrupted, and the next capture against that build rewrites it.
482
+ continue;
483
+ }
484
+ const product = record.fingerprint?.product;
485
+ if (opts.product && product !== opts.product) continue;
486
+ record.isReference = Boolean(product && references[product]?.buildId === record.fingerprint.id);
487
+ out.push(record);
488
+ }
489
+ return out.sort((a, b) => (a.lastSeenAt < b.lastSeenAt ? 1 : a.lastSeenAt > b.lastSeenAt ? -1 : 0));
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // The reference — which build a product calls 'working'
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * @param {Store} store
498
+ * @returns {Promise<Record<string, ReferencePointer>>}
499
+ */
500
+ async function loadReferences(store) {
501
+ try {
502
+ const raw = await fsp.readFile(store.referencesFile, 'utf8');
503
+ const parsed = JSON.parse(raw);
504
+ return parsed && typeof parsed === 'object' ? parsed : {};
505
+ } catch {
506
+ return {};
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Which build is this product's reference, and what do we know about it?
512
+ *
513
+ * Returns null on a product that has never been shipped with the hook in place. That is the
514
+ * cold start, it is expected on any existing product, and the caller has to say so out loud
515
+ * rather than quietly comparing against nothing.
516
+ *
517
+ * @param {Store} store
518
+ * @param {string} product
519
+ * @returns {Promise<BuildRecord|null>}
520
+ */
521
+ export async function referenceFor(store, product) {
522
+ const pointer = (await loadReferences(store))[product];
523
+ if (!pointer) return null;
524
+ const record = await loadBuild(store, pointer.buildId);
525
+ if (!record) return null;
526
+ record.isReference = true;
527
+ return record;
528
+ }
529
+
530
+ /**
531
+ * The pointer itself — who set it, when, and why. For the summary line, and for telling a
532
+ * stale reference from a missing one.
533
+ *
534
+ * @param {Store} store
535
+ * @param {string} product
536
+ * @returns {Promise<ReferencePointer|null>}
537
+ */
538
+ export async function referencePointer(store, product) {
539
+ return (await loadReferences(store))[product] ?? null;
540
+ }
541
+
542
+ /**
543
+ * Point a product's reference at a build.
544
+ *
545
+ * This is the only place in the tool that decides what "working" means, and it must only ever
546
+ * be called for an act a person performed — saying ship. An agent may write a waiver; an agent
547
+ * may not write a reference. Whoever calls this owes the summary a line saying they did.
548
+ *
549
+ * @param {Store} store
550
+ * @param {string} buildId
551
+ * @param {{product?: string, setBy?: string, note?: string, at?: string}} [opts]
552
+ * @returns {Promise<ReferencePointer>}
553
+ */
554
+ export async function setReference(store, buildId, opts = {}) {
555
+ const record = await loadBuild(store, buildId);
556
+ const product = opts.product ?? record?.fingerprint?.product;
557
+ if (!product) {
558
+ throw new StaysFixedError(`Cannot make ${buildId} the reference: nothing here says which product it is of.`, {
559
+ hint: 'Save a capture against the build first, or pass the product name.',
560
+ });
561
+ }
562
+ if (!record) {
563
+ throw new StaysFixedError(`Cannot make ${buildId} the reference for ${product}: nothing has ever been observed against that build.`, {
564
+ hint: 'A reference has to have observations behind it, or there is nothing to compare the next build with.',
565
+ });
566
+ }
567
+
568
+ const references = await loadReferences(store);
569
+ /** @type {ReferencePointer} */
570
+ const pointer = {
571
+ product,
572
+ buildId,
573
+ setAt: opts.at ?? new Date().toISOString(),
574
+ };
575
+ if (opts.setBy) pointer.setBy = opts.setBy;
576
+ if (opts.note) pointer.note = opts.note;
577
+ references[product] = pointer;
578
+ await writeAtomic(store.referencesFile, JSON.stringify(references, null, 2) + '\n');
579
+ return pointer;
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Housekeeping
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * Throw away all but the newest few captures per journey for a build.
588
+ *
589
+ * Reference builds are refused, loudly. Their captures are the only record of what "working"
590
+ * looked like, and once they are gone the next check has nothing to compare against.
591
+ *
592
+ * @param {Store} store
593
+ * @param {string} buildId
594
+ * @param {{keepPerJourney?: number}} [opts]
595
+ * @returns {Promise<{removed: number, kept: number}>}
596
+ */
597
+ export async function pruneBuild(store, buildId, opts = {}) {
598
+ const keep = Math.max(1, opts.keepPerJourney ?? 4);
599
+ const record = await loadBuild(store, buildId);
600
+ const references = await loadReferences(store);
601
+ if (record && references[record.fingerprint.product]?.buildId === buildId) {
602
+ throw new StaysFixedError(`${buildId} is the reference for ${record.fingerprint.product}, so its observations cannot be pruned.`, {
603
+ hint: 'Point the reference at a newer build first, with setReference.',
604
+ });
605
+ }
606
+
607
+ let removed = 0;
608
+ let kept = 0;
609
+ const dir = buildDir(store, buildId);
610
+ for (const journeyDir of await subdirs(dir)) {
611
+ const refs = await listCaptures(store, { buildId, journey: journeyDir });
612
+ const doomed = refs.slice(0, Math.max(0, refs.length - keep));
613
+ for (const ref of doomed) {
614
+ await fsp.rm(ref.file, { force: true });
615
+ removed++;
616
+ }
617
+ kept += refs.length - doomed.length;
618
+ }
619
+ return { removed, kept };
620
+ }
621
+
622
+ /**
623
+ * Delete `.part` files left behind by runs that died.
624
+ *
625
+ * They are harmless — nothing reads them — but they are also invisible, and an invisible pile
626
+ * of half-written megabytes is how a tool ends up blamed for a full disk.
627
+ *
628
+ * @param {Store} store
629
+ * @param {{olderThanMs?: number, buildId?: string}} [opts]
630
+ * `buildId` narrows the sweep to one build. A run knows which build it just wrote, and
631
+ * clearing up after itself should not reach into every other product in the store.
632
+ * `olderThanMs` of 0 sweeps everything, however fresh — which is what a run that has
633
+ * just finished its own build wants.
634
+ * @returns {Promise<{removed: number}>}
635
+ */
636
+ export async function sweepIncomplete(store, opts = {}) {
637
+ const cutoff = Date.now() - (opts.olderThanMs ?? 60 * 60 * 1000);
638
+ let removed = 0;
639
+ const dirs = opts.buildId ? [safeName(opts.buildId)] : await subdirs(store.buildsDir);
640
+ for (const buildDirName of dirs) {
641
+ const base = path.join(store.buildsDir, buildDirName);
642
+ for (const journeyDir of await subdirs(base)) {
643
+ const full = path.join(base, journeyDir);
644
+ for (const name of await entries(full)) {
645
+ if (!name.endsWith('.part')) continue;
646
+ const file = path.join(full, name);
647
+ try {
648
+ const stat = await fsp.stat(file);
649
+ if (stat.mtimeMs > cutoff) continue;
650
+ await fsp.rm(file, { force: true });
651
+ removed++;
652
+ } catch {
653
+ // Gone while we looked at it. Somebody else's cleanup, and none of our business.
654
+ }
655
+ }
656
+ }
657
+ }
658
+ return { removed };
659
+ }
660
+
661
+ /**
662
+ * Is there a v2 store here at all?
663
+ * @param {Store} store
664
+ * @returns {boolean}
665
+ */
666
+ export function storeExists(store) {
667
+ return fs.existsSync(store.dir);
668
+ }
669
+
670
+ /**
671
+ * @param {Store} store
672
+ */
673
+ export async function ensureStore(store) {
674
+ await fsp.mkdir(store.buildsDir, { recursive: true });
675
+ }
676
+
677
+ /**
678
+ * Directory names inside a folder, or nothing when the folder is not there.
679
+ * @param {string} dir
680
+ * @returns {Promise<string[]>}
681
+ */
682
+ async function subdirs(dir) {
683
+ try {
684
+ const items = await fsp.readdir(dir, { withFileTypes: true });
685
+ return items.filter((d) => d.isDirectory()).map((d) => d.name);
686
+ } catch {
687
+ return [];
688
+ }
689
+ }
690
+
691
+ /**
692
+ * File names inside a folder, or nothing when the folder is not there.
693
+ * @param {string} dir
694
+ * @returns {Promise<string[]>}
695
+ */
696
+ async function entries(dir) {
697
+ try {
698
+ const items = await fsp.readdir(dir, { withFileTypes: true });
699
+ return items.filter((d) => d.isFile()).map((d) => d.name);
700
+ } catch {
701
+ return [];
702
+ }
703
+ }