@playcraft/cli 0.1.60-beta → 0.1.62-beta

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.
Files changed (40) hide show
  1. package/dist/commands/build.js +16 -7
  2. package/dist/project-skills/builtin-import.js +2 -2
  3. package/dist/skill-registry/messages.js +2 -2
  4. package/dist/skill-registry/platform-package.js +2 -4
  5. package/dist/utils/agent-api-client.js +22 -0
  6. package/dist/workspace-runtime/codex/app-server.js +24 -1
  7. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  8. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  9. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  10. package/dist/workspace-runtime/codex/model-broker.js +13 -3
  11. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  12. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  13. package/dist/workspace-runtime/serve.js +12 -1
  14. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  15. package/dist/workspace-runtime/server/directory-access.js +11 -4
  16. package/dist/workspace-runtime/server/dispatch.js +70 -3
  17. package/dist/workspace-runtime/server/http.js +61 -3
  18. package/dist/workspace-runtime/server/operation-record.js +4 -1
  19. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  20. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  21. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  22. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  23. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  24. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  28. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
  29. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
  30. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
  31. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
  32. package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
  33. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  34. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  35. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  36. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  37. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  38. package/package.json +4 -3
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +11 -1
@@ -0,0 +1,184 @@
1
+ import { mkdtemp, mkdir, writeFile, lstat, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { workspaceRoot, revisionScope } from '../registry.js';
7
+ import { openDirectoryToDevelopment, openScratchToDevelopment } from '../directory-access.js';
8
+ import { runDeveloperCommand } from '../developer-command.js';
9
+ import { artifactMime } from '../artifact-files.js';
10
+ import { checkedFile, dependencyHash, digest } from './files.js';
11
+ import { readProceduralModelCandidates } from './read-candidates.js';
12
+ import { createHostReviewFiles } from './review-page.js';
13
+ import { canonicalJson, REVIEW_DIRECTORY, REVIEW_PAGE, reviewProfileSchema } from './review-contracts.js';
14
+ import { ModelPreviewStore, MODEL_PREVIEW_MAX_BYTES } from './model-preview-store.js';
15
+ import { recoverModelPreviewOperations } from './model-preview-recovery.js';
16
+ import { cancelPendingDirectoryAccess } from '../../server/directory-access.js';
17
+ const active = new WeakMap();
18
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
19
+ export const modelPreviewError = (error) => error instanceof Error && /^model_[a-z_]+$/.test(error.message)
20
+ ? error.message : 'model_preview_build_failed';
21
+ /** Parse declarative fields and require exact equality with the host's own page generator. */
22
+ export function parseFrozenModelReview(bytes, helpers) {
23
+ const page = bytes.toString('utf8');
24
+ const string = '"(?:\\\\.|[^"\\\\])*"';
25
+ const match = (expression) => {
26
+ const value = new RegExp(expression).exec(page)?.[1];
27
+ if (!value)
28
+ throw new Error('model_preview_custom_page_unsupported');
29
+ return JSON.parse(value);
30
+ };
31
+ const path = match(`import \\* as factory from (${string});`);
32
+ const exportName = match(`const createModel = factory\\[(${string})\\];`);
33
+ const parameters = JSON.parse(match(`const parameters = JSON.parse\\((${string})\\);`));
34
+ const profile = reviewProfileSchema.parse(JSON.parse(match(`const profile = JSON.parse\\((${string})\\);`)));
35
+ if (!path.startsWith('/') || !/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/.test(exportName) ||
36
+ !parameters || typeof parameters !== 'object' || Array.isArray(parameters))
37
+ throw new Error('model_preview_custom_page_unsupported');
38
+ canonicalJson(parameters);
39
+ const factoryPath = path.slice(1).split('/').map(decodeURIComponent).join('/');
40
+ const review = { factoryPath, exportName, parameters, profile };
41
+ if (!createHostReviewFiles(review, helpers)[0].bytes.equals(bytes))
42
+ throw new Error('model_preview_custom_page_unsupported');
43
+ return review;
44
+ }
45
+ export async function cancelModelPreview(state, workspaceId, directoryName, previewId) {
46
+ const root = await workspaceRoot(state, workspaceId, directoryName);
47
+ const cancelled = await new ModelPreviewStore(state.config.stateDir, root).cancel(previewId);
48
+ const running = active.get(state)?.get(previewId);
49
+ if (cancelled && running && running.workspaceId === workspaceId && running.directoryName === directoryName)
50
+ running.controller.abort();
51
+ else if (cancelled) {
52
+ const storedTarget = state.journal.listOperations().find(item => (item.operation.gatewayOperationId ?? item.operation.operationId) === previewId);
53
+ const operation = storedTarget?.operation;
54
+ if (operation && operation.workspaceId === workspaceId && (operation.directoryName ?? 'basic') === directoryName) {
55
+ if (operation.status === 'queued') {
56
+ await cancelPendingDirectoryAccess(state, `operation:${operation.operationId}`);
57
+ const queued = state.journal.getOperationById(operation.operationId);
58
+ const stored = state.journal.listOperations().find(item => item.operation.operationId === operation.operationId);
59
+ if (queued?.status === 'queued' && stored) {
60
+ await state.journal.putOperation(stored.scopeKey, transitionOperation(queued, 'interrupted', { error: 'model_preview_cancelled' }));
61
+ await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
62
+ return;
63
+ }
64
+ }
65
+ await recoverModelPreviewOperations(state, { identityMatches: operation.generation === state.config.generation,
66
+ ...(process.env.PLAYCRAFT_LAUNCHER_SOCKET ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) }, previewId);
67
+ if (state.journal.getOperationById(operation.operationId)?.status === 'uncertain')
68
+ throw new Error('model_preview_process_unknown');
69
+ }
70
+ }
71
+ }
72
+ export async function runModelPreviewBuild(state, operation, input, options = {}) {
73
+ if (!operation.workspaceId || operation.directoryName === 'root')
74
+ throw new Error('model_preview_scope_invalid');
75
+ const directoryName = operation.directoryName ?? 'basic';
76
+ const root = await workspaceRoot(state, operation.workspaceId, directoryName);
77
+ const store = new ModelPreviewStore(state.config.stateDir, root), previewId = operation.gatewayOperationId ?? operation.operationId;
78
+ if (await store.cancelled(previewId))
79
+ throw new Error('model_preview_cancelled');
80
+ const previous = await store.read(previewId);
81
+ if (previous) {
82
+ if (previous.runId !== input.runId || previous.candidateId !== input.candidateId || previous.sourceHash !== input.sourceHash ||
83
+ previous.dependencyHash !== input.dependencyHash || canonicalJson(previous.parameterOverrides ?? {}) !== canonicalJson(input.parameterOverrides ?? {}))
84
+ throw new Error('model_preview_binding_changed');
85
+ if (previous.status === 'ready') {
86
+ await store.content(previewId);
87
+ return;
88
+ }
89
+ throw new Error(previous.status === 'cancelled' ? 'model_preview_cancelled' : 'model_preview_recovery_required');
90
+ }
91
+ const listing = await readProceduralModelCandidates(root, state.config.stateDir);
92
+ const candidate = listing.candidates.find(item => item.runId === input.runId && item.candidateId === input.candidateId);
93
+ if (!candidate || candidate.integrity !== 'verified')
94
+ throw new Error('model_preview_candidate_unavailable');
95
+ if (candidate.sourceHash !== input.sourceHash || candidate.dependencyHash !== input.dependencyHash)
96
+ throw new Error('model_preview_binding_changed');
97
+ const prefix = `.playcraft/procedural-3d/runs/${input.runId}/candidates/${input.candidateId}/source/`;
98
+ if (candidate.preview.kind !== 'scene-preview' || candidate.preview.entryPath !== `${prefix}${REVIEW_PAGE}`)
99
+ throw new Error('model_preview_custom_page_unsupported');
100
+ const scene = await checkedFile(root, `${prefix}${REVIEW_DIRECTORY}/review-scene.mjs`);
101
+ const profile = await checkedFile(root, `${prefix}${REVIEW_DIRECTORY}/review-profile.mjs`);
102
+ const review = parseFrozenModelReview(await checkedFile(root, `${prefix}${REVIEW_PAGE}`), { scene, profile });
103
+ const parameters = { ...(review.parameters ?? {}) };
104
+ for (const [key, value] of Object.entries(input.parameterOverrides ?? {})) {
105
+ if (!Object.hasOwn(parameters, key) || typeof parameters[key] !== typeof value)
106
+ throw new Error('model_preview_parameter_unsupported');
107
+ parameters[key] = value;
108
+ }
109
+ const publicParameters = Object.fromEntries(Object.entries(parameters).filter(([, value]) => typeof value === 'boolean' || typeof value === 'string' && value.length <= 256 || typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 100000)
110
+ .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key) && !['__proto__', 'prototype', 'constructor'].includes(key)).slice(0, 64));
111
+ if (!candidate.files.some(file => file.path === `${prefix}${review.factoryPath}` && file.source))
112
+ throw new Error('model_preview_factory_missing');
113
+ const record = { previewId, root, ...input, parameters: publicParameters,
114
+ parameterHash: digest(canonicalJson(publicParameters)), status: 'running', createdAt: new Date().toISOString() };
115
+ await store.write(record);
116
+ const controller = new AbortController(), known = active.get(state) ?? new Map();
117
+ active.set(state, known);
118
+ known.set(previewId, { workspaceId: operation.workspaceId, directoryName, controller });
119
+ let scratch, stopped = true;
120
+ const writeId = `model-preview-${previewId}`;
121
+ try {
122
+ if (await store.cancelled(previewId))
123
+ controller.abort();
124
+ if (controller.signal.aborted)
125
+ throw new Error('model_preview_cancelled');
126
+ scratch = await mkdtemp(join(tmpdir(), 'playcraft-model-preview-'));
127
+ await openScratchToDevelopment(scratch, (await lstat(root)).gid);
128
+ const sourceRoot = join(scratch, 'source');
129
+ await mkdir(sourceRoot);
130
+ const resources = {};
131
+ for (const file of candidate.files) {
132
+ const path = file.path.slice(prefix.length), bytes = await checkedFile(root, file.path);
133
+ if (digest(bytes) !== file.contentHash)
134
+ throw new Error('model_preview_binding_changed');
135
+ const target = join(sourceRoot, path);
136
+ await mkdir(dirname(target), { recursive: true });
137
+ await writeFile(target, bytes);
138
+ if (!/\.[cm]?[jt]sx?$|\.html$/i.test(path))
139
+ resources[path] = `data:${artifactMime(path)};base64,${bytes.toString('base64')}`;
140
+ }
141
+ const outputPath = join(scratch, 'output/index.html');
142
+ const config = { projectRoot: root, sourceRoot, outputPath,
143
+ factoryPath: review.factoryPath, exportName: review.exportName, parameters, profile: review.profile,
144
+ resources, previewId, runId: input.runId, candidateId: input.candidateId, sourceHash: input.sourceHash };
145
+ const configPath = join(scratch, 'input.json');
146
+ await writeFile(configPath, JSON.stringify(config));
147
+ await openDirectoryToDevelopment(scratch, (await lstat(root)).gid);
148
+ const worker = options.workerPath ?? fileURLToPath(new URL('./model-preview-worker.js', import.meta.url));
149
+ const outcome = await runDeveloperCommand(state, { processId: writeId, cwd: scratch,
150
+ command: `${quote(process.execPath)} ${quote(worker)} ${quote(configPath)}`, timeoutMs: 120_000, signal: controller.signal,
151
+ onOutput: () => undefined, onStarted: async (identity) => {
152
+ stopped = false;
153
+ await state.journal.putManagedWrite({ writeId, workspaceId: revisionScope(state, operation.workspaceId, directoryName) ?? 'single',
154
+ source: 'manual', directoryAccessMode: 'exclusive', startedAt: new Date().toISOString(), pid: identity.pid,
155
+ ...(identity.launcher ? { executionMode: 'launcher', launcher: identity.launcher, runtimeEpoch: state.runtimeEpoch } : { executionMode: 'local' }) });
156
+ } });
157
+ stopped = outcome.code !== undefined;
158
+ if (!stopped)
159
+ throw new Error('model_preview_process_unknown');
160
+ const managed = state.journal.listManagedWrites().find(item => item.writeId === writeId);
161
+ if (managed)
162
+ await state.journal.putManagedWrite({ ...managed, stoppedAt: new Date().toISOString() });
163
+ if (outcome.canceled || await store.cancelled(previewId))
164
+ throw new Error('model_preview_cancelled');
165
+ if (outcome.timedOut)
166
+ throw new Error('model_preview_timeout');
167
+ if (outcome.code !== 0)
168
+ throw new Error('model_preview_build_failed');
169
+ if (await dependencyHash(root) !== input.dependencyHash)
170
+ throw new Error('model_preview_binding_changed');
171
+ const bytes = await checkedFile(scratch, 'output/index.html', MODEL_PREVIEW_MAX_BYTES);
172
+ await store.publish(record, bytes);
173
+ }
174
+ catch (error) {
175
+ const code = stopped ? modelPreviewError(error) : 'model_preview_process_unknown';
176
+ await store.write({ ...record, status: code === 'model_preview_cancelled' ? 'cancelled' : 'failed', error: code });
177
+ throw new Error(code);
178
+ }
179
+ finally {
180
+ known.delete(previewId);
181
+ if (scratch && stopped)
182
+ await rm(scratch, { recursive: true, force: true });
183
+ }
184
+ }
@@ -0,0 +1,77 @@
1
+ import { transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { stopLauncherExecution } from '../../codex/launcher-client.js';
3
+ import { reconcileDirectoryAccess } from '../../server/directory-access.js';
4
+ import { workspaceRoot } from '../registry.js';
5
+ import { ModelPreviewStore } from './model-preview-store.js';
6
+ function absent(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return false;
10
+ }
11
+ catch (error) {
12
+ return error.code === 'ESRCH';
13
+ }
14
+ }
15
+ async function stopRecoveredWriter(writer, recovery) {
16
+ if (writer.stoppedAt)
17
+ return true;
18
+ if (recovery.confirmStopped)
19
+ return recovery.confirmStopped(writer);
20
+ if (writer.executionMode === 'launcher') {
21
+ if (!recovery.identityMatches || !recovery.launcherSocket || !writer.launcher ||
22
+ writer.launcher.processId !== writer.writeId || writer.launcher.runtimeEpoch !== writer.runtimeEpoch)
23
+ return false;
24
+ if (recovery.previousRuntimeEpoch !== undefined && recovery.runtimeEpoch !== undefined &&
25
+ recovery.runtimeEpoch > recovery.previousRuntimeEpoch && writer.launcher.runtimeEpoch < recovery.runtimeEpoch)
26
+ return true;
27
+ try {
28
+ await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // A recycled local process id is not authority to signal a process after restart.
36
+ return Boolean(writer.pid && absent(-writer.pid) && absent(writer.pid));
37
+ }
38
+ /** Recovered work is observed/stopped, never rebuilt or re-inferred automatically. */
39
+ export async function recoverModelPreviewOperations(state, recovery, onlyId) {
40
+ for (const { scopeKey, operation: snapshot } of state.journal.listOperations()) {
41
+ if (snapshot.kind !== 'model.preview' || onlyId && (snapshot.gatewayOperationId ?? snapshot.operationId) !== onlyId ||
42
+ !['dispatched', 'accepted', 'uncertain'].includes(snapshot.status))
43
+ continue;
44
+ const operation = state.journal.getOperationById(snapshot.operationId) ?? snapshot;
45
+ const previewId = operation.gatewayOperationId ?? operation.operationId;
46
+ if (!['dispatched', 'accepted', 'uncertain'].includes(operation.status))
47
+ continue;
48
+ const writer = state.journal.listManagedWrites().find(item => item.writeId === `model-preview-${previewId}`);
49
+ const stopped = writer ? await stopRecoveredWriter(writer, recovery) : false;
50
+ let store, ready = false;
51
+ try {
52
+ const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName ?? 'basic');
53
+ store = new ModelPreviewStore(state.config.stateDir, root);
54
+ ready = (await store.read(previewId))?.status === 'ready';
55
+ if (ready)
56
+ await store.content(previewId);
57
+ }
58
+ catch {
59
+ ready = false;
60
+ }
61
+ const current = state.journal.getOperationById(operation.operationId) ?? operation;
62
+ if (!['dispatched', 'accepted', 'uncertain'].includes(current.status))
63
+ continue;
64
+ if (!stopped) {
65
+ await state.journal.putOperation(scopeKey, transitionOperation(current, 'uncertain', { error: 'model_preview_process_unknown' }));
66
+ continue;
67
+ }
68
+ if (writer && !writer.stoppedAt)
69
+ await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
70
+ const record = await store?.read(previewId).catch(() => undefined);
71
+ if (record && !ready)
72
+ await store.write({ ...record,
73
+ status: await store.cancelled(previewId) ? 'cancelled' : 'failed', error: 'model_preview_interrupted' }).catch(() => undefined);
74
+ await state.journal.putOperation(scopeKey, transitionOperation(current, ready ? 'completed' : 'interrupted', ready ? {} : { error: 'model_preview_interrupted' }));
75
+ }
76
+ reconcileDirectoryAccess(state);
77
+ }
@@ -0,0 +1,170 @@
1
+ import { readdir, mkdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { PREVIEW_HTML_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { checkedFile, digest, writeControlJson } from './files.js';
7
+ export const MODEL_PREVIEW_MAX_BYTES = PREVIEW_HTML_MAX_BYTES;
8
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
9
+ const parameterKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}$/)
10
+ .refine(value => !['__proto__', 'prototype', 'constructor'].includes(value));
11
+ const parameters = z.unknown().superRefine((value, context) => {
12
+ if (value && typeof value === 'object' && Object.keys(value).some(key => !parameterKey.safeParse(key).success))
13
+ context.addIssue({ code: 'custom', message: 'invalid_model_parameter_key' });
14
+ }).pipe(z.record(parameterKey, z.union([z.string().max(256), z.number().finite().min(-100000).max(100000), z.boolean()]))
15
+ .refine(value => Object.keys(value).length <= 64));
16
+ const recordSchema = z.object({ previewId: z.string().uuid(), root: z.string(), runId: z.string().uuid(),
17
+ candidateId: z.string().uuid(), sourceHash: hash, dependencyHash: hash,
18
+ status: z.enum(['running', 'ready', 'failed', 'cancelled']), createdAt: z.string().datetime(),
19
+ contentHash: hash.optional(), error: z.string().regex(/^[a-z_]+$/).optional(),
20
+ parameters: parameters.optional(), parameterHash: hash.optional(),
21
+ parameterOverrides: parameters.optional() }).strict().superRefine((record, context) => {
22
+ const normalizedParameters = record.parameters && JSON.stringify(Object.fromEntries(Object.keys(record.parameters).sort().map(key => [key, record.parameters[key]])));
23
+ if (record.status === 'ready' && !record.contentHash || Boolean(record.parameters) !== Boolean(record.parameterHash) ||
24
+ normalizedParameters && digest(normalizedParameters) !== record.parameterHash)
25
+ context.addIssue({ code: 'custom', message: 'model_preview_receipt_invalid' });
26
+ });
27
+ const transactions = new Map();
28
+ async function exclusive(key, action) {
29
+ const previous = transactions.get(key) ?? Promise.resolve();
30
+ let release;
31
+ const current = new Promise(done => { release = done; });
32
+ transactions.set(key, current);
33
+ await previous;
34
+ try {
35
+ return await action();
36
+ }
37
+ finally {
38
+ release();
39
+ if (transactions.get(key) === current)
40
+ transactions.delete(key);
41
+ }
42
+ }
43
+ export class ModelPreviewStore {
44
+ stateDir;
45
+ root;
46
+ relative;
47
+ constructor(stateDir, root) {
48
+ this.stateDir = stateDir;
49
+ this.root = root;
50
+ this.relative = `model-previews/${digest(root)}`;
51
+ }
52
+ async cancel(previewId) {
53
+ if (!z.string().uuid().safeParse(previewId).success)
54
+ throw new Error('model_preview_invalid_id');
55
+ return exclusive(join(this.stateDir, this.relative, previewId), async () => {
56
+ if ((await this.read(previewId))?.status === 'ready')
57
+ return false;
58
+ await writeControlJson(join(this.stateDir, this.relative, 'cancelled', `${previewId}.json`), { previewId, root: this.root });
59
+ return true;
60
+ });
61
+ }
62
+ async cancelled(previewId) {
63
+ if (!z.string().uuid().safeParse(previewId).success)
64
+ throw new Error('model_preview_invalid_id');
65
+ try {
66
+ const receipt = JSON.parse((await checkedFile(this.stateDir, `${this.relative}/cancelled/${previewId}.json`, 4096)).toString('utf8'));
67
+ if (receipt.previewId !== previewId || receipt.root !== this.root)
68
+ throw new Error('model_preview_scope_mismatch');
69
+ return true;
70
+ }
71
+ catch (error) {
72
+ if (error.code === 'ENOENT')
73
+ return false;
74
+ throw error;
75
+ }
76
+ }
77
+ async write(record) {
78
+ recordSchema.parse(record);
79
+ if (record.root !== this.root)
80
+ throw new Error('model_preview_scope_mismatch');
81
+ if (record.status === 'ready') {
82
+ const index = `${record.createdAt.replace(/\D/g, '')}-${record.previewId}.json`;
83
+ await writeControlJson(join(this.stateDir, this.relative, 'bindings', this.binding(record), index), { previewId: record.previewId });
84
+ }
85
+ await writeControlJson(join(this.stateDir, this.relative, 'records', `${record.previewId}.json`), record);
86
+ }
87
+ binding(input) {
88
+ return digest(JSON.stringify([input.runId, input.candidateId, input.sourceHash, input.dependencyHash]));
89
+ }
90
+ async read(previewId) {
91
+ if (!z.string().uuid().safeParse(previewId).success)
92
+ throw new Error('model_preview_invalid_id');
93
+ try {
94
+ const record = recordSchema.parse(JSON.parse((await checkedFile(this.stateDir, `${this.relative}/records/${previewId}.json`, 16 * 1024)).toString('utf8')));
95
+ if (record.root !== this.root || record.previewId !== previewId)
96
+ throw new Error('model_preview_scope_mismatch');
97
+ return record;
98
+ }
99
+ catch (error) {
100
+ if (error.code === 'ENOENT')
101
+ return undefined;
102
+ throw error;
103
+ }
104
+ }
105
+ async publish(record, bytes) {
106
+ return exclusive(join(this.stateDir, this.relative, record.previewId), async () => {
107
+ if (await this.cancelled(record.previewId))
108
+ throw new Error('model_preview_cancelled');
109
+ if (!bytes.length || bytes.length > MODEL_PREVIEW_MAX_BYTES)
110
+ throw new Error('model_preview_size_limit');
111
+ const previous = await this.read(record.previewId);
112
+ if (previous?.status === 'ready') {
113
+ if (previous.runId !== record.runId || previous.candidateId !== record.candidateId || previous.sourceHash !== record.sourceHash ||
114
+ previous.dependencyHash !== record.dependencyHash || previous.contentHash !== digest(bytes))
115
+ throw new Error('model_preview_binding_changed');
116
+ return;
117
+ }
118
+ const directory = join(this.stateDir, this.relative, 'content');
119
+ await mkdir(directory, { recursive: true, mode: 0o700 }); // private
120
+ const target = join(directory, `${record.previewId}.html`), temporary = `${target}.${randomUUID()}.pending`;
121
+ try {
122
+ await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); // private
123
+ await rename(temporary, target);
124
+ await this.write({ ...record, status: 'ready', contentHash: digest(bytes) });
125
+ }
126
+ finally {
127
+ await rm(temporary, { force: true });
128
+ }
129
+ });
130
+ }
131
+ async content(previewId) {
132
+ const record = await this.read(previewId);
133
+ if (!record || record.status !== 'ready' || !record.contentHash)
134
+ throw new Error('model_preview_not_ready');
135
+ if (await this.cancelled(previewId))
136
+ throw new Error('model_preview_cancelled');
137
+ const bytes = await checkedFile(this.stateDir, `${this.relative}/content/${previewId}.html`, MODEL_PREVIEW_MAX_BYTES);
138
+ if (digest(bytes) !== record.contentHash)
139
+ throw new Error('model_preview_content_changed');
140
+ return { bytes, record };
141
+ }
142
+ async latest(input) {
143
+ const records = await this.versions(input);
144
+ if (records[0])
145
+ return records[0];
146
+ return undefined;
147
+ }
148
+ async versions(input) {
149
+ let names;
150
+ try {
151
+ names = (await readdir(join(this.stateDir, this.relative, 'bindings', this.binding(input))))
152
+ .filter(name => /^\d{17}-[a-f0-9-]{36}\.json$/i.test(name)).sort().reverse().slice(0, 32);
153
+ }
154
+ catch (error) {
155
+ if (error.code === 'ENOENT')
156
+ return [];
157
+ throw error;
158
+ }
159
+ const records = [];
160
+ for (const name of names) {
161
+ const record = await this.read(name.slice(18, -5));
162
+ if (record && record.runId === input.runId && record.candidateId === input.candidateId &&
163
+ record.sourceHash === input.sourceHash && record.dependencyHash === input.dependencyHash && record.status === 'ready' && record.contentHash &&
164
+ !await this.cancelled(record.previewId))
165
+ records.push(record);
166
+ }
167
+ records.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
168
+ return records.slice(0, 32);
169
+ }
170
+ }
@@ -0,0 +1,85 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { pathToFileURL, fileURLToPath } from 'node:url';
4
+ import { readFile, writeFile, mkdir, realpath } from 'node:fs/promises';
5
+ import { PREVIEW_HTML_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
6
+ /** Bundles only the frozen factory and the installed Three.js; no project config or plugin runs. */
7
+ export async function buildModelPreviewHtml(input) {
8
+ const sourceRoot = await realpath(input.sourceRoot);
9
+ const project = createRequire(join(input.projectRoot, 'package.json'));
10
+ const vite = await import(pathToFileURL(project.resolve('vite')).href);
11
+ const threeEntry = project.resolve('three');
12
+ const threeRoot = resolve(dirname(threeEntry), '..');
13
+ const literal = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
14
+ const entry = join(sourceRoot, '__model-preview-entry.mjs');
15
+ const code = `import * as THREE from 'three';
16
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
17
+ import * as factory from ${literal(`./${input.factoryPath}`)};
18
+ const binding=${literal({ previewId: input.previewId, runId: input.runId, candidateId: input.candidateId, sourceHash: input.sourceHash })};
19
+ const profile=${literal(input.profile)}, resources=${literal(input.resources)};
20
+ let parameters=${literal(input.parameters)}, model, renderer, scene, camera, controls, stopped=false, revision=0,lastState,pendingParameters,rebuilding=false,resourceHung=false,renderFailed=false;
21
+ let viewportWidth=0,viewportHeight=0,viewportRatio=0,viewportRevision=0,renderReadyRevision=0,resumeFrames=0,viewportObserver;
22
+ const validKey=key=>/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key)&&!['__proto__','prototype','constructor'].includes(key);
23
+ const primitiveParameters=()=>Object.fromEntries(Object.entries(parameters).filter(([key,value])=>validKey(key)&&(typeof value==='boolean'||typeof value==='string'&&value.length<=256||typeof value==='number'&&Number.isFinite(value)&&Math.abs(value)<=100000)).slice(0,64));
24
+ const capabilities=['orbit','fixed-views',...(Object.keys(primitiveParameters()).length?['factory-parameters']:[])];
25
+ const send=state=>{lastState=state;parent.postMessage({type:'playcraft:model-preview',schemaVersion:1,...binding,state,capabilities,parameters:primitiveParameters()},'*');};
26
+ const syncViewport=()=>{if(stopped)return false;const width=Math.floor(document.documentElement?.clientWidth||innerWidth||0),height=Math.floor(document.documentElement?.clientHeight||innerHeight||0);if(!Number.isFinite(width)||!Number.isFinite(height)||width<1||height<1){resumeFrames=0;if(lastState==='ready')send('loading');return false;}const ratio=Math.min(devicePixelRatio||1,2),canvas=renderer.domElement,box=canvas.getBoundingClientRect?.();if(width!==viewportWidth||height!==viewportHeight||ratio!==viewportRatio||canvas.width!==Math.floor(width*ratio)||canvas.height!==Math.floor(height*ratio)||box&&(box.width<1||box.height<1)){renderer.setPixelRatio(ratio);renderer.setSize(width,height);camera.aspect=width/height;camera.updateProjectionMatrix();viewportWidth=width;viewportHeight=height;viewportRatio=ratio;viewportRevision++;resumeFrames=0;}const actual=canvas.getBoundingClientRect?.(),positive=canvas.width>0&&canvas.height>0&&(!actual||actual.width>0&&actual.height>0);if(!positive){resumeFrames=0;if(lastState==='ready')send('loading');}return positive;};
27
+ const drawFrame=()=>{if(renderFailed)throw new Error('model_preview_frame_failed');if(!syncViewport())return false;try{const context=renderer.getContext();if(context.isContextLost())throw new Error('model_preview_context_lost');controls.update();renderer.render(scene,camera);if(renderFailed||context.isContextLost()||context.getError()!==context.NO_ERROR)throw new Error('model_preview_frame_failed');return renderer.domElement.width>0&&renderer.domElement.height>0;}catch(error){renderFailed=true;throw error;}};
28
+ const waitForVisibleFrames=async current=>{let frames=0,sizeRevision=-1;while(!stopped&&current===revision&&!pendingParameters){await new Promise(resolve=>requestAnimationFrame(resolve));if(stopped||current!==revision||pendingParameters)return false;if(renderer.getContext().isContextLost())throw new Error('model_preview_context_lost');if(!drawFrame()){frames=0;continue;}if(renderer.info.render.calls<1)throw new Error('model_preview_empty_frame');if(sizeRevision!==viewportRevision){sizeRevision=viewportRevision;frames=0;}if(++frames>=2)return true;}return false;};
29
+ const disposeModel=()=>{if(!model)return;const owned=new Set();model.traverse(o=>{if(o.geometry)owned.add(o.geometry);for(const m of [o.material].flat().filter(Boolean)){owned.add(m);for(const v of Object.values(m))if(v?.isTexture)owned.add(v);}});for(const resource of owned)resource.dispose();scene.remove(model);model=undefined;};
30
+ const rebuildOnce=async()=>{const current=++revision;disposeModel();let loading=false,loadSettled=false,loadFailed=false,done,failed;const ready=new Promise((resolve,reject)=>{done=resolve;failed=reject;});void ready.catch(()=>{});
31
+ const manager=THREE.DefaultLoadingManager;manager.setURLModifier(url=>{if(url.startsWith('data:'))return url;const key=decodeURIComponent(url).replace(/^\\.?\\//,'').split('?')[0];if(!Object.hasOwn(resources,key))throw new Error('model_preview_resource_unavailable');return resources[key];});
32
+ manager.onStart=()=>{loading=true;};manager.onLoad=()=>{loadSettled=true;loadFailed?failed(new Error('model_preview_resource_failed')):done();};manager.onError=()=>{loadFailed=true;};
33
+ try{const create=factory[${literal(input.exportName)}];if(typeof create!=='function')throw new Error('model_preview_factory_missing');model=await create(parameters);if(!model?.isObject3D)throw new Error('model_preview_model_invalid');if(stopped){disposeModel();return;}scene.add(model);model.updateMatrixWorld(true);if(new THREE.Box3().setFromObject(model).isEmpty())throw new Error('model_preview_model_empty');if(!loading){loadSettled=true;done();}let timeout;try{await Promise.race([ready,new Promise((_,reject)=>{timeout=setTimeout(()=>{resourceHung=true;reject(new Error('model_preview_resource_timeout'));},30000);})]);}finally{clearTimeout(timeout);}
34
+ if(stopped||current!==revision||pendingParameters)return;renderReadyRevision=current;if(await waitForVisibleFrames(current)&&!stopped&&current===revision&&!pendingParameters)send('ready');}catch(error){if(loading&&!loadSettled)resourceHung=true;throw error;}};
35
+ const queueRebuild=next=>{if(stopped||resourceHung||renderFailed)return;pendingParameters=structuredClone(next);if(rebuilding)return;rebuilding=true;void(async()=>{while(pendingParameters&&!stopped&&!resourceHung&&!renderFailed){parameters=pendingParameters;pendingParameters=undefined;send('loading');try{await rebuildOnce();}catch{if(!stopped)send('error');}}})().finally(()=>{rebuilding=false;});};
36
+ try{scene=new THREE.Scene();scene.background=new THREE.Color(profile.background);camera=new THREE.PerspectiveCamera(profile.fovDegrees,1,profile.near,profile.far);camera.position.fromArray(profile.views.hero);renderer=new THREE.WebGLRenderer({antialias:true});renderer.debug.checkShaderErrors=true;renderer.debug.onShaderError=()=>{renderFailed=true;renderReadyRevision=0;if(lastState!=='error')send('error');};renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;document.body.append(renderer.domElement);controls=new OrbitControls(camera,renderer.domElement);controls.target.fromArray(profile.target);controls.update();scene.add(new THREE.HemisphereLight(0xffffff,0x6b6a68,2));const light=new THREE.DirectionalLight(0xffffff,3);light.position.fromArray(profile.keyLightPosition);scene.add(light);syncViewport();renderer.setAnimationLoop(()=>{if(stopped||renderFailed)return;try{if(renderer.getContext().isContextLost())throw new Error('model_preview_context_lost');if(!drawFrame())return;if(!rebuilding&&renderReadyRevision===revision&&!pendingParameters&&!resourceHung&&lastState==='loading'&&renderer.info.render.calls>0){if(++resumeFrames>=2)send('ready');}else resumeFrames=0;}catch{renderFailed=true;renderReadyRevision=0;if(lastState!=='error')send('error');}});
37
+ addEventListener('resize',syncViewport);if(typeof ResizeObserver==='function'){viewportObserver=new ResizeObserver(syncViewport);viewportObserver.observe(document.documentElement);}
38
+ addEventListener('message',event=>{const m=event.data;if(event.source!==parent||!m||m.schemaVersion!==1||m.runId!==binding.runId||m.candidateId!==binding.candidateId||m.previewId!==binding.previewId||m.sourceHash!==binding.sourceHash)return;if(m.type==='playcraft:model-preview:status'){if(lastState)send(lastState);return;}if(m.type!=='playcraft:model-preview-command')return;if(m.action==='view'&&Object.hasOwn(profile.views,m.view)){camera.position.fromArray(profile.views[m.view]);controls.target.fromArray(profile.target);controls.update();}if(m.action==='parameters'&&m.parameters&&typeof m.parameters==='object'&&!Array.isArray(m.parameters)){const next={...(pendingParameters??parameters)};for(const [key,value]of Object.entries(m.parameters)){if(!validKey(key)||!Object.hasOwn(parameters,key)||!['string','number','boolean'].includes(typeof value)||typeof value!==typeof parameters[key]||typeof value==='number'&&(!Number.isFinite(value)||Math.abs(value)>100000)||typeof value==='string'&&value.length>256)return;next[key]=value;}queueRebuild(next);}});
39
+ addEventListener('pagehide',()=>{stopped=true;revision++;viewportObserver?.disconnect();renderer.setAnimationLoop(null);controls.dispose();disposeModel();renderer.dispose();},{once:true});
40
+ Object.defineProperty(window,'__PLAYCRAFT_MODEL_PREVIEW__',{value:Object.freeze({...binding,capabilities}),writable:false});queueRebuild(parameters);
41
+ }catch{send('error');}`;
42
+ await writeFile(entry, code);
43
+ const allowed = (path) => {
44
+ const candidate = resolve(path);
45
+ return [sourceRoot, threeRoot].some(root => { const value = relative(resolve(root), candidate); return value === '' || !value.startsWith('..') && !value.startsWith('/'); });
46
+ };
47
+ const built = await vite.build({
48
+ configFile: false, envFile: false, publicDir: false, root: sourceRoot, logLevel: 'silent',
49
+ cacheDir: join(sourceRoot, '.vite-cache'),
50
+ resolve: { alias: [{ find: /^three$/, replacement: join(threeRoot, 'build/three.module.js') },
51
+ { find: /^three\/addons\//, replacement: `${threeRoot}/examples/jsm/` }] },
52
+ plugins: [{ name: 'playcraft-model-preview-boundary', enforce: 'pre', resolveId(id, importer) {
53
+ if (id.startsWith('\0') || id === 'three' || id.startsWith('three/addons/'))
54
+ return null;
55
+ if (id.startsWith('.') && importer && allowed(resolve(dirname(importer), id)))
56
+ return null;
57
+ if (id.startsWith('/') && allowed(id))
58
+ return null;
59
+ throw new Error('model_preview_import_denied');
60
+ } }],
61
+ build: { write: false, minify: false, sourcemap: false, emptyOutDir: false,
62
+ rollupOptions: { input: entry, output: { format: 'iife', inlineDynamicImports: true } } },
63
+ });
64
+ const output = (Array.isArray(built) ? built[0] : built).output;
65
+ if (output.length !== 1 || output[0].type !== 'chunk')
66
+ throw new Error('model_preview_bundle_invalid');
67
+ const javascript = output[0].code.replace(/<\/script/gi, '\\u003c/script');
68
+ return Buffer.from(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}canvas{display:block;touch-action:none}</style></head><body><script>${javascript}</script></body></html>`);
69
+ }
70
+ async function main() {
71
+ const path = process.argv[2];
72
+ if (!path)
73
+ throw new Error('model_preview_input_missing');
74
+ const raw = await readFile(path);
75
+ if (raw.length > 96 * 1024 * 1024)
76
+ throw new Error('model_preview_size_limit');
77
+ const input = JSON.parse(raw.toString('utf8'));
78
+ const bytes = await buildModelPreviewHtml(input);
79
+ if (bytes.length > PREVIEW_HTML_MAX_BYTES)
80
+ throw new Error('model_preview_size_limit');
81
+ await mkdir(dirname(input.outputPath), { recursive: true });
82
+ await writeFile(input.outputPath, bytes, { flag: 'wx' });
83
+ }
84
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
85
+ main().catch(() => { process.stderr.write('model_preview_build_failed\n'); process.exitCode = 1; });