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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/store.js
ADDED
|
@@ -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
|
+
}
|