@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.
@@ -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,107 @@ 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
+ function retentionSourceContextFromLease(lease, sourceRevision) {
792
+ return lease?.sourceRootStable === true
793
+ ? sourceContextFromLease(lease, sourceRevision)
794
+ : undefined;
795
+ }
796
+
797
+ /**
798
+ * Refines a conservative cache decision only while the requested checkout is
799
+ * pinned. Fingerprint hits, recipe mismatches, and frames without observed
800
+ * dependencies never enter this path; only source-verifiable candidates pay
801
+ * for the short source lease.
802
+ */
803
+ async function withOptionalSourceLease(sourceRevision, label, task) {
804
+ if (typeof acquireSourceLease !== 'function') return task(undefined);
805
+ let lease;
806
+ try {
807
+ lease = await acquireSourceLease(sourceRevision, label);
808
+ } catch {
809
+ return task(undefined);
810
+ }
811
+ const sourceContext = sourceContextFromLease(lease, sourceRevision);
812
+ if (!sourceContext || typeof lease?.release !== 'function') {
813
+ await lease?.release?.();
814
+ return task(undefined);
815
+ }
816
+ let result;
817
+ let invalidated = false;
818
+ try {
819
+ result = await task(sourceContext);
820
+ try {
821
+ await validateSourceLease(lease, sourceRevision);
822
+ } catch (error) {
823
+ if (!error?.pygmalionSourceLeaseUnavailable) throw error;
824
+ invalidated = true;
825
+ }
826
+ } finally {
827
+ await lease.release();
828
+ }
829
+ return invalidated ? task(undefined) : result;
830
+ }
831
+
551
832
  async function withCaptureLease(sourceRevision, task) {
552
833
  // A capture reads the dev mirror for minutes. Hold a lease for that whole
553
834
  // window so a sync on another server cannot switch the checkout the frames
554
835
  // are being rendered from — the artifact would carry this revision's name
555
836
  // and another revision's screens.
556
- const lease =
557
- typeof acquireLease === 'function'
558
- ? await acquireLease(`preview-artifact ${sourceRevision.slice(0, 8)}`)
559
- : null;
837
+ const label = `preview-artifact ${sourceRevision.slice(0, 8)}`;
838
+ let lease;
839
+ if (typeof acquireSourceLease === 'function') {
840
+ lease = await acquireSourceLease(sourceRevision, label);
841
+ if (
842
+ !lease ||
843
+ lease.sourceRevision !== sourceRevision ||
844
+ typeof lease.sourceRoot !== 'string' ||
845
+ !lease.sourceRoot.trim() ||
846
+ typeof lease.release !== 'function'
847
+ ) {
848
+ await lease?.release?.();
849
+ requestRuntime?.();
850
+ throw new Error(
851
+ `The exact source checkout for ${sourceRevision.slice(0, 8)} is unavailable.`,
852
+ );
853
+ }
854
+ } else {
855
+ // Compatibility for standalone plugin users that do not compose with a
856
+ // mirror. createPygmalionVitePlugins always supplies the exact provider.
857
+ lease =
858
+ typeof acquireLease === 'function'
859
+ ? await acquireLease(label)
860
+ : null;
861
+ }
560
862
  try {
561
- return await task();
863
+ return await task(lease);
562
864
  } finally {
563
865
  await lease?.release();
564
866
  }
@@ -575,29 +877,48 @@ export function pygmalionPreviewArtifactPlugin({
575
877
  if (existing) return existing;
576
878
  const progress = startCaptureProgress();
577
879
  try {
578
- const generated = await withCaptureLease(sourceRevision, () =>
579
- generateArtifact(
880
+ const generated = await withCaptureLease(sourceRevision, async (lease) => {
881
+ const sourceContext = sourceContextFromLease(lease, sourceRevision);
882
+ const retentionSourceContext = retentionSourceContextFromLease(
883
+ lease,
884
+ sourceRevision,
885
+ );
886
+ const captured = await generateArtifact(
580
887
  {
581
888
  namespace,
582
889
  sourceRevision,
583
890
  ...(captureBaseUrl ? { captureBaseUrl } : {}),
584
891
  },
585
892
  { 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
893
  );
596
- }
597
- progress.finalizing();
598
- await artifactStore.publishArtifact(generated, {
599
- sourceRevision,
600
- recordRevision: true,
894
+ const validation = validateRoutePreviewArtifactBundle(captured);
895
+ if (!validation.valid) {
896
+ throw badGeneratorOutput('Generated preview artifact is invalid.');
897
+ }
898
+ if (!exactArtifact(captured, namespace, sourceRevision)) {
899
+ throw badGeneratorOutput(
900
+ 'Generated preview artifact identity does not match the request.',
901
+ );
902
+ }
903
+ const observedDependenciesByFrame = sourceContext
904
+ ? await artifactStore.observeArtifactDependencies(
905
+ captured,
906
+ sourceContext.sourceRoot,
907
+ )
908
+ : undefined;
909
+ await validateSourceLease(lease, sourceRevision);
910
+ progress.finalizing();
911
+ await artifactStore.publishArtifact(captured, {
912
+ sourceRevision,
913
+ recordRevision: true,
914
+ ...(retentionSourceContext
915
+ ? { sourceContext: retentionSourceContext }
916
+ : {}),
917
+ ...(observedDependenciesByFrame
918
+ ? { observedDependenciesByFrame }
919
+ : {}),
920
+ });
921
+ return captured;
601
922
  });
602
923
  return generated;
603
924
  } finally {
@@ -621,19 +942,36 @@ export function pygmalionPreviewArtifactPlugin({
621
942
  return deduplicateRequest(request, () =>
622
943
  enqueueIdentity(namespace, sourceRevision, async () => {
623
944
  // 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(
945
+ // Fingerprint hits remain lock-free. A stale candidate is rechecked under
946
+ // the capture lease before any generation work starts.
947
+ const quick = await artifactStore.readFrameSelection(
626
948
  namespace,
627
949
  sourceRevision,
628
950
  wanted,
629
951
  );
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);
952
+ const quickAbsent = new Set([...quick.missing, ...quick.stale]);
953
+ if (quickAbsent.size === 0) return quick;
954
+ const progress = startCaptureProgress(quickAbsent.size);
634
955
  try {
635
- const generated = await withCaptureLease(sourceRevision, () =>
636
- generateArtifact(
956
+ return await withCaptureLease(sourceRevision, async (lease) => {
957
+ const sourceContext = sourceContextFromLease(lease, sourceRevision);
958
+ const retentionSourceContext = retentionSourceContextFromLease(
959
+ lease,
960
+ sourceRevision,
961
+ );
962
+ const before = await artifactStore.readFrameSelection(
963
+ namespace,
964
+ sourceRevision,
965
+ wanted,
966
+ { sourceContext },
967
+ );
968
+ const absent = new Set([...before.missing, ...before.stale]);
969
+ const remaining = wanted.filter((frame) => absent.has(frame.id));
970
+ if (remaining.length === 0) {
971
+ await validateSourceLease(lease, sourceRevision);
972
+ return before;
973
+ }
974
+ const captured = await generateArtifact(
637
975
  {
638
976
  namespace,
639
977
  sourceRevision,
@@ -641,33 +979,54 @@ export function pygmalionPreviewArtifactPlugin({
641
979
  ...(captureBaseUrl ? { captureBaseUrl } : {}),
642
980
  },
643
981
  { 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
982
  );
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,
983
+ const validation = validateRoutePreviewArtifactBundle(captured);
984
+ if (!validation.valid || captured.version !== 3) {
985
+ throw badGeneratorOutput('Generated preview artifact is invalid.');
986
+ }
987
+ if (!exactArtifact(captured, namespace, sourceRevision)) {
988
+ throw badGeneratorOutput(
989
+ 'Generated preview artifact identity does not match the request.',
990
+ );
991
+ }
992
+ if (!generatedFramesMatchRequest(captured, remaining)) {
993
+ throw badGeneratorOutput(
994
+ 'Generated preview artifact frames do not match the request.',
995
+ );
996
+ }
997
+ const observedDependenciesByFrame = sourceContext
998
+ ? await artifactStore.observeArtifactDependencies(
999
+ captured,
1000
+ sourceContext.sourceRoot,
1001
+ )
1002
+ : undefined;
1003
+ await validateSourceLease(lease, sourceRevision);
1004
+ progress.finalizing();
1005
+ await artifactStore.publishArtifact(captured, {
1006
+ sourceRevision,
1007
+ recordRevision: false,
1008
+ frameRequests: remaining,
1009
+ ...(retentionSourceContext
1010
+ ? { sourceContext: retentionSourceContext }
1011
+ : {}),
1012
+ ...(observedDependenciesByFrame
1013
+ ? { observedDependenciesByFrame }
1014
+ : {}),
1015
+ });
1016
+ // Retention is allowed to decline a newly generated object when older
1017
+ // entries have earned higher read recency. That cache decision must not
1018
+ // discard the result from the request that just paid to generate it.
1019
+ const responseArtifact = mergeRoutePreviewArtifactV3(
1020
+ before.bundle,
1021
+ captured,
1022
+ );
1023
+ return preserveVerifiedFreshness(
1024
+ selectRoutePreviewArtifactFrames(responseArtifact, wanted, {
1025
+ includeStale: true,
1026
+ sourceRevision,
1027
+ }),
1028
+ freshFrameIds(before),
1029
+ );
671
1030
  });
672
1031
  } finally {
673
1032
  progress.finish();
@@ -687,6 +1046,71 @@ export function pygmalionPreviewArtifactPlugin({
687
1046
  void artifactStore.applyRetention?.(undefined)?.catch?.(() => undefined);
688
1047
  server.middlewares.use(async (request, response, next) => {
689
1048
  const url = new URL(request.url ?? '/', 'http://localhost');
1049
+ if (catalogFile != null && url.pathname === catalogEndpoint) {
1050
+ if (request.method !== 'GET') {
1051
+ response.setHeader('allow', 'GET');
1052
+ sendJson(response, 405, {
1053
+ ok: false,
1054
+ error: 'method_not_allowed',
1055
+ });
1056
+ return;
1057
+ }
1058
+ if (!isLocalRequestHost(request.headers.host)) {
1059
+ sendJson(response, 403, {
1060
+ ok: false,
1061
+ error: 'local_host_required',
1062
+ });
1063
+ return;
1064
+ }
1065
+ const params = paramsFromQuery(url);
1066
+ const namespace = requestedIdentity(params, 'namespace');
1067
+ const sourceRevision = requestedIdentity(params, 'sourceRevision');
1068
+ if (!namespace || !sourceRevision) {
1069
+ sendJson(response, 400, {
1070
+ ok: false,
1071
+ error: 'invalid_identity',
1072
+ });
1073
+ return;
1074
+ }
1075
+ if (!runtimePrepared()) {
1076
+ answerCatalogPreparing(response);
1077
+ return;
1078
+ }
1079
+ try {
1080
+ const catalog = await readExactRevisionCatalog(
1081
+ namespace,
1082
+ sourceRevision,
1083
+ );
1084
+ if (!catalog) {
1085
+ // Readiness can change after the optimistic check above. A sync
1086
+ // announces that transition before it waits for readers, so a
1087
+ // lease miss during that window is temporary rather than an
1088
+ // identity mismatch the client should cache as permanent.
1089
+ if (!runtimePrepared()) {
1090
+ answerCatalogPreparing(response);
1091
+ return;
1092
+ }
1093
+ sendJson(response, 409, {
1094
+ ok: false,
1095
+ error: 'revision_catalog_identity_mismatch',
1096
+ });
1097
+ return;
1098
+ }
1099
+ sendJson(response, 200, { ok: true, catalog });
1100
+ } catch (error) {
1101
+ if (error?.pygmalionSourceLeaseUnavailable) {
1102
+ answerCatalogPreparing(response);
1103
+ return;
1104
+ }
1105
+ sendJson(response, 500, {
1106
+ ok: false,
1107
+ error: error?.pygmalionBadRevisionCatalog
1108
+ ? 'revision_catalog_invalid'
1109
+ : 'revision_catalog_read_failed',
1110
+ });
1111
+ }
1112
+ return;
1113
+ }
690
1114
  if (url.pathname === progressEndpoint) {
691
1115
  if (request.method !== 'GET') {
692
1116
  response.setHeader('allow', 'GET');
@@ -790,7 +1214,7 @@ export function pygmalionPreviewArtifactPlugin({
790
1214
  }
791
1215
  if (wanted !== undefined) {
792
1216
  if (resolutionOnly(params)) {
793
- const resolution = await artifactStore.resolveFrameSelection(
1217
+ const quickResolution = await artifactStore.resolveFrameSelection(
794
1218
  namespace,
795
1219
  sourceRevision,
796
1220
  wanted,
@@ -800,21 +1224,52 @@ export function pygmalionPreviewArtifactPlugin({
800
1224
  // policy decides by. A cache nobody can inspect is a cache nobody
801
1225
  // trusts, and "valid vs provenance-only" explains both the size and
802
1226
  // the next eviction.
803
- const store = await artifactStore
804
- .inspect(namespace)
805
- .catch(() => null);
1227
+ const resolution = quickResolution.sourcePending?.length
1228
+ ? await withOptionalSourceLease(
1229
+ sourceRevision,
1230
+ `preview-freshness ${sourceRevision.slice(0, 8)}`,
1231
+ async (sourceContext) =>
1232
+ sourceContext
1233
+ ? await artifactStore.resolveFrameSelection(
1234
+ namespace,
1235
+ sourceRevision,
1236
+ wanted,
1237
+ { sourceContext },
1238
+ )
1239
+ : quickResolution,
1240
+ )
1241
+ : quickResolution;
1242
+ // Inspection is deliberately context-free on this hot path. It
1243
+ // reports observed entries as unknown without re-hashing the whole
1244
+ // cache while a one-frame resolution lease is held.
1245
+ const store = await artifactStore.inspect(namespace).catch(() => null);
806
1246
  sendJson(response, 200, {
807
1247
  ok: true,
808
- ...resolution,
1248
+ ...publicFrameSelection(resolution),
809
1249
  ...(store ? { store } : {}),
810
1250
  });
811
1251
  return;
812
1252
  }
813
- let picked = await artifactStore.readFrameSelection(
1253
+ const quickPicked = await artifactStore.readFrameSelection(
814
1254
  namespace,
815
1255
  sourceRevision,
816
1256
  wanted,
817
1257
  );
1258
+ let picked = quickPicked.sourcePending?.length
1259
+ ? await withOptionalSourceLease(
1260
+ sourceRevision,
1261
+ `preview-freshness ${sourceRevision.slice(0, 8)}`,
1262
+ (sourceContext) =>
1263
+ sourceContext
1264
+ ? artifactStore.readFrameSelection(
1265
+ namespace,
1266
+ sourceRevision,
1267
+ wanted,
1268
+ { sourceContext },
1269
+ )
1270
+ : quickPicked,
1271
+ )
1272
+ : quickPicked;
818
1273
  const absent = [...picked.missing, ...picked.stale];
819
1274
  if (absent.length && generateArtifact && generationAllowed(params)) {
820
1275
  if (!runtimePrepared()) {
@@ -830,11 +1285,15 @@ export function pygmalionPreviewArtifactPlugin({
830
1285
  wanted.filter((frame) => requested.has(frame.id)),
831
1286
  priority,
832
1287
  );
833
- picked = selectRoutePreviewArtifactFrames(
1288
+ const merged = selectRoutePreviewArtifactFrames(
834
1289
  mergeRoutePreviewArtifactV3(picked.bundle, captured.bundle),
835
1290
  wanted,
836
1291
  { includeStale: true, sourceRevision },
837
1292
  );
1293
+ picked = preserveVerifiedFreshness(
1294
+ merged,
1295
+ [...freshFrameIds(picked), ...freshFrameIds(captured)],
1296
+ );
838
1297
  } catch (error) {
839
1298
  sendGenerationFailure(response, error);
840
1299
  return;