@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.
- package/README.md +75 -2
- package/dist-lib/{FrozenRoutePreview-BwkyYvUV.js → FrozenRoutePreview-BbF5Ov5K.js} +2901 -2740
- package/dist-lib/pygmalion.js +11165 -9856
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/captureSupply.d.ts +2 -0
- 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/projectBootGate.d.ts +15 -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/routePreviewStatus.d.ts +2 -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 +567 -112
- package/node/preview-artifact-plugin.mjs +527 -68
- package/node/preview-artifact-store.mjs +370 -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,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
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
//
|
|
625
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
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
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|