staysfixed 0.6.2 → 0.7.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.
- package/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/store.js
CHANGED
|
@@ -108,14 +108,84 @@ export function newCaptureId(run, now = new Date()) {
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Write a file so nobody can ever read it half-finished.
|
|
111
|
+
*
|
|
112
|
+
* Three things happen here that a plain `writeFile` does not do, and all three exist because
|
|
113
|
+
* his disk hit zero bytes on 2026-08-30.
|
|
114
|
+
*
|
|
115
|
+
* - The bytes are counted back off the disk before the file is renamed into place. A write
|
|
116
|
+
* onto a full disk can come back without throwing and with only some of the text on the
|
|
117
|
+
* platter, and a half-written references.json that gets renamed into place is a store that
|
|
118
|
+
* has quietly forgotten which build was working.
|
|
119
|
+
* - A failure says, in words, that the disk is full, rather than handing back a five-letter
|
|
120
|
+
* error code to somebody who is not a programmer.
|
|
121
|
+
* - The half-written temporary file is removed on the way out. Otherwise every failed write
|
|
122
|
+
* leaves its wreckage behind and the disk that was already full gets fuller.
|
|
123
|
+
*
|
|
111
124
|
* @param {string} file
|
|
112
125
|
* @param {string} text
|
|
113
126
|
*/
|
|
114
127
|
async function writeAtomic(file, text) {
|
|
115
128
|
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
116
129
|
const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.part`;
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
const wanted = Buffer.byteLength(text, 'utf8');
|
|
131
|
+
try {
|
|
132
|
+
await fsp.writeFile(temp, text);
|
|
133
|
+
const landed = (await fsp.stat(temp)).size;
|
|
134
|
+
if (landed !== wanted) {
|
|
135
|
+
throw new StaysFixedError(
|
|
136
|
+
`Only ${landed} of ${wanted} bytes of ${path.basename(file)} reached the disk, so it was not saved.`,
|
|
137
|
+
{ hint: 'The disk this project sits on is full, or something else is writing to the same folder. Free some space and run it again.' },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
await fsp.rename(temp, file);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
await fsp.rm(temp, { force: true }).catch(() => {});
|
|
143
|
+
throw noRoom(e, file);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The same failure, said in a way somebody who is not a programmer can act on.
|
|
149
|
+
*
|
|
150
|
+
* A full disk is the one storage failure that happens to real people mid-run, and ENOSPC on
|
|
151
|
+
* its own tells them nothing. Anything else is passed through untouched rather than dressed
|
|
152
|
+
* up as something it is not.
|
|
153
|
+
*
|
|
154
|
+
* @param {unknown} e
|
|
155
|
+
* @param {string} file
|
|
156
|
+
* @returns {unknown}
|
|
157
|
+
*/
|
|
158
|
+
function noRoom(e, file) {
|
|
159
|
+
const code = /** @type {{code?: string}} */ (e)?.code;
|
|
160
|
+
if (code === 'ENOSPC') {
|
|
161
|
+
return new StaysFixedError(`There was no room left on the disk to save ${path.basename(file)}.`, {
|
|
162
|
+
hint: 'Free some space and run the check again. Nothing was lost except this record; the answer the run already worked out is still good.',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (code === 'EDQUOT') {
|
|
166
|
+
return new StaysFixedError(`This account has used up its disk allowance, so ${path.basename(file)} could not be saved.`, {
|
|
167
|
+
hint: 'Ask for more space, or delete something, and run the check again.',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return e;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Is this the ordinary "there is no such file" that means nothing is wrong?
|
|
175
|
+
*
|
|
176
|
+
* Everything else — a permission that was taken away, a folder where a file should be, a
|
|
177
|
+
* disk that will not read — is a file that EXISTS and cannot be read, and those two answers
|
|
178
|
+
* must never come back as the same `null`. One of them means "this product has never been
|
|
179
|
+
* shipped"; the other means "the record of what working looks like is damaged", and a tool
|
|
180
|
+
* that reports the second as the first tells somebody to start from scratch when what they
|
|
181
|
+
* needed to hear was that their evidence is hurt.
|
|
182
|
+
*
|
|
183
|
+
* @param {unknown} e
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function justNotThere(e) {
|
|
187
|
+
const code = /** @type {{code?: string}} */ (e)?.code;
|
|
188
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
119
189
|
}
|
|
120
190
|
|
|
121
191
|
/**
|
|
@@ -134,7 +204,18 @@ export async function saveBuild(store, fingerprint, opts = {}) {
|
|
|
134
204
|
if (!fingerprint.product) throw new StaysFixedError(`Build ${fingerprint.id} does not say which product it is of.`);
|
|
135
205
|
|
|
136
206
|
const at = opts.at ?? new Date().toISOString();
|
|
137
|
-
|
|
207
|
+
// A damaged record is loud everywhere else — a reader must never mistake it for a build
|
|
208
|
+
// nobody has heard of. Here it is different: this function is about to write a correct
|
|
209
|
+
// record over the top of it, and refusing would leave the damage in place for good and
|
|
210
|
+
// break every future run against that build. What is lost is the first-seen date and the
|
|
211
|
+
// journey list, which are rebuilt from the next few runs.
|
|
212
|
+
/** @type {BuildRecord|null} */
|
|
213
|
+
let existing = null;
|
|
214
|
+
try {
|
|
215
|
+
existing = await loadBuild(store, fingerprint.id);
|
|
216
|
+
} catch {
|
|
217
|
+
existing = null;
|
|
218
|
+
}
|
|
138
219
|
const journeys = new Set(existing?.journeys ?? []);
|
|
139
220
|
if (opts.journey) journeys.add(opts.journey);
|
|
140
221
|
|
|
@@ -203,29 +284,77 @@ export async function openCaptureWriter(store, opts) {
|
|
|
203
284
|
if (opts.rules) shell.rules = opts.rules;
|
|
204
285
|
await handle.write(JSON.stringify(headerOf(shell)) + '\n');
|
|
205
286
|
|
|
287
|
+
let open = true;
|
|
288
|
+
/** Close once, and never let a failure to close hide the failure that caused it. */
|
|
289
|
+
const shut = async () => {
|
|
290
|
+
if (!open) return;
|
|
291
|
+
open = false;
|
|
292
|
+
await handle.close().catch(() => {});
|
|
293
|
+
};
|
|
294
|
+
|
|
206
295
|
return {
|
|
207
296
|
ref,
|
|
208
297
|
async append(o) {
|
|
209
298
|
count++;
|
|
210
|
-
await handle
|
|
299
|
+
await writeLine(handle, JSON.stringify(o) + '\n', temp, shut);
|
|
211
300
|
},
|
|
212
301
|
async close(end = {}) {
|
|
213
302
|
const finished = { ...shell, durationMs: end.durationMs ?? Date.now() - started };
|
|
214
303
|
if (end.coverage) finished.coverage = end.coverage;
|
|
215
304
|
if (end.note) finished.note = end.note;
|
|
216
|
-
await handle
|
|
217
|
-
await
|
|
218
|
-
|
|
305
|
+
await writeLine(handle, JSON.stringify(endOf(finished, count)) + '\n', temp, shut);
|
|
306
|
+
await shut();
|
|
307
|
+
try {
|
|
308
|
+
await fsp.rename(temp, ref.file);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
await fsp.rm(temp, { force: true }).catch(() => {});
|
|
311
|
+
throw noRoom(e, ref.file);
|
|
312
|
+
}
|
|
219
313
|
await bumpBuild(store, finished);
|
|
220
314
|
return ref;
|
|
221
315
|
},
|
|
222
316
|
async abandon() {
|
|
223
|
-
|
|
224
|
-
|
|
317
|
+
// The file comes off the disk whatever the handle does. A close that throws used to
|
|
318
|
+
// leave the half-written capture sitting there for the sweeper to find an hour later.
|
|
319
|
+
await shut();
|
|
320
|
+
await fsp.rm(temp, { force: true }).catch(() => {});
|
|
225
321
|
},
|
|
226
322
|
};
|
|
227
323
|
}
|
|
228
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Write one whole line, or say plainly that it did not go on the disk.
|
|
327
|
+
*
|
|
328
|
+
* `handle.write` is allowed to write only part of what it was given and come back without
|
|
329
|
+
* throwing — which is exactly what a disk with a few hundred bytes left on it does. The half
|
|
330
|
+
* line that lands takes the newline with it, so the NEXT line is joined onto it and two
|
|
331
|
+
* observations are lost inside one unreadable line. `loadCapture` would still notice, because
|
|
332
|
+
* the end line counts what should be there; this stops it happening at all, and stops the
|
|
333
|
+
* run pretending the capture is finished.
|
|
334
|
+
*
|
|
335
|
+
* @param {import('node:fs/promises').FileHandle} handle
|
|
336
|
+
* @param {string} line
|
|
337
|
+
* @param {string} temp
|
|
338
|
+
* @param {() => Promise<void>} shut
|
|
339
|
+
* @returns {Promise<void>}
|
|
340
|
+
*/
|
|
341
|
+
async function writeLine(handle, line, temp, shut) {
|
|
342
|
+
const wanted = Buffer.byteLength(line, 'utf8');
|
|
343
|
+
try {
|
|
344
|
+
const { bytesWritten } = await handle.write(line, null, 'utf8');
|
|
345
|
+
if (bytesWritten !== wanted) {
|
|
346
|
+
throw new StaysFixedError(
|
|
347
|
+
`Only ${bytesWritten} of ${wanted} bytes of this observation reached the disk, so the capture was abandoned rather than finished half-written.`,
|
|
348
|
+
{ hint: 'The disk is full. Free some space and run the check again.' },
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
} catch (e) {
|
|
352
|
+
await shut();
|
|
353
|
+
await fsp.rm(temp, { force: true }).catch(() => {});
|
|
354
|
+
throw noRoom(e, temp);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
229
358
|
/**
|
|
230
359
|
* @param {Store} store
|
|
231
360
|
* @param {Capture} capture
|
|
@@ -315,8 +444,17 @@ export async function loadCapture(store, where) {
|
|
|
315
444
|
let raw;
|
|
316
445
|
try {
|
|
317
446
|
raw = await fsp.readFile(file, 'utf8');
|
|
318
|
-
} catch {
|
|
319
|
-
|
|
447
|
+
} catch (e) {
|
|
448
|
+
// A capture that is not there and a capture that is there and unreadable came back as
|
|
449
|
+
// the same `null` until 2026-08-30, and they are opposite answers. "Not there" is the
|
|
450
|
+
// cold start and is fine. "There and unreadable" — a permission taken away, a folder
|
|
451
|
+
// where a file should be, a disk that will not read — means the record of what working
|
|
452
|
+
// looks like is damaged, and swallowing it turns damaged evidence into "no evidence",
|
|
453
|
+
// which reads as a clean start and lets a release through.
|
|
454
|
+
if (justNotThere(e)) return null;
|
|
455
|
+
throw new StaysFixedError(`${file} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
|
|
456
|
+
hint: 'This is a record of what the old build did. It is not missing, it is damaged, so nothing should be compared against it until it is either readable or deleted.',
|
|
457
|
+
});
|
|
320
458
|
}
|
|
321
459
|
|
|
322
460
|
const lines = raw.split('\n');
|
|
@@ -420,7 +558,10 @@ export async function listCaptures(store, opts) {
|
|
|
420
558
|
/**
|
|
421
559
|
* The most recent capture of one journey against one build.
|
|
422
560
|
* @param {Store} store
|
|
423
|
-
* @param {{buildId: string, journey: string, run?: CaptureRun}} opts
|
|
561
|
+
* @param {{buildId: string, journey: string, run?: CaptureRun, onProblem?: (message: string) => void}} opts
|
|
562
|
+
* `onProblem` is told about every record that had to be stepped over on the way back. The
|
|
563
|
+
* stepping over is right — one bad file must not cost the whole reference — but it means
|
|
564
|
+
* the answer is an OLDER record than the newest one, and nothing said so.
|
|
424
565
|
* @returns {Promise<Capture|null>}
|
|
425
566
|
*/
|
|
426
567
|
export async function latestCapture(store, opts) {
|
|
@@ -430,12 +571,15 @@ export async function latestCapture(store, opts) {
|
|
|
430
571
|
let capture = null;
|
|
431
572
|
try {
|
|
432
573
|
capture = await loadCapture(store, refs[i]);
|
|
433
|
-
} catch {
|
|
574
|
+
} catch (e) {
|
|
434
575
|
// One file nobody can read must never take the whole reference with it. Asking for
|
|
435
576
|
// a named capture that turns out not to be one is an error and stays one; scanning
|
|
436
577
|
// for the newest usable record steps over it and keeps looking. Otherwise a single
|
|
437
578
|
// interrupted run leaves the next check with "nothing to compare against", which
|
|
438
579
|
// reads as a pass and lets a release through.
|
|
580
|
+
opts.onProblem?.(
|
|
581
|
+
`The newest stored record of "${opts.journey}" (${refs[i].captureId}) could not be read: ${e instanceof Error ? e.message : String(e)}. An older one was used instead, so this comparison is against something further back than it looks.`,
|
|
582
|
+
);
|
|
439
583
|
continue;
|
|
440
584
|
}
|
|
441
585
|
if (!capture) continue;
|
|
@@ -451,11 +595,24 @@ export async function latestCapture(store, opts) {
|
|
|
451
595
|
* @returns {Promise<BuildRecord|null>}
|
|
452
596
|
*/
|
|
453
597
|
export async function loadBuild(store, buildId) {
|
|
598
|
+
const file = path.join(buildDir(store, buildId), 'build.json');
|
|
599
|
+
/** @type {string} */
|
|
600
|
+
let raw;
|
|
601
|
+
try {
|
|
602
|
+
raw = await fsp.readFile(file, 'utf8');
|
|
603
|
+
} catch (e) {
|
|
604
|
+
// Never seen is null. Seen and damaged is loud — see justNotThere.
|
|
605
|
+
if (justNotThere(e)) return null;
|
|
606
|
+
throw new StaysFixedError(`${file} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
|
|
607
|
+
hint: 'Nothing should treat this build as unknown while its record is sitting there damaged.',
|
|
608
|
+
});
|
|
609
|
+
}
|
|
454
610
|
try {
|
|
455
|
-
const raw = await fsp.readFile(path.join(buildDir(store, buildId), 'build.json'), 'utf8');
|
|
456
611
|
return /** @type {BuildRecord} */ (JSON.parse(raw));
|
|
457
|
-
} catch {
|
|
458
|
-
|
|
612
|
+
} catch (e) {
|
|
613
|
+
throw new StaysFixedError(`${file} is not readable as JSON: ${e instanceof Error ? e.message : String(e)}`, {
|
|
614
|
+
hint: 'A half-written build record means a run was interrupted. Delete the file and run the check again; it will be rewritten.',
|
|
615
|
+
});
|
|
459
616
|
}
|
|
460
617
|
}
|
|
461
618
|
|
|
@@ -463,7 +620,11 @@ export async function loadBuild(store, buildId) {
|
|
|
463
620
|
* Every build the store knows about, newest first.
|
|
464
621
|
*
|
|
465
622
|
* @param {Store} store
|
|
466
|
-
* @param {{product?: string}} [opts]
|
|
623
|
+
* @param {{product?: string, onProblem?: (message: string) => void}} [opts]
|
|
624
|
+
* `onProblem` is told about every build folder that had to be skipped. Without it a build
|
|
625
|
+
* whose record is damaged simply is not in the list, and "not in the list" is how every
|
|
626
|
+
* caller spells "never existed" — so a build that HAS captures and a broken record reads
|
|
627
|
+
* as a build nobody ever made, in the coverage ledger and in `--against` alike.
|
|
467
628
|
* @returns {Promise<BuildRecord[]>}
|
|
468
629
|
*/
|
|
469
630
|
export async function listBuilds(store, opts = {}) {
|
|
@@ -476,9 +637,16 @@ export async function listBuilds(store, opts = {}) {
|
|
|
476
637
|
try {
|
|
477
638
|
const raw = await fsp.readFile(path.join(store.buildsDir, dirName, 'build.json'), 'utf8');
|
|
478
639
|
record = /** @type {BuildRecord} */ (JSON.parse(raw));
|
|
479
|
-
} catch {
|
|
640
|
+
} catch (e) {
|
|
480
641
|
// A build folder with no readable record is not worth failing a run over. It happens
|
|
481
642
|
// when a write was interrupted, and the next capture against that build rewrites it.
|
|
643
|
+
// It IS worth saying out loud, because everything above reads a missing build as a
|
|
644
|
+
// build that never existed.
|
|
645
|
+
opts.onProblem?.(
|
|
646
|
+
justNotThere(e)
|
|
647
|
+
? `The build folder ${dirName} has no record in it, so whatever was stored against that build is not counted here.`
|
|
648
|
+
: `The build folder ${dirName} has a record that could not be read (${e instanceof Error ? e.message : String(e)}), so whatever was stored against that build is not counted here.`,
|
|
649
|
+
);
|
|
482
650
|
continue;
|
|
483
651
|
}
|
|
484
652
|
const product = record.fingerprint?.product;
|
|
@@ -498,13 +666,36 @@ export async function listBuilds(store, opts = {}) {
|
|
|
498
666
|
* @returns {Promise<Record<string, ReferencePointer>>}
|
|
499
667
|
*/
|
|
500
668
|
async function loadReferences(store) {
|
|
669
|
+
/** @type {string} */
|
|
670
|
+
let raw;
|
|
501
671
|
try {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
672
|
+
raw = await fsp.readFile(store.referencesFile, 'utf8');
|
|
673
|
+
} catch (e) {
|
|
674
|
+
// No file at all is the honest cold start: nothing has ever been shipped with the hook
|
|
675
|
+
// in place. A file that is there and cannot be read is the opposite — this product HAS a
|
|
676
|
+
// reference and the pointer to it is damaged — and returning an empty map for both meant
|
|
677
|
+
// a damaged store reported itself as a brand new one and every run after it compared
|
|
678
|
+
// against nothing while saying so in the gentlest possible words.
|
|
679
|
+
if (justNotThere(e)) return {};
|
|
680
|
+
throw new StaysFixedError(`${store.referencesFile} is there and could not be read: ${e instanceof Error ? e.message : String(e)}`, {
|
|
681
|
+
hint: 'This file is the only record of which build you called working. Until it can be read, no run can honestly say what it is comparing against.',
|
|
682
|
+
});
|
|
507
683
|
}
|
|
684
|
+
/** @type {unknown} */
|
|
685
|
+
let parsed;
|
|
686
|
+
try {
|
|
687
|
+
parsed = JSON.parse(raw);
|
|
688
|
+
} catch (e) {
|
|
689
|
+
throw new StaysFixedError(`${store.referencesFile} is not readable as JSON: ${e instanceof Error ? e.message : String(e)}`, {
|
|
690
|
+
hint: 'It says which build of each product counts as working. Restore it from git, or ship again to write a fresh one. Treating it as empty would quietly turn every check into a first run.',
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
694
|
+
throw new StaysFixedError(`${store.referencesFile} does not hold a set of reference pointers.`, {
|
|
695
|
+
hint: 'It should be an object keyed by product name. Delete it and ship again rather than letting a check run against nothing.',
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return /** @type {Record<string, ReferencePointer>} */ (parsed);
|
|
508
699
|
}
|
|
509
700
|
|
|
510
701
|
/**
|
|
@@ -598,9 +789,23 @@ export async function pruneBuild(store, buildId, opts = {}) {
|
|
|
598
789
|
const keep = Math.max(1, opts.keepPerJourney ?? 4);
|
|
599
790
|
const record = await loadBuild(store, buildId);
|
|
600
791
|
const references = await loadReferences(store);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
792
|
+
|
|
793
|
+
// Asked of the POINTERS, not of the build's own record. Reading it the other way round —
|
|
794
|
+
// "what product does this build say it is of, and is that product's reference this build" —
|
|
795
|
+
// has a hole in it exactly where it matters: a build whose build.json is missing answers
|
|
796
|
+
// nothing, the guard is skipped, and the captures that are the only record of what working
|
|
797
|
+
// looks like get deleted. Every pointer is checked here, so an unreadable record can never
|
|
798
|
+
// be the reason a reference is thrown away.
|
|
799
|
+
const pointedAt = Object.values(references).filter((p) => p?.buildId === buildId);
|
|
800
|
+
if (pointedAt.length > 0) {
|
|
801
|
+
throw new StaysFixedError(
|
|
802
|
+
`${buildId} is the reference for ${pointedAt.map((p) => p.product).join(', ')}, so its observations cannot be pruned.`,
|
|
803
|
+
{ hint: 'Point the reference at a newer build first, with setReference.' },
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (!record) {
|
|
807
|
+
throw new StaysFixedError(`Nothing here says what ${buildId} is, so its observations will not be thrown away.`, {
|
|
808
|
+
hint: 'Its build.json is missing. Deleting captures on the strength of a record nobody can read is how the evidence for "this used to work" disappears. Run a check against that build to rewrite the record, or delete the whole folder deliberately.',
|
|
604
809
|
});
|
|
605
810
|
}
|
|
606
811
|
|
|
@@ -636,25 +841,34 @@ export async function pruneBuild(store, buildId, opts = {}) {
|
|
|
636
841
|
export async function sweepIncomplete(store, opts = {}) {
|
|
637
842
|
const cutoff = Date.now() - (opts.olderThanMs ?? 60 * 60 * 1000);
|
|
638
843
|
let removed = 0;
|
|
844
|
+
|
|
845
|
+
/** @param {string} dir */
|
|
846
|
+
const sweep = async (dir) => {
|
|
847
|
+
for (const name of await entries(dir)) {
|
|
848
|
+
if (!name.endsWith('.part')) continue;
|
|
849
|
+
const file = path.join(dir, name);
|
|
850
|
+
try {
|
|
851
|
+
const stat = await fsp.stat(file);
|
|
852
|
+
if (stat.mtimeMs > cutoff) continue;
|
|
853
|
+
await fsp.rm(file, { force: true });
|
|
854
|
+
removed++;
|
|
855
|
+
} catch {
|
|
856
|
+
// Gone while we looked at it. Somebody else's cleanup, and none of our business.
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
639
861
|
const dirs = opts.buildId ? [safeName(opts.buildId)] : await subdirs(store.buildsDir);
|
|
640
862
|
for (const buildDirName of dirs) {
|
|
641
863
|
const base = path.join(store.buildsDir, buildDirName);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
}
|
|
864
|
+
// The build's own folder as well as each journey's. A half-written build.json lands
|
|
865
|
+
// beside the journey folders rather than inside one, so sweeping only the journeys left
|
|
866
|
+
// those behind for good — invisible, and counting against a disk that was already full.
|
|
867
|
+
await sweep(base);
|
|
868
|
+
for (const journeyDir of await subdirs(base)) await sweep(path.join(base, journeyDir));
|
|
657
869
|
}
|
|
870
|
+
// And the store's own root, where a half-written references.json lands.
|
|
871
|
+
if (!opts.buildId) await sweep(store.dir);
|
|
658
872
|
return { removed };
|
|
659
873
|
}
|
|
660
874
|
|
|
@@ -683,8 +897,15 @@ async function subdirs(dir) {
|
|
|
683
897
|
try {
|
|
684
898
|
const items = await fsp.readdir(dir, { withFileTypes: true });
|
|
685
899
|
return items.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
686
|
-
} catch {
|
|
687
|
-
|
|
900
|
+
} catch (e) {
|
|
901
|
+
// Not there is an empty list and always was. A folder that IS there and will not open —
|
|
902
|
+
// a permission taken away, a mount that went — used to come back as the same empty list,
|
|
903
|
+
// and an empty list here means "this product has never been walked". Everything above
|
|
904
|
+
// then reports a store full of evidence as a store with nothing in it.
|
|
905
|
+
if (justNotThere(e)) return [];
|
|
906
|
+
throw new StaysFixedError(`${dir} is there and could not be listed: ${e instanceof Error ? e.message : String(e)}`, {
|
|
907
|
+
hint: 'Reading it as empty would report everything already stored in it as never having been walked.',
|
|
908
|
+
});
|
|
688
909
|
}
|
|
689
910
|
}
|
|
690
911
|
|
|
@@ -697,7 +918,10 @@ async function entries(dir) {
|
|
|
697
918
|
try {
|
|
698
919
|
const items = await fsp.readdir(dir, { withFileTypes: true });
|
|
699
920
|
return items.filter((d) => d.isFile()).map((d) => d.name);
|
|
700
|
-
} catch {
|
|
701
|
-
return [];
|
|
921
|
+
} catch (e) {
|
|
922
|
+
if (justNotThere(e)) return [];
|
|
923
|
+
throw new StaysFixedError(`${dir} is there and could not be listed: ${e instanceof Error ? e.message : String(e)}`, {
|
|
924
|
+
hint: 'Reading it as empty would report every capture inside it as never having been taken.',
|
|
925
|
+
});
|
|
702
926
|
}
|
|
703
927
|
}
|
package/src/v2/watch/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import { attachPanel, panelPlan } from './events.js';
|
|
|
44
44
|
/** @typedef {import('./window.js').Panel} Panel */
|
|
45
45
|
/** @typedef {import('./window.js').BesideThis} BesideThis */
|
|
46
46
|
/** @typedef {import('./window.js').PanelHealth} PanelHealth */
|
|
47
|
+
/** @typedef {import('./window.js').PanelBrowser} PanelBrowser */
|
|
47
48
|
|
|
48
49
|
/** Panel width when nobody says otherwise. */
|
|
49
50
|
const DEFAULT_WIDTH = 480;
|
|
@@ -52,16 +53,33 @@ const DEFAULT_WIDTH = 480;
|
|
|
52
53
|
const SNAP_WAIT_MS = 6000;
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* How long stopping will wait for a window that is STILL opening
|
|
56
|
+
* How long stopping will wait for a window that is STILL opening, when the window is going
|
|
57
|
+
* to be closed anyway.
|
|
56
58
|
*
|
|
57
59
|
* Short, and it has to be. A check that finishes in two seconds on a machine where a browser
|
|
58
60
|
* takes twenty to start would otherwise sit there at the end, done, with nothing to say,
|
|
59
61
|
* waiting on a window nobody is going to read. So stopping calls the opening off and gives it
|
|
60
62
|
* a moment to tidy up; a window that arrives after that closes itself, because the opening
|
|
61
|
-
* sequence already knows it was
|
|
63
|
+
* sequence already knows it was abandoned.
|
|
62
64
|
*/
|
|
63
65
|
const STOP_WAIT_MS = 1500;
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How long stopping will wait for a window that is still opening and is going to be LEFT UP.
|
|
69
|
+
*
|
|
70
|
+
* A different situation with the opposite answer, and getting the two confused is what made
|
|
71
|
+
* `--watch` show nothing at all on anything that finishes quickly. A browser takes two or
|
|
72
|
+
* three seconds to start; a check on a command-line product takes one. Under the short wait
|
|
73
|
+
* the run reached the end first, called the window off, and a person who explicitly asked to
|
|
74
|
+
* watch got no window and no result — every time, for the fastest and most common case.
|
|
75
|
+
*
|
|
76
|
+
* So when the window is going to stay up, the end of the check waits for it. The check itself
|
|
77
|
+
* is long over by then: nothing is being held up except the moment the terminal comes back,
|
|
78
|
+
* and the person asked for a window, so a window is what they get — with the whole run
|
|
79
|
+
* already drawn on it, because a late listener is handed everything it missed.
|
|
80
|
+
*/
|
|
81
|
+
const LATE_WINDOW_MS = 25_000;
|
|
82
|
+
|
|
65
83
|
/**
|
|
66
84
|
* Anything with the two halves of an event stream on it.
|
|
67
85
|
*
|
|
@@ -76,10 +94,12 @@ const STOP_WAIT_MS = 1500;
|
|
|
76
94
|
/**
|
|
77
95
|
* What the panel is told to do.
|
|
78
96
|
*
|
|
79
|
-
* `
|
|
80
|
-
*
|
|
97
|
+
* The shared `WatchOptions` shape already carries every one of these, `snap` included, so
|
|
98
|
+
* this is a name for it rather than an extension of it. It used to add `snap` back on top;
|
|
99
|
+
* that stopped being true when the shared shape learned about it, and an intersection that
|
|
100
|
+
* adds nothing is a thing to read twice and understand once.
|
|
81
101
|
*
|
|
82
|
-
* @typedef {import('../../types.js').WatchOptions
|
|
102
|
+
* @typedef {import('../../types.js').WatchOptions} PanelOptions
|
|
83
103
|
*/
|
|
84
104
|
|
|
85
105
|
/**
|
|
@@ -92,6 +112,14 @@ const STOP_WAIT_MS = 1500;
|
|
|
92
112
|
* @property {PanelOptions} [watch]
|
|
93
113
|
* @property {string} [dir] Where to remember the window position. The project's own folder.
|
|
94
114
|
* @property {{width: number, height: number}} [appViewport]
|
|
115
|
+
* @property {(browser: PanelBrowser) => void} [onOpen]
|
|
116
|
+
* Told once, when the window really is up, which browser
|
|
117
|
+
* it opened in and whether that browser belongs to the
|
|
118
|
+
* person. Nothing can know this in advance: the window is
|
|
119
|
+
* opened in the background and the browser is chosen while
|
|
120
|
+
* it opens. Anything that has to treat the panel's window
|
|
121
|
+
* as the tool's own — the screen guard does — has to be
|
|
122
|
+
* told rather than ask.
|
|
95
123
|
*/
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -105,11 +133,16 @@ const STOP_WAIT_MS = 1500;
|
|
|
105
133
|
* `health` is there so the claim this whole file makes — that the window never held the check
|
|
106
134
|
* up — has a number behind it rather than being taken on trust.
|
|
107
135
|
*
|
|
136
|
+
* `browser` names what the window actually opened in, and whether that browser belongs to
|
|
137
|
+
* the person rather than to the tool. Anything that decides what "ours" means on this screen
|
|
138
|
+
* has to read it: a window in the person's own browser is not ours to push around.
|
|
139
|
+
*
|
|
108
140
|
* @typedef {object} Watcher
|
|
109
141
|
* @property {() => Promise<void>} stop
|
|
110
142
|
* @property {(beside: BesideThis) => Promise<void>} snapTo
|
|
111
143
|
* @property {() => PanelHealth|null} health
|
|
112
144
|
* @property {() => boolean} open Is there a window right now.
|
|
145
|
+
* @property {() => PanelBrowser|null} browser
|
|
113
146
|
*/
|
|
114
147
|
|
|
115
148
|
/**
|
|
@@ -143,7 +176,13 @@ function soon(work, ms) {
|
|
|
143
176
|
* @returns {Watcher}
|
|
144
177
|
*/
|
|
145
178
|
function noWatcher() {
|
|
146
|
-
return {
|
|
179
|
+
return {
|
|
180
|
+
stop: async () => {},
|
|
181
|
+
snapTo: async () => {},
|
|
182
|
+
health: () => null,
|
|
183
|
+
open: () => false,
|
|
184
|
+
browser: () => null,
|
|
185
|
+
};
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
/**
|
|
@@ -187,11 +226,20 @@ export async function attachWatcher(events, opts = {}) {
|
|
|
187
226
|
let panel = null;
|
|
188
227
|
/** @type {(() => void)|null} */
|
|
189
228
|
let unsubscribe = null;
|
|
229
|
+
// The check is over. Nothing new will be said.
|
|
190
230
|
let stopped = false;
|
|
231
|
+
// ...and the window is not wanted at all, so one that arrives late puts itself away.
|
|
232
|
+
// Kept apart from `stopped` on purpose: a window that is going to be left up is still
|
|
233
|
+
// wanted after the check has finished, because the finished result is what it is for.
|
|
234
|
+
let abandoned = false;
|
|
191
235
|
// How stopping reaches a window that has not finished opening. Every wait inside `openPanel`
|
|
192
236
|
// watches this, so calling it off ends them all at once instead of one timeout at a time.
|
|
193
237
|
const givingUp = new AbortController();
|
|
194
238
|
|
|
239
|
+
// A window that will be closed at the end is not worth waiting for; a window that will be
|
|
240
|
+
// left standing with the result on it is the whole reason somebody typed --watch.
|
|
241
|
+
const leaveItUp = watch.keepOpen !== false;
|
|
242
|
+
|
|
195
243
|
/**
|
|
196
244
|
* Start the window and, when it is up, start feeding it.
|
|
197
245
|
*
|
|
@@ -209,13 +257,21 @@ export async function attachWatcher(events, opts = {}) {
|
|
|
209
257
|
})
|
|
210
258
|
.then((open) => {
|
|
211
259
|
if (!open) return null;
|
|
212
|
-
if (
|
|
213
|
-
//
|
|
214
|
-
// for standing on somebody's screen.
|
|
260
|
+
if (abandoned) {
|
|
261
|
+
// Given up on while it was still opening. Close it rather than leave a window nobody
|
|
262
|
+
// asked for standing on somebody's screen.
|
|
215
263
|
void open.close().catch(() => {});
|
|
216
264
|
return null;
|
|
217
265
|
}
|
|
218
266
|
panel = open;
|
|
267
|
+
if (opts.onOpen) {
|
|
268
|
+
try {
|
|
269
|
+
opts.onOpen(open.browser);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
// Somebody wanting to know is never allowed to be the reason a window fails.
|
|
272
|
+
detail(`The watch window opened, and telling the check about it went wrong. ${messageOf(e)}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
219
275
|
// Everything that already happened arrives here first, in order, before the first live
|
|
220
276
|
// event does — which is the whole reason opening the window in the background is safe.
|
|
221
277
|
//
|
|
@@ -258,23 +314,45 @@ export async function attachWatcher(events, opts = {}) {
|
|
|
258
314
|
stop: () => {
|
|
259
315
|
stopping ??= (async () => {
|
|
260
316
|
stopped = true;
|
|
261
|
-
|
|
317
|
+
|
|
318
|
+
if (!panel && !leaveItUp) {
|
|
319
|
+
// It is still opening and it would only be closed again. Call it off, and give it a
|
|
320
|
+
// moment to tidy up rather than waiting it out. A window that arrives after this
|
|
321
|
+
// finds itself abandoned and closes itself.
|
|
322
|
+
abandoned = true;
|
|
323
|
+
givingUp.abort();
|
|
324
|
+
} else if (!panel) {
|
|
325
|
+
detail('The check finished before the watch window had opened. Waiting for it, because the result is what it is for.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Deliberately BEFORE anything stops listening. A window that opens now subscribes as
|
|
329
|
+
// it lands and is handed everything that already happened, in order, including the
|
|
330
|
+
// verdict — which is how a window that missed the whole run still shows all of it.
|
|
331
|
+
const open = panel ?? (await soon(opening, leaveItUp ? LATE_WINDOW_MS : STOP_WAIT_MS));
|
|
332
|
+
|
|
262
333
|
try {
|
|
263
334
|
if (unsubscribe) unsubscribe();
|
|
264
335
|
} catch {
|
|
265
336
|
// Already gone. Nothing left to stop listening to.
|
|
266
337
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
338
|
+
|
|
339
|
+
if (open) {
|
|
340
|
+
await open.close().catch(() => {});
|
|
341
|
+
} else {
|
|
342
|
+
// It never came. Nothing should be left starting up behind a finished check.
|
|
343
|
+
abandoned = true;
|
|
344
|
+
givingUp.abort();
|
|
345
|
+
}
|
|
272
346
|
})();
|
|
273
347
|
return stopping;
|
|
274
348
|
},
|
|
275
349
|
|
|
276
350
|
snapTo: async (beside) => {
|
|
277
351
|
if (!maySnap) return;
|
|
352
|
+
// The check is over. Something that only just announced itself is something on its
|
|
353
|
+
// way out, and dragging windows around at the end of a run is worse than not
|
|
354
|
+
// arranging them at all.
|
|
355
|
+
if (stopped) return;
|
|
278
356
|
try {
|
|
279
357
|
const open = await panelSoon();
|
|
280
358
|
if (open) await open.snapTo(beside);
|
|
@@ -287,6 +365,7 @@ export async function attachWatcher(events, opts = {}) {
|
|
|
287
365
|
|
|
288
366
|
health: () => (panel ? panel.health() : null),
|
|
289
367
|
open: () => panel !== null,
|
|
368
|
+
browser: () => (panel ? panel.browser : null),
|
|
290
369
|
};
|
|
291
370
|
}
|
|
292
371
|
|
|
@@ -322,8 +401,8 @@ export async function attachWatcher(events, opts = {}) {
|
|
|
322
401
|
*/
|
|
323
402
|
export function watchOptionsFrom(config, cli) {
|
|
324
403
|
const raw = config?.watch;
|
|
325
|
-
//
|
|
326
|
-
//
|
|
404
|
+
// `watch: true` in a settings file is the short way of saying `watch: {enabled: true}`,
|
|
405
|
+
// and anything that is not an object at all says nothing.
|
|
327
406
|
const settings = /** @type {PanelOptions} */ (
|
|
328
407
|
raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
|
|
329
408
|
);
|