@pygmalionjs/pygmalion 0.6.32 → 0.6.34

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.
@@ -141,30 +141,164 @@ function processIsAlive(pid) {
141
141
  }
142
142
  }
143
143
 
144
+ async function acquireFilesystemReclaimMutex(lockPath, deadline) {
145
+ const reclaimPath = `${lockPath}.reclaim`;
146
+ const token = randomUUID();
147
+ const payload = `${JSON.stringify({
148
+ token,
149
+ pid: process.pid,
150
+ createdAt: new Date().toISOString(),
151
+ })}\n`;
152
+ while (true) {
153
+ const candidate = `${reclaimPath}.candidate-${randomUUID()}`;
154
+ try {
155
+ await fs.writeFile(candidate, payload, { encoding: 'utf8', flag: 'wx' });
156
+ await fs.link(candidate, reclaimPath);
157
+ return async () => {
158
+ const current = await readJson(reclaimPath)
159
+ .then((owner) => owner?.token)
160
+ .catch(() => null);
161
+ if (current === token) {
162
+ await fs.unlink(reclaimPath).catch(() => undefined);
163
+ }
164
+ };
165
+ } catch (error) {
166
+ if (error?.code !== 'EEXIST') throw error;
167
+ // Recovery of this guard would recreate the same ABA race one level down.
168
+ // A dead guard therefore fails closed until it is removed manually.
169
+ if (Date.now() >= deadline) {
170
+ throw new Error('Timed out waiting for the preview artifact store lock.');
171
+ }
172
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
173
+ } finally {
174
+ await fs.rm(candidate, { force: true }).catch(() => undefined);
175
+ }
176
+ }
177
+ }
178
+
179
+ async function reclaimStaleFilesystemLock({
180
+ lockPath,
181
+ deadline,
182
+ staleMs,
183
+ onStaleLockConfirmed,
184
+ }) {
185
+ const releaseReclaim = await acquireFilesystemReclaimMutex(lockPath, deadline);
186
+ try {
187
+ const staleDeadOwner = async () => {
188
+ const stat = await fs.stat(lockPath).catch(() => null);
189
+ if (!stat || Date.now() - stat.mtimeMs <= staleMs) return false;
190
+ const owner = await readJson(path.join(lockPath, 'owner.json')).catch(
191
+ () => null,
192
+ );
193
+ // An absent or malformed owner cannot prove that its initializer died.
194
+ // New locks are atomically published complete, so keep legacy/corrupt
195
+ // shapes fail-closed instead of guessing and deleting a live directory.
196
+ if (
197
+ !owner ||
198
+ typeof owner.token !== 'string' ||
199
+ !owner.token ||
200
+ !Number.isSafeInteger(owner.pid) ||
201
+ owner.pid <= 0
202
+ ) {
203
+ return false;
204
+ }
205
+ return processIsAlive(owner.pid) === false;
206
+ };
207
+ if (!(await staleDeadOwner())) return false;
208
+ await onStaleLockConfirmed?.({ lockPath });
209
+ // Re-read after any delay while every other reclaimer is excluded. The
210
+ // fixed path may now name a new live generation, which must be preserved.
211
+ if (!(await staleDeadOwner())) return false;
212
+ await fs.rm(lockPath, { recursive: true, force: true });
213
+ return true;
214
+ } finally {
215
+ await releaseReclaim();
216
+ }
217
+ }
218
+
219
+ async function filesystemLockPathExists(lockPath) {
220
+ try {
221
+ await fs.lstat(lockPath);
222
+ return true;
223
+ } catch (error) {
224
+ if (error?.code === 'ENOENT') return false;
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ async function filesystemLockPublishConflict(error, lockPath, candidate) {
230
+ if (['EEXIST', 'ENOTEMPTY'].includes(error?.code)) return true;
231
+ if (!['EACCES', 'EPERM'].includes(error?.code)) return false;
232
+ // Windows can report an existing destination directory as a permission
233
+ // failure. Treat that shape as contention, but propagate a genuine parent or
234
+ // candidate permission failure when no competing fixed directory exists.
235
+ const [fixedExists, candidateExists] = await Promise.all([
236
+ filesystemLockPathExists(lockPath),
237
+ fs
238
+ .lstat(candidate)
239
+ .then((stat) => stat.isDirectory())
240
+ .catch(() => false),
241
+ ]);
242
+ return fixedExists && candidateExists;
243
+ }
244
+
144
245
  async function withFilesystemLock(lockPath, task, options = {}) {
145
246
  const timeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
146
247
  const staleMs = options.lockStaleMs ?? LOCK_STALE_MS;
147
248
  const deadline = Date.now() + timeoutMs;
249
+ const ownerToken = randomUUID();
250
+ const ownerPayload = `${JSON.stringify({
251
+ token: ownerToken,
252
+ pid: process.pid,
253
+ createdAt: new Date().toISOString(),
254
+ })}\n`;
255
+ let owner;
148
256
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
149
- while (true) {
257
+ while (!owner) {
258
+ const candidate = `${lockPath}.candidate-${randomUUID()}`;
259
+ let publicationConflict = false;
260
+ let publicationError;
150
261
  try {
151
- await fs.mkdir(lockPath);
152
- break;
262
+ await fs.mkdir(candidate);
263
+ await fs.writeFile(path.join(candidate, 'owner.json'), ownerPayload, 'utf8');
264
+ await options.onLockOwnerReady?.({ candidate, lockPath });
265
+ if (await filesystemLockPathExists(lockPath)) {
266
+ publicationConflict = true;
267
+ } else {
268
+ try {
269
+ await fs.rename(candidate, lockPath);
270
+ owner = path.join(lockPath, 'owner.json');
271
+ } catch (error) {
272
+ publicationConflict = await filesystemLockPublishConflict(
273
+ error,
274
+ lockPath,
275
+ candidate,
276
+ );
277
+ if (!publicationConflict) publicationError = error;
278
+ }
279
+ }
153
280
  } catch (error) {
154
- if (error?.code !== 'EEXIST') throw error;
281
+ publicationError = error;
282
+ } finally {
283
+ await fs.rm(candidate, { recursive: true, force: true }).catch(
284
+ () => undefined,
285
+ );
286
+ }
287
+ if (owner) continue;
288
+ if (publicationError) throw publicationError;
289
+ if (publicationConflict) {
155
290
  const stale = await fs
156
291
  .stat(lockPath)
157
292
  .then((stat) => Date.now() - stat.mtimeMs > staleMs)
158
293
  .catch(() => false);
159
294
  if (stale) {
160
- const ownerState = await readJson(path.join(lockPath, 'owner.json'))
161
- .then((owner) => processIsAlive(owner?.pid))
162
- .catch(() => null);
163
- // A delayed but live writer must never lose its lock: it may resume and
164
- // publish a manifest read before another writer. Recover only when the
165
- // owning process is confirmed dead, or an old lock never got an owner.
166
- if (ownerState === false || ownerState === null) {
167
- await fs.rm(lockPath, { recursive: true, force: true });
295
+ const reclaimed = await reclaimStaleFilesystemLock({
296
+ lockPath,
297
+ deadline,
298
+ staleMs,
299
+ onStaleLockConfirmed: options.onStaleLockConfirmed,
300
+ });
301
+ if (reclaimed) {
168
302
  continue;
169
303
  }
170
304
  }
@@ -172,24 +306,9 @@ async function withFilesystemLock(lockPath, task, options = {}) {
172
306
  throw new Error('Timed out waiting for the preview artifact store lock.');
173
307
  }
174
308
  await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
309
+ continue;
175
310
  }
176
- }
177
-
178
- const owner = path.join(lockPath, 'owner.json');
179
- const ownerToken = randomUUID();
180
- try {
181
- await fs.writeFile(
182
- owner,
183
- `${JSON.stringify({
184
- token: ownerToken,
185
- pid: process.pid,
186
- createdAt: new Date().toISOString(),
187
- })}\n`,
188
- 'utf8',
189
- );
190
- } catch (error) {
191
- await fs.rm(lockPath, { recursive: true, force: true });
192
- throw error;
311
+ throw new Error('Preview artifact store lock publication did not settle.');
193
312
  }
194
313
  const heartbeat = setInterval(() => {
195
314
  const now = new Date();
@@ -254,12 +373,8 @@ export function createPreviewArtifactStore({
254
373
  lockTimeoutMs,
255
374
  lockStaleMs,
256
375
  onLockAcquired,
257
- /**
258
- * Application source root. With it, a frame records the files it actually
259
- * rendered and freshness becomes a question about their content instead of
260
- * about the revision that happened to capture them.
261
- */
262
- sourceRoot,
376
+ onLockOwnerReady,
377
+ onStaleLockConfirmed,
263
378
  observedAlwaysInclude,
264
379
  /** Byte budget for stored objects. See the retention comment above. */
265
380
  maxBytes = PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.maxBytes,
@@ -271,13 +386,12 @@ export function createPreviewArtifactStore({
271
386
  }
272
387
  const legacyFile = path.resolve(artifactFile);
273
388
  const root = path.resolve(storeDirectory);
274
- const currentSourceRoot = () => {
275
- const candidate =
276
- typeof sourceRoot === 'function' ? sourceRoot() : sourceRoot;
277
- return typeof candidate === 'string' && candidate
278
- ? path.resolve(candidate)
389
+ const sourceRootForRevision = (sourceContext, sourceRevision) =>
390
+ sourceContext?.sourceRevision === sourceRevision &&
391
+ typeof sourceContext?.sourceRoot === 'string' &&
392
+ sourceContext.sourceRoot
393
+ ? path.resolve(sourceContext.sourceRoot)
279
394
  : undefined;
280
- };
281
395
  let legacyCache = null;
282
396
  const objectCache = new Map();
283
397
  let objectCacheBytes = 0;
@@ -457,12 +571,17 @@ export function createPreviewArtifactStore({
457
571
  valid: 2,
458
572
  });
459
573
 
460
- const classifyFrameEntry = async (entry) => {
574
+ const classifyFrameEntry = async (entry, sourceContext) => {
461
575
  const observed = normalizeObservedDependencies(entry.sourceFiles);
462
576
  if (!observed) return 'unknown';
577
+ const exactSourceRoot = sourceRootForRevision(
578
+ sourceContext,
579
+ sourceContext?.sourceRevision,
580
+ );
581
+ if (!exactSourceRoot) return 'unknown';
463
582
  return (await observedDependenciesUnchanged({
464
583
  recorded: observed,
465
- sourceRoot: currentSourceRoot(),
584
+ sourceRoot: exactSourceRoot,
466
585
  }))
467
586
  ? 'valid'
468
587
  : 'invalid';
@@ -477,7 +596,10 @@ export function createPreviewArtifactStore({
477
596
  * is what keeps a concurrent reader from losing a bundle it is about to
478
597
  * fetch.
479
598
  */
480
- const evictToBudget = async (manifest, { at }) => {
599
+ const evictToBudget = async (
600
+ manifest,
601
+ { at, sourceContext, admittedFrameObjects = new Set() } = {},
602
+ ) => {
481
603
  const referenced = new Map();
482
604
  const remember = async (hash) => {
483
605
  if (!referenced.has(hash)) referenced.set(hash, await objectByteSize(hash));
@@ -508,7 +630,13 @@ export function createPreviewArtifactStore({
508
630
  candidates.push({
509
631
  kind: 'frame',
510
632
  entry,
511
- rank: EVICTION_RANK[await classifyFrameEntry(entry)],
633
+ // A caller that supplied normalized observations recorded from the
634
+ // artifact's own snapshot has already paid to produce this object.
635
+ // Protect that admission from older unknown entries without rereading
636
+ // a source root that may not be physically pinned.
637
+ rank: admittedFrameObjects.has(entry.object)
638
+ ? EVICTION_RANK.valid
639
+ : EVICTION_RANK[await classifyFrameEntry(entry, sourceContext)],
512
640
  // Protection within a rank, never across it: the last copy of a frame
513
641
  // is worth more than a surplus copy, but an invalid sole entry is still
514
642
  // garbage and goes before a valid one.
@@ -632,6 +760,40 @@ export function createPreviewArtifactStore({
632
760
  };
633
761
  };
634
762
 
763
+ /**
764
+ * Records every frame's observed source set before publication can wait on the
765
+ * namespace lock. A Map entry is present even when observation is unavailable,
766
+ * so `null` is an explicit pin rather than permission to inspect another root.
767
+ */
768
+ const observeArtifactDependencies = async (artifact, explicitSourceRoot) => {
769
+ const validation = validateRoutePreviewArtifactBundle(artifact);
770
+ if (!validation.valid) {
771
+ throw new Error('Preview artifact store refused an invalid artifact.');
772
+ }
773
+ const observedByFrame = new Map();
774
+ if (artifact.version !== 3) return observedByFrame;
775
+ const pinnedSourceRoot =
776
+ typeof explicitSourceRoot === 'string' && explicitSourceRoot
777
+ ? path.resolve(explicitSourceRoot)
778
+ : undefined;
779
+ for (const [id, frame] of Object.entries(artifact.frames)) {
780
+ const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
781
+ // Artifact frames are an open record in the v3 wire format, so an
782
+ // unvalidated `sourceFiles` property supplied by a generator cannot be a
783
+ // freshness proof. Recompute it from the serialized snapshot and the
784
+ // explicitly pinned root every time.
785
+ const observed = await recordObservedDependencies({
786
+ snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
787
+ sourceRoot: pinnedSourceRoot,
788
+ ...(observedAlwaysInclude
789
+ ? { alwaysInclude: observedAlwaysInclude }
790
+ : {}),
791
+ });
792
+ observedByFrame.set(id, observed);
793
+ }
794
+ return observedByFrame;
795
+ };
796
+
635
797
  const publishArtifact = async (
636
798
  artifact,
637
799
  {
@@ -639,6 +801,8 @@ export function createPreviewArtifactStore({
639
801
  sourceRevision = artifact?.sourceRevision,
640
802
  materializeLegacy = true,
641
803
  frameRequests = [],
804
+ observedDependenciesByFrame,
805
+ sourceContext,
642
806
  } = {},
643
807
  ) => {
644
808
  const validation = validateRoutePreviewArtifactBundle(artifact);
@@ -652,7 +816,51 @@ export function createPreviewArtifactStore({
652
816
  ) {
653
817
  throw new Error('Preview artifact store refused a mismatched revision.');
654
818
  }
819
+ if (
820
+ observedDependenciesByFrame != null &&
821
+ !(observedDependenciesByFrame instanceof Map)
822
+ ) {
823
+ throw new TypeError(
824
+ 'Preview artifact observed dependencies must be provided as a Map.',
825
+ );
826
+ }
827
+ let pinnedObservedDependenciesByFrame;
828
+ if (observedDependenciesByFrame instanceof Map && artifact.version === 3) {
829
+ const frameIds = new Set(Object.keys(artifact.frames));
830
+ if (
831
+ observedDependenciesByFrame.size !== frameIds.size ||
832
+ [...frameIds].some((id) => !observedDependenciesByFrame.has(id)) ||
833
+ [...observedDependenciesByFrame.keys()].some((id) => !frameIds.has(id))
834
+ ) {
835
+ throw new TypeError(
836
+ 'Preview artifact observed dependencies must cover every frame exactly.',
837
+ );
838
+ }
839
+ pinnedObservedDependenciesByFrame = new Map();
840
+ for (const observed of observedDependenciesByFrame.values()) {
841
+ if (observed !== null && normalizeObservedDependencies(observed) == null) {
842
+ throw new TypeError(
843
+ 'Preview artifact observed dependencies contain an invalid entry.',
844
+ );
845
+ }
846
+ }
847
+ for (const id of frameIds) {
848
+ const normalized = normalizeObservedDependencies(
849
+ observedDependenciesByFrame.get(id),
850
+ );
851
+ pinnedObservedDependenciesByFrame.set(
852
+ id,
853
+ normalized?.map(([file, hash]) => [file, hash]) ?? null,
854
+ );
855
+ }
856
+ }
655
857
  const namespace = artifact.namespace;
858
+ const exactSourceContext = sourceRootForRevision(
859
+ sourceContext,
860
+ sourceRevision,
861
+ )
862
+ ? sourceContext
863
+ : undefined;
656
864
  return withFilesystemLock(
657
865
  lockPath(namespace),
658
866
  async () => {
@@ -673,6 +881,7 @@ export function createPreviewArtifactStore({
673
881
  ...(stored?.frames ?? []),
674
882
  ].map((entry) => entry.object),
675
883
  );
884
+ const admittedFrameObjects = new Set();
676
885
  if (recordRevision && sourceRevision) {
677
886
  const wholeObject = await writeObject(artifact);
678
887
  const nextRevision = {
@@ -701,16 +910,13 @@ export function createPreviewArtifactStore({
701
910
  const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
702
911
  const object = await writeObject(selected.bundle);
703
912
  // Recorded from the stored snapshot, so the set describes exactly
704
- // the artifact it travels with. Null keeps today's behaviour.
705
- const observed =
706
- normalizeObservedDependencies(frame.sourceFiles) ??
707
- (await recordObservedDependencies({
708
- snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
709
- sourceRoot: currentSourceRoot(),
710
- ...(observedAlwaysInclude
711
- ? { alwaysInclude: observedAlwaysInclude }
712
- : {}),
713
- }));
913
+ // the artifact it travels with. Null keeps the entry on the
914
+ // revision/fingerprint-only fallback.
915
+ const observed = pinnedObservedDependenciesByFrame
916
+ ? normalizeObservedDependencies(
917
+ pinnedObservedDependenciesByFrame.get(id),
918
+ )
919
+ : null;
714
920
  const nextFrame = {
715
921
  id,
716
922
  fingerprint:
@@ -726,6 +932,7 @@ export function createPreviewArtifactStore({
726
932
  generation,
727
933
  ...(observed ? { sourceFiles: observed } : {}),
728
934
  };
935
+ if (observed) admittedFrameObjects.add(object);
729
936
  const alreadyRecorded = frames.some(
730
937
  (entry) =>
731
938
  entry.id === nextFrame.id &&
@@ -742,7 +949,11 @@ export function createPreviewArtifactStore({
742
949
  // reclaim whatever has been unreferenced long enough to be safe. No
743
950
  // human is asked when the store should be cleaned.
744
951
  const at = now();
745
- const trimmed = await evictToBudget(manifest, { at });
952
+ const trimmed = await evictToBudget(manifest, {
953
+ at,
954
+ sourceContext: exactSourceContext,
955
+ admittedFrameObjects,
956
+ });
746
957
  manifest = trimmed.dropped
747
958
  ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
748
959
  : trimmed.manifest;
@@ -808,7 +1019,13 @@ export function createPreviewArtifactStore({
808
1019
  }
809
1020
  return artifact;
810
1021
  },
811
- { lockTimeoutMs, lockStaleMs, onLockAcquired },
1022
+ {
1023
+ lockTimeoutMs,
1024
+ lockStaleMs,
1025
+ onLockAcquired,
1026
+ onLockOwnerReady,
1027
+ onStaleLockConfirmed,
1028
+ },
812
1029
  );
813
1030
  };
814
1031
 
@@ -911,9 +1128,17 @@ export function createPreviewArtifactStore({
911
1128
  * it survives an edit to a file the frame never rendered, which a route-wide
912
1129
  * digest cannot express in a single-page application.
913
1130
  */
914
- const frameIsFresh = async (entry, request, sourceRevision) => {
1131
+ const frameFreshness = async (
1132
+ entry,
1133
+ request,
1134
+ sourceRevision,
1135
+ sourceContext,
1136
+ ) => {
915
1137
  if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
916
- return true;
1138
+ return 'fresh';
1139
+ }
1140
+ if (request.fingerprint == null) {
1141
+ return entry.sourceRevision === sourceRevision ? 'fresh' : 'stale';
917
1142
  }
918
1143
  // Source hashes cannot prove that viewport, environment, or interaction
919
1144
  // inputs stayed the same. They may bridge a changed source fingerprint only
@@ -924,30 +1149,45 @@ export function createPreviewArtifactStore({
924
1149
  entry.recipeFingerprint == null ||
925
1150
  entry.recipeFingerprint !== request.recipeFingerprint)
926
1151
  ) {
927
- return false;
1152
+ return 'stale';
928
1153
  }
929
1154
  const observed = normalizeObservedDependencies(entry.sourceFiles);
930
1155
  if (observed) {
931
- return observedDependenciesUnchanged({
1156
+ const exactSourceRoot = sourceRootForRevision(
1157
+ sourceContext,
1158
+ sourceRevision,
1159
+ );
1160
+ if (!exactSourceRoot) return 'source-pending';
1161
+ return (await observedDependenciesUnchanged({
932
1162
  recorded: observed,
933
- sourceRoot: currentSourceRoot(),
934
- });
1163
+ sourceRoot: exactSourceRoot,
1164
+ }))
1165
+ ? 'fresh'
1166
+ : 'stale';
935
1167
  }
936
1168
  // Nothing content-based to go on: fall back to the old rule so a frame is
937
1169
  // never treated as fresher than it was before.
938
1170
  return (
939
1171
  entry.sourceRevision === sourceRevision &&
940
- (request.fingerprint == null || entry.fingerprint === request.fingerprint)
941
- );
1172
+ entry.fingerprint === request.fingerprint
1173
+ )
1174
+ ? 'fresh'
1175
+ : 'stale';
942
1176
  };
943
1177
 
944
1178
  /** Resolves freshness from the compact manifest without loading frame payloads. */
945
- const resolveFrameSelection = async (namespace, sourceRevision, wanted) => {
1179
+ const resolveFrameSelection = async (
1180
+ namespace,
1181
+ sourceRevision,
1182
+ wanted,
1183
+ { sourceContext } = {},
1184
+ ) => {
946
1185
  const manifest = await readManifest(namespace);
947
1186
  const candidatesById = indexFrameCandidates(manifest, wanted);
948
1187
  const exact = [];
949
1188
  const missing = [];
950
1189
  const stale = [];
1190
+ const sourcePending = [];
951
1191
  for (const request of wanted) {
952
1192
  const candidates = candidatesById.get(request.id) ?? [];
953
1193
  if (!candidates.length) {
@@ -955,20 +1195,41 @@ export function createPreviewArtifactStore({
955
1195
  continue;
956
1196
  }
957
1197
  let current = false;
1198
+ let mayBecomeCurrent = false;
958
1199
  for (const entry of candidates) {
959
- if (await frameIsFresh(entry, request, sourceRevision)) {
1200
+ const freshness = await frameFreshness(
1201
+ entry,
1202
+ request,
1203
+ sourceRevision,
1204
+ sourceContext,
1205
+ );
1206
+ if (freshness === 'fresh') {
960
1207
  current = true;
961
1208
  break;
962
1209
  }
1210
+ if (freshness === 'source-pending') mayBecomeCurrent = true;
963
1211
  }
1212
+ if (!current && mayBecomeCurrent) sourcePending.push(request.id);
964
1213
  (current ? exact : stale).push(request.id);
965
1214
  }
966
- return { exact, missing, stale };
1215
+ return {
1216
+ exact,
1217
+ missing,
1218
+ stale,
1219
+ ...(sourcePending.length ? { sourcePending } : {}),
1220
+ };
967
1221
  };
968
1222
 
969
- const readFrameSelection = async (namespace, sourceRevision, wanted) => {
1223
+ const readFrameSelection = async (
1224
+ namespace,
1225
+ sourceRevision,
1226
+ wanted,
1227
+ { sourceContext } = {},
1228
+ ) => {
970
1229
  const manifest = await readManifest(namespace);
971
1230
  let aggregate = null;
1231
+ const contentFreshIds = new Set();
1232
+ const sourcePendingIds = new Set();
972
1233
  if (manifest) {
973
1234
  // Index once per read. The earlier path filtered the complete manifest for
974
1235
  // every wanted frame, which made a storyboard probe O(wanted × entries).
@@ -977,7 +1238,16 @@ export function createPreviewArtifactStore({
977
1238
  const candidates = candidatesById.get(request.id) ?? [];
978
1239
  const fresh = [];
979
1240
  for (const entry of candidates) {
980
- if (await frameIsFresh(entry, request, sourceRevision)) fresh.push(entry);
1241
+ const freshness = await frameFreshness(
1242
+ entry,
1243
+ request,
1244
+ sourceRevision,
1245
+ sourceContext,
1246
+ );
1247
+ if (freshness === 'fresh') fresh.push(entry);
1248
+ if (freshness === 'source-pending') {
1249
+ sourcePendingIds.add(request.id);
1250
+ }
981
1251
  }
982
1252
  // Several revisions can now be fresh at once, so provenance decides
983
1253
  // which one is served: the requested revision's own capture first, then
@@ -1016,16 +1286,30 @@ export function createPreviewArtifactStore({
1016
1286
  aggregate,
1017
1287
  withProvenance,
1018
1288
  );
1289
+ if (exactSet.has(entry)) {
1290
+ contentFreshIds.add(request.id);
1291
+ sourcePendingIds.delete(request.id);
1292
+ }
1019
1293
  break;
1020
1294
  }
1021
1295
  }
1022
1296
  }
1023
1297
  }
1024
- return selectRoutePreviewArtifactFrames(
1298
+ const selection = selectRoutePreviewArtifactFrames(
1025
1299
  aggregate ?? emptyBundle(namespace, sourceRevision),
1026
1300
  wanted,
1027
1301
  { includeStale: true, sourceRevision },
1028
1302
  );
1303
+ return {
1304
+ ...selection,
1305
+ // The wire-level selector only knows fingerprints. A frame whose recipe
1306
+ // and observed files were verified against the requested source context is
1307
+ // also current, even when its coarse source fingerprint changed.
1308
+ stale: selection.stale.filter((id) => !contentFreshIds.has(id)),
1309
+ ...(sourcePendingIds.size
1310
+ ? { sourcePending: [...sourcePendingIds] }
1311
+ : {}),
1312
+ };
1029
1313
  };
1030
1314
 
1031
1315
  /**
@@ -1035,7 +1319,7 @@ export function createPreviewArtifactStore({
1035
1319
  * provenance-only" is the split that explains both the size and the next
1036
1320
  * eviction — so it is reported rather than left to be inferred.
1037
1321
  */
1038
- const inspect = async (namespace) => {
1322
+ const inspect = async (namespace, { sourceContext } = {}) => {
1039
1323
  const manifest = await readManifest(namespace);
1040
1324
  if (!manifest) {
1041
1325
  return {
@@ -1057,7 +1341,7 @@ export function createPreviewArtifactStore({
1057
1341
  }
1058
1342
  const counts = { valid: 0, unknown: 0, invalid: 0 };
1059
1343
  for (const entry of manifest.frames) {
1060
- counts[await classifyFrameEntry(entry)] += 1;
1344
+ counts[await classifyFrameEntry(entry, sourceContext)] += 1;
1061
1345
  }
1062
1346
  return {
1063
1347
  namespace,
@@ -1082,7 +1366,7 @@ export function createPreviewArtifactStore({
1082
1366
  * merely being read would stay over budget until the next capture. Callers
1083
1367
  * run this when the server starts.
1084
1368
  */
1085
- const applyRetention = async (namespace) => {
1369
+ const applyRetention = async (namespace, { sourceContext } = {}) => {
1086
1370
  // No namespace means every namespace: at server start the caller has not
1087
1371
  // seen a request yet and so cannot name one.
1088
1372
  if (namespace === undefined) {
@@ -1099,7 +1383,9 @@ export function createPreviewArtifactStore({
1099
1383
  () => null,
1100
1384
  );
1101
1385
  if (typeof stored?.namespace !== 'string') continue;
1102
- const result = await applyRetention(stored.namespace).catch(() => null);
1386
+ const result = await applyRetention(stored.namespace, {
1387
+ sourceContext,
1388
+ }).catch(() => null);
1103
1389
  if (result) dropped.push({ namespace: stored.namespace, ...result });
1104
1390
  }
1105
1391
  return dropped;
@@ -1110,7 +1396,7 @@ export function createPreviewArtifactStore({
1110
1396
  const stored = await readManifest(namespace);
1111
1397
  if (!stored) return null;
1112
1398
  const at = now();
1113
- const trimmed = await evictToBudget(stored, { at });
1399
+ const trimmed = await evictToBudget(stored, { at, sourceContext });
1114
1400
  let manifest = trimmed.dropped
1115
1401
  ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
1116
1402
  : trimmed.manifest;
@@ -1123,7 +1409,13 @@ export function createPreviewArtifactStore({
1123
1409
  }
1124
1410
  return trimmed.dropped;
1125
1411
  },
1126
- { lockTimeoutMs, lockStaleMs, onLockAcquired },
1412
+ {
1413
+ lockTimeoutMs,
1414
+ lockStaleMs,
1415
+ onLockAcquired,
1416
+ onLockOwnerReady,
1417
+ onStaleLockConfirmed,
1418
+ },
1127
1419
  );
1128
1420
  };
1129
1421
 
@@ -1131,6 +1423,7 @@ export function createPreviewArtifactStore({
1131
1423
  artifactFile: legacyFile,
1132
1424
  storeDirectory: root,
1133
1425
  importLegacyArtifact,
1426
+ observeArtifactDependencies,
1134
1427
  publishArtifact,
1135
1428
  readExactArtifact,
1136
1429
  readFrameSelection,