@pygmalionjs/pygmalion 0.6.31 → 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.
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import fs from 'node:fs/promises';
2
3
  import {
3
4
  mergeRoutePreviewArtifactV3,
4
5
  selectRoutePreviewArtifactFrames,
@@ -9,6 +10,7 @@ import { createPreviewArtifactStore } from './preview-artifact-store.mjs';
9
10
  export const PYGMALION_PREVIEW_ARTIFACT_ENDPOINT =
10
11
  '/__pygmalion-route-preview/artifact';
11
12
  export const PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX = '/progress';
13
+ export const PYGMALION_REVISION_CATALOG_SUFFIX = '/catalog';
12
14
  export const DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE =
13
15
  'artifacts/pygmalion-route-previews.json';
14
16
 
@@ -21,6 +23,7 @@ const MAX_FRAME_REQUEST_COUNT = 2_048;
21
23
  // headers). Those arrive as a JSON body instead; the ceiling keeps a stray
22
24
  // client from parking megabytes in memory.
23
25
  const MAX_FRAME_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
26
+ const MAX_REVISION_CATALOG_BYTES = 16 * 1024 * 1024;
24
27
  /**
25
28
  * Where a request sits among captures waiting on the same identity. Smaller
26
29
  * runs first. The editor sends a frame the designer selected ahead of a
@@ -159,12 +162,15 @@ function requestedFrames(params) {
159
162
  }
160
163
  if (!Array.isArray(parsed) || parsed.length > MAX_FRAME_REQUEST_COUNT) return null;
161
164
  const wanted = [];
165
+ const ids = new Set();
162
166
  for (const entry of parsed) {
163
167
  const item = typeof entry === 'string' ? { id: entry } : entry;
164
168
  if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
165
169
  const id = item.id;
166
170
  if (typeof id !== 'string' || !id.trim() || id !== id.trim()) return null;
167
171
  if (id.length > MAX_IDENTITY_LENGTH) return null;
172
+ if (ids.has(id)) return null;
173
+ ids.add(id);
168
174
  const fingerprint = item.fingerprint;
169
175
  if (fingerprint !== undefined) {
170
176
  if (typeof fingerprint !== 'string' || !fingerprint.trim()) return null;
@@ -259,6 +265,131 @@ function exactArtifact(artifact, namespace, sourceRevision) {
259
265
  );
260
266
  }
261
267
 
268
+ function generatedFramesMatchRequest(artifact, wanted) {
269
+ const generatedIds = Object.keys(artifact.frames);
270
+ if (generatedIds.length !== wanted.length) return false;
271
+ const requested = new Map(wanted.map((frame) => [frame.id, frame]));
272
+ for (const id of generatedIds) {
273
+ const request = requested.get(id);
274
+ if (!request) return false;
275
+ if (
276
+ request.fingerprint != null &&
277
+ artifact.frames[id].fingerprint !== request.fingerprint
278
+ ) {
279
+ return false;
280
+ }
281
+ }
282
+ return true;
283
+ }
284
+
285
+ function publicFrameSelection(selection) {
286
+ const { sourcePending: _sourcePending, ...publicSelection } = selection;
287
+ return publicSelection;
288
+ }
289
+
290
+ function freshFrameIds(selection) {
291
+ const unavailable = new Set([...selection.missing, ...selection.stale]);
292
+ return Object.keys(selection.bundle.frames).filter((id) => !unavailable.has(id));
293
+ }
294
+
295
+ function preserveVerifiedFreshness(selection, verifiedIds) {
296
+ const verified = new Set(verifiedIds);
297
+ return {
298
+ ...selection,
299
+ stale: selection.stale.filter((id) => !verified.has(id)),
300
+ };
301
+ }
302
+
303
+ function revisionCatalogErrors(catalog, namespace, sourceRevision) {
304
+ const errors = [];
305
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) {
306
+ return ['catalog must be an object'];
307
+ }
308
+ if (catalog.version !== 1) errors.push('catalog version must be 1');
309
+ if (catalog.namespace !== namespace) {
310
+ errors.push('catalog namespace does not match the request');
311
+ }
312
+ if (catalog.sourceRevision !== sourceRevision) {
313
+ errors.push('catalog sourceRevision does not match the request');
314
+ }
315
+ const screens = catalog.screens;
316
+ if (!screens || typeof screens !== 'object' || Array.isArray(screens)) {
317
+ errors.push('catalog screens must be an object');
318
+ return errors;
319
+ }
320
+ if (!Number.isInteger(screens.expectedCount) || screens.expectedCount < 0) {
321
+ errors.push('catalog screens.expectedCount must be a non-negative integer');
322
+ }
323
+ if (!Array.isArray(screens.assets)) {
324
+ errors.push('catalog screens.assets must be an array');
325
+ } else if (
326
+ Number.isInteger(screens.expectedCount) &&
327
+ screens.assets.length !== screens.expectedCount
328
+ ) {
329
+ errors.push('catalog screen asset count does not match expectedCount');
330
+ }
331
+ if (!Array.isArray(screens.pages)) {
332
+ errors.push('catalog screens.pages must be an array');
333
+ return errors;
334
+ }
335
+ const pageIds = new Set();
336
+ for (const page of screens.pages) {
337
+ const id = page?.importPageId;
338
+ if (
339
+ !page ||
340
+ typeof page !== 'object' ||
341
+ Array.isArray(page) ||
342
+ typeof id !== 'string' ||
343
+ !id.trim() ||
344
+ id !== id.trim() ||
345
+ page.importKind !== 'screens' ||
346
+ typeof page.route !== 'string' ||
347
+ !page.route.trim()
348
+ ) {
349
+ errors.push('catalog contains an invalid screen page');
350
+ continue;
351
+ }
352
+ if (pageIds.has(id)) errors.push(`catalog screen page "${id}" is duplicated`);
353
+ pageIds.add(id);
354
+ }
355
+ for (const asset of Array.isArray(screens.assets) ? screens.assets : []) {
356
+ if (
357
+ !asset ||
358
+ typeof asset !== 'object' ||
359
+ Array.isArray(asset) ||
360
+ typeof asset.id !== 'string' ||
361
+ !asset.id.trim() ||
362
+ typeof asset.pageId !== 'string' ||
363
+ !pageIds.has(asset.pageId)
364
+ ) {
365
+ errors.push('catalog contains an invalid screen asset');
366
+ }
367
+ }
368
+ return errors;
369
+ }
370
+
371
+ async function resolveCatalogFile(sourceRoot, catalogFile) {
372
+ const resolvedSourceRoot = path.resolve(sourceRoot);
373
+ const resolved = path.resolve(resolvedSourceRoot, catalogFile);
374
+ if (
375
+ resolved !== resolvedSourceRoot &&
376
+ !resolved.startsWith(`${resolvedSourceRoot}${path.sep}`)
377
+ ) {
378
+ throw new Error('Revision catalog file must stay inside the source root.');
379
+ }
380
+ const [realSourceRoot, realFile] = await Promise.all([
381
+ fs.realpath(resolvedSourceRoot),
382
+ fs.realpath(resolved),
383
+ ]);
384
+ if (
385
+ realFile !== realSourceRoot &&
386
+ !realFile.startsWith(`${realSourceRoot}${path.sep}`)
387
+ ) {
388
+ throw new Error('Revision catalog file must stay inside the source root.');
389
+ }
390
+ return realFile;
391
+ }
392
+
262
393
  /**
263
394
  * Serves an exact local preview artifact identity to the editor runtime.
264
395
  * Artifact validation, local-only access, and path containment are enforced by
@@ -267,12 +398,14 @@ function exactArtifact(artifact, namespace, sourceRevision) {
267
398
  */
268
399
  export function pygmalionPreviewArtifactPlugin({
269
400
  root = process.cwd(),
270
- sourceRoot,
271
401
  artifactFile = DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
272
402
  endpoint = PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
403
+ catalogFile,
404
+ catalogEndpoint = `${endpoint}${PYGMALION_REVISION_CATALOG_SUFFIX}`,
273
405
  disabled = () => false,
274
406
  generateArtifact,
275
407
  acquireLease,
408
+ acquireSourceLease,
276
409
  requestRuntime,
277
410
  runtimePrepared = () => true,
278
411
  artifactStoreMaxBytes,
@@ -293,6 +426,32 @@ export function pygmalionPreviewArtifactPlugin({
293
426
  ) {
294
427
  throw new Error('Preview artifact endpoint must be an absolute URL path.');
295
428
  }
429
+ const progressEndpoint = captureProgressEndpoint(endpoint);
430
+ if (
431
+ catalogFile != null &&
432
+ (typeof catalogFile !== 'string' ||
433
+ !catalogFile.trim() ||
434
+ path.isAbsolute(catalogFile) ||
435
+ path.normalize(catalogFile).split(path.sep).includes('..'))
436
+ ) {
437
+ throw new Error('Revision catalog file must be a relative path.');
438
+ }
439
+ if (
440
+ typeof catalogEndpoint !== 'string' ||
441
+ !catalogEndpoint.startsWith('/') ||
442
+ catalogEndpoint.includes('?') ||
443
+ catalogEndpoint.includes('#')
444
+ ) {
445
+ throw new Error('Revision catalog endpoint must be an absolute URL path.');
446
+ }
447
+ if (
448
+ catalogFile != null &&
449
+ (catalogEndpoint === endpoint || catalogEndpoint === progressEndpoint)
450
+ ) {
451
+ throw new Error(
452
+ 'Revision catalog endpoint must differ from preview artifact endpoints.',
453
+ );
454
+ }
296
455
  if (typeof disabled !== 'function') {
297
456
  throw new TypeError('Preview artifact disabled option must be a function.');
298
457
  }
@@ -306,18 +465,26 @@ export function pygmalionPreviewArtifactPlugin({
306
465
  'Preview artifact runtime request option must be a function.',
307
466
  );
308
467
  }
468
+ if (
469
+ acquireSourceLease != null &&
470
+ typeof acquireSourceLease !== 'function'
471
+ ) {
472
+ throw new TypeError(
473
+ 'Revision catalog source lease option must be a function.',
474
+ );
475
+ }
476
+ if (catalogFile != null && typeof acquireSourceLease !== 'function') {
477
+ throw new TypeError(
478
+ 'Revision catalog file requires an exact source lease provider.',
479
+ );
480
+ }
309
481
  if (typeof runtimePrepared !== 'function') {
310
482
  throw new TypeError(
311
483
  'Preview artifact runtime readiness option must be a function.',
312
484
  );
313
485
  }
314
- const progressEndpoint = captureProgressEndpoint(endpoint);
315
-
316
486
  const artifactStore = createPreviewArtifactStore({
317
487
  artifactFile: resolvedArtifact,
318
- // With the source root, a published frame records the files it actually
319
- // rendered, so freshness stops depending on which revision captured it.
320
- sourceRoot: sourceRoot ?? resolvedRoot,
321
488
  ...(artifactStoreMaxBytes == null
322
489
  ? {}
323
490
  : { maxBytes: artifactStoreMaxBytes }),
@@ -534,6 +701,51 @@ export function pygmalionPreviewArtifactPlugin({
534
701
  });
535
702
  }
536
703
 
704
+ function answerCatalogPreparing(response) {
705
+ requestRuntime?.();
706
+ sendJson(response, 503, {
707
+ ok: false,
708
+ error: 'revision_catalog_unavailable',
709
+ retryable: true,
710
+ preparing: true,
711
+ details: ['The source checkout is being prepared.'],
712
+ });
713
+ }
714
+
715
+ async function readExactRevisionCatalog(namespace, sourceRevision) {
716
+ const lease = await acquireSourceLease(
717
+ sourceRevision,
718
+ `revision-catalog ${sourceRevision.slice(0, 8)}`,
719
+ );
720
+ if (!lease) return null;
721
+ try {
722
+ if (
723
+ lease.sourceRevision !== sourceRevision ||
724
+ typeof lease.sourceRoot !== 'string' ||
725
+ !lease.sourceRoot.trim()
726
+ ) {
727
+ return null;
728
+ }
729
+ const file = await resolveCatalogFile(lease.sourceRoot, catalogFile);
730
+ const stat = await fs.stat(file);
731
+ if (!stat.isFile() || stat.size > MAX_REVISION_CATALOG_BYTES) {
732
+ throw new Error('Revision catalog file is missing or too large.');
733
+ }
734
+ const catalog = JSON.parse(await fs.readFile(file, 'utf8'));
735
+ const errors = revisionCatalogErrors(catalog, namespace, sourceRevision);
736
+ if (errors.length > 0) {
737
+ throw Object.assign(
738
+ new Error(`Invalid revision catalog: ${errors.join(', ')}`),
739
+ { pygmalionBadRevisionCatalog: true },
740
+ );
741
+ }
742
+ await validateSourceLease(lease, sourceRevision);
743
+ return catalog;
744
+ } finally {
745
+ await lease.release();
746
+ }
747
+ }
748
+
537
749
  /**
538
750
  * A generator that ran and produced the wrong thing.
539
751
  *
@@ -548,17 +760,101 @@ export function pygmalionPreviewArtifactPlugin({
548
760
  return Object.assign(new Error(message), { pygmalionBadGeneratorOutput: true });
549
761
  }
550
762
 
763
+ async function validateSourceLease(lease, sourceRevision) {
764
+ if (typeof lease?.validate !== 'function') return;
765
+ let current = false;
766
+ try {
767
+ current = (await lease.validate()) === true;
768
+ } catch {
769
+ current = false;
770
+ }
771
+ if (current) return;
772
+ throw Object.assign(
773
+ new Error(
774
+ `The source checkout for ${sourceRevision.slice(0, 8)} changed while it was being read.`,
775
+ ),
776
+ { pygmalionSourceLeaseUnavailable: true },
777
+ );
778
+ }
779
+
780
+ function sourceContextFromLease(lease, sourceRevision) {
781
+ if (
782
+ lease?.sourceRevision !== sourceRevision ||
783
+ typeof lease?.sourceRoot !== 'string' ||
784
+ !lease.sourceRoot.trim()
785
+ ) {
786
+ return undefined;
787
+ }
788
+ return { sourceRevision, sourceRoot: lease.sourceRoot };
789
+ }
790
+
791
+ /**
792
+ * Refines a conservative cache decision only while the requested checkout is
793
+ * pinned. Fingerprint hits, recipe mismatches, and frames without observed
794
+ * dependencies never enter this path; only source-verifiable candidates pay
795
+ * for the short source lease.
796
+ */
797
+ async function withOptionalSourceLease(sourceRevision, label, task) {
798
+ if (typeof acquireSourceLease !== 'function') return task(undefined);
799
+ let lease;
800
+ try {
801
+ lease = await acquireSourceLease(sourceRevision, label);
802
+ } catch {
803
+ return task(undefined);
804
+ }
805
+ const sourceContext = sourceContextFromLease(lease, sourceRevision);
806
+ if (!sourceContext || typeof lease?.release !== 'function') {
807
+ await lease?.release?.();
808
+ return task(undefined);
809
+ }
810
+ let result;
811
+ let invalidated = false;
812
+ try {
813
+ result = await task(sourceContext);
814
+ try {
815
+ await validateSourceLease(lease, sourceRevision);
816
+ } catch (error) {
817
+ if (!error?.pygmalionSourceLeaseUnavailable) throw error;
818
+ invalidated = true;
819
+ }
820
+ } finally {
821
+ await lease.release();
822
+ }
823
+ return invalidated ? task(undefined) : result;
824
+ }
825
+
551
826
  async function withCaptureLease(sourceRevision, task) {
552
827
  // A capture reads the dev mirror for minutes. Hold a lease for that whole
553
828
  // window so a sync on another server cannot switch the checkout the frames
554
829
  // are being rendered from — the artifact would carry this revision's name
555
830
  // and another revision's screens.
556
- const lease =
557
- typeof acquireLease === 'function'
558
- ? await acquireLease(`preview-artifact ${sourceRevision.slice(0, 8)}`)
559
- : null;
831
+ const label = `preview-artifact ${sourceRevision.slice(0, 8)}`;
832
+ let lease;
833
+ if (typeof acquireSourceLease === 'function') {
834
+ lease = await acquireSourceLease(sourceRevision, label);
835
+ if (
836
+ !lease ||
837
+ lease.sourceRevision !== sourceRevision ||
838
+ typeof lease.sourceRoot !== 'string' ||
839
+ !lease.sourceRoot.trim() ||
840
+ typeof lease.release !== 'function'
841
+ ) {
842
+ await lease?.release?.();
843
+ requestRuntime?.();
844
+ throw new Error(
845
+ `The exact source checkout for ${sourceRevision.slice(0, 8)} is unavailable.`,
846
+ );
847
+ }
848
+ } else {
849
+ // Compatibility for standalone plugin users that do not compose with a
850
+ // mirror. createPygmalionVitePlugins always supplies the exact provider.
851
+ lease =
852
+ typeof acquireLease === 'function'
853
+ ? await acquireLease(label)
854
+ : null;
855
+ }
560
856
  try {
561
- return await task();
857
+ return await task(lease);
562
858
  } finally {
563
859
  await lease?.release();
564
860
  }
@@ -575,29 +871,40 @@ export function pygmalionPreviewArtifactPlugin({
575
871
  if (existing) return existing;
576
872
  const progress = startCaptureProgress();
577
873
  try {
578
- const generated = await withCaptureLease(sourceRevision, () =>
579
- generateArtifact(
874
+ const generated = await withCaptureLease(sourceRevision, async (lease) => {
875
+ const captured = await generateArtifact(
580
876
  {
581
877
  namespace,
582
878
  sourceRevision,
583
879
  ...(captureBaseUrl ? { captureBaseUrl } : {}),
584
880
  },
585
881
  { onProgress: progress.update },
586
- ),
587
- );
588
- const validation = validateRoutePreviewArtifactBundle(generated);
589
- if (!validation.valid) {
590
- throw badGeneratorOutput('Generated preview artifact is invalid.');
591
- }
592
- if (!exactArtifact(generated, namespace, sourceRevision)) {
593
- throw badGeneratorOutput(
594
- 'Generated preview artifact identity does not match the request.',
595
882
  );
596
- }
597
- progress.finalizing();
598
- await artifactStore.publishArtifact(generated, {
599
- sourceRevision,
600
- recordRevision: true,
883
+ const validation = validateRoutePreviewArtifactBundle(captured);
884
+ if (!validation.valid) {
885
+ throw badGeneratorOutput('Generated preview artifact is invalid.');
886
+ }
887
+ if (!exactArtifact(captured, namespace, sourceRevision)) {
888
+ throw badGeneratorOutput(
889
+ 'Generated preview artifact identity does not match the request.',
890
+ );
891
+ }
892
+ const observedDependenciesByFrame = lease?.sourceRoot
893
+ ? await artifactStore.observeArtifactDependencies(
894
+ captured,
895
+ lease.sourceRoot,
896
+ )
897
+ : undefined;
898
+ await validateSourceLease(lease, sourceRevision);
899
+ progress.finalizing();
900
+ await artifactStore.publishArtifact(captured, {
901
+ sourceRevision,
902
+ recordRevision: true,
903
+ ...(observedDependenciesByFrame
904
+ ? { observedDependenciesByFrame }
905
+ : {}),
906
+ });
907
+ return captured;
601
908
  });
602
909
  return generated;
603
910
  } finally {
@@ -621,19 +928,32 @@ export function pygmalionPreviewArtifactPlugin({
621
928
  return deduplicateRequest(request, () =>
622
929
  enqueueIdentity(namespace, sourceRevision, async () => {
623
930
  // A prior request may have filled some frames while this request waited.
624
- // Recheck under the namespace/source queue and capture only what remains.
625
- const before = await artifactStore.readFrameSelection(
931
+ // Fingerprint hits remain lock-free. A stale candidate is rechecked under
932
+ // the capture lease before any generation work starts.
933
+ const quick = await artifactStore.readFrameSelection(
626
934
  namespace,
627
935
  sourceRevision,
628
936
  wanted,
629
937
  );
630
- const absent = new Set([...before.missing, ...before.stale]);
631
- const remaining = wanted.filter((frame) => absent.has(frame.id));
632
- if (remaining.length === 0) return before;
633
- const progress = startCaptureProgress(remaining.length);
938
+ const quickAbsent = new Set([...quick.missing, ...quick.stale]);
939
+ if (quickAbsent.size === 0) return quick;
940
+ const progress = startCaptureProgress(quickAbsent.size);
634
941
  try {
635
- const generated = await withCaptureLease(sourceRevision, () =>
636
- generateArtifact(
942
+ return await withCaptureLease(sourceRevision, async (lease) => {
943
+ const sourceContext = sourceContextFromLease(lease, sourceRevision);
944
+ const before = await artifactStore.readFrameSelection(
945
+ namespace,
946
+ sourceRevision,
947
+ wanted,
948
+ { sourceContext },
949
+ );
950
+ const absent = new Set([...before.missing, ...before.stale]);
951
+ const remaining = wanted.filter((frame) => absent.has(frame.id));
952
+ if (remaining.length === 0) {
953
+ await validateSourceLease(lease, sourceRevision);
954
+ return before;
955
+ }
956
+ const captured = await generateArtifact(
637
957
  {
638
958
  namespace,
639
959
  sourceRevision,
@@ -641,33 +961,51 @@ export function pygmalionPreviewArtifactPlugin({
641
961
  ...(captureBaseUrl ? { captureBaseUrl } : {}),
642
962
  },
643
963
  { onProgress: progress.update },
644
- ),
645
- );
646
- const validation = validateRoutePreviewArtifactBundle(generated);
647
- if (!validation.valid || generated.version !== 3) {
648
- throw new Error('Generated preview artifact is invalid.');
649
- }
650
- if (!exactArtifact(generated, namespace, sourceRevision)) {
651
- throw new Error(
652
- 'Generated preview artifact identity does not match the request.',
653
964
  );
654
- }
655
- progress.finalizing();
656
- await artifactStore.publishArtifact(generated, {
657
- sourceRevision,
658
- recordRevision: false,
659
- frameRequests: remaining,
660
- });
661
- // Retention is allowed to decline a newly generated object when older
662
- // entries have earned higher read recency. That cache decision must not
663
- // discard the result from the request that just paid to generate it.
664
- const responseArtifact = mergeRoutePreviewArtifactV3(
665
- before.bundle,
666
- generated,
667
- );
668
- return selectRoutePreviewArtifactFrames(responseArtifact, wanted, {
669
- includeStale: true,
670
- sourceRevision,
965
+ const validation = validateRoutePreviewArtifactBundle(captured);
966
+ if (!validation.valid || captured.version !== 3) {
967
+ throw badGeneratorOutput('Generated preview artifact is invalid.');
968
+ }
969
+ if (!exactArtifact(captured, namespace, sourceRevision)) {
970
+ throw badGeneratorOutput(
971
+ 'Generated preview artifact identity does not match the request.',
972
+ );
973
+ }
974
+ if (!generatedFramesMatchRequest(captured, remaining)) {
975
+ throw badGeneratorOutput(
976
+ 'Generated preview artifact frames do not match the request.',
977
+ );
978
+ }
979
+ const observedDependenciesByFrame = lease?.sourceRoot
980
+ ? await artifactStore.observeArtifactDependencies(
981
+ captured,
982
+ lease.sourceRoot,
983
+ )
984
+ : undefined;
985
+ await validateSourceLease(lease, sourceRevision);
986
+ progress.finalizing();
987
+ await artifactStore.publishArtifact(captured, {
988
+ sourceRevision,
989
+ recordRevision: false,
990
+ frameRequests: remaining,
991
+ ...(observedDependenciesByFrame
992
+ ? { observedDependenciesByFrame }
993
+ : {}),
994
+ });
995
+ // Retention is allowed to decline a newly generated object when older
996
+ // entries have earned higher read recency. That cache decision must not
997
+ // discard the result from the request that just paid to generate it.
998
+ const responseArtifact = mergeRoutePreviewArtifactV3(
999
+ before.bundle,
1000
+ captured,
1001
+ );
1002
+ return preserveVerifiedFreshness(
1003
+ selectRoutePreviewArtifactFrames(responseArtifact, wanted, {
1004
+ includeStale: true,
1005
+ sourceRevision,
1006
+ }),
1007
+ freshFrameIds(before),
1008
+ );
671
1009
  });
672
1010
  } finally {
673
1011
  progress.finish();
@@ -687,6 +1025,71 @@ export function pygmalionPreviewArtifactPlugin({
687
1025
  void artifactStore.applyRetention?.(undefined)?.catch?.(() => undefined);
688
1026
  server.middlewares.use(async (request, response, next) => {
689
1027
  const url = new URL(request.url ?? '/', 'http://localhost');
1028
+ if (catalogFile != null && url.pathname === catalogEndpoint) {
1029
+ if (request.method !== 'GET') {
1030
+ response.setHeader('allow', 'GET');
1031
+ sendJson(response, 405, {
1032
+ ok: false,
1033
+ error: 'method_not_allowed',
1034
+ });
1035
+ return;
1036
+ }
1037
+ if (!isLocalRequestHost(request.headers.host)) {
1038
+ sendJson(response, 403, {
1039
+ ok: false,
1040
+ error: 'local_host_required',
1041
+ });
1042
+ return;
1043
+ }
1044
+ const params = paramsFromQuery(url);
1045
+ const namespace = requestedIdentity(params, 'namespace');
1046
+ const sourceRevision = requestedIdentity(params, 'sourceRevision');
1047
+ if (!namespace || !sourceRevision) {
1048
+ sendJson(response, 400, {
1049
+ ok: false,
1050
+ error: 'invalid_identity',
1051
+ });
1052
+ return;
1053
+ }
1054
+ if (!runtimePrepared()) {
1055
+ answerCatalogPreparing(response);
1056
+ return;
1057
+ }
1058
+ try {
1059
+ const catalog = await readExactRevisionCatalog(
1060
+ namespace,
1061
+ sourceRevision,
1062
+ );
1063
+ if (!catalog) {
1064
+ // Readiness can change after the optimistic check above. A sync
1065
+ // announces that transition before it waits for readers, so a
1066
+ // lease miss during that window is temporary rather than an
1067
+ // identity mismatch the client should cache as permanent.
1068
+ if (!runtimePrepared()) {
1069
+ answerCatalogPreparing(response);
1070
+ return;
1071
+ }
1072
+ sendJson(response, 409, {
1073
+ ok: false,
1074
+ error: 'revision_catalog_identity_mismatch',
1075
+ });
1076
+ return;
1077
+ }
1078
+ sendJson(response, 200, { ok: true, catalog });
1079
+ } catch (error) {
1080
+ if (error?.pygmalionSourceLeaseUnavailable) {
1081
+ answerCatalogPreparing(response);
1082
+ return;
1083
+ }
1084
+ sendJson(response, 500, {
1085
+ ok: false,
1086
+ error: error?.pygmalionBadRevisionCatalog
1087
+ ? 'revision_catalog_invalid'
1088
+ : 'revision_catalog_read_failed',
1089
+ });
1090
+ }
1091
+ return;
1092
+ }
690
1093
  if (url.pathname === progressEndpoint) {
691
1094
  if (request.method !== 'GET') {
692
1095
  response.setHeader('allow', 'GET');
@@ -790,7 +1193,7 @@ export function pygmalionPreviewArtifactPlugin({
790
1193
  }
791
1194
  if (wanted !== undefined) {
792
1195
  if (resolutionOnly(params)) {
793
- const resolution = await artifactStore.resolveFrameSelection(
1196
+ const quickResolution = await artifactStore.resolveFrameSelection(
794
1197
  namespace,
795
1198
  sourceRevision,
796
1199
  wanted,
@@ -800,21 +1203,52 @@ export function pygmalionPreviewArtifactPlugin({
800
1203
  // policy decides by. A cache nobody can inspect is a cache nobody
801
1204
  // trusts, and "valid vs provenance-only" explains both the size and
802
1205
  // the next eviction.
803
- const store = await artifactStore
804
- .inspect(namespace)
805
- .catch(() => null);
1206
+ const resolution = quickResolution.sourcePending?.length
1207
+ ? await withOptionalSourceLease(
1208
+ sourceRevision,
1209
+ `preview-freshness ${sourceRevision.slice(0, 8)}`,
1210
+ async (sourceContext) =>
1211
+ sourceContext
1212
+ ? await artifactStore.resolveFrameSelection(
1213
+ namespace,
1214
+ sourceRevision,
1215
+ wanted,
1216
+ { sourceContext },
1217
+ )
1218
+ : quickResolution,
1219
+ )
1220
+ : quickResolution;
1221
+ // Inspection is deliberately context-free on this hot path. It
1222
+ // reports observed entries as unknown without re-hashing the whole
1223
+ // cache while a one-frame resolution lease is held.
1224
+ const store = await artifactStore.inspect(namespace).catch(() => null);
806
1225
  sendJson(response, 200, {
807
1226
  ok: true,
808
- ...resolution,
1227
+ ...publicFrameSelection(resolution),
809
1228
  ...(store ? { store } : {}),
810
1229
  });
811
1230
  return;
812
1231
  }
813
- let picked = await artifactStore.readFrameSelection(
1232
+ const quickPicked = await artifactStore.readFrameSelection(
814
1233
  namespace,
815
1234
  sourceRevision,
816
1235
  wanted,
817
1236
  );
1237
+ let picked = quickPicked.sourcePending?.length
1238
+ ? await withOptionalSourceLease(
1239
+ sourceRevision,
1240
+ `preview-freshness ${sourceRevision.slice(0, 8)}`,
1241
+ (sourceContext) =>
1242
+ sourceContext
1243
+ ? artifactStore.readFrameSelection(
1244
+ namespace,
1245
+ sourceRevision,
1246
+ wanted,
1247
+ { sourceContext },
1248
+ )
1249
+ : quickPicked,
1250
+ )
1251
+ : quickPicked;
818
1252
  const absent = [...picked.missing, ...picked.stale];
819
1253
  if (absent.length && generateArtifact && generationAllowed(params)) {
820
1254
  if (!runtimePrepared()) {
@@ -830,11 +1264,15 @@ export function pygmalionPreviewArtifactPlugin({
830
1264
  wanted.filter((frame) => requested.has(frame.id)),
831
1265
  priority,
832
1266
  );
833
- picked = selectRoutePreviewArtifactFrames(
1267
+ const merged = selectRoutePreviewArtifactFrames(
834
1268
  mergeRoutePreviewArtifactV3(picked.bundle, captured.bundle),
835
1269
  wanted,
836
1270
  { includeStale: true, sourceRevision },
837
1271
  );
1272
+ picked = preserveVerifiedFreshness(
1273
+ merged,
1274
+ [...freshFrameIds(picked), ...freshFrameIds(captured)],
1275
+ );
838
1276
  } catch (error) {
839
1277
  sendGenerationFailure(response, error);
840
1278
  return;