@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.
- package/README.md +24 -31
- package/dist/commands/build.js +70 -5
- package/dist/commands/create.js +4 -4
- package/dist/commands/inspect.js +4 -6
- package/dist/index.js +27 -19
- package/dist/model-adaptation/node-inspection.js +173 -0
- package/dist/model-adaptation/project-template.js +40 -8
- package/dist/model-adaptation/project.js +2 -2
- package/dist/model-preparation/pipeline-engine.js +157 -0
- package/dist/model-preparation/pipeline-files.js +108 -0
- package/dist/model-preparation/pipeline-worker.js +32 -0
- package/dist/workspace-runtime/batch/deliver.js +2 -2
- package/dist/workspace-runtime/codex/delivery-tools.js +36 -3
- package/dist/workspace-runtime/previews/publish.js +2 -2
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +27 -47
- package/dist/workspace-runtime/server/http.js +12 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
- package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
- package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
- package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
- package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
- package/package.json +4 -4
- package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/empty/AGENTS.md +6 -4
- package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- 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.
|
|
51
|
+
'AGENTS.md': '# Adapted model project\n\nSource files and the parent adaptation bytes are immutable copies. Save derived configuration through normal workspace operations. Workspace artifacts belong in root dist/, including the preview at dist/preview/index.html; every resource they depend on must be packaged there. Channel packages come from platform export and never go in dist/. 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
|
|
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 --
|
|
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.
|
|
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/
|
|
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
|
|
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', '
|
|
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,
|
|
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', '
|
|
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;
|