@pygmalionjs/pygmalion 0.2.44 → 0.5.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.
@@ -0,0 +1,529 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import { validateRoutePreviewArtifactBundle } from './route-preview-artifact-v3.mjs';
4
+
5
+ const DEFAULT_IDLE_MS = 300_000;
6
+ const DEFAULT_JOB_TIMEOUT_MS = 1_200_000;
7
+ const DEFAULT_MAX_RESTARTS = 3;
8
+ const DEFAULT_RESTART_WINDOW_MS = 600_000;
9
+ const DEFAULT_PING_TIMEOUT_MS = 10_000;
10
+ const MAX_RESTART_BACKOFF_MS = 8_000;
11
+ const STOP_KILL_GRACE_MS = 5_000;
12
+ const STDERR_TAIL_LIMIT = 8_000;
13
+
14
+ function assertPositiveNumber(value, name, fallback) {
15
+ if (value == null) return fallback;
16
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
17
+ throw new TypeError(`Preview capture worker ${name} must be a positive number.`);
18
+ }
19
+ return value;
20
+ }
21
+
22
+ function assertNonNegativeInteger(value, name, fallback) {
23
+ if (value == null) return fallback;
24
+ if (!Number.isInteger(value) || value < 0) {
25
+ throw new TypeError(
26
+ `Preview capture worker ${name} must be a non-negative integer.`,
27
+ );
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function normalizeArgs(args) {
33
+ if (args == null) return [];
34
+ if (
35
+ !Array.isArray(args) ||
36
+ args.some((entry) => typeof entry !== 'string')
37
+ ) {
38
+ throw new TypeError(
39
+ 'Preview capture worker args must be an array of strings.',
40
+ );
41
+ }
42
+ return [...args];
43
+ }
44
+
45
+ function normalizeRequest(request) {
46
+ if (!request || typeof request !== 'object') {
47
+ throw new TypeError('Preview capture request must be an object.');
48
+ }
49
+ const { namespace, sourceRevision, frames, captureBaseUrl } = request;
50
+ if (typeof namespace !== 'string' || !namespace.trim()) {
51
+ throw new TypeError('Preview capture request requires a namespace.');
52
+ }
53
+ if (typeof sourceRevision !== 'string' || !sourceRevision.trim()) {
54
+ throw new TypeError('Preview capture request requires a source revision.');
55
+ }
56
+ if (frames != null && !Array.isArray(frames)) {
57
+ throw new TypeError('Preview capture request frames must be an array.');
58
+ }
59
+ if (captureBaseUrl != null && typeof captureBaseUrl !== 'string') {
60
+ throw new TypeError(
61
+ 'Preview capture request captureBaseUrl must be a string.',
62
+ );
63
+ }
64
+ return {
65
+ namespace,
66
+ sourceRevision,
67
+ ...(frames == null ? {} : { frames: frames.map((frame) => ({ ...frame })) }),
68
+ ...(captureBaseUrl == null ? {} : { captureBaseUrl }),
69
+ };
70
+ }
71
+
72
+ function attachLineParser(stream, onMessage) {
73
+ let buffer = '';
74
+ stream.setEncoding('utf8');
75
+ stream.on('data', (chunk) => {
76
+ buffer += chunk;
77
+ let newline = buffer.indexOf('\n');
78
+ while (newline >= 0) {
79
+ const line = buffer.slice(0, newline).trim();
80
+ buffer = buffer.slice(newline + 1);
81
+ newline = buffer.indexOf('\n');
82
+ if (!line) continue;
83
+ let message;
84
+ try {
85
+ message = JSON.parse(line);
86
+ } catch {
87
+ continue; // Not a protocol line; the worker owns its own noise.
88
+ }
89
+ if (message && typeof message === 'object') onMessage(message);
90
+ }
91
+ });
92
+ }
93
+
94
+ function attachStderrTail(stream, worker) {
95
+ stream.setEncoding('utf8');
96
+ stream.on('data', (chunk) => {
97
+ worker.stderrTail = `${worker.stderrTail}${chunk}`.slice(-STDERR_TAIL_LIMIT);
98
+ });
99
+ }
100
+
101
+ async function consumeArtifactFile(response) {
102
+ const file = response?.artifactFile;
103
+ if (typeof file !== 'string' || !file.trim()) {
104
+ throw new Error('Preview capture worker returned no artifact file.');
105
+ }
106
+ try {
107
+ const bundle = JSON.parse(await fs.readFile(file, 'utf8'));
108
+ const validation = validateRoutePreviewArtifactBundle(bundle);
109
+ if (!validation.valid) {
110
+ throw new Error(
111
+ `Preview capture worker artifact is invalid: ${(
112
+ validation.errors ?? ['unknown validation error']
113
+ ).join(', ')}`,
114
+ );
115
+ }
116
+ return bundle;
117
+ } finally {
118
+ // The hand-off file is single-use: the bundle now lives in memory and the
119
+ // plugin persists it through the artifact store, so the temp file would
120
+ // only accumulate.
121
+ await fs.rm(file, { force: true }).catch(() => undefined);
122
+ }
123
+ }
124
+
125
+ function workerFailure(worker, fallbackMessage) {
126
+ const tail = worker.stderrTail.trim();
127
+ return new Error(tail ? `${fallbackMessage}\n${tail}` : fallbackMessage);
128
+ }
129
+
130
+ /**
131
+ * Long-lived capture worker channel: one child process that keeps its expensive
132
+ * boot (runtime preflight, case bundling, browser launch) warm across artifact
133
+ * requests, speaking JSON lines over stdio.
134
+ *
135
+ * Protocol: the channel writes `{ id, type: 'capture', payload }` — payload is
136
+ * `{ namespace, sourceRevision, frames?, captureBaseUrl? }` — and the worker
137
+ * answers `{ id, ok: true, artifactFile }` with the bundle in a temp file the
138
+ * channel reads, validates, and deletes, or `{ id, ok: false, error }`.
139
+ * `{ id, type: 'ping' }` must be answered immediately even while booting; the
140
+ * channel uses it to detect a wedged worker before reusing it.
141
+ *
142
+ * Failure policy: a crash rejects the in-flight job and the worker restarts on
143
+ * the next job with capped backoff. After `maxRestarts` crashes inside
144
+ * `restartWindowMs` the channel degrades permanently to spawning the script
145
+ * per request with `--once` — slower, but never dark.
146
+ */
147
+ export function createPreviewCaptureWorkerChannel({
148
+ script,
149
+ args = [],
150
+ env,
151
+ cwd,
152
+ idleMs = DEFAULT_IDLE_MS,
153
+ jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS,
154
+ maxRestarts = DEFAULT_MAX_RESTARTS,
155
+ restartWindowMs = DEFAULT_RESTART_WINDOW_MS,
156
+ pingTimeoutMs = DEFAULT_PING_TIMEOUT_MS,
157
+ } = {}) {
158
+ if (typeof script !== 'string' || !script.trim()) {
159
+ throw new TypeError(
160
+ 'Preview capture worker script must be a non-empty string.',
161
+ );
162
+ }
163
+ const workerArgs = normalizeArgs(args);
164
+ if (env != null && (typeof env !== 'object' || Array.isArray(env))) {
165
+ throw new TypeError('Preview capture worker env must be an object.');
166
+ }
167
+ if (cwd != null && (typeof cwd !== 'string' || !cwd.trim())) {
168
+ throw new TypeError('Preview capture worker cwd must be a non-empty string.');
169
+ }
170
+ const resolvedIdleMs = assertPositiveNumber(idleMs, 'idleMs', DEFAULT_IDLE_MS);
171
+ const resolvedJobTimeoutMs = assertPositiveNumber(
172
+ jobTimeoutMs,
173
+ 'jobTimeoutMs',
174
+ DEFAULT_JOB_TIMEOUT_MS,
175
+ );
176
+ const resolvedMaxRestarts = assertNonNegativeInteger(
177
+ maxRestarts,
178
+ 'maxRestarts',
179
+ DEFAULT_MAX_RESTARTS,
180
+ );
181
+ const resolvedRestartWindowMs = assertPositiveNumber(
182
+ restartWindowMs,
183
+ 'restartWindowMs',
184
+ DEFAULT_RESTART_WINDOW_MS,
185
+ );
186
+ const resolvedPingTimeoutMs = assertPositiveNumber(
187
+ pingTimeoutMs,
188
+ 'pingTimeoutMs',
189
+ DEFAULT_PING_TIMEOUT_MS,
190
+ );
191
+
192
+ const spawnEnv = env ? { ...process.env, ...env } : undefined;
193
+ let worker = null;
194
+ let inFlight = false;
195
+ let degraded = false;
196
+ let disposed = false;
197
+ let idleTimer = null;
198
+ let requestCounter = 0;
199
+ const crashTimestamps = [];
200
+
201
+ function nextRequestId() {
202
+ requestCounter += 1;
203
+ return `capture-${process.pid}-${requestCounter}`;
204
+ }
205
+
206
+ function recordCrash(now = Date.now()) {
207
+ while (
208
+ crashTimestamps.length > 0 &&
209
+ now - crashTimestamps[0] > resolvedRestartWindowMs
210
+ ) {
211
+ crashTimestamps.shift();
212
+ }
213
+ crashTimestamps.push(now);
214
+ if (crashTimestamps.length > resolvedMaxRestarts) degraded = true;
215
+ }
216
+
217
+ function restartBackoffMs() {
218
+ const recent = crashTimestamps.length;
219
+ if (recent === 0) return 0;
220
+ return Math.min(500 * 2 ** (recent - 1), MAX_RESTART_BACKOFF_MS);
221
+ }
222
+
223
+ function clearIdleTimer() {
224
+ if (idleTimer) clearTimeout(idleTimer);
225
+ idleTimer = null;
226
+ }
227
+
228
+ function scheduleIdleShutdown() {
229
+ clearIdleTimer();
230
+ if (!worker || disposed) return;
231
+ idleTimer = setTimeout(() => {
232
+ // Idle means no job is in flight, so the worker drains instantly.
233
+ void stopWorker();
234
+ }, resolvedIdleMs);
235
+ idleTimer.unref?.();
236
+ }
237
+
238
+ function settlePending(current, settle) {
239
+ const pending = current.pending;
240
+ if (!pending) return;
241
+ current.pending = null;
242
+ clearTimeout(pending.timer);
243
+ settle(pending);
244
+ }
245
+
246
+ function spawnWorker() {
247
+ const proc = spawn(process.execPath, [script, ...workerArgs], {
248
+ ...(cwd ? { cwd } : {}),
249
+ ...(spawnEnv ? { env: spawnEnv } : {}),
250
+ stdio: ['pipe', 'pipe', 'pipe'],
251
+ });
252
+ const current = {
253
+ proc,
254
+ pending: null,
255
+ stopping: false,
256
+ stderrTail: '',
257
+ exited: new Promise((resolve) => {
258
+ proc.once('exit', resolve);
259
+ proc.once('error', resolve);
260
+ }),
261
+ };
262
+ attachLineParser(proc.stdout, (message) => {
263
+ const pending = current.pending;
264
+ if (!pending || message.id !== pending.id) return;
265
+ if (pending.kind === 'ping') {
266
+ settlePending(current, ({ resolve }) => resolve(message));
267
+ return;
268
+ }
269
+ if (message.ok === true) {
270
+ settlePending(current, ({ resolve }) => resolve(message));
271
+ } else {
272
+ // A refused job is the worker doing its job — the process stays warm.
273
+ settlePending(current, ({ reject }) =>
274
+ reject(
275
+ new Error(
276
+ typeof message.error === 'string' && message.error
277
+ ? message.error
278
+ : 'Preview capture worker reported a failure.',
279
+ ),
280
+ ),
281
+ );
282
+ }
283
+ });
284
+ attachStderrTail(proc.stderr, current);
285
+ const onGone = () => {
286
+ if (worker === current) worker = null;
287
+ if (current.stopping) return;
288
+ current.stopping = true;
289
+ recordCrash();
290
+ settlePending(current, ({ reject }) =>
291
+ reject(workerFailure(current, 'Preview capture worker exited unexpectedly.')),
292
+ );
293
+ };
294
+ proc.once('exit', onGone);
295
+ proc.once('error', onGone);
296
+ return current;
297
+ }
298
+
299
+ async function stopWorker() {
300
+ const current = worker;
301
+ if (!current) return;
302
+ worker = null;
303
+ current.stopping = true;
304
+ settlePending(current, ({ reject }) =>
305
+ reject(new Error('Preview capture worker channel is shutting down.')),
306
+ );
307
+ try {
308
+ current.proc.stdin.end();
309
+ } catch {
310
+ // Already closed; the signal below still applies.
311
+ }
312
+ current.proc.kill('SIGTERM');
313
+ const killTimer = setTimeout(() => {
314
+ current.proc.kill('SIGKILL');
315
+ }, STOP_KILL_GRACE_MS);
316
+ killTimer.unref?.();
317
+ await current.exited;
318
+ clearTimeout(killTimer);
319
+ }
320
+
321
+ function killWorker(current) {
322
+ if (worker === current) worker = null;
323
+ current.stopping = true;
324
+ current.proc.kill('SIGKILL');
325
+ }
326
+
327
+ function dispatch(current, message, { kind, timeoutMs, onTimeout }) {
328
+ return new Promise((resolve, reject) => {
329
+ const timer = setTimeout(() => {
330
+ current.pending = null;
331
+ onTimeout();
332
+ reject(
333
+ workerFailure(
334
+ current,
335
+ kind === 'ping'
336
+ ? 'Preview capture worker did not answer a health check.'
337
+ : `Preview capture job timed out after ${timeoutMs}ms.`,
338
+ ),
339
+ );
340
+ }, timeoutMs);
341
+ timer.unref?.();
342
+ current.pending = { id: message.id, kind, resolve, reject, timer };
343
+ try {
344
+ current.proc.stdin.write(`${JSON.stringify(message)}\n`);
345
+ } catch (error) {
346
+ settlePending(current, () => undefined);
347
+ reject(
348
+ workerFailure(
349
+ current,
350
+ `Preview capture worker is not accepting requests: ${
351
+ error?.message ?? error
352
+ }`,
353
+ ),
354
+ );
355
+ }
356
+ });
357
+ }
358
+
359
+ async function pingWorker(current) {
360
+ await dispatch(
361
+ current,
362
+ { id: nextRequestId(), type: 'ping' },
363
+ {
364
+ kind: 'ping',
365
+ timeoutMs: resolvedPingTimeoutMs,
366
+ onTimeout: () => {
367
+ // A worker that cannot answer a ping cannot run a job either.
368
+ recordCrash();
369
+ killWorker(current);
370
+ },
371
+ },
372
+ );
373
+ }
374
+
375
+ /** Returns a live worker, or null when the channel degraded on the way. */
376
+ async function ensureWorker() {
377
+ if (worker) {
378
+ const current = worker;
379
+ try {
380
+ await pingWorker(current);
381
+ return current;
382
+ } catch {
383
+ // The wedged worker was killed and the crash recorded. When that
384
+ // crash exhausted the restart budget, the caller takes the degraded
385
+ // path; otherwise fall through to a fresh spawn below.
386
+ if (degraded) return null;
387
+ }
388
+ }
389
+ const backoff = restartBackoffMs();
390
+ if (backoff > 0) {
391
+ await new Promise((resolve) => {
392
+ const timer = setTimeout(resolve, backoff);
393
+ timer.unref?.();
394
+ });
395
+ }
396
+ if (disposed) {
397
+ throw new Error('Preview capture worker channel is disposed.');
398
+ }
399
+ worker = spawnWorker();
400
+ return worker;
401
+ }
402
+
403
+ function runWorkerJob(current, payload) {
404
+ return dispatch(
405
+ current,
406
+ { id: nextRequestId(), type: 'capture', payload },
407
+ {
408
+ kind: 'capture',
409
+ timeoutMs: resolvedJobTimeoutMs,
410
+ onTimeout: () => {
411
+ recordCrash();
412
+ killWorker(current);
413
+ },
414
+ },
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Degraded path: one spawn per request with `--once`, the same protocol over
420
+ * the child's own stdio. Slow again, but on-demand capture keeps answering.
421
+ */
422
+ function runOnceJob(payload) {
423
+ return new Promise((resolve, reject) => {
424
+ const proc = spawn(
425
+ process.execPath,
426
+ [script, ...workerArgs, '--once'],
427
+ {
428
+ ...(cwd ? { cwd } : {}),
429
+ ...(spawnEnv ? { env: spawnEnv } : {}),
430
+ stdio: ['pipe', 'pipe', 'pipe'],
431
+ },
432
+ );
433
+ const once = { stderrTail: '' };
434
+ let settled = false;
435
+ const settle = (fn) => {
436
+ if (settled) return;
437
+ settled = true;
438
+ clearTimeout(timer);
439
+ fn();
440
+ };
441
+ const timer = setTimeout(() => {
442
+ settle(() => {
443
+ proc.kill('SIGKILL');
444
+ reject(
445
+ new Error(`Preview capture job timed out after ${resolvedJobTimeoutMs}ms.`),
446
+ );
447
+ });
448
+ }, resolvedJobTimeoutMs);
449
+ timer.unref?.();
450
+ const id = nextRequestId();
451
+ attachLineParser(proc.stdout, (message) => {
452
+ if (message.id !== id) return;
453
+ if (message.ok === true) {
454
+ settle(() => resolve(message));
455
+ } else {
456
+ settle(() =>
457
+ reject(
458
+ new Error(
459
+ typeof message.error === 'string' && message.error
460
+ ? message.error
461
+ : 'Preview capture worker reported a failure.',
462
+ ),
463
+ ),
464
+ );
465
+ }
466
+ proc.stdin.end();
467
+ });
468
+ attachStderrTail(proc.stderr, once);
469
+ const onGone = () => {
470
+ settle(() =>
471
+ reject(
472
+ workerFailure(once, 'Preview capture worker exited without a response.'),
473
+ ),
474
+ );
475
+ };
476
+ proc.once('exit', onGone);
477
+ proc.once('error', onGone);
478
+ try {
479
+ proc.stdin.write(
480
+ `${JSON.stringify({ id, type: 'capture', payload })}\n`,
481
+ );
482
+ } catch (error) {
483
+ settle(() =>
484
+ reject(
485
+ new Error(
486
+ `Preview capture worker is not accepting requests: ${
487
+ error?.message ?? error
488
+ }`,
489
+ ),
490
+ ),
491
+ );
492
+ }
493
+ });
494
+ }
495
+
496
+ async function generateArtifact(request) {
497
+ if (disposed) {
498
+ throw new Error('Preview capture worker channel is disposed.');
499
+ }
500
+ // The artifact plugin serializes per identity; overlap here means a caller
501
+ // bypassed that queue, and interleaving two captures on one warm browser
502
+ // would let them poison each other.
503
+ if (inFlight) {
504
+ throw new Error('Preview capture worker accepts one job at a time.');
505
+ }
506
+ const payload = normalizeRequest(request);
507
+ inFlight = true;
508
+ clearIdleTimer();
509
+ try {
510
+ const current = degraded ? null : await ensureWorker();
511
+ const response = current
512
+ ? await runWorkerJob(current, payload)
513
+ : await runOnceJob(payload);
514
+ return await consumeArtifactFile(response);
515
+ } finally {
516
+ inFlight = false;
517
+ scheduleIdleShutdown();
518
+ }
519
+ }
520
+
521
+ async function dispose() {
522
+ if (disposed) return;
523
+ disposed = true;
524
+ clearIdleTimer();
525
+ await stopWorker();
526
+ }
527
+
528
+ return { generateArtifact, dispose };
529
+ }
@@ -1,3 +1,7 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
1
5
  function normalizedRoutePath(value) {
2
6
  if (typeof value !== 'string' || !value.trim()) return null;
3
7
  try {
@@ -60,3 +64,150 @@ export function resolvePreviewRouteDependencyDigest(route, entries) {
60
64
  );
61
65
  return candidates[0]?.digest;
62
66
  }
67
+
68
+ // Observed dependencies — the source files a captured frame actually rendered.
69
+ //
70
+ // Freshness used to be decided by the source revision, so every commit retired
71
+ // every frame even when a frame's own content could not have changed. A route
72
+ // digest is not a usable replacement in a single-page application: nearly every
73
+ // screen lives on one or two routes, so a route's dependency closure is the
74
+ // whole application and any edit invalidates everything.
75
+ //
76
+ // A frozen snapshot stamps the file each element came from, so the set of files
77
+ // a screen really rendered is recoverable from the capture itself. That set is
78
+ // finer than a route and cannot drift from the truth, because it is an
79
+ // observation rather than a declaration.
80
+
81
+ /** Attributes the DOM importer stamps a source file onto. */
82
+ const SOURCE_ATTRIBUTES = ['data-pygmalion-source', 'data-pygmalion-own-source'];
83
+
84
+ /**
85
+ * Minimum stamped elements before an observed set is trusted.
86
+ *
87
+ * A frame whose stamps are missing (a host that never wired sourceComponents,
88
+ * or a capture that failed early) would otherwise read as "depends on nothing"
89
+ * and never be invalidated again. Too few stamps therefore means unknown, and
90
+ * unknown must fall back to the coarse key rather than claim precision.
91
+ */
92
+ export const OBSERVED_DEPENDENCY_MIN_STAMPS = 8;
93
+
94
+ /** Files whose content decides a screen without ever being stamped. */
95
+ const DEFAULT_ALWAYS_INCLUDED = Object.freeze([]);
96
+
97
+ function normalizedSourcePath(value) {
98
+ if (typeof value !== 'string') return null;
99
+ // A stamp is "<file>|<hash>|<tag>"; only the file participates in identity.
100
+ const file = value.split('|', 1)[0].trim();
101
+ if (!file || file.startsWith('/') || file.includes('..')) return null;
102
+ return file.split(path.sep).join('/');
103
+ }
104
+
105
+ /**
106
+ * Source files stamped into a serialized snapshot.
107
+ *
108
+ * Parsing the stored HTML rather than collecting in the page keeps the answer
109
+ * true to what was actually stored: a serializer that prunes or rewrites nodes
110
+ * cannot make the recorded set disagree with the artifact it describes. It also
111
+ * means one function serves every producer, since all of them publish HTML.
112
+ */
113
+ export function extractObservedSourceFiles(snapshot) {
114
+ // A stored snapshot is `{ document, head, body }`, but callers also hold the
115
+ // raw body string; accept both so one function serves every producer.
116
+ const html =
117
+ typeof snapshot === 'string'
118
+ ? snapshot
119
+ : snapshot && typeof snapshot === 'object'
120
+ ? [snapshot.document, snapshot.head, snapshot.body]
121
+ .filter((part) => typeof part === 'string')
122
+ .join('\n')
123
+ : '';
124
+ if (!html) {
125
+ return { files: [], stamps: 0 };
126
+ }
127
+ const files = new Set();
128
+ let stamps = 0;
129
+ for (const attribute of SOURCE_ATTRIBUTES) {
130
+ const pattern = new RegExp(`${attribute}="([^"]*)"`, 'g');
131
+ for (const match of html.matchAll(pattern)) {
132
+ stamps += 1;
133
+ const file = normalizedSourcePath(match[1]);
134
+ if (file) files.add(file);
135
+ }
136
+ }
137
+ return { files: [...files].sort(), stamps };
138
+ }
139
+
140
+ async function fileContentHash(root, file) {
141
+ try {
142
+ const resolved = path.resolve(root, file);
143
+ if (!resolved.startsWith(path.resolve(root) + path.sep)) return null;
144
+ const source = await fs.readFile(resolved);
145
+ return createHash('sha256').update(source).digest('hex').slice(0, 32);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Records the observed set as `[path, contentHash]` pairs.
153
+ *
154
+ * Pairs rather than one digest: freshness is then a comparison against the
155
+ * files as they are now, so recording the set never changes the key the entry
156
+ * was written under. Storing a digest in the key instead would retire the very
157
+ * entry that just produced it.
158
+ */
159
+ export async function recordObservedDependencies({
160
+ snapshot,
161
+ sourceRoot,
162
+ alwaysInclude = DEFAULT_ALWAYS_INCLUDED,
163
+ minStamps = OBSERVED_DEPENDENCY_MIN_STAMPS,
164
+ }) {
165
+ if (typeof sourceRoot !== 'string' || !sourceRoot) return null;
166
+ const { files, stamps } = extractObservedSourceFiles(snapshot);
167
+ if (stamps < minStamps || files.length === 0) return null;
168
+ const wanted = [
169
+ ...new Set([
170
+ ...files,
171
+ ...alwaysInclude
172
+ .map((file) => normalizedSourcePath(file))
173
+ .filter((file) => file != null),
174
+ ]),
175
+ ].sort();
176
+ const recorded = [];
177
+ for (const file of wanted) {
178
+ const hash = await fileContentHash(sourceRoot, file);
179
+ // A stamped file we cannot read leaves the set unverifiable, and an
180
+ // unverifiable set must not be treated as fresh later.
181
+ if (hash == null) return null;
182
+ recorded.push([file, hash]);
183
+ }
184
+ return recorded;
185
+ }
186
+
187
+ /** True when every recorded file still hashes to what it did at capture time. */
188
+ export async function observedDependenciesUnchanged({ recorded, sourceRoot }) {
189
+ if (!Array.isArray(recorded) || recorded.length === 0) return false;
190
+ if (typeof sourceRoot !== 'string' || !sourceRoot) return false;
191
+ for (const pair of recorded) {
192
+ if (!Array.isArray(pair) || pair.length !== 2) return false;
193
+ const [file, hash] = pair;
194
+ if (typeof file !== 'string' || typeof hash !== 'string') return false;
195
+ const current = await fileContentHash(sourceRoot, file);
196
+ if (current !== hash) return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ /** Normalizes a persisted set, dropping anything malformed. */
202
+ export function normalizeObservedDependencies(value) {
203
+ if (!Array.isArray(value) || value.length === 0) return null;
204
+ const pairs = [];
205
+ for (const pair of value) {
206
+ if (!Array.isArray(pair) || pair.length !== 2) return null;
207
+ const file = normalizedSourcePath(pair[0]);
208
+ const hash = typeof pair[1] === 'string' ? pair[1].trim() : '';
209
+ if (!file || !hash) return null;
210
+ pairs.push([file, hash]);
211
+ }
212
+ return pairs.sort((left, right) => left[0].localeCompare(right[0]));
213
+ }
@@ -652,11 +652,16 @@ export function selectRoutePreviewArtifactFrames(bundle, wanted, options = {}) {
652
652
  }
653
653
  const fingerprint = isPlainRecord(request) ? request.fingerprint : undefined;
654
654
  const frameSourceRevision = frame.sourceRevision ?? bundle.sourceRevision;
655
- if (
656
- (options.sourceRevision != null &&
657
- frameSourceRevision !== options.sourceRevision) ||
658
- (fingerprint != null && frame.fingerprint !== fingerprint)
659
- ) {
655
+ // A fingerprint is content identity, so it decides on its own and the
656
+ // revision stays provenance. Requiring both retired every frame on every
657
+ // commit, even when the fingerprint proved the frame could not differ.
658
+ // Without a fingerprint the revision is the only identity available.
659
+ const staleForRequest =
660
+ fingerprint != null
661
+ ? frame.fingerprint !== fingerprint
662
+ : options.sourceRevision != null &&
663
+ frameSourceRevision !== options.sourceRevision;
664
+ if (staleForRequest) {
660
665
  stale.push(id);
661
666
  if (options.includeStale === true) frames[id] = frame;
662
667
  continue;