@pygmalionjs/pygmalion 0.6.32 → 0.6.33

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,7 @@ 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 (manifest, { at, sourceContext } = {}) => {
481
600
  const referenced = new Map();
482
601
  const remember = async (hash) => {
483
602
  if (!referenced.has(hash)) referenced.set(hash, await objectByteSize(hash));
@@ -508,7 +627,7 @@ export function createPreviewArtifactStore({
508
627
  candidates.push({
509
628
  kind: 'frame',
510
629
  entry,
511
- rank: EVICTION_RANK[await classifyFrameEntry(entry)],
630
+ rank: EVICTION_RANK[await classifyFrameEntry(entry, sourceContext)],
512
631
  // Protection within a rank, never across it: the last copy of a frame
513
632
  // is worth more than a surplus copy, but an invalid sole entry is still
514
633
  // garbage and goes before a valid one.
@@ -632,6 +751,40 @@ export function createPreviewArtifactStore({
632
751
  };
633
752
  };
634
753
 
754
+ /**
755
+ * Records every frame's observed source set before publication can wait on the
756
+ * namespace lock. A Map entry is present even when observation is unavailable,
757
+ * so `null` is an explicit pin rather than permission to inspect another root.
758
+ */
759
+ const observeArtifactDependencies = async (artifact, explicitSourceRoot) => {
760
+ const validation = validateRoutePreviewArtifactBundle(artifact);
761
+ if (!validation.valid) {
762
+ throw new Error('Preview artifact store refused an invalid artifact.');
763
+ }
764
+ const observedByFrame = new Map();
765
+ if (artifact.version !== 3) return observedByFrame;
766
+ const pinnedSourceRoot =
767
+ typeof explicitSourceRoot === 'string' && explicitSourceRoot
768
+ ? path.resolve(explicitSourceRoot)
769
+ : undefined;
770
+ for (const [id, frame] of Object.entries(artifact.frames)) {
771
+ const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
772
+ // Artifact frames are an open record in the v3 wire format, so an
773
+ // unvalidated `sourceFiles` property supplied by a generator cannot be a
774
+ // freshness proof. Recompute it from the serialized snapshot and the
775
+ // explicitly pinned root every time.
776
+ const observed = await recordObservedDependencies({
777
+ snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
778
+ sourceRoot: pinnedSourceRoot,
779
+ ...(observedAlwaysInclude
780
+ ? { alwaysInclude: observedAlwaysInclude }
781
+ : {}),
782
+ });
783
+ observedByFrame.set(id, observed);
784
+ }
785
+ return observedByFrame;
786
+ };
787
+
635
788
  const publishArtifact = async (
636
789
  artifact,
637
790
  {
@@ -639,6 +792,8 @@ export function createPreviewArtifactStore({
639
792
  sourceRevision = artifact?.sourceRevision,
640
793
  materializeLegacy = true,
641
794
  frameRequests = [],
795
+ observedDependenciesByFrame,
796
+ sourceContext,
642
797
  } = {},
643
798
  ) => {
644
799
  const validation = validateRoutePreviewArtifactBundle(artifact);
@@ -652,7 +807,51 @@ export function createPreviewArtifactStore({
652
807
  ) {
653
808
  throw new Error('Preview artifact store refused a mismatched revision.');
654
809
  }
810
+ if (
811
+ observedDependenciesByFrame != null &&
812
+ !(observedDependenciesByFrame instanceof Map)
813
+ ) {
814
+ throw new TypeError(
815
+ 'Preview artifact observed dependencies must be provided as a Map.',
816
+ );
817
+ }
818
+ let pinnedObservedDependenciesByFrame;
819
+ if (observedDependenciesByFrame instanceof Map && artifact.version === 3) {
820
+ const frameIds = new Set(Object.keys(artifact.frames));
821
+ if (
822
+ observedDependenciesByFrame.size !== frameIds.size ||
823
+ [...frameIds].some((id) => !observedDependenciesByFrame.has(id)) ||
824
+ [...observedDependenciesByFrame.keys()].some((id) => !frameIds.has(id))
825
+ ) {
826
+ throw new TypeError(
827
+ 'Preview artifact observed dependencies must cover every frame exactly.',
828
+ );
829
+ }
830
+ pinnedObservedDependenciesByFrame = new Map();
831
+ for (const observed of observedDependenciesByFrame.values()) {
832
+ if (observed !== null && normalizeObservedDependencies(observed) == null) {
833
+ throw new TypeError(
834
+ 'Preview artifact observed dependencies contain an invalid entry.',
835
+ );
836
+ }
837
+ }
838
+ for (const id of frameIds) {
839
+ const normalized = normalizeObservedDependencies(
840
+ observedDependenciesByFrame.get(id),
841
+ );
842
+ pinnedObservedDependenciesByFrame.set(
843
+ id,
844
+ normalized?.map(([file, hash]) => [file, hash]) ?? null,
845
+ );
846
+ }
847
+ }
655
848
  const namespace = artifact.namespace;
849
+ const exactSourceContext = sourceRootForRevision(
850
+ sourceContext,
851
+ sourceRevision,
852
+ )
853
+ ? sourceContext
854
+ : undefined;
656
855
  return withFilesystemLock(
657
856
  lockPath(namespace),
658
857
  async () => {
@@ -701,16 +900,13 @@ export function createPreviewArtifactStore({
701
900
  const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
702
901
  const object = await writeObject(selected.bundle);
703
902
  // 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
- }));
903
+ // the artifact it travels with. Null keeps the entry on the
904
+ // revision/fingerprint-only fallback.
905
+ const observed = pinnedObservedDependenciesByFrame
906
+ ? normalizeObservedDependencies(
907
+ pinnedObservedDependenciesByFrame.get(id),
908
+ )
909
+ : null;
714
910
  const nextFrame = {
715
911
  id,
716
912
  fingerprint:
@@ -742,7 +938,10 @@ export function createPreviewArtifactStore({
742
938
  // reclaim whatever has been unreferenced long enough to be safe. No
743
939
  // human is asked when the store should be cleaned.
744
940
  const at = now();
745
- const trimmed = await evictToBudget(manifest, { at });
941
+ const trimmed = await evictToBudget(manifest, {
942
+ at,
943
+ sourceContext: exactSourceContext,
944
+ });
746
945
  manifest = trimmed.dropped
747
946
  ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
748
947
  : trimmed.manifest;
@@ -808,7 +1007,13 @@ export function createPreviewArtifactStore({
808
1007
  }
809
1008
  return artifact;
810
1009
  },
811
- { lockTimeoutMs, lockStaleMs, onLockAcquired },
1010
+ {
1011
+ lockTimeoutMs,
1012
+ lockStaleMs,
1013
+ onLockAcquired,
1014
+ onLockOwnerReady,
1015
+ onStaleLockConfirmed,
1016
+ },
812
1017
  );
813
1018
  };
814
1019
 
@@ -911,9 +1116,17 @@ export function createPreviewArtifactStore({
911
1116
  * it survives an edit to a file the frame never rendered, which a route-wide
912
1117
  * digest cannot express in a single-page application.
913
1118
  */
914
- const frameIsFresh = async (entry, request, sourceRevision) => {
1119
+ const frameFreshness = async (
1120
+ entry,
1121
+ request,
1122
+ sourceRevision,
1123
+ sourceContext,
1124
+ ) => {
915
1125
  if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
916
- return true;
1126
+ return 'fresh';
1127
+ }
1128
+ if (request.fingerprint == null) {
1129
+ return entry.sourceRevision === sourceRevision ? 'fresh' : 'stale';
917
1130
  }
918
1131
  // Source hashes cannot prove that viewport, environment, or interaction
919
1132
  // inputs stayed the same. They may bridge a changed source fingerprint only
@@ -924,30 +1137,45 @@ export function createPreviewArtifactStore({
924
1137
  entry.recipeFingerprint == null ||
925
1138
  entry.recipeFingerprint !== request.recipeFingerprint)
926
1139
  ) {
927
- return false;
1140
+ return 'stale';
928
1141
  }
929
1142
  const observed = normalizeObservedDependencies(entry.sourceFiles);
930
1143
  if (observed) {
931
- return observedDependenciesUnchanged({
1144
+ const exactSourceRoot = sourceRootForRevision(
1145
+ sourceContext,
1146
+ sourceRevision,
1147
+ );
1148
+ if (!exactSourceRoot) return 'source-pending';
1149
+ return (await observedDependenciesUnchanged({
932
1150
  recorded: observed,
933
- sourceRoot: currentSourceRoot(),
934
- });
1151
+ sourceRoot: exactSourceRoot,
1152
+ }))
1153
+ ? 'fresh'
1154
+ : 'stale';
935
1155
  }
936
1156
  // Nothing content-based to go on: fall back to the old rule so a frame is
937
1157
  // never treated as fresher than it was before.
938
1158
  return (
939
1159
  entry.sourceRevision === sourceRevision &&
940
- (request.fingerprint == null || entry.fingerprint === request.fingerprint)
941
- );
1160
+ entry.fingerprint === request.fingerprint
1161
+ )
1162
+ ? 'fresh'
1163
+ : 'stale';
942
1164
  };
943
1165
 
944
1166
  /** Resolves freshness from the compact manifest without loading frame payloads. */
945
- const resolveFrameSelection = async (namespace, sourceRevision, wanted) => {
1167
+ const resolveFrameSelection = async (
1168
+ namespace,
1169
+ sourceRevision,
1170
+ wanted,
1171
+ { sourceContext } = {},
1172
+ ) => {
946
1173
  const manifest = await readManifest(namespace);
947
1174
  const candidatesById = indexFrameCandidates(manifest, wanted);
948
1175
  const exact = [];
949
1176
  const missing = [];
950
1177
  const stale = [];
1178
+ const sourcePending = [];
951
1179
  for (const request of wanted) {
952
1180
  const candidates = candidatesById.get(request.id) ?? [];
953
1181
  if (!candidates.length) {
@@ -955,20 +1183,41 @@ export function createPreviewArtifactStore({
955
1183
  continue;
956
1184
  }
957
1185
  let current = false;
1186
+ let mayBecomeCurrent = false;
958
1187
  for (const entry of candidates) {
959
- if (await frameIsFresh(entry, request, sourceRevision)) {
1188
+ const freshness = await frameFreshness(
1189
+ entry,
1190
+ request,
1191
+ sourceRevision,
1192
+ sourceContext,
1193
+ );
1194
+ if (freshness === 'fresh') {
960
1195
  current = true;
961
1196
  break;
962
1197
  }
1198
+ if (freshness === 'source-pending') mayBecomeCurrent = true;
963
1199
  }
1200
+ if (!current && mayBecomeCurrent) sourcePending.push(request.id);
964
1201
  (current ? exact : stale).push(request.id);
965
1202
  }
966
- return { exact, missing, stale };
1203
+ return {
1204
+ exact,
1205
+ missing,
1206
+ stale,
1207
+ ...(sourcePending.length ? { sourcePending } : {}),
1208
+ };
967
1209
  };
968
1210
 
969
- const readFrameSelection = async (namespace, sourceRevision, wanted) => {
1211
+ const readFrameSelection = async (
1212
+ namespace,
1213
+ sourceRevision,
1214
+ wanted,
1215
+ { sourceContext } = {},
1216
+ ) => {
970
1217
  const manifest = await readManifest(namespace);
971
1218
  let aggregate = null;
1219
+ const contentFreshIds = new Set();
1220
+ const sourcePendingIds = new Set();
972
1221
  if (manifest) {
973
1222
  // Index once per read. The earlier path filtered the complete manifest for
974
1223
  // every wanted frame, which made a storyboard probe O(wanted × entries).
@@ -977,7 +1226,16 @@ export function createPreviewArtifactStore({
977
1226
  const candidates = candidatesById.get(request.id) ?? [];
978
1227
  const fresh = [];
979
1228
  for (const entry of candidates) {
980
- if (await frameIsFresh(entry, request, sourceRevision)) fresh.push(entry);
1229
+ const freshness = await frameFreshness(
1230
+ entry,
1231
+ request,
1232
+ sourceRevision,
1233
+ sourceContext,
1234
+ );
1235
+ if (freshness === 'fresh') fresh.push(entry);
1236
+ if (freshness === 'source-pending') {
1237
+ sourcePendingIds.add(request.id);
1238
+ }
981
1239
  }
982
1240
  // Several revisions can now be fresh at once, so provenance decides
983
1241
  // which one is served: the requested revision's own capture first, then
@@ -1016,16 +1274,30 @@ export function createPreviewArtifactStore({
1016
1274
  aggregate,
1017
1275
  withProvenance,
1018
1276
  );
1277
+ if (exactSet.has(entry)) {
1278
+ contentFreshIds.add(request.id);
1279
+ sourcePendingIds.delete(request.id);
1280
+ }
1019
1281
  break;
1020
1282
  }
1021
1283
  }
1022
1284
  }
1023
1285
  }
1024
- return selectRoutePreviewArtifactFrames(
1286
+ const selection = selectRoutePreviewArtifactFrames(
1025
1287
  aggregate ?? emptyBundle(namespace, sourceRevision),
1026
1288
  wanted,
1027
1289
  { includeStale: true, sourceRevision },
1028
1290
  );
1291
+ return {
1292
+ ...selection,
1293
+ // The wire-level selector only knows fingerprints. A frame whose recipe
1294
+ // and observed files were verified against the requested source context is
1295
+ // also current, even when its coarse source fingerprint changed.
1296
+ stale: selection.stale.filter((id) => !contentFreshIds.has(id)),
1297
+ ...(sourcePendingIds.size
1298
+ ? { sourcePending: [...sourcePendingIds] }
1299
+ : {}),
1300
+ };
1029
1301
  };
1030
1302
 
1031
1303
  /**
@@ -1035,7 +1307,7 @@ export function createPreviewArtifactStore({
1035
1307
  * provenance-only" is the split that explains both the size and the next
1036
1308
  * eviction — so it is reported rather than left to be inferred.
1037
1309
  */
1038
- const inspect = async (namespace) => {
1310
+ const inspect = async (namespace, { sourceContext } = {}) => {
1039
1311
  const manifest = await readManifest(namespace);
1040
1312
  if (!manifest) {
1041
1313
  return {
@@ -1057,7 +1329,7 @@ export function createPreviewArtifactStore({
1057
1329
  }
1058
1330
  const counts = { valid: 0, unknown: 0, invalid: 0 };
1059
1331
  for (const entry of manifest.frames) {
1060
- counts[await classifyFrameEntry(entry)] += 1;
1332
+ counts[await classifyFrameEntry(entry, sourceContext)] += 1;
1061
1333
  }
1062
1334
  return {
1063
1335
  namespace,
@@ -1082,7 +1354,7 @@ export function createPreviewArtifactStore({
1082
1354
  * merely being read would stay over budget until the next capture. Callers
1083
1355
  * run this when the server starts.
1084
1356
  */
1085
- const applyRetention = async (namespace) => {
1357
+ const applyRetention = async (namespace, { sourceContext } = {}) => {
1086
1358
  // No namespace means every namespace: at server start the caller has not
1087
1359
  // seen a request yet and so cannot name one.
1088
1360
  if (namespace === undefined) {
@@ -1099,7 +1371,9 @@ export function createPreviewArtifactStore({
1099
1371
  () => null,
1100
1372
  );
1101
1373
  if (typeof stored?.namespace !== 'string') continue;
1102
- const result = await applyRetention(stored.namespace).catch(() => null);
1374
+ const result = await applyRetention(stored.namespace, {
1375
+ sourceContext,
1376
+ }).catch(() => null);
1103
1377
  if (result) dropped.push({ namespace: stored.namespace, ...result });
1104
1378
  }
1105
1379
  return dropped;
@@ -1110,7 +1384,7 @@ export function createPreviewArtifactStore({
1110
1384
  const stored = await readManifest(namespace);
1111
1385
  if (!stored) return null;
1112
1386
  const at = now();
1113
- const trimmed = await evictToBudget(stored, { at });
1387
+ const trimmed = await evictToBudget(stored, { at, sourceContext });
1114
1388
  let manifest = trimmed.dropped
1115
1389
  ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
1116
1390
  : trimmed.manifest;
@@ -1123,7 +1397,13 @@ export function createPreviewArtifactStore({
1123
1397
  }
1124
1398
  return trimmed.dropped;
1125
1399
  },
1126
- { lockTimeoutMs, lockStaleMs, onLockAcquired },
1400
+ {
1401
+ lockTimeoutMs,
1402
+ lockStaleMs,
1403
+ onLockAcquired,
1404
+ onLockOwnerReady,
1405
+ onStaleLockConfirmed,
1406
+ },
1127
1407
  );
1128
1408
  };
1129
1409
 
@@ -1131,6 +1411,7 @@ export function createPreviewArtifactStore({
1131
1411
  artifactFile: legacyFile,
1132
1412
  storeDirectory: root,
1133
1413
  importLegacyArtifact,
1414
+ observeArtifactDependencies,
1134
1415
  publishArtifact,
1135
1416
  readExactArtifact,
1136
1417
  readFrameSelection,