@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.
- package/README.md +75 -2
- package/dist-lib/{FrozenRoutePreview-BwkyYvUV.js → FrozenRoutePreview-BbF5Ov5K.js} +2901 -2740
- package/dist-lib/pygmalion.js +10738 -9503
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/documentSync.d.ts +15 -1
- package/dist-lib/types/editor/host.d.ts +9 -0
- package/dist-lib/types/editor/previewEnvironmentControls.d.ts +2 -0
- package/dist-lib/types/editor/revisionCatalog.d.ts +135 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +62 -0
- package/dist-lib/types/editor/routePreview.d.ts +1 -1
- package/dist-lib/types/editor/store.d.ts +36 -37
- package/dist-lib/types/editor/tokens.d.ts +9 -0
- package/dist-lib/types/lib.d.ts +12 -3
- package/node/dev-mirror.mjs +564 -112
- package/node/preview-artifact-plugin.mjs +506 -68
- package/node/preview-artifact-store.mjs +358 -77
- package/node/vite.mjs +48 -2
- package/package.json +1 -1
- package/vite.d.ts +61 -4
|
@@ -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
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
//
|
|
625
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
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
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|