@pygmalionjs/pygmalion 0.9.0 → 0.10.0

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.
@@ -0,0 +1,159 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import { withSourceArchive } from './source-archive.mjs';
5
+
6
+ const execute = promisify(execFile);
7
+ const COMMIT_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
8
+
9
+ function revision(value) {
10
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
11
+ if (!COMMIT_SHA.test(normalized)) throw new Error('A revision catalog requires the exact complete source commit.');
12
+ return normalized;
13
+ }
14
+
15
+ function freeze(value) {
16
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
17
+ Object.values(value).forEach(freeze);
18
+ Object.freeze(value);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function capture(catalog, sourceRevision) {
24
+ const portable = JSON.parse(JSON.stringify(catalog));
25
+ if (portable?.version !== 1 || portable.sourceRevision !== sourceRevision || !Array.isArray(portable.screens?.pages)) {
26
+ throw new Error(`The catalog does not match its requested source revision: ${sourceRevision.slice(0, 8)}`);
27
+ }
28
+ return freeze(portable);
29
+ }
30
+
31
+ /** A commit must be attached to this checkout or reachable from one of its refs. */
32
+ async function reachableFromRefs(projectRoot, sourceRevision, signal) {
33
+ const git = args => execute('git', ['-C', projectRoot, ...args], { encoding: 'utf8', signal });
34
+ const head = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'])).stdout.trim();
35
+ if (head === sourceRevision) return true;
36
+ try {
37
+ await git(['cat-file', '-e', `${sourceRevision}^{commit}`]);
38
+ } catch (error) {
39
+ if (error?.code === 1 || error?.code === 128) return false;
40
+ throw error;
41
+ }
42
+ const { stdout } = await git([
43
+ 'for-each-ref', '--count=1', '--format=%(refname)', `--contains=${sourceRevision}`, 'refs/heads', 'refs/remotes', 'refs/tags',
44
+ ]);
45
+ return stdout.trim().length > 0;
46
+ }
47
+
48
+ /** Hosts provide catalog declarations; the SDK owns archive lifetime and bounded shared work. */
49
+ export function createRevisionCatalogResolver({
50
+ projectRoot = process.cwd(),
51
+ appDirectory = '.',
52
+ currentCatalog,
53
+ generate,
54
+ reachable = (sourceRevision, signal) => reachableFromRefs(path.resolve(projectRoot), sourceRevision, signal),
55
+ archive = (sourceRevision, signal, operation) => withSourceArchive({ projectRoot, appDirectory, sourceRevision, signal }, operation),
56
+ maxEntries = 4,
57
+ maxAgeMs = 30 * 60_000,
58
+ } = {}) {
59
+ if (typeof currentCatalog !== 'function' || typeof generate !== 'function'
60
+ || typeof reachable !== 'function' || typeof archive !== 'function') {
61
+ throw new TypeError('A catalog resolver requires currentCatalog and generate callbacks.');
62
+ }
63
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 0 || !Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
64
+ throw new TypeError('Catalog cache limits must be finite non-negative values.');
65
+ }
66
+ const entries = new Map();
67
+ const pending = new Map();
68
+ const controllers = new Set();
69
+ let queue = Promise.resolve();
70
+ let disposed = false;
71
+ const check = signal => {
72
+ signal?.throwIfAborted();
73
+ if (disposed) throw new Error('The revision catalog resolver has been disposed.');
74
+ };
75
+ const prune = () => {
76
+ const now = Date.now();
77
+ for (const [key, entry] of entries) {
78
+ if (now - entry.createdAt >= maxAgeMs) entries.delete(key);
79
+ }
80
+ };
81
+ const remember = (sourceRevision, catalog) => {
82
+ prune();
83
+ if (maxEntries === 0 || maxAgeMs === 0) return;
84
+ entries.delete(sourceRevision);
85
+ entries.set(sourceRevision, { catalog, createdAt: Date.now() });
86
+ while (entries.size > maxEntries) entries.delete(entries.keys().next().value);
87
+ };
88
+ const cached = sourceRevision => {
89
+ prune();
90
+ const entry = entries.get(sourceRevision);
91
+ if (!entry) return undefined;
92
+ entries.delete(sourceRevision); entries.set(sourceRevision, entry);
93
+ return entry.catalog;
94
+ };
95
+ const current = async signal => {
96
+ check(signal);
97
+ const catalog = await currentCatalog(signal);
98
+ check(signal);
99
+ return capture(catalog, revision(catalog?.sourceRevision));
100
+ };
101
+ const build = sourceRevision => {
102
+ const controller = new AbortController();
103
+ controllers.add(controller);
104
+ const work = (async () => {
105
+ // Archive extraction and compilation share one bounded execution lane.
106
+ await queue;
107
+ controller.signal.throwIfAborted();
108
+ if (!(await reachable(sourceRevision, controller.signal))) {
109
+ throw new Error(`The source revision is not reachable in this repository: ${sourceRevision.slice(0, 8)}`);
110
+ }
111
+ controller.signal.throwIfAborted();
112
+ const catalog = await archive(sourceRevision, controller.signal,
113
+ ({ appRoot, signal }) => generate({ archiveRoot: appRoot, sourceRevision, signal }));
114
+ controller.signal.throwIfAborted();
115
+ const portable = capture(catalog, sourceRevision);
116
+ if (!disposed) remember(sourceRevision, portable);
117
+ return portable;
118
+ })();
119
+ queue = work.catch(() => {});
120
+ const cleanup = () => {
121
+ controllers.delete(controller);
122
+ if (pending.get(sourceRevision) === work) pending.delete(sourceRevision);
123
+ };
124
+ void work.then(cleanup, cleanup);
125
+ return work;
126
+ };
127
+ const awaitShared = (work, signal) => {
128
+ if (!signal) return work;
129
+ return new Promise((resolve, reject) => {
130
+ const abort = () => reject(signal.reason ?? new DOMException('Catalog request cancelled.', 'AbortError'));
131
+ if (signal.aborted) { abort(); return; }
132
+ signal.addEventListener('abort', abort, { once: true });
133
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
134
+ });
135
+ };
136
+ return Object.freeze({
137
+ async resolve(sourceRevision, signal) {
138
+ check(signal);
139
+ const normalized = revision(sourceRevision);
140
+ const own = await current(signal);
141
+ if (own.sourceRevision === normalized) return own;
142
+ const hit = cached(normalized);
143
+ if (hit) return hit;
144
+ const work = pending.get(normalized) ?? build(normalized);
145
+ pending.set(normalized, work);
146
+ const result = await awaitShared(work, signal);
147
+ check(signal);
148
+ return result;
149
+ },
150
+ async currentRevision(signal) { return (await current(signal)).sourceRevision; },
151
+ retained() { prune(); return [...entries.keys()]; },
152
+ dispose() {
153
+ if (disposed) return;
154
+ disposed = true;
155
+ for (const controller of controllers) controller.abort(new Error('The revision catalog resolver has been disposed.'));
156
+ entries.clear(); pending.clear();
157
+ },
158
+ });
159
+ }
@@ -0,0 +1,63 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ function localPath(value) {
5
+ return typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)
6
+ && !/[\\\x00-\x1f\x7f]/.test(value)
7
+ && !/^[a-z]:/i.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
8
+ }
9
+
10
+ async function directory(root) {
11
+ return (await lstat(root).catch(error => {
12
+ if (error.code === 'ENOENT') return undefined;
13
+ throw error;
14
+ }))?.isDirectory() ?? false;
15
+ }
16
+
17
+ /** Select an existing install only when its lockfile is byte-identical to the archive. */
18
+ export async function resolveSourceArchiveDependencies({ archiveRoot, dependencyRoots, lockfile = 'package-lock.json' }) {
19
+ if (!localPath(lockfile) || !Array.isArray(dependencyRoots)) {
20
+ throw new TypeError('Archive dependencies require a relative lockfile and candidate root list.');
21
+ }
22
+ if (await directory(path.join(archiveRoot, 'node_modules'))) return [];
23
+ const lock = await readFile(path.join(archiveRoot, lockfile)).catch(error => {
24
+ if (error.code === 'ENOENT') throw new Error(`The source archive is missing ${lockfile}.`, { cause: error });
25
+ throw error;
26
+ });
27
+ const rejected = [];
28
+ for (const candidate of dependencyRoots) {
29
+ const root = path.resolve(candidate);
30
+ const modules = path.join(root, 'node_modules');
31
+ if (!(await directory(modules))) { rejected.push(`${root}: missing node_modules`); continue; }
32
+ const candidateLock = await readFile(path.join(root, lockfile)).catch(error => {
33
+ if (error.code === 'ENOENT') return undefined;
34
+ throw error;
35
+ });
36
+ if (!candidateLock?.equals(lock)) { rejected.push(`${root}: ${lockfile} differs`); continue; }
37
+ return [modules];
38
+ }
39
+ throw new Error(`No dependency install matches the source archive lockfile.${rejected.length ? ` (${rejected.join('; ')})` : ''}`);
40
+ }
41
+
42
+ /** Check the isolation boundary before a trusted generator writes into extracted source. */
43
+ export async function assertIsolatedSourceRoot({ archiveRoot, hostRoot, requiredDirectories = [] }) {
44
+ if (!Array.isArray(requiredDirectories) || !requiredDirectories.every(localPath)) {
45
+ throw new TypeError('Required source directories must be relative paths.');
46
+ }
47
+ const root = await realpath(archiveRoot);
48
+ const host = await realpath(hostRoot);
49
+ const relative = path.relative(host, root);
50
+ if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
51
+ throw new Error('Source generation requires an isolated archive outside the host working tree.');
52
+ }
53
+ const metadata = await lstat(path.join(root, '.git')).catch(error => {
54
+ if (error.code === 'ENOENT') return undefined;
55
+ throw error;
56
+ });
57
+ if (metadata) throw new Error('The isolated source archive must not contain Git working-tree metadata.');
58
+ if (!(await directory(root))) throw new Error('The source archive must be a directory.');
59
+ for (const name of requiredDirectories) {
60
+ if (!(await directory(path.join(root, name)))) throw new Error(`The source archive is missing a regular directory: ${name}`);
61
+ }
62
+ return root;
63
+ }
@@ -0,0 +1,17 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, isAbsolute } from 'node:path';
3
+
4
+ /** Evaluates trusted declaration code after the supplied bundler produces one in-memory CommonJS module. */
5
+ export async function evaluateBundledSourceModule({ build, buildOptions, filename, require: requireModule }) {
6
+ if (typeof build !== 'function') throw new TypeError('A source module bundler is required.');
7
+ if (typeof filename !== 'string' || !isAbsolute(filename)) throw new TypeError('An absolute source module filename is required.');
8
+ if (requireModule !== undefined && typeof requireModule !== 'function') throw new TypeError('require must be a module loader.');
9
+ const result = await build({ ...buildOptions, bundle: true, format: 'cjs', platform: 'node', write: false });
10
+ if (result.outputFiles?.length !== 1 || typeof result.outputFiles[0]?.text !== 'string') {
11
+ throw new Error('The source module bundler must produce exactly one in-memory output.');
12
+ }
13
+ const module = { exports: {} };
14
+ const evaluate = new Function('module', 'exports', 'require', '__filename', '__dirname', result.outputFiles[0].text);
15
+ evaluate(module, module.exports, requireModule ?? createRequire(filename), filename, dirname(filename));
16
+ return module.exports;
17
+ }
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ const relative = file => typeof file === 'string' && file.length > 0 && !path.isAbsolute(file)
7
+ && !/^[a-z]:/i.test(file) && !/[\\\x00-\x1f\x7f?#]/.test(file)
8
+ && !file.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
9
+
10
+ /** Absence is only proven through ordinary directories, including every existing ancestor. */
11
+ export async function assertSourceInputParent(root, file) {
12
+ if (!relative(file)) throw new Error('A source input requires a relative file path.');
13
+ let directory = root;
14
+ for (const part of file.split('/').slice(0, -1)) {
15
+ directory = path.join(directory, part);
16
+ let stat;
17
+ try { stat = await lstat(directory); }
18
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
19
+ if (!stat.isDirectory() || await realpath(directory) !== directory) throw new Error('Source inputs cannot traverse linked directories.');
20
+ }
21
+ }
22
+
23
+ export async function readSourceInputs(appRoot, files, { optionalFiles = [], signal, maxBytes = 32 * 1024 * 1024 } = {}) {
24
+ if (!Array.isArray(files) || !files.length || files.length > 3000 || new Set(files).size !== files.length
25
+ || files.some(file => !relative(file)) || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Source inputs require unique relative files and a positive byte limit.');
26
+ const selected = [...files]; const optional = new Set(optionalFiles); const inputs = {}; let bytes = 0;
27
+ const root = await realpath(appRoot);
28
+ for (const file of selected) {
29
+ signal?.throwIfAborted();
30
+ await assertSourceInputParent(root, file);
31
+ const absolute = path.join(root, file);
32
+ let stat;
33
+ try { stat = await lstat(absolute); }
34
+ catch (error) { if (!optional.has(file) || error.code !== 'ENOENT') throw error; Object.defineProperty(inputs, file, { value: null, enumerable: true }); continue; }
35
+ if (!stat.isFile() || await realpath(absolute) !== absolute) throw new Error(`Source input must be a regular unlinked file: ${file}`);
36
+ if (bytes + stat.size > maxBytes) throw new Error('Source inputs exceed the byte limit.');
37
+ const buffer = await readFile(absolute); bytes += buffer.length;
38
+ if (bytes > maxBytes || !Buffer.from(buffer.toString('utf8')).equals(buffer)) throw new Error('Source inputs must be UTF-8 within the byte limit.');
39
+ Object.defineProperty(inputs, file, { value: buffer.toString('utf8'), enumerable: true, configurable: true, writable: true });
40
+ }
41
+ signal?.throwIfAborted();
42
+ return inputs;
43
+ }
44
+
45
+ /** Seed isolated UTF-8 source files and always remove the directory after the callback settles. */
46
+ export async function withSourceFiles({ files, signal }, operation) {
47
+ const entries = files instanceof Map ? [...files] : null;
48
+ if (!entries || entries.length > 3000 || typeof operation !== 'function'
49
+ || entries.some(([file, source]) => !relative(file) || typeof source !== 'string' || Buffer.from(source).toString('utf8') !== source)) {
50
+ throw new Error('Isolated source files require a map of relative paths to UTF-8 strings.');
51
+ }
52
+ signal?.throwIfAborted();
53
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pygmalion-source-'));
54
+ try {
55
+ for (const [file, source] of entries) {
56
+ signal?.throwIfAborted();
57
+ const destination = path.join(root, file);
58
+ await mkdir(path.dirname(destination), { recursive: true });
59
+ await writeFile(destination, source, { flag: 'wx' });
60
+ }
61
+ signal?.throwIfAborted();
62
+ const result = await operation(root);
63
+ signal?.throwIfAborted();
64
+ return result;
65
+ } finally { await rm(root, { recursive: true, force: true }); }
66
+ }
67
+
68
+ export async function readProvenSourceFiles(appRoot, proofs, options = {}) {
69
+ if (!Array.isArray(proofs) || !proofs.length || proofs.length > 500 || proofs.some(proof => !proof ||
70
+ !(proof.sha256 === null && options.optionalFiles?.includes(proof.file) || typeof proof.sha256 === 'string' && /^[a-f0-9]{64}$/.test(proof.sha256)))) {
71
+ throw new Error('Source proofs require exact hashes or explicitly allowed absence.');
72
+ }
73
+ const selected = proofs.map(({ file, sha256 }) => ({ file, sha256 }));
74
+ const inputs = await readSourceInputs(appRoot, selected.map(proof => proof.file), options);
75
+ return new Map(selected.map(proof => {
76
+ const source = inputs[proof.file];
77
+ const hash = source === null ? null : createHash('sha256').update(source).digest('hex');
78
+ if (hash !== proof.sha256) throw new Error(`Source changed since the baseline was loaded: ${proof.file}`);
79
+ return [proof.file, { ...proof, source }];
80
+ }));
81
+ }
@@ -0,0 +1,57 @@
1
+ /** Local JSON transport. Operation names and all filesystem/build callbacks come from host configuration. */
2
+ export function sourceServicePlugin({ name = 'pygmalion-source-service', endpoint, operations, createService, transformInput = (_operation, input) => input, maxBytes = 40 * 1024 * 1024 }) {
3
+ if (!/^\/[A-Za-z0-9/_-]+$/.test(endpoint) || endpoint.endsWith('/') || !Array.isArray(operations) || !operations.length
4
+ || operations.some(operation => !/^[a-z][A-Za-z]+$/.test(operation) || ['constructor', 'dispose'].includes(operation))
5
+ || typeof createService !== 'function' || typeof transformInput !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
6
+ throw new Error('A source transport requires a local endpoint, operation allowlist, factory, and byte limit.');
7
+ }
8
+ operations = [...operations];
9
+ let service; const controllers = new Set();
10
+ const active = () => { if (!service) throw new Error('The source service has not started.'); return service; };
11
+ return {
12
+ validatePreviewRequest(request, signal) { return active().validatePreviewRequest(request, signal); },
13
+ preparePreviewSource(request, root, signal) { return active().preparePreviewSource(request, root, signal); },
14
+ plugin: {
15
+ name, apply: 'serve',
16
+ configureServer(server) {
17
+ service = createService(server);
18
+ if (operations.some(operation => typeof service?.[operation] !== 'function')) throw new Error('The source service must implement each configured operation.');
19
+ server.httpServer?.once('close', () => {
20
+ for (const controller of controllers) controller.abort();
21
+ controllers.clear();
22
+ Promise.resolve().then(() => service.dispose()).catch(error => server.config?.logger?.error(String(error)));
23
+ });
24
+ server.middlewares.use(async (req, res, next) => {
25
+ const pathname = req.url?.split('?')[0];
26
+ const operation = pathname?.startsWith(`${endpoint}/`) ? pathname.slice(endpoint.length + 1) : undefined;
27
+ if (!operations.includes(operation)) return next();
28
+ res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store');
29
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(JSON.stringify({ error: 'Source operations require POST.' })); return; }
30
+ try {
31
+ if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) throw new Error('Origin mismatch.');
32
+ } catch { res.statusCode = 403; res.end(JSON.stringify({ error: 'Source operations require the editor origin.' })); return; }
33
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) {
34
+ res.statusCode = 415; res.end(JSON.stringify({ error: 'Source operations require JSON.' })); return;
35
+ }
36
+ const controller = new AbortController(); controllers.add(controller);
37
+ const abort = () => { if (!res.writableEnded) controller.abort(); };
38
+ req.once('aborted', abort); res.once('close', abort);
39
+ try {
40
+ const chunks = []; let bytes = 0;
41
+ for await (const chunk of req) {
42
+ bytes += chunk.length;
43
+ if (bytes > maxBytes) throw new Error('Source request exceeds the byte limit.');
44
+ chunks.push(chunk);
45
+ }
46
+ controller.signal.throwIfAborted();
47
+ const input = transformInput(operation, JSON.parse(Buffer.concat(chunks).toString('utf8')));
48
+ const result = await service[operation](input, controller.signal);
49
+ controller.signal.throwIfAborted(); res.end(JSON.stringify(result));
50
+ } catch (error) {
51
+ if (!res.destroyed && !res.writableEnded) { res.statusCode = 409; res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); }
52
+ } finally { controllers.delete(controller); req.removeListener('aborted', abort); res.removeListener('close', abort); }
53
+ });
54
+ },
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,4 @@
1
+ export { createWorkspaceSourceSession } from './workspace-source-session.mjs';
2
+ export { createTokenSourceService } from './token-source-service.mjs';
3
+ export { sourceServicePlugin } from './source-service-plugin.mjs';
4
+ export { assertSourceInputParent, readSourceInputs, readProvenSourceFiles, withSourceFiles } from './source-service-inputs.mjs';
@@ -0,0 +1,151 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
3
+ import { applySourceFilePlan, previewSourceFilePlan, captureSourceFilePlan } from './source-file-plan.mjs';
4
+
5
+ const capture = value => JSON.parse(fingerprint(value));
6
+ const keyOf = value => createHash('sha256').update(fingerprint(value)).digest('hex');
7
+ const expired = receipt => { const now = Date.now(); return now < receipt.createdAt || now - receipt.createdAt >= 30 * 60_000; };
8
+ function completePreview(result, plan) {
9
+ const changed = new Set(plan.changes.map(change => change.file));
10
+ const complete = entries => Array.isArray(entries) && entries.length === changed.size
11
+ && new Set(entries).size === changed.size && entries.every(file => changed.has(file));
12
+ if (!result || !Array.isArray(result.files) || !complete(result.files.map(entry => entry?.file))
13
+ || !complete(result.affectedFiles) || result.files.some(entry => typeof entry.diff !== 'string' || !entry.diff.trim())) {
14
+ throw new Error('Review must include each changed file exactly once with a nonempty diff and affected-file entry.');
15
+ }
16
+ }
17
+
18
+ /** Token codecs describe the product files; this service owns receipts, review, cancellation, and source application. */
19
+ export function createTokenSourceService({
20
+ appRoot, loadCodec, assertRevision, assertTarget, archive, loadSource, compilePlan, listConsumers,
21
+ previewPlan = previewSourceFilePlan, applyPlan = applySourceFilePlan, currentRevision,
22
+ regenerate, install, applyMessage = 'The reviewed token source changes were applied.',
23
+ regenerationFailureMessage = error => `Source changes were applied, but regeneration failed: ${error instanceof Error ? error.message : String(error)}`,
24
+ }) {
25
+ if (![loadCodec, assertRevision, assertTarget, archive, loadSource, compilePlan, listConsumers, currentRevision, regenerate, install].every(callback => typeof callback === 'function')) {
26
+ throw new Error('A token source service requires explicit codec, source, revision, and build callbacks.');
27
+ }
28
+ const receipts = new Map();
29
+ let busy = false; let revision = 0; let disposed = false;
30
+ const checkActive = signal => {
31
+ signal.throwIfAborted();
32
+ if (disposed) throw new Error('The token source service has been disposed.');
33
+ };
34
+ const checkRevision = async (value, signal) => {
35
+ checkActive(signal);
36
+ if (typeof value !== 'string' || !/^[a-f0-9]{40}$/.test(value)) throw new Error('An exact source revision is required.');
37
+ await assertRevision(value, signal); checkActive(signal);
38
+ };
39
+ const receiptKey = document => keyOf({ sourceRevision: document.sourceRevision, document });
40
+ const validateRequest = async (input, signal, requireReview = true) => {
41
+ const request = capture(input);
42
+ await checkRevision(request?.target?.sourceRevision, signal);
43
+ if (request.sourceKind !== 'file-plan') throw new Error('A compiled file-plan request is required.');
44
+ await assertTarget(request.target, signal); checkActive(signal);
45
+ const receipt = receipts.get(receiptKey(request.document));
46
+ if (!receipt || receipt.consumed || expired(receipt)) throw new Error('The token source receipt is unavailable or expired. Compile and review again.');
47
+ if (request.target.sourceRevision !== request.document.sourceRevision || receipt.plan.sourceRevision !== request.target.sourceRevision) throw new Error('The target, token document, and source plan revisions differ.');
48
+ const beforeFingerprint = fingerprint(receipt.before);
49
+ const parts = { version: 1, target: request.target, documentId: request.document.id, beforeFingerprint,
50
+ afterFingerprint: fingerprint(request.document),
51
+ mappingFingerprint: fingerprint({ version: 1, target: request.target, documentId: request.document.id, baselineFingerprint: beforeFingerprint, files: receipt.plan.proofs }),
52
+ payloadFingerprint: fingerprint(receipt.plan) };
53
+ if (fingerprint(request.payload) !== parts.payloadFingerprint || fingerprint(request.identity) !== fingerprint({ ...parts, fingerprint: fingerprint(parts) })) {
54
+ throw new Error('The complete token document and source plan differ from the reviewed request.');
55
+ }
56
+ if (requireReview && !receipt.reviewed.has(request.identity.fingerprint)) throw new Error('Review the complete token source diff for this target first.');
57
+ return { request, receipt };
58
+ };
59
+ const service = {
60
+ async load(sourceRevision, signal) {
61
+ await checkRevision(sourceRevision, signal);
62
+ const codec = await loadCodec();
63
+ checkActive(signal);
64
+ const result = await archive(sourceRevision, signal, async ({ appRoot: root }) => {
65
+ const baseline = await loadSource({ appRoot: root, sourceRevision, codec, signal });
66
+ checkActive(signal);
67
+ return capture({ document: baseline.document, proofs: baseline.proofs, sourceFiles: baseline.sourceFiles, ledger: baseline.ledger });
68
+ });
69
+ checkActive(signal);
70
+ return result;
71
+ },
72
+ async compile(input, signal) {
73
+ const { baseline, document } = capture(input);
74
+ receipts.delete(receiptKey(document));
75
+ await checkRevision(document.sourceRevision, signal);
76
+ const codec = await loadCodec();
77
+ checkActive(signal);
78
+ const receipt = await archive(document.sourceRevision, signal, async ({ appRoot: root }) => {
79
+ const actual = await loadSource({ appRoot: root, sourceRevision: document.sourceRevision, codec, signal });
80
+ checkActive(signal);
81
+ if (fingerprint(baseline) !== fingerprint({ document: actual.document, proofs: actual.proofs })) throw new Error('The token source baseline differs from the loaded source.');
82
+ if (document.id !== actual.document.id || actual.document.sourceRevision !== document.sourceRevision) throw new Error('The edited token library differs from the source identity.');
83
+ await install(root, signal); checkActive(signal);
84
+ const compiled = await compilePlan({ appRoot: root, sourceRevision: document.sourceRevision, before: actual.document, after: document, codec, signal, dependenciesReady: true });
85
+ checkActive(signal);
86
+ const plan = captureSourceFilePlan(compiled, { sourceRevision: document.sourceRevision, allowedFiles: actual.proofs.map(proof => proof.file) });
87
+ if (plan.version !== 1 || plan.sourceRevision !== document.sourceRevision || fingerprint(plan.proofs) !== fingerprint(actual.proofs)) throw new Error('The compiled plan proofs differ from the loaded source.');
88
+ if (!plan.changes.length) throw new Error('There are no token source changes to apply.');
89
+ return { before: capture(actual.document), plan: capture(plan), consumerFiles: [...actual.consumerFiles], reviewed: new Set(), consumed: false, createdAt: Date.now() };
90
+ });
91
+
92
+ checkActive(signal);
93
+ const key = receiptKey(document); receipts.delete(key); receipts.set(key, receipt);
94
+ for (const [oldKey, oldReceipt] of receipts) if (expired(oldReceipt)) receipts.delete(oldKey);
95
+ while (receipts.size > 8) receipts.delete(receipts.keys().next().value);
96
+ return capture(receipt.plan);
97
+ },
98
+ async preview(input, signal) {
99
+ const { request, receipt } = await validateRequest(input, signal, false);
100
+ receipt.reviewed.delete(request.identity.fingerprint);
101
+ const result = await archive(request.target.sourceRevision, signal, ({ appRoot: root }) => previewPlan(root, capture(receipt.plan), {
102
+ sourceRevision: request.target.sourceRevision, allowedFiles: receipt.plan.changes.map(change => change.file), signal,
103
+ }));
104
+ checkActive(signal);
105
+ if (receipts.get(receiptKey(request.document)) !== receipt || receipt.consumed || expired(receipt)) throw new Error('The token source receipt changed or expired during review. Review again.');
106
+ completePreview(result, receipt.plan);
107
+ receipt.reviewed.add(request.identity.fingerprint);
108
+ return { ...result, revision: ++revision };
109
+ },
110
+ async validatePreviewRequest(input, signal) {
111
+ const { request } = await validateRequest(input, signal);
112
+ return request;
113
+ },
114
+ async apply(input, signal) {
115
+ const { request, receipt } = await validateRequest(input, signal);
116
+ receipt.consumed = true; receipts.clear();
117
+ if (await currentRevision() !== request.target.sourceRevision) throw new Error('The working source revision differs from the selected revision.');
118
+ checkActive(signal);
119
+ if (fingerprint(await listConsumers(appRoot, signal)) !== fingerprint(receipt.consumerFiles)) throw new Error('The token consumer file set changed after review. Compile and review again.');
120
+ checkActive(signal);
121
+ const result = await applyPlan(appRoot, capture(receipt.plan), {
122
+ sourceRevision: request.target.sourceRevision, allowedFiles: receipt.plan.changes.map(change => change.file), signal,
123
+ });
124
+ receipts.clear();
125
+ checkActive(signal);
126
+ if (!result || !Number.isSafeInteger(result.applied) || result.applied < 1) throw new Error('The source writer did not record the reviewed changes. Compile and review again.');
127
+ try { await regenerate(signal); }
128
+ catch (error) { return { applied: result.applied, message: regenerationFailureMessage(error) }; }
129
+ return { applied: result.applied, message: applyMessage };
130
+ },
131
+ dispose() { disposed = true; receipts.clear(); },
132
+ };
133
+ const operations = new Set();
134
+ return Object.freeze(Object.fromEntries(Object.entries(service).map(([name, operation]) => [name,
135
+ name === 'dispose' ? () => {
136
+ for (const controller of operations) controller.abort(new Error('The token source service has been disposed.'));
137
+ operation();
138
+ } : async (input, signal) => {
139
+ if (disposed) throw new Error('The token source service has been disposed.');
140
+ signal?.throwIfAborted();
141
+ const exclusive = name !== 'load';
142
+ if (exclusive && busy) throw new Error('Another token source operation is in progress.');
143
+ if (exclusive) busy = true;
144
+ const controller = new AbortController(); operations.add(controller);
145
+ const abort = () => controller.abort(signal.reason);
146
+ signal?.addEventListener('abort', abort, { once: true });
147
+ try { const result = await operation(input, controller.signal); checkActive(controller.signal); return result; }
148
+ finally { signal?.removeEventListener('abort', abort); operations.delete(controller); if (exclusive) busy = false; }
149
+ },
150
+ ])));
151
+ }
@@ -0,0 +1,52 @@
1
+ function text(value, name) {
2
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
3
+ return value;
4
+ }
5
+
6
+ function literal(value) {
7
+ const serialized = workspaceSourceFingerprint(value);
8
+ return serialized.replace(/[<\u2028\u2029]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
9
+ }
10
+
11
+ /** Creates a browser module that reports a mounted draft recipe through the workspace readiness protocol. */
12
+ export function createWorkspaceDraftEntry({
13
+ documentRevision,
14
+ recipe,
15
+ sdkModule = '@pygmalionjs/pygmalion',
16
+ desiredStateAdapter,
17
+ failureMessage = 'Draft state could not be reproduced: ',
18
+ }) {
19
+ text(documentRevision, 'documentRevision');
20
+ text(sdkModule, 'sdkModule');
21
+ text(failureMessage, 'failureMessage');
22
+ if (!recipe || typeof recipe !== 'object' || Array.isArray(recipe)) throw new TypeError('recipe must be an object.');
23
+ if (desiredStateAdapter) {
24
+ text(desiredStateAdapter.module, 'desiredStateAdapter.module');
25
+ text(desiredStateAdapter.exportName, 'desiredStateAdapter.exportName');
26
+ }
27
+ return `import { runWorkspaceDraftRecipe } from ${literal(sdkModule)};
28
+ ${desiredStateAdapter ? `import * as desiredStateAdapter from ${literal(desiredStateAdapter.module)};` : ''}
29
+ const controller = new AbortController();
30
+ addEventListener('pagehide', () => controller.abort(), { once: true });
31
+ document.documentElement.dataset.pygmalionDraftStatus = 'preparing';
32
+ Promise.resolve().then(() => {
33
+ controller.signal.throwIfAborted();
34
+ return runWorkspaceDraftRecipe({ document, window }, JSON.parse(${literal(workspaceSourceFingerprint(recipe))}), {
35
+ signal: controller.signal,
36
+ ${desiredStateAdapter ? `applyDesiredState(state, context) { return desiredStateAdapter[${literal(desiredStateAdapter.exportName)}](state, context); },` : ''}
37
+ });
38
+ }).then(() => {
39
+ if (controller.signal.aborted) return;
40
+ document.documentElement.dataset.pygmalionDraftStatus = 'ready';
41
+ parent.postMessage({ type: 'pygmalion:workspace-draft-ready', documentRevision: ${literal(documentRevision)}, status: 'ready' }, '*');
42
+ }, error => {
43
+ if (controller.signal.aborted) return;
44
+ document.documentElement.dataset.pygmalionDraftStatus = 'failed';
45
+ const message = document.createElement('div');
46
+ message.setAttribute('role', 'alert');
47
+ message.textContent = ${literal(failureMessage)} + String(error?.message ?? error);
48
+ document.body.prepend(message);
49
+ parent.postMessage({ type: 'pygmalion:workspace-draft-ready', documentRevision: ${literal(documentRevision)}, status: 'failed', message: message.textContent }, '*');
50
+ });`;
51
+ }
52
+ import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
@@ -8,6 +8,7 @@ import { writeInspectChanges } from './inspect-writeback.mjs';
8
8
  import { validateInspectSourceProofs } from './source-file-proofs.mjs';
9
9
  import { applySourceFilePlan } from './source-file-plan.mjs';
10
10
  import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
11
+ import { WORKSPACE_DRAFT_TEMP_PREFIX, WORKSPACE_DRAFT_OUTPUT_DIRECTORY } from './workspace-draft-runtime.mjs';
11
12
 
12
13
  const hash = value => createHash('sha256').update(value).digest('hex');
13
14
  const same = (left, right) => workspaceSourceFingerprint(left) === workspaceSourceFingerprint(right);
@@ -129,7 +130,7 @@ export function createWorkspaceDraftPreviewService(options) {
129
130
  run.done = (async () => {
130
131
  let ready = false;
131
132
  try {
132
- run.temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'pygmalion-draft-preview-'));
133
+ run.temporary = await fsp.mkdtemp(path.join(os.tmpdir(), WORKSPACE_DRAFT_TEMP_PREFIX));
133
134
  controller.signal.throwIfAborted();
134
135
  // The archive holds exactly the requested commit, whatever the repository's
135
136
  // working tree or HEAD currently contains. Every host callback receives that
@@ -158,7 +159,7 @@ export function createWorkspaceDraftPreviewService(options) {
158
159
  if (!(await fsp.stat(dependencies)).isDirectory()) throw new Error('The dependency root must identify an existing node_modules directory.');
159
160
  await fsp.symlink(dependencies, path.join(appRoot, 'node_modules'), 'dir');
160
161
  }
161
- const outputDirectory = path.join(run.temporary, 'output');
162
+ const outputDirectory = path.join(run.temporary, WORKSPACE_DRAFT_OUTPUT_DIRECTORY);
162
163
  await fsp.mkdir(outputDirectory);
163
164
  await build({ appRoot, outputDirectory, request: captured.request, sourceRevision, signal: controller.signal });
164
165
  controller.signal.throwIfAborted();