@playcraft/cli 0.1.64 → 0.1.66

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 (29) hide show
  1. package/README.md +24 -31
  2. package/dist/commands/build.js +70 -5
  3. package/dist/commands/create.js +4 -4
  4. package/dist/commands/inspect.js +4 -6
  5. package/dist/index.js +27 -19
  6. package/dist/model-adaptation/node-inspection.js +173 -0
  7. package/dist/model-adaptation/project-template.js +40 -8
  8. package/dist/model-adaptation/project.js +2 -2
  9. package/dist/model-preparation/pipeline-engine.js +157 -0
  10. package/dist/model-preparation/pipeline-files.js +108 -0
  11. package/dist/model-preparation/pipeline-worker.js +32 -0
  12. package/dist/workspace-runtime/batch/deliver.js +2 -2
  13. package/dist/workspace-runtime/codex/delivery-tools.js +36 -3
  14. package/dist/workspace-runtime/previews/publish.js +2 -2
  15. package/dist/workspace-runtime/serve.js +5 -0
  16. package/dist/workspace-runtime/server/directory-access.js +1 -1
  17. package/dist/workspace-runtime/server/dispatch.js +27 -47
  18. package/dist/workspace-runtime/server/http.js +12 -0
  19. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  20. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  21. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  22. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  23. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  24. package/package.json +4 -4
  25. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  26. package/project-templates/empty/AGENTS.md +6 -4
  27. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  28. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  29. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
@@ -0,0 +1,75 @@
1
+ import { constants } from 'node:fs';
2
+ import { copyFile, cp, lstat, mkdir, readdir, readlink, symlink } from 'node:fs/promises';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ import { listWorkspaceBuildInputs } from './git.js';
5
+ import { ensureSharedDirectory } from './shared-write.js';
6
+ /** Staging under the workspace from earlier runtimes' sandbox delivery builds; never a build input. */
7
+ const STAGING_DIRECTORY = '.playcraft-delivery';
8
+ /** Never copied into the build copy: history, outputs and caches that the build neither reads nor owns. */
9
+ const ISOLATE_SKIP = new Set(['.git', 'dist', '.turbo', STAGING_DIRECTORY]);
10
+ /** Copy-on-write clone where the filesystem supports it (APFS, btrfs, XFS); a plain copy elsewhere. */
11
+ const CLONE = constants.COPYFILE_FICLONE;
12
+ const COPY_CONCURRENCY = 16;
13
+ function skipped(path) {
14
+ const parts = path.split(/[\\/]/);
15
+ return ISOLATE_SKIP.has(parts[0]) || parts.includes('node_modules');
16
+ }
17
+ async function copyInput(root, destination, path) {
18
+ const source = join(root, path);
19
+ const target = join(destination, path);
20
+ let entry;
21
+ try {
22
+ entry = await lstat(source);
23
+ }
24
+ catch (error) {
25
+ // A tracked file deleted in the work tree is not a build input.
26
+ if (error.code === 'ENOENT')
27
+ return;
28
+ throw error;
29
+ }
30
+ await mkdir(dirname(target), { recursive: true });
31
+ if (entry.isSymbolicLink())
32
+ await symlink(await readlink(source), target);
33
+ else if (entry.isDirectory())
34
+ await cp(source, target, { recursive: true, verbatimSymlinks: true, mode: CLONE });
35
+ else
36
+ await copyFile(source, target, CLONE);
37
+ }
38
+ /**
39
+ * Copies the workspace into `destination` for one build, like the platform export runner, with the
40
+ * top-level `node_modules` linked (the build only reads it). Builders rewrite project files in place
41
+ * (`builds.config.js` and its `.bak`, `package.json` scripts, migrated imports), so the build must
42
+ * never run in the workspace itself, where autosave would commit those rewrites.
43
+ *
44
+ * Only the files Git would carry are copied: tracked ones and untracked ones `.gitignore` keeps, so
45
+ * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
46
+ */
47
+ export async function isolateWorkspaceForBuild(root, destination) {
48
+ // The build runs as the development identity and writes its scratch folder inside this copy.
49
+ await ensureSharedDirectory(root, destination);
50
+ const modules = join(root, 'node_modules');
51
+ const modulesEntry = await lstat(modules).catch(() => undefined);
52
+ if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
53
+ await symlink(modulesEntry.isSymbolicLink() ? await readlink(modules) : modules, join(destination, 'node_modules'), 'dir');
54
+ }
55
+ const inputs = await listWorkspaceBuildInputs(root).catch(() => undefined);
56
+ const paths = inputs
57
+ ?? (await readdir(root)).filter((name) => name !== 'node_modules');
58
+ const queue = paths.filter((path) => !skipped(path));
59
+ const workers = Array.from({ length: Math.min(COPY_CONCURRENCY, queue.length) }, async () => {
60
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
61
+ if (inputs)
62
+ await copyInput(root, destination, path);
63
+ else {
64
+ await cp(join(root, path), join(destination, path), {
65
+ recursive: true,
66
+ verbatimSymlinks: true,
67
+ mode: CLONE,
68
+ // Nested dependency folders stay out as well; the build resolves through the linked root.
69
+ filter: (source) => !relative(join(root, path), source).split(sep).includes('node_modules'),
70
+ });
71
+ }
72
+ }
73
+ });
74
+ await Promise.all(workers);
75
+ }
@@ -118,6 +118,16 @@ export async function readWorkspaceModelBuildInputs(root, stateDir) {
118
118
  throw new Error('model_build_resource_manifest_mismatch');
119
119
  for (const file of source.files)
120
120
  inputs.push((await read(file.path, file.contentHash)).file);
121
+ // Resolve the actual installed dependency inputs even when a generated declaration predates installation.
122
+ for (const path of ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
123
+ try {
124
+ inputs.push((await read(path)).file);
125
+ }
126
+ catch (error) {
127
+ if (error.code !== 'ENOENT')
128
+ throw error;
129
+ }
130
+ }
121
131
  }
122
132
  else {
123
133
  const candidate = candidates?.candidates.find(item => item.runId === source.runId && item.candidateId === source.candidateId);
@@ -174,6 +184,23 @@ export async function verifyWorkspaceModelBuildInputs(root, snapshot) {
174
184
  if (model.source.kind === 'procedural' &&
175
185
  await dependencyHash(root) !== model.source.dependencyHash)
176
186
  throw new Error('model_build_input_changed');
187
+ for (const model of snapshot.models)
188
+ if (model.source.kind === 'resource') {
189
+ for (const path of ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
190
+ const frozen = model.inputs.find(file => file.path === path);
191
+ try {
192
+ const bytes = await checkedFile(root, path, 64 * 1024 * 1024);
193
+ if (!frozen || digest(bytes) !== frozen.contentHash)
194
+ throw new Error('model_build_input_changed');
195
+ }
196
+ catch (error) {
197
+ if (error.code !== 'ENOENT')
198
+ throw error;
199
+ if (frozen)
200
+ throw new Error('model_build_input_changed');
201
+ }
202
+ }
203
+ }
177
204
  }
178
205
  /** The marker makes otherwise identical HTML from different input declarations distinct artifacts. */
179
206
  export function markWorkspaceModelBuild(bytes, snapshot) {
@@ -0,0 +1,134 @@
1
+ import { lstat, mkdir, readdir, realpath, readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { modelPreparationReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { hash, hashData, preparationPath, readPreparationFile, readPreparationJson, writePreparationJson } from '../../model-preparation/pipeline-files.js';
5
+ import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
6
+ /** Control-owned step truth. Workspace metadata is never accepted as an execution receipt. */
7
+ export class ModelPreparationStore {
8
+ workspaceId;
9
+ directoryName;
10
+ pipelineId;
11
+ stepId;
12
+ root;
13
+ constructor(stateDir, workspaceId, directoryName, pipelineId, stepId) {
14
+ this.workspaceId = workspaceId;
15
+ this.directoryName = directoryName;
16
+ this.pipelineId = pipelineId;
17
+ this.stepId = stepId;
18
+ if (!/^[a-f0-9-]{36}$/.test(workspaceId) || !/^[a-f0-9-]{36}$/.test(pipelineId) || stepId.length < 1 || stepId.length > 128)
19
+ throw Error('model_prepare_scope_invalid');
20
+ this.root = join(stateDir, 'model-preparations', hashData({ workspaceId, directoryName }), pipelineId, hash(stepId));
21
+ }
22
+ async read() {
23
+ const value = await readPreparationJson(this.root, 'record.json');
24
+ if (value && (value.schemaVersion !== 1 || value.workspaceId !== this.workspaceId || value.directoryName !== this.directoryName ||
25
+ value.pipelineId !== this.pipelineId || value.stepId !== this.stepId))
26
+ throw Error('model_prepare_scope_mismatch');
27
+ if (value?.receipt) {
28
+ const parsed = modelPreparationReceiptSchema.parse(value.receipt), { receiptHash, ...body } = parsed;
29
+ if (hashData(body) !== receiptHash)
30
+ throw Error('model_prepare_receipt_changed');
31
+ }
32
+ return value;
33
+ }
34
+ async write(value) {
35
+ if (value.workspaceId !== this.workspaceId || value.directoryName !== this.directoryName || value.pipelineId !== this.pipelineId || value.stepId !== this.stepId)
36
+ throw Error('model_prepare_scope_mismatch');
37
+ if (value.receipt)
38
+ modelPreparationReceiptSchema.parse(value.receipt);
39
+ await writePreparationJson(join(this.root, 'record.json'), value);
40
+ }
41
+ }
42
+ /** New standalone template baseline, including preserved instructions; never traverse repository internals. */
43
+ export async function targetPreparationSnapshot(root) {
44
+ const result = [];
45
+ let size = 0;
46
+ async function walk(prefix = '') {
47
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
48
+ if (['.git', 'node_modules'].includes(entry.name))
49
+ continue;
50
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
51
+ preparationPath(path);
52
+ if (entry.isSymbolicLink())
53
+ throw Error('model_prepare_target_unsafe');
54
+ if (entry.isDirectory())
55
+ await walk(path);
56
+ else {
57
+ const file = await readPreparationFile(root, path);
58
+ size += file.bytes.length;
59
+ if (result.length >= 1024 || size > 256 * 1024 * 1024)
60
+ throw Error('model_prepare_target_bound');
61
+ result.push({ path, contentHash: file.contentHash, bytes: file.bytes.length });
62
+ }
63
+ }
64
+ }
65
+ await walk();
66
+ return result.sort((a, b) => a.path.localeCompare(b.path, 'en'));
67
+ }
68
+ export function preparationWriteFiles(result) {
69
+ return result.files.filter(file => !['AGENTS.md', 'README.md'].includes(file.path));
70
+ }
71
+ /** Every output is either its frozen preimage or this exact transaction's postimage. */
72
+ export async function promotePreparationProject(store, target, record, options) {
73
+ if (!record.result || !record.targetSnapshot)
74
+ throw Error('model_prepare_transaction_missing');
75
+ if (await realpath(target) !== target || !(await lstat(target)).isDirectory())
76
+ throw Error('model_prepare_target_unsafe');
77
+ const expected = new Map(record.targetSnapshot.map(file => [file.path, file.contentHash]));
78
+ if (!record.transaction) {
79
+ const actual = await targetPreparationSnapshot(target);
80
+ if (hashData(actual) !== hashData(record.targetSnapshot))
81
+ throw Error('model_prepare_target_changed');
82
+ record.transaction = { files: preparationWriteFiles(record.result).map(file => ({ ...file, before: expected.get(file.path) ?? null })), published: [] };
83
+ record.phase = 'promoting';
84
+ await store.write(record);
85
+ }
86
+ const transaction = record.transaction;
87
+ const expectedPaths = new Set([...record.targetSnapshot.map(file => file.path), ...transaction.files.map(file => file.path)]);
88
+ for (const file of await targetPreparationSnapshot(target)) {
89
+ if (!expectedPaths.has(file.path))
90
+ throw Error('model_prepare_target_changed');
91
+ const write = transaction.files.find(item => item.path === file.path);
92
+ if (file.contentHash !== (expected.get(file.path) ?? null) && (!write || file.contentHash !== write.contentHash))
93
+ throw Error('model_prepare_target_changed');
94
+ }
95
+ // A removed non-output instruction is also a conflict, rather than a reason to recreate it.
96
+ for (const file of record.targetSnapshot)
97
+ if (!transaction.files.some(item => item.path === file.path) &&
98
+ (await observeWorkspaceBinaryFile(target, file.path))?.contentHash !== file.contentHash)
99
+ throw Error('model_prepare_target_changed');
100
+ for (const file of transaction.files) {
101
+ options.signal?.throwIfAborted();
102
+ await options.assertCurrent();
103
+ const current = await observeWorkspaceBinaryFile(target, file.path);
104
+ if (current?.contentHash !== file.contentHash) {
105
+ if ((current?.contentHash ?? null) !== file.before)
106
+ throw Error('model_prepare_target_changed');
107
+ const source = await readPreparationFile(record.result.projectRoot, file.path);
108
+ if (source.contentHash !== file.contentHash || source.bytes.length !== file.bytes)
109
+ throw Error('model_prepare_worker_output_changed');
110
+ if (current) {
111
+ const backup = join(store.root, 'preimages', file.path);
112
+ await mkdir(dirname(backup), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
113
+ const old = await readPreparationFile(target, file.path);
114
+ if (old.contentHash !== file.before)
115
+ throw Error('model_prepare_target_changed');
116
+ const { writeFile } = await import('node:fs/promises');
117
+ await writeFile(backup, old.bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => {
118
+ if (error.code !== 'EEXIST' || hash(await readFile(backup)) !== file.before)
119
+ throw error;
120
+ });
121
+ }
122
+ await writeWorkspaceBinaryFile(target, { path: file.path, contentBase64: source.bytes.toString('base64'), expectedContentHash: file.before });
123
+ }
124
+ if (!transaction.published.includes(file.path)) {
125
+ const actual = await observeWorkspaceBinaryFile(target, file.path);
126
+ if (!actual || actual.contentHash !== file.contentHash)
127
+ throw Error('model_prepare_target_changed');
128
+ await options.onFile({ path: file.path, ...actual });
129
+ transaction.published.push(file.path);
130
+ await store.write(record);
131
+ }
132
+ }
133
+ await options.assertCurrent();
134
+ }
@@ -0,0 +1,357 @@
1
+ import { chmod, chown, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { canonicalModelAdaptation, hashOperationPayload, modelPreparationPayloadSchema, modelPreparationReceiptSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { workspaceRoot, revisionScope } from './registry.js';
8
+ import { runDeveloperCommand } from './developer-command.js';
9
+ import { openScratchToDevelopment } from './directory-access.js';
10
+ import { stopLauncherExecution } from '../codex/launcher-client.js';
11
+ import { assertOperationDirectoryAccess } from '../server/directory-access.js';
12
+ import { ModelPreparationStore, promotePreparationProject, targetPreparationSnapshot } from './model-preparation-store.js';
13
+ import { hash, hashData, preparationInventory, readPreparationFile, readPreparationJson, writePreparationJson } from '../../model-preparation/pipeline-files.js';
14
+ const active = new WeakMap();
15
+ const tasks = new WeakMap();
16
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
17
+ const identity = (payload) => { const { resumeFromReceiptHash: _resume, ...value } = payload; return hashData(value); };
18
+ const requestKey = (workspaceId, payload) => hashData({ workspaceId, pipelineId: payload.pipelineId, stepId: payload.stepId });
19
+ export async function preparationToolManifest() {
20
+ const files = ['model-preparation/pipeline-worker.js', 'model-preparation/pipeline-engine.js', 'model-preparation/pipeline-files.js',
21
+ 'model-preparation/glb.js', 'model-preparation/geometry.js', 'model-preparation/split-rigid.js', 'model-preparation/vertex-repairs.js',
22
+ 'model-adaptation/node-inspection.js', 'model-adaptation/source.js', 'model-adaptation/project.js', 'model-adaptation/project-template.js',
23
+ 'model-optimization/optimize.js', 'workspace-runtime/workspaces/model-preparation.js', 'workspace-runtime/workspaces/model-preparation-store.js'];
24
+ const common = createRequire(import.meta.url).resolve('@playcraft/common/cloud-workspace/runtime');
25
+ const manifest = await Promise.all(files.map(async (path) => ({ path, contentHash: hash(await readFile(new URL(`../../../dist/${path}`, import.meta.url))) })));
26
+ const visitCommon = async (path, prefix = '') => {
27
+ for (const entry of await readdir(path, { withFileTypes: true })) {
28
+ if (entry.isSymbolicLink())
29
+ throw Error('model_prepare_tool_path_unsafe');
30
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
31
+ if (entry.isDirectory())
32
+ await visitCommon(join(path, entry.name), name);
33
+ else if (entry.name.endsWith('.js'))
34
+ manifest.push({ path: `common/${name}`, contentHash: hash(await readFile(join(path, entry.name))) });
35
+ }
36
+ };
37
+ await visitCommon(dirname(common));
38
+ const require = createRequire(import.meta.url), threeRoot = dirname(dirname(require.resolve('three')));
39
+ for (const [name, path] of [['three-module', join(threeRoot, 'build/three.module.js')], ['three-core', join(threeRoot, 'build/three.core.js')],
40
+ ['three-loader', require.resolve('three/examples/jsm/loaders/GLTFLoader.js')], ['earcut', require.resolve('earcut')],
41
+ ...Object.keys(require.cache).filter(path => /sharp.*\.node$/.test(path)).sort().map((path, index) => [`sharp-native-${index}`, path])])
42
+ manifest.push({ path: `dependency/${name}`, contentHash: hash(await readFile(path)) });
43
+ manifest.push({ path: 'cli/package.json', contentHash: hash(await readFile(new URL('../../../package.json', import.meta.url))) });
44
+ manifest.sort((a, b) => a.path.localeCompare(b.path, 'en'));
45
+ return { schemaVersion: 1, files: manifest, toolHash: hashData(manifest) };
46
+ }
47
+ async function seal(path, groupId) {
48
+ const stat = await lstat(path);
49
+ if (stat.isSymbolicLink())
50
+ throw Error('model_prepare_path_unsafe');
51
+ if (stat.gid !== groupId)
52
+ await chown(path, stat.uid, groupId);
53
+ if (stat.isDirectory()) {
54
+ for (const entry of await readdir(path))
55
+ await seal(join(path, entry), groupId);
56
+ await chmod(path, 0o550);
57
+ }
58
+ else
59
+ await chmod(path, 0o440);
60
+ }
61
+ async function verifyOriginalSource(root, record) {
62
+ for (const file of record.sourceSnapshot ?? []) {
63
+ const actual = await readPreparationFile(root, file.path), before = record.sourceStats?.find(item => item.path === file.path);
64
+ if (actual.contentHash !== file.contentHash || actual.bytes.length !== file.bytes || before &&
65
+ ['ino', 'dev', 'size', 'mtimeMs', 'ctimeMs'].some(key => actual.stat[key] !== before[key]))
66
+ throw Error('model_prepare_source_changed');
67
+ }
68
+ }
69
+ async function snapshotSource(root, store, record) {
70
+ const files = [], stats = [];
71
+ let total = 0;
72
+ for (const file of record.payload.source.files) {
73
+ if (file.path.split('/').includes('.playcraft'))
74
+ throw Error('model_prepare_source_path_unsafe');
75
+ const actual = await readPreparationFile(root, file.path);
76
+ total += actual.bytes.length;
77
+ if (actual.contentHash !== file.contentHash || total > 128 * 1024 * 1024)
78
+ throw Error('model_prepare_source_identity_mismatch');
79
+ const path = join(store.root, 'source', file.path);
80
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
81
+ await writeFile(path, actual.bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => { if (error.code !== 'EEXIST' || hash(await readFile(path)) !== file.contentHash)
82
+ throw error; }); // private: controller-only preparation state
83
+ files.push({ ...file, bytes: actual.bytes.length });
84
+ const { ino, dev, size, mtimeMs, ctimeMs } = actual.stat;
85
+ stats.push({ path: file.path, ino, dev, size, mtimeMs, ctimeMs });
86
+ }
87
+ record.sourceSnapshot = files;
88
+ record.sourceStats = stats;
89
+ await verifyOriginalSource(root, record);
90
+ record.phase = 'snapshotted';
91
+ await store.write(record);
92
+ }
93
+ function receipt(record, status, reasonCode) {
94
+ const result = record.result;
95
+ const base = { kind: 'model-preparation', schemaVersion: 1, pipelineId: record.pipelineId, stepId: record.stepId,
96
+ status, ...(reasonCode ? { reasonCode } : {}), inputHash: record.inputHash, sourceScope: record.payload.sourceScope, source: record.payload.source,
97
+ ...(status === 'prepared' && result ? { model: result.model, adaptationPath: result.adaptationPath, adaptationHash: result.adaptationHash,
98
+ manifestPath: '.playcraft/model-build-inputs.json', modelBytes: result.modelBytes, triangles: result.triangles,
99
+ lineageFiles: result.lineageFiles, writePaths: record.transaction?.files.map(file => file.path) ?? [] } : { lineageFiles: [], writePaths: record.transaction?.published ?? [] }),
100
+ toolHash: record.toolHash, browserValidation: 'pending', modelAcceptance: false };
101
+ return modelPreparationReceiptSchema.parse({ ...base, receiptHash: hashData(base) });
102
+ }
103
+ export async function readModelPreparation(state, workspaceId, directoryName, pipelineId, stepId) {
104
+ await workspaceRoot(state, workspaceId, directoryName);
105
+ const record = await new ModelPreparationStore(state.config.stateDir, workspaceId, directoryName, pipelineId, stepId).read();
106
+ return record?.receipt ?? null;
107
+ }
108
+ export function trackModelPreparation(state, task) {
109
+ const known = tasks.get(state) ?? new Set();
110
+ tasks.set(state, known);
111
+ known.add(task);
112
+ void task.finally(() => known.delete(task)).catch(() => undefined);
113
+ }
114
+ export async function stopModelPreparations(state) {
115
+ for (const controller of active.get(state)?.values() ?? [])
116
+ controller.abort();
117
+ await Promise.allSettled([...(tasks.get(state) ?? [])]);
118
+ }
119
+ /** A controlled local step creates source and receipts; it never installs, builds, infers or validates a game. */
120
+ export async function runModelPreparation(state, operation, value, options) {
121
+ const payload = modelPreparationPayloadSchema.parse(value);
122
+ if (!operation.workspaceId || operation.directoryName !== 'basic' || payload.targetDirectoryName !== 'basic' ||
123
+ state.journal.getWorkspace(operation.workspaceId)?.targetKind !== 'standalone')
124
+ throw Error('model_prepare_target_scope_invalid');
125
+ const target = await workspaceRoot(state, operation.workspaceId, 'basic'), source = await workspaceRoot(state, payload.sourceScope.workspaceId, payload.sourceScope.directoryName);
126
+ const store = new ModelPreparationStore(state.config.stateDir, operation.workspaceId, 'basic', payload.pipelineId, payload.stepId);
127
+ const tools = await preparationToolManifest(), inputHash = hashOperationPayload(payload), semanticInputHash = identity(payload), key = requestKey(operation.workspaceId, payload);
128
+ const known = active.get(state) ?? new Map();
129
+ active.set(state, known);
130
+ if (known.has(key))
131
+ throw Error('model_prepare_already_running');
132
+ let record = await store.read();
133
+ if (record && (record.semanticInputHash !== semanticInputHash || record.toolHash !== tools.toolHash))
134
+ throw Error('model_prepare_resume_identity_mismatch');
135
+ if (payload.resumeFromReceiptHash && record?.receipt?.receiptHash !== payload.resumeFromReceiptHash)
136
+ throw Error('model_prepare_resume_receipt_mismatch');
137
+ if (record?.phase === 'uncertain' || record?.phase === 'running')
138
+ throw Error('model_prepare_recovery_required');
139
+ if (record?.receipt) {
140
+ if (record.inputHash !== inputHash) {
141
+ await writePreparationJson(join(store.root, 'receipts', `${record.receipt.receiptHash}.json`), record.receipt);
142
+ record.inputHash = inputHash;
143
+ record.payload = payload;
144
+ record.receipt = receipt(record, record.receipt.status, record.receipt.reasonCode);
145
+ await store.write(record);
146
+ }
147
+ return record.receipt;
148
+ }
149
+ const assertCurrent = async () => {
150
+ if (state.journal.destroyed() || (operation.leaseFence ?? 0) < state.acceptedLeaseFence || operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch)
151
+ throw Error('model_prepare_stale_operation');
152
+ await assertOperationDirectoryAccess(state, operation, 'exclusive');
153
+ };
154
+ await assertCurrent();
155
+ const controller = new AbortController();
156
+ known.set(key, controller);
157
+ const writeId = `model-prepare-${operation.gatewayOperationId ?? operation.operationId}`;
158
+ let stopped = true;
159
+ try {
160
+ const ownerPath = join(dirname(dirname(store.root)), 'target-owner.json');
161
+ const owner = await readPreparationJson(dirname(ownerPath), 'target-owner.json');
162
+ if (owner && owner.pipelineId !== payload.pipelineId)
163
+ throw Error('model_prepare_target_owned');
164
+ if (!owner)
165
+ await writePreparationJson(ownerPath, { pipelineId: payload.pipelineId, workspaceId: operation.workspaceId, directoryName: 'basic' });
166
+ if (!record) {
167
+ const targetSnapshot = await targetPreparationSnapshot(target);
168
+ if (payload.expectedTargetSnapshotHash && hashData(targetSnapshot) !== payload.expectedTargetSnapshotHash)
169
+ throw Error('model_prepare_target_changed');
170
+ record = { schemaVersion: 1, pipelineId: payload.pipelineId, stepId: payload.stepId, workspaceId: operation.workspaceId, directoryName: 'basic',
171
+ inputHash, semanticInputHash, toolHash: tools.toolHash, payload, phase: 'planned', createdAt: new Date().toISOString(), targetSnapshot };
172
+ await store.write(record);
173
+ await writePreparationJson(join(store.root, 'tools.json'), tools);
174
+ }
175
+ record.inputHash = inputHash;
176
+ record.payload = payload;
177
+ await store.write(record);
178
+ await writePreparationJson(join(state.config.stateDir, 'model-preparations', 'operations', `${operation.operationId}.json`), {
179
+ workspaceId: operation.workspaceId, directoryName: 'basic', pipelineId: payload.pipelineId, stepId: payload.stepId,
180
+ });
181
+ if (!record.sourceSnapshot)
182
+ await snapshotSource(source, store, record);
183
+ else
184
+ await verifyOriginalSource(source, record);
185
+ if (!record.result) {
186
+ let scratch = record.scratch;
187
+ if (scratch && !await lstat(scratch).then(value => value.isDirectory() && !value.isSymbolicLink(), () => false)) {
188
+ await writePreparationJson(join(store.root, 'lost-scratch', `${Date.now()}.json`), { path: scratch, reason: 'worker-scratch-unavailable', stagesRecomputedOnExplicitResume: true });
189
+ scratch = undefined;
190
+ }
191
+ if (!scratch) {
192
+ scratch = await realpath(await mkdtemp(join(tmpdir(), 'playcraft-model-prepare-')));
193
+ const groupId = (await lstat(target)).gid;
194
+ await openScratchToDevelopment(scratch, groupId);
195
+ await chmod(scratch, 0o750);
196
+ const inputRoot = join(scratch, 'input');
197
+ await mkdir(inputRoot);
198
+ for (const file of record.sourceSnapshot ?? []) {
199
+ const bytes = (await readPreparationFile(join(store.root, 'source'), file.path)).bytes, destination = join(inputRoot, file.path);
200
+ await mkdir(dirname(destination), { recursive: true });
201
+ await writeFile(destination, bytes, { flag: 'wx' });
202
+ }
203
+ await seal(inputRoot, groupId);
204
+ await mkdir(join(scratch, 'output'));
205
+ await openScratchToDevelopment(join(scratch, 'output'), groupId);
206
+ const configPath = join(scratch, 'input.json');
207
+ await writePreparationJson(configPath, { payload, sourceRoot: inputRoot, outputRoot: join(scratch, 'output'), toolHash: tools.toolHash, tools });
208
+ await seal(configPath, groupId);
209
+ record.scratch = scratch;
210
+ await store.write(record);
211
+ }
212
+ const configPath = join(scratch, 'input.json'), configHash = hash(await readFile(configPath));
213
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
214
+ if (identity(modelPreparationPayloadSchema.parse(config.payload)) !== semanticInputHash || config.toolHash !== tools.toolHash || hashData(config.tools) !== hashData(tools) ||
215
+ config.sourceRoot !== join(scratch, 'input') || config.outputRoot !== join(scratch, 'output'))
216
+ throw Error('model_prepare_worker_input_changed');
217
+ record.phase = 'running';
218
+ await store.write(record);
219
+ const outcome = await runDeveloperCommand(state, { processId: writeId, cwd: scratch,
220
+ command: `${quote(process.execPath)} --max-old-space-size=768 ${quote(options.workerPath ?? fileURLToPath(new URL('../../../dist/model-preparation/pipeline-worker.js', import.meta.url)))} ${quote(configPath)} ${quote(configHash)}`,
221
+ timeoutMs: 600_000, signal: controller.signal, onOutput: () => undefined,
222
+ onStarted: async (process) => {
223
+ stopped = false;
224
+ await state.journal.putManagedWrite({ writeId,
225
+ workspaceId: revisionScope(state, operation.workspaceId, 'basic') ?? 'single', source: 'manual', directoryAccessMode: 'exclusive',
226
+ startedAt: new Date().toISOString(), pid: process.pid,
227
+ ...(process.launcher ? { executionMode: 'launcher', launcher: process.launcher, runtimeEpoch: state.runtimeEpoch } : { executionMode: 'local' }) });
228
+ } });
229
+ stopped = outcome.code !== undefined;
230
+ if (!stopped)
231
+ throw Error('model_prepare_process_unknown');
232
+ const writer = state.journal.listManagedWrites().find(item => item.writeId === writeId);
233
+ if (writer)
234
+ await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
235
+ if (outcome.canceled)
236
+ throw Error('model_prepare_cancelled');
237
+ if (outcome.timedOut)
238
+ throw Error('model_prepare_timeout');
239
+ if (outcome.code !== 0)
240
+ throw Error('model_prepare_worker_failed');
241
+ const result = await readPreparationJson(join(scratch, 'output'), 'result.json', 8 * 1024 * 1024);
242
+ if (!result)
243
+ throw Error('model_prepare_worker_receipt_missing');
244
+ if (result.status === 'blocked') {
245
+ if (!/^([a-z]+_)+[a-z]+$/.test(result.reasonCode))
246
+ throw Error('model_prepare_worker_receipt_invalid');
247
+ record.phase = 'blocked';
248
+ record.receipt = receipt(record, 'blocked', result.reasonCode);
249
+ await store.write(record);
250
+ return record.receipt;
251
+ }
252
+ if (result.status !== 'prepared' || !/^adapt-[a-f0-9-]{36}\/project$/.test(relative(join(scratch, 'output'), result.projectRoot)) ||
253
+ await realpath(result.projectRoot) !== result.projectRoot)
254
+ throw Error('model_prepare_worker_receipt_invalid');
255
+ const actual = await preparationInventory(result.projectRoot);
256
+ if (canonicalModelAdaptation(actual) !== canonicalModelAdaptation(result.files))
257
+ throw Error('model_prepare_worker_output_changed');
258
+ const produced = join(store.root, 'produced');
259
+ await mkdir(produced, { recursive: true, mode: 0o700 }); // private: controller-only preparation state
260
+ for (const file of actual) {
261
+ const bytes = (await readPreparationFile(result.projectRoot, file.path)).bytes, path = join(produced, file.path);
262
+ if (hash(bytes) !== file.contentHash)
263
+ throw Error('model_prepare_worker_output_changed');
264
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
265
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => { if (error.code !== 'EEXIST' || hash(await readFile(path)) !== file.contentHash)
266
+ throw error; }); // private: controller-only preparation state
267
+ }
268
+ if (canonicalModelAdaptation(await preparationInventory(produced)) !== canonicalModelAdaptation(actual))
269
+ throw Error('model_prepare_worker_output_changed');
270
+ record.result = { ...result, projectRoot: produced };
271
+ record.phase = 'produced';
272
+ await store.write(record);
273
+ }
274
+ if ((await preparationToolManifest()).toolHash !== tools.toolHash)
275
+ throw Error('model_prepare_tool_changed');
276
+ await verifyOriginalSource(source, record);
277
+ await assertCurrent();
278
+ if (payload.sourceScope.workspaceId === operation.workspaceId && payload.sourceScope.directoryName === 'basic') {
279
+ const writePaths = new Set(record.result.files.filter(file => !['AGENTS.md', 'README.md'].includes(file.path)).map(file => file.path));
280
+ if (payload.source.files.some(file => writePaths.has(file.path)))
281
+ throw Error('model_prepare_source_output_overlap');
282
+ }
283
+ await promotePreparationProject(store, target, record, { assertCurrent, onFile: options.onFile, signal: controller.signal });
284
+ await verifyOriginalSource(source, record);
285
+ record.phase = 'prepared';
286
+ record.receipt = receipt(record, 'prepared');
287
+ await store.write(record);
288
+ return record.receipt;
289
+ }
290
+ catch (error) {
291
+ if (record && stopped && error instanceof Error && /^model_prepare_(target_changed|source_changed|source_identity_mismatch|target_unsafe|source_output_overlap)$/.test(error.message)) {
292
+ record.phase = 'blocked';
293
+ record.receipt = receipt(record, 'blocked', error.message);
294
+ await store.write(record);
295
+ return record.receipt;
296
+ }
297
+ if (record) {
298
+ record.phase = stopped ? 'interrupted' : 'uncertain';
299
+ await store.write(record);
300
+ }
301
+ throw error;
302
+ }
303
+ finally {
304
+ known.delete(key);
305
+ }
306
+ }
307
+ /** Startup never relaunches a partial worker; it only confirms process termination and retains stages. */
308
+ export async function recoverModelPreparationOperations(state, recovery) {
309
+ for (const { operation, scopeKey } of state.journal.listOperations()) {
310
+ if (operation.kind !== 'model.prepare' || !operation.workspaceId || !['dispatched', 'accepted', 'uncertain'].includes(operation.status))
311
+ continue;
312
+ const id = operation.gatewayOperationId ?? operation.operationId, writer = state.journal.listManagedWrites().find(item => item.writeId === `model-prepare-${id}`);
313
+ let stopped = Boolean(writer?.stoppedAt);
314
+ if (writer && !stopped) {
315
+ if (recovery.confirmStopped)
316
+ stopped = await recovery.confirmStopped(writer);
317
+ else if (writer.executionMode === 'launcher' && recovery.identityMatches && recovery.launcherSocket && writer.launcher && writer.launcher.processId === writer.writeId) {
318
+ if (recovery.previousRuntimeEpoch !== undefined && recovery.runtimeEpoch !== undefined && recovery.runtimeEpoch > recovery.previousRuntimeEpoch && writer.launcher.runtimeEpoch < recovery.runtimeEpoch)
319
+ stopped = true;
320
+ else
321
+ try {
322
+ await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
323
+ stopped = true;
324
+ }
325
+ catch { /* retain uncertain */ }
326
+ }
327
+ else if (writer.executionMode === 'local' && writer.pid) {
328
+ const absent = (pid) => { try {
329
+ process.kill(pid, 0);
330
+ return false;
331
+ }
332
+ catch (error) {
333
+ return error.code === 'ESRCH';
334
+ } };
335
+ stopped = absent(writer.pid) && absent(-writer.pid);
336
+ }
337
+ }
338
+ const binding = await readPreparationJson(state.config.stateDir, `model-preparations/operations/${operation.operationId}.json`);
339
+ const store = binding?.workspaceId === operation.workspaceId && binding.directoryName === 'basic'
340
+ ? new ModelPreparationStore(state.config.stateDir, binding.workspaceId, binding.directoryName, binding.pipelineId, binding.stepId) : undefined;
341
+ const record = await store?.read();
342
+ // onStarted persists the writer before opening its stdin gate; absent writer means it never ran.
343
+ if (!writer)
344
+ stopped = true;
345
+ if (writer && stopped && !writer.stoppedAt)
346
+ await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
347
+ if (record?.receipt && stopped) {
348
+ await state.journal.putOperation(scopeKey, transitionOperation(operation, 'completed', { result: record.receipt }));
349
+ continue;
350
+ }
351
+ if (record && store) {
352
+ record.phase = stopped ? 'interrupted' : 'uncertain';
353
+ await store.write(record);
354
+ }
355
+ await state.journal.putOperation(scopeKey, transitionOperation(operation, stopped ? 'interrupted' : 'uncertain', { error: stopped ? 'model_prepare_interrupted' : 'model_prepare_process_unknown' }));
356
+ }
357
+ }
@@ -123,6 +123,7 @@ export async function startPlatformExport(state, input) {
123
123
  commitHash: commit,
124
124
  platforms: input.channels,
125
125
  clientRequestId: input.clientRequestId,
126
+ ...(input.options && Object.keys(input.options).length ? { options: input.options } : {}),
126
127
  },
127
128
  });
128
129
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -53,8 +53,8 @@
53
53
  "@gltf-transform/core": "^4.3.0",
54
54
  "@gltf-transform/extensions": "^4.3.0",
55
55
  "@gltf-transform/functions": "^4.3.0",
56
- "@playcraft/build": "0.1.64",
57
- "@playcraft/common": "0.1.64",
56
+ "@playcraft/build": "0.1.66",
57
+ "@playcraft/common": "0.1.66",
58
58
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
59
59
  "chokidar": "^4.0.3",
60
60
  "commander": "^13.1.0",
@@ -77,6 +77,7 @@
77
77
  "picocolors": "^1.1.1",
78
78
  "playwright-core": "1.52.0",
79
79
  "sharp": "^0.34.5",
80
+ "three": "0.183.2",
80
81
  "update-notifier": "^7.3.1",
81
82
  "ws": "^8.19.0",
82
83
  "zod": "^3.25.76"
@@ -93,7 +94,6 @@
93
94
  "@types/update-notifier": "^6.0.8",
94
95
  "@types/ws": "^8.18.1",
95
96
  "esbuild": "^0.28.1",
96
- "three": "^0.183.2",
97
97
  "typescript": "^5.9.3",
98
98
  "vitest": "^3.2.4"
99
99
  }