@playcraft/cli 0.1.64 → 0.1.65

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 +1 -1
  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
@@ -39,7 +39,7 @@ export async function generateModelAdaptationProject(input) {
39
39
  'src/runtime/model-adaptation-core.js': core, 'src/runtime/model-adaptation-core.js.map': coreMap, 'src/assets/packed-model.glb': source.packed,
40
40
  '.playcraft/workspace.json': json({ parameters: { schemaPath: 'src/theme/theme.schema.json', dataPath: '.playcraft/model-adaptation.json', themeId: 'default' } }),
41
41
  '.playcraft/model-adaptation.json': json(plan), '.playcraft/parent-model-adaptation.json': parentRaw,
42
- '.playcraft/model-adaptation-lineage.json': json({ schemaVersion: 1, fixtureOnly: input.fixtureOnly === true, parentSource: parentPlan.source,
42
+ '.playcraft/model-adaptation-lineage.json': json({ schemaVersion: 1, buildBindingVersion: 2, fixtureOnly: input.fixtureOnly === true, parentSource: parentPlan.source,
43
43
  parentPlanHash: modelSourceDigest(parentRaw), derivedSource, derivedPlanHash: modelSourceDigest(json(plan)), coreHash, packedHash: source.packedHash,
44
44
  candidateId, loaderVersion: '0.183.2', loaderRevision: '183', geometryValidation: 'pending-runtime', modelInference: false, modelAcceptance: false }),
45
45
  'src/theme/theme.schema.json': json({ type: 'object', additionalProperties: false, properties: {
@@ -48,7 +48,7 @@ export async function generateModelAdaptationProject(input) {
48
48
  transform: { type: 'object', properties: { scale: { type: 'number', minimum: .000001, maximum: 1000000, title: 'Model scale' }, position: { type: 'array', readOnly: true }, rotationDegrees: { type: 'array', readOnly: true } } },
49
49
  materialOverrides: { type: 'array', readOnly: true }, visibility: { type: 'array', readOnly: true }, hinges: { type: 'array', readOnly: true },
50
50
  } }),
51
- 'AGENTS.md': '# Adapted model project\n\nSource files and the parent adaptation bytes are immutable copies. Save derived configuration through normal workspace operations. Final deliveries belong in root dist/, including dist/playable/index.html; all delivery dependencies must be packaged there. Private inputs and failed evidence stay outside dist/. A static model has no inferred opening capability.\n',
51
+ 'AGENTS.md': '# Adapted model project\n\nSource files and the parent adaptation bytes are immutable copies. Save derived configuration through normal workspace operations. Final deliveries belong in root dist/, including dist/preview/index.html; all delivery dependencies must be packaged there. Private inputs and failed evidence stay outside dist/. A static model has no inferred opening capability.\n',
52
52
  'README.md': '# Saved-model adaptation project\n\nThis prepares source without installation, building, inference or acceptance. Install pinned dependencies and build normally. The workspace parameter panel edits scalar production values; structural mappings and arrays belong to the adaptation panel. Both save .playcraft/model-adaptation.json. Original source/ files and exact parent plan are retained. Runtime geometry identities are checked by the same core as the model viewer. Skin, animation, morph targets and instancing are unsupported; merged parts need a real split. Preview controls are temporary.\n',
53
53
  };
54
54
  const adaptationPaths = Object.keys(files).filter(path => path.startsWith('src/') || path.startsWith('scripts/') ||
@@ -0,0 +1,157 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { modelPreparationPayloadSchema, workspaceModelAdaptationSchema, canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { inspectModelSourceFiles } from '../model-adaptation/source.js';
6
+ import { generateModelAdaptationProject } from '../model-adaptation/project.js';
7
+ import { inspectRigidModel } from '../model-adaptation/node-inspection.js';
8
+ import { applyRigidVertexRepairs } from './vertex-repairs.js';
9
+ import { prepareRigidSplit } from './split-rigid.js';
10
+ import { optimizeModel } from '../model-optimization/optimize.js';
11
+ import { hash, hashData, preparationInventory, readPreparationFile, readPreparationJson, writePreparationJson } from './pipeline-files.js';
12
+ /** Deterministic stages only. All destinations are fresh; prior incomplete attempts remain available. */
13
+ export async function runPreparationEngine(input) {
14
+ const payload = modelPreparationPayloadSchema.parse(input.payload), { resumeFromReceiptHash: _resume, ...request } = payload;
15
+ const inputHash = hashData(request);
16
+ await mkdir(input.outputRoot, { recursive: true });
17
+ const checkpoint = await readPreparationJson(input.outputRoot, 'checkpoint.json') ?? {
18
+ schemaVersion: 1, inputHash, toolHash: input.toolHash, stages: {},
19
+ };
20
+ if (checkpoint.schemaVersion !== 1 || checkpoint.inputHash !== inputHash || checkpoint.toolHash !== input.toolHash)
21
+ throw Error('model_prepare_checkpoint_identity_changed');
22
+ const stage = async (name, identity, action) => {
23
+ input.signal?.throwIfAborted();
24
+ const stageHash = hashData({ inputHash, toolHash: input.toolHash, identity });
25
+ const prior = checkpoint.stages[name];
26
+ if (prior) {
27
+ if (prior.inputHash !== stageHash || !new RegExp(`^${name}-[a-f0-9-]{36}$`).test(prior.directory))
28
+ throw Error('model_prepare_checkpoint_identity_changed');
29
+ const actual = await preparationInventory(join(input.outputRoot, prior.directory));
30
+ if (canonicalModelAdaptation(actual) !== canonicalModelAdaptation(prior.files))
31
+ throw Error('model_prepare_checkpoint_output_changed');
32
+ return join(input.outputRoot, prior.directory);
33
+ }
34
+ const relative = `${name}-${randomUUID()}`, directory = join(input.outputRoot, relative);
35
+ await mkdir(directory);
36
+ await action(directory);
37
+ input.signal?.throwIfAborted();
38
+ const files = await preparationInventory(directory);
39
+ checkpoint.stages[name] = { inputHash: stageHash, directory: relative, files };
40
+ await writePreparationJson(join(input.outputRoot, 'checkpoint.json'), checkpoint, true);
41
+ return directory;
42
+ };
43
+ const observed = await inspectModelSourceFiles({ projectRoot: input.sourceRoot, modelPath: payload.source.path, signal: input.signal });
44
+ if (canonicalModelAdaptation({ ...observed.source, files: [...observed.source.files].sort((a, b) => a.path.localeCompare(b.path)) }) !==
45
+ canonicalModelAdaptation({ ...payload.source, files: [...payload.source.files].sort((a, b) => a.path.localeCompare(b.path)) }))
46
+ throw Error('model_prepare_source_identity_mismatch');
47
+ if (observed.metadata.unsupported.length)
48
+ throw Error('model_prepare_rigid_source_required');
49
+ if (payload.recipe.adaptation && canonicalModelAdaptation(payload.recipe.adaptation.source) !== canonicalModelAdaptation(payload.source))
50
+ throw Error('model_prepare_adaptation_source_mismatch');
51
+ if (payload.recipe.repair && (!/\.glb$/i.test(payload.source.path) || payload.source.files.length !== 1))
52
+ throw Error('model_prepare_repair_glb_required');
53
+ const initial = await stage('inspect', payload.source, async (directory) => {
54
+ await writeFile(join(directory, 'model.glb'), observed.packed, { flag: 'wx' });
55
+ await writeFile(join(directory, 'source-inspection.json'), JSON.stringify({ source: observed.source, packedHash: observed.packedHash, metadata: observed.metadata }) + '\n', { flag: 'wx' });
56
+ });
57
+ let current = join(initial, 'model.glb');
58
+ const lineage = [{ name: 'inspect', directory: initial }];
59
+ if (payload.recipe.repair) {
60
+ const bytes = new Uint8Array(await readFile(current)), recipe = { ...payload.recipe.repair, weldedPositionIdentity: 'exact-local-float32-lexicographic-v1' };
61
+ const repaired = await stage('repair', { parentHash: hash(bytes), recipe }, async (directory) => {
62
+ const result = applyRigidVertexRepairs(bytes, recipe);
63
+ await writeFile(join(directory, 'model.glb'), result.bytes, { flag: 'wx' });
64
+ await writeFile(join(directory, 'repair-manifest.json'), JSON.stringify(result.manifest) + '\n', { flag: 'wx' });
65
+ });
66
+ current = join(repaired, 'model.glb');
67
+ lineage.push({ name: 'repair', directory: repaired });
68
+ }
69
+ let splitManifest;
70
+ if (payload.recipe.split) {
71
+ const bytes = new Uint8Array(await readFile(current));
72
+ const split = await stage('split', { parentHash: hash(bytes), recipe: payload.recipe.split }, async (directory) => {
73
+ const result = prepareRigidSplit(bytes, payload.recipe.split, input.signal);
74
+ await writeFile(join(directory, 'model.glb'), result.bytes, { flag: 'wx' });
75
+ await writeFile(join(directory, 'split-manifest.json'), JSON.stringify(result.manifest) + '\n', { flag: 'wx' });
76
+ await writeFile(join(directory, 'exterior-trace.json'), JSON.stringify(result.trace) + '\n', { flag: 'wx' });
77
+ });
78
+ current = join(split, 'model.glb');
79
+ splitManifest = JSON.parse(await readFile(join(split, 'split-manifest.json'), 'utf8'));
80
+ lineage.push({ name: 'split', directory: split });
81
+ }
82
+ // Verify exact runtime fingerprints across texture-only optimization, including grouped meshes.
83
+ const beforeBytes = new Uint8Array(await readFile(current));
84
+ const beforeSource = { kind: 'resource', path: 'prepared/model.glb', contentHash: hash(beforeBytes), files: [{ path: 'prepared/model.glb', contentHash: hash(beforeBytes) }] };
85
+ const before = await inspectRigidModel({ bytes: beforeBytes, source: beforeSource,
86
+ adaptation: payload.recipe.adaptation, split: splitManifest, requiredCapability: payload.requiredCapability, signal: input.signal });
87
+ if (payload.recipe.optimize) {
88
+ const optimized = await stage('optimize', { parentHash: hash(beforeBytes), recipe: payload.recipe.optimize }, async (directory) => {
89
+ await optimizeModel({ input: current, output: join(directory, 'model.glb'), lineagePath: join(directory, 'optimization.json'),
90
+ ...payload.recipe.optimize, texturesOnly: true, maxOutputBytes: payload.maxModelBytes });
91
+ });
92
+ current = join(optimized, 'model.glb');
93
+ lineage.push({ name: 'optimize', directory: optimized });
94
+ }
95
+ const finalBytes = new Uint8Array(await readFile(current));
96
+ if (finalBytes.length > payload.maxModelBytes)
97
+ throw Error('model_prepare_model_budget_exceeded');
98
+ const derived = Boolean(payload.recipe.repair || payload.recipe.split || payload.recipe.optimize);
99
+ const source = derived ? { ...beforeSource, contentHash: hash(finalBytes), files: [{ path: beforeSource.path, contentHash: hash(finalBytes) }] } : payload.source;
100
+ const inspection = await inspectRigidModel({ bytes: finalBytes, source, adaptation: payload.recipe.adaptation, split: splitManifest,
101
+ requiredCapability: payload.requiredCapability, signal: input.signal });
102
+ if (inspection.triangles > payload.maxTriangles)
103
+ throw Error('model_prepare_triangle_budget_exceeded');
104
+ const geometry = (value) => value.inventory.nodes.map(({ sourceNodeId, geometryHash }) => ({ sourceNodeId, geometryHash }));
105
+ if (canonicalModelAdaptation(geometry(before)) !== canonicalModelAdaptation(geometry(inspection)))
106
+ throw Error('model_prepare_optimized_geometry_changed');
107
+ const adapted = await stage('adapt', { source, plan: inspection.plan }, async (directory) => {
108
+ const inputRoot = join(directory, 'input');
109
+ await mkdir(inputRoot);
110
+ if (derived) {
111
+ await mkdir(join(inputRoot, 'prepared'));
112
+ await writeFile(join(inputRoot, source.path), finalBytes, { flag: 'wx' });
113
+ }
114
+ else
115
+ for (const [path, bytes] of observed.originalFiles) {
116
+ await mkdir(dirname(join(inputRoot, path)), { recursive: true });
117
+ await writeFile(join(inputRoot, path), bytes, { flag: 'wx' });
118
+ }
119
+ await mkdir(join(inputRoot, '.playcraft'));
120
+ await writeFile(join(inputRoot, '.playcraft/model-adaptation.json'), JSON.stringify(inspection.plan, null, 2) + '\n', { flag: 'wx' });
121
+ const generated = await generateModelAdaptationProject({ projectRoot: inputRoot, planPath: '.playcraft/model-adaptation.json',
122
+ outputRoot: join(directory, 'project'), candidateId: payload.pipelineId, signal: input.signal });
123
+ const project = generated.outputRoot, proofPrefix = `.playcraft/model-production/${payload.pipelineId}`, proofRoot = join(project, proofPrefix);
124
+ await mkdir(proofRoot, { recursive: true });
125
+ const proof = { schemaVersion: 1, pipelineId: payload.pipelineId, stepId: payload.stepId, inputHash,
126
+ sourceScope: payload.sourceScope, parentSource: payload.source, recipe: payload.recipe, toolHash: input.toolHash,
127
+ outputSource: generated.manifest.derivedSource, actualInspection: inspection, modelAcceptance: false, browserValidation: 'pending' };
128
+ await writeFile(join(proofRoot, 'lineage.json'), JSON.stringify(proof, null, 2) + '\n', { flag: 'wx' });
129
+ await writeFile(join(proofRoot, 'recipe.json'), JSON.stringify(payload.recipe, null, 2) + '\n', { flag: 'wx' });
130
+ await writeFile(join(proofRoot, 'tool.json'), JSON.stringify({ toolHash: input.toolHash, manifest: input.toolManifest, actualInspector: inspection.tool }, null, 2) + '\n', { flag: 'wx' });
131
+ for (const [path, bytes] of observed.originalFiles) {
132
+ await mkdir(dirname(join(proofRoot, 'original', path)), { recursive: true });
133
+ await writeFile(join(proofRoot, 'original', path), bytes, { flag: 'wx' });
134
+ }
135
+ for (const item of lineage)
136
+ for (const file of await preparationInventory(item.directory)) {
137
+ await writeFile(join(proofRoot, `${item.name}-${file.path.replaceAll('/', '-')}`), (await readPreparationFile(item.directory, file.path)).bytes, { flag: 'wx' });
138
+ }
139
+ const buildPath = join(project, '.playcraft/model-build-inputs.json'), build = JSON.parse(await readFile(buildPath, 'utf8'));
140
+ for (const file of await preparationInventory(proofRoot))
141
+ build.models[0].adaptationFiles.push({ path: `${proofPrefix}/${file.path}`, contentHash: file.contentHash });
142
+ if (build.models[0].adaptationFiles.length > 64)
143
+ throw Error('model_prepare_lineage_limit');
144
+ await writeFile(buildPath, JSON.stringify(build, null, 2) + '\n');
145
+ const sourceManifestPath = join(project, 'model-adaptation-source-manifest.json');
146
+ const sourceManifest = JSON.parse(await readFile(sourceManifestPath, 'utf8'));
147
+ sourceManifest.files = (await preparationInventory(project)).filter(file => !['AGENTS.md', 'README.md', 'model-adaptation-source-manifest.json'].includes(file.path));
148
+ sourceManifest.preservedWorkspaceInstructions = true;
149
+ await writeFile(sourceManifestPath, JSON.stringify(sourceManifest, null, 2) + '\n');
150
+ });
151
+ const projectRoot = join(adapted, 'project'), config = await readPreparationFile(projectRoot, '.playcraft/model-adaptation.json');
152
+ const plan = workspaceModelAdaptationSchema.parse(JSON.parse(config.bytes.toString('utf8')));
153
+ const files = await preparationInventory(projectRoot);
154
+ return { status: 'prepared', projectRoot, model: plan.source, adaptationPath: '.playcraft/model-adaptation.json', adaptationHash: config.contentHash,
155
+ modelBytes: finalBytes.length, triangles: inspection.triangles, files,
156
+ lineageFiles: files.filter(file => file.path.startsWith(`.playcraft/model-production/${payload.pipelineId}/`)).map(({ path, contentHash }) => ({ path, contentHash })) };
157
+ }
@@ -0,0 +1,108 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { workspaceRelativePathSchema, canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { modelSourceDigest as hash } from '../model-adaptation/source.js';
7
+ export const hashData = (value) => hash(canonicalModelAdaptation(value));
8
+ export function preparationPath(value) {
9
+ workspaceRelativePathSchema.parse(value);
10
+ if (value.split('/').some(part => ['.git', '.ssh', 'node_modules'].includes(part) || part === '.env' || part.startsWith('.env.')))
11
+ throw Error('model_prepare_path_unsafe');
12
+ return value;
13
+ }
14
+ const same = (a, b) => a.ino === b.ino && a.dev === b.dev && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
15
+ /** Safe byte reads include inode/parent identity checks before and after reading, not only a digest. */
16
+ export async function readPreparationFile(root, relative, maximum = 64 * 1024 * 1024) {
17
+ preparationPath(relative);
18
+ const base = resolve(root);
19
+ if (await realpath(base) !== base || !(await lstat(base)).isDirectory())
20
+ throw Error('model_prepare_root_unsafe');
21
+ const parents = [];
22
+ let path = base;
23
+ for (const part of relative.split('/').slice(0, -1)) {
24
+ path = join(path, part);
25
+ const stat = await lstat(path);
26
+ if (!stat.isDirectory() || stat.isSymbolicLink())
27
+ throw Error('model_prepare_path_unsafe');
28
+ parents.push({ path, stat });
29
+ }
30
+ path = join(base, relative);
31
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
32
+ try {
33
+ const stat = await handle.stat();
34
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > maximum)
35
+ throw Error('model_prepare_file_bound');
36
+ const bytes = await handle.readFile();
37
+ if (!same(stat, await handle.stat()) || !same(stat, await lstat(path)) || await realpath(path) !== path)
38
+ throw Error('model_prepare_source_changed');
39
+ for (const parent of parents) {
40
+ const now = await lstat(parent.path);
41
+ if (!now.isDirectory() || now.isSymbolicLink() || now.ino !== parent.stat.ino || now.dev !== parent.stat.dev)
42
+ throw Error('model_prepare_source_changed');
43
+ }
44
+ return { bytes, stat, contentHash: hash(bytes) };
45
+ }
46
+ finally {
47
+ await handle.close();
48
+ }
49
+ }
50
+ export async function writePreparationJson(path, value, shared = false) {
51
+ await mkdir(dirname(path), { recursive: true, mode: shared ? 0o2770 : 0o700 });
52
+ const temporary = `${path}.${randomUUID()}.pending`;
53
+ try {
54
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, shared ? 0o660 : 0o600);
55
+ try {
56
+ await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
57
+ await handle.sync();
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ await rename(temporary, path);
63
+ const directory = await open(dirname(path), constants.O_RDONLY);
64
+ try {
65
+ await directory.sync();
66
+ }
67
+ finally {
68
+ await directory.close();
69
+ }
70
+ }
71
+ finally {
72
+ await rm(temporary, { force: true });
73
+ }
74
+ }
75
+ export async function preparationInventory(root) {
76
+ const files = [];
77
+ let total = 0;
78
+ async function walk(prefix = '') {
79
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
80
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
81
+ preparationPath(path);
82
+ if (entry.isSymbolicLink())
83
+ throw Error('model_prepare_path_unsafe');
84
+ if (entry.isDirectory())
85
+ await walk(path);
86
+ else {
87
+ const file = await readPreparationFile(root, path);
88
+ total += file.bytes.length;
89
+ if (files.length >= 256 || total > 256 * 1024 * 1024)
90
+ throw Error('model_prepare_inventory_bound');
91
+ files.push({ path, contentHash: file.contentHash, bytes: file.bytes.length });
92
+ }
93
+ }
94
+ }
95
+ await walk();
96
+ return files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
97
+ }
98
+ export async function readPreparationJson(root, path, maximum = 4 * 1024 * 1024) {
99
+ try {
100
+ return JSON.parse((await readPreparationFile(root, path, maximum)).bytes.toString('utf8'));
101
+ }
102
+ catch (error) {
103
+ if (error.code === 'ENOENT')
104
+ return undefined;
105
+ throw error;
106
+ }
107
+ }
108
+ export { hash };
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { modelPreparationPayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { runPreparationEngine } from './pipeline-engine.js';
6
+ import { hash, writePreparationJson } from './pipeline-files.js';
7
+ /** Internal frozen CLI worker; accepts neither a project module nor executable recipe strings. */
8
+ export async function modelPrepareWorker(configPath, expectedHash) {
9
+ const bytes = await readFile(configPath);
10
+ if (hash(bytes) !== expectedHash)
11
+ throw Error('model_prepare_worker_input_changed');
12
+ const config = JSON.parse(bytes.toString('utf8'));
13
+ const payload = modelPreparationPayloadSchema.parse(config.payload);
14
+ try {
15
+ const result = await runPreparationEngine({ payload, sourceRoot: config.sourceRoot, outputRoot: config.outputRoot, toolHash: config.toolHash, toolManifest: config.tools });
16
+ await writePreparationJson(join(config.outputRoot, 'result.json'), result, true);
17
+ }
18
+ catch (error) {
19
+ const safeCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
20
+ && /^model_adaptation_[a-z_]+$/.test(error.code) ? error.code : undefined;
21
+ const message = safeCode ?? (error instanceof Error ? error.message : '');
22
+ let code = message.split(':')[0];
23
+ if (['invalid_model', 'invalid_model_resource', 'invalid_model_image', 'asset_too_large'].includes(code))
24
+ code = `model_prepare_${code}`;
25
+ if (!/^(model_prepare_|model_preparation_|model_adaptation_|model_source_|optimization_)[a-z_]+$/.test(code))
26
+ throw error;
27
+ await writePreparationJson(join(config.outputRoot, 'result.json'), { status: 'blocked', reasonCode: code }, true);
28
+ }
29
+ }
30
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
31
+ modelPrepareWorker(process.argv[2] ?? '', process.argv[3] ?? '').catch(() => { process.stderr.write('model_prepare_worker_failed\n'); process.exitCode = 1; });
32
+ }
@@ -9,10 +9,10 @@ import { workspaceRoot } from '../workspaces/registry.js';
9
9
  import { processHasExited, runProcessGroup, stopProcessGroup } from './processes.js';
10
10
  import { stopMemberWriters } from './writer-proof.js';
11
11
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
12
- /** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
12
+ /** Base build writes dist/index.html. Channel packaging is done by platform export. */
13
13
  function buildCommand() {
14
14
  return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
15
- ?? 'umask 002 && CI=1 playcraft build --mode base --output dist .');
15
+ ?? 'umask 002 && CI=1 playcraft build --base --output dist .');
16
16
  }
17
17
  /**
18
18
  * The batch build runs as the runtime identity while the agent wrote the tree as the development
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { DELIVERY_CHANNELS, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { DELIVERY_CHANNELS, deliveryExportOptionsSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { rejectTool, toolContent } from './tool-response.js';
4
4
  export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Other options reuse the channel\'s latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Build options given in `options` apply to every requested channel and replace that setting from the channel\'s latest export on this branch; options not given reuse that export, or the defaults when it has none. Store links not given come from the project\'s remembered export links, then from the channel\'s latest export. A later export without `options` keeps the settings this one used. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -16,6 +16,32 @@ export const WORKSPACE_DELIVERY_TOOLS = [
16
16
  maxItems: DELIVERY_CHANNELS.length,
17
17
  uniqueItems: true,
18
18
  },
19
+ options: {
20
+ type: 'object',
21
+ description: 'Optional. Set only the options to change; each one left out keeps its current setting. Each option affects the size and quality of the channel packages.',
22
+ properties: {
23
+ compressImages: { type: 'boolean', description: 'Lossy palette compression (pngquant) of PNG images.' },
24
+ imageQuality: { type: 'integer', minimum: 40, maximum: 95, description: 'Quality of lossy image compression, 40 to 95; lower is smaller and blurrier. Used with compressImages.' },
25
+ convertToWebP: { type: 'boolean', description: 'Convert PNG and JPG images to WebP, usually much smaller; on by default.' },
26
+ compressModels: { type: 'boolean', description: 'Compress 3D model geometry.' },
27
+ modelCompression: { type: 'string', enum: ['draco', 'meshopt'], description: 'Model compression method: draco is widely supported, meshopt compresses further. Used with compressModels.' },
28
+ jsMinify: { type: 'boolean', description: 'Minify and obfuscate JavaScript; on by default.' },
29
+ cssMinify: { type: 'boolean', description: 'Minify CSS; on by default.' },
30
+ selectedScenes: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100, uniqueItems: true, description: 'Package just these scene names; scenes left out are not in the package.' },
31
+ esmMode: { type: 'string', enum: ['auto', 'enabled', 'disabled'], description: 'How ES modules are bundled; auto detects them.' },
32
+ storeUrls: {
33
+ type: 'object',
34
+ description: 'App store links the call-to-action opens. A link given is used for that store; a store not given keeps the project\'s remembered link, or the channel\'s latest export link.',
35
+ properties: {
36
+ ios: { type: 'string', format: 'uri', description: 'iOS App Store URL (http or https).' },
37
+ android: { type: 'string', format: 'uri', description: 'Google Play URL (http or https).' },
38
+ },
39
+ minProperties: 1,
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ additionalProperties: false,
44
+ },
19
45
  },
20
46
  additionalProperties: false,
21
47
  },
@@ -70,7 +96,7 @@ export class WorkspaceDeliveryTools {
70
96
  await this.reject(client, requestId, 'permission_denied');
71
97
  return;
72
98
  }
73
- const unknown = Object.keys(input).filter(key => key !== 'channels');
99
+ const unknown = Object.keys(input).filter(key => key !== 'channels' && key !== 'options');
74
100
  if (unknown.length) {
75
101
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
76
102
  return;
@@ -82,6 +108,12 @@ export class WorkspaceDeliveryTools {
82
108
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
83
109
  return;
84
110
  }
111
+ const options = input.options === undefined ? { success: true, data: undefined }
112
+ : deliveryExportOptionsSchema.safeParse(input.options);
113
+ if (!options.success || (options.data?.selectedScenes && new Set(options.data.selectedScenes).size !== options.data.selectedScenes.length)) {
114
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
115
+ return;
116
+ }
85
117
  try {
86
118
  const result = await this.handlers.build({
87
119
  ...context,
@@ -89,6 +121,7 @@ export class WorkspaceDeliveryTools {
89
121
  ? payload.callId
90
122
  : randomUUID(),
91
123
  channels: channels,
124
+ ...(options.data ? { options: options.data } : {}),
92
125
  });
93
126
  await this.respond(client, requestId, result);
94
127
  }
@@ -12,7 +12,7 @@ import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
12
12
  import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
13
13
  import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
14
14
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
15
- import { isolateWorkspaceForBuild } from '../workspaces/delivery-build.js';
15
+ import { isolateWorkspaceForBuild } from '../workspaces/build-isolation.js';
16
16
  import { openScratchToDevelopment } from '../workspaces/directory-access.js';
17
17
  import { injectPreviewDiagnostics } from './diagnostics.js';
18
18
  import { OperationLogStore } from '../workspaces/operation-log.js';
@@ -27,7 +27,7 @@ const BUILDER_TEMP_FOLDER = '.playcraft-temp';
27
27
  * The generic single-file build. `--mode full` keeps the builder from asking which stages to run.
28
28
  * Group-writable output lets the control user, a member of the development group, remove it.
29
29
  */
30
- const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build --mode full --platform playcraft --format html';
30
+ const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build';
31
31
  class PreviewFailure extends Error {
32
32
  code;
33
33
  constructor(code) {
@@ -36,6 +36,7 @@ import { platformExportTarget, readPlatformExport, startPlatformExport } from '.
36
36
  import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
37
37
  import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
38
38
  import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
39
+ import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
39
40
  export async function startWorkspaceRuntime(config, options = {}) {
40
41
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
41
42
  const negotiated = negotiateProtocol(config);
@@ -84,6 +85,9 @@ export async function startWorkspaceRuntime(config, options = {}) {
84
85
  runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
85
86
  ...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
86
87
  ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
88
+ await recoverModelPreparationOperations(state, { identityMatches: previousIdentityMatches,
89
+ runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
90
+ ...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
87
91
  const recoveredChanges = [];
88
92
  const recoveredWrites = [];
89
93
  const recoverLibraryImports = async (root) => {
@@ -316,6 +320,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
316
320
  async close() {
317
321
  const serverClosed = closeServer(server);
318
322
  await stopParameterVerifications(state);
323
+ await stopModelPreparations(state);
319
324
  await closeCliCredentials(state);
320
325
  await gitCredentials?.close();
321
326
  try {
@@ -8,7 +8,7 @@ const instances = new WeakMap();
8
8
  const executing = new WeakMap();
9
9
  const freezing = new WeakMap();
10
10
  const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
11
- const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'model.preview', 'directory.create',
11
+ const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'model.preview', 'model.prepare', 'directory.create',
12
12
  'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
13
13
  'checkpoint.create', 'checkpoint.restore']);
14
14
  export function managedDirectoryScope(state, value) {
@@ -6,7 +6,7 @@ import { shareWorkspaceGit } from '../workspaces/shared-git.js';
6
6
  import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
7
7
  import { join } from 'node:path';
8
8
  import { z } from 'zod';
9
- import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, } from '@playcraft/common/cloud-workspace/runtime';
10
10
  import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
11
11
  import { artifactFormat } from '../workspaces/artifact-files.js';
12
12
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -42,8 +42,8 @@ import { DirectoryMutationError, mutateWorkspaceDirectory } from '../workspaces/
42
42
  import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.js';
43
43
  import { publishAssetTransfer } from '../workspaces/asset-transfer-publish.js';
44
44
  import { AssetTransferError } from '../workspaces/asset-transfer-storage.js';
45
- import { runDeliveryBuild, cancelDeliveryBuild, trackDeliveryBuild, DeliveryBuildError } from '../workspaces/delivery-build.js';
46
45
  import { runModelPreviewBuild, cancelModelPreview, modelPreviewError } from '../workspaces/procedural-3d/model-preview-build.js';
46
+ import { runModelPreparation, trackModelPreparation } from '../workspaces/model-preparation.js';
47
47
  const CONVERSATION_KINDS = new Set([
48
48
  'session.create',
49
49
  'session.turn',
@@ -76,10 +76,9 @@ const LOCAL_KINDS = new Set([
76
76
  'parameter.patch',
77
77
  'parameter.verify',
78
78
  'preview.publish',
79
- 'delivery.build',
80
- 'delivery.cancel',
81
79
  'model.preview',
82
80
  'model.preview.cancel',
81
+ 'model.prepare',
83
82
  'skills.update',
84
83
  ]);
85
84
  const parameterVerifications = new WeakMap();
@@ -123,7 +122,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
123
122
  return executeScopedOperation(state, scopeKey, operation, payload, recovery);
124
123
  }, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
125
124
  };
126
- if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel', 'model.preview', 'model.preview.cancel'].includes(operation.kind)) {
125
+ if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'model.preview', 'model.preview.cancel', 'model.prepare'].includes(operation.kind)) {
127
126
  return run();
128
127
  }
129
128
  return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
@@ -199,15 +198,32 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
199
198
  if (operation.kind === 'preview.publish') {
200
199
  return executePreviewPublish(state, scopeKey, current);
201
200
  }
202
- if (operation.kind === 'delivery.build') {
203
- return executeDeliveryBuild(state, scopeKey, current, payload);
204
- }
205
- if (operation.kind === 'delivery.cancel') {
206
- return executeDeliveryCancel(state, scopeKey, current, payload);
207
- }
208
201
  if (operation.kind === 'model.preview' || operation.kind === 'model.preview.cancel') {
209
202
  return executeModelPreview(state, scopeKey, current, payload);
210
203
  }
204
+ if (operation.kind === 'model.prepare') {
205
+ const accepted = await persist(state, scopeKey, transitionOperation(current, 'accepted'));
206
+ const task = runModelPreparation(state, accepted, payload, { onFile: async (file) => {
207
+ const scope = revisionScope(state, accepted.workspaceId, accepted.directoryName);
208
+ const existing = state.journal.listEvents().some(event => {
209
+ const payload = asRecord(event.payload);
210
+ return event.type === 'file.written' &&
211
+ payload.operationId === accepted.operationId && payload.path === file.path && payload.contentHash === file.contentHash;
212
+ });
213
+ if (existing)
214
+ return;
215
+ const previous = state.journal.fileRevisions(scope)[file.path];
216
+ const revision = previous ? previous.revision + (previous.contentHash === file.contentHash ? 0 : 1) : 1;
217
+ await state.journal.putFileRevision(file.path, { contentHash: file.contentHash, revision }, scope);
218
+ await recordNativeEvents(state, accepted, [{ type: 'file.written', payload: { ...file, revision, writer: 'manual',
219
+ operationId: accepted.operationId, source: 'model.prepare' }, occurredAt: new Date().toISOString() }]);
220
+ } }).then(async (result) => { await persist(state, scopeKey, transitionOperation(accepted, 'completed', { result })); }, async (error) => {
221
+ const message = error instanceof Error ? error.message : '', code = /^model_prepare_[a-z_]+$/.test(message) ? message : 'model_prepare_failed';
222
+ await persist(state, scopeKey, transitionOperation(accepted, code === 'model_prepare_process_unknown' ? 'uncertain' : ['model_prepare_cancelled', 'model_prepare_timeout'].includes(code) ? 'interrupted' : 'failed', { error: code }));
223
+ });
224
+ trackModelPreparation(state, task);
225
+ return accepted;
226
+ }
211
227
  if (!state.native?.ready()) {
212
228
  return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
213
229
  }
@@ -1533,42 +1549,6 @@ async function executeModelPreview(state, scopeKey, operation, payload) {
1533
1549
  }).catch(() => undefined));
1534
1550
  return accepted;
1535
1551
  }
1536
- async function executeDeliveryBuild(state, scopeKey, operation, payload) {
1537
- const parsed = deliveryBuildPayloadSchema.safeParse(payload);
1538
- if (!parsed.success) {
1539
- return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_build' }));
1540
- }
1541
- if (!operation.workspaceId) {
1542
- return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace' }));
1543
- }
1544
- const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1545
- const task = runDeliveryBuild(state, {
1546
- buildId: operation.operationId,
1547
- workspaceId: operation.workspaceId,
1548
- directoryName: operation.directoryName ?? 'basic',
1549
- payload: parsed.data,
1550
- actor: parsed.data.actor ?? 'user',
1551
- }).then(async (result) => {
1552
- const error = result.record.error
1553
- ?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
1554
- await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : error === 'delivery_process_unknown' ? 'uncertain' : 'failed', error ? { error } : {}));
1555
- }, async (error) => {
1556
- await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
1557
- error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
1558
- }));
1559
- }).catch(() => undefined);
1560
- trackDeliveryBuild(state, task);
1561
- return accepted;
1562
- }
1563
- async function executeDeliveryCancel(state, scopeKey, operation, payload) {
1564
- const parsed = deliveryCancelPayloadSchema.safeParse(payload);
1565
- if (!parsed.success || !operation.workspaceId) {
1566
- return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_cancel' }));
1567
- }
1568
- const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1569
- const canceled = await cancelDeliveryBuild(state, operation.workspaceId, operation.directoryName ?? 'basic', parsed.data.buildId);
1570
- return persist(state, scopeKey, transitionOperation(accepted, canceled ? 'completed' : 'failed', canceled ? {} : { error: 'delivery_build_not_found' }));
1571
- }
1572
1552
  async function executeTerminalCreate(state, scopeKey, operation, payload) {
1573
1553
  const parsed = terminalCreatePayloadSchema.safeParse(payload ?? {});
1574
1554
  if (!parsed.success) {
@@ -49,6 +49,7 @@ import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktree
49
49
  import { withDirectoryWrite } from './directory-access.js';
50
50
  import { readProceduralModelCandidates } from '../workspaces/procedural-3d/read-candidates.js';
51
51
  import { ModelPreviewStore } from '../workspaces/procedural-3d/model-preview-store.js';
52
+ import { readModelPreparation } from '../workspaces/model-preparation.js';
52
53
  export async function handleRuntimeRequest(state, request, response) {
53
54
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
54
55
  const workspaceId = url.searchParams.get('workspaceId');
@@ -567,6 +568,17 @@ async function dispatchRuntimeRequest(state, request, response) {
567
568
  writeJson(response, 200, await readProceduralModelCandidates(root, state.config.stateDir));
568
569
  return;
569
570
  }
571
+ if (request.method === 'GET' && url.pathname === '/runtime/model-preparations') {
572
+ if (writeDestroyed(state, response))
573
+ return;
574
+ const workspaceId = url.searchParams.get('workspaceId'), pipelineId = url.searchParams.get('pipelineId'), stepId = url.searchParams.get('stepId');
575
+ if (!workspaceId || !pipelineId || !stepId || directoryName === 'root') {
576
+ writeJson(response, 400, { error: 'model_prepare_scope_invalid' });
577
+ return;
578
+ }
579
+ writeJson(response, 200, await readModelPreparation(state, workspaceId, directoryName, pipelineId, stepId));
580
+ return;
581
+ }
570
582
  if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
571
583
  if (writeDestroyed(state, response))
572
584
  return;