@playcraft/cli 0.1.63 → 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 (41) hide show
  1. package/README.md +24 -31
  2. package/SKILLS_REGISTRY.md +1 -1
  3. package/dist/commands/3d.js +41 -33
  4. package/dist/commands/build.js +70 -5
  5. package/dist/commands/create.js +4 -4
  6. package/dist/commands/inspect.js +4 -6
  7. package/dist/index.js +27 -19
  8. package/dist/model-adaptation/node-inspection.js +173 -0
  9. package/dist/model-adaptation/project-template.js +194 -0
  10. package/dist/model-adaptation/project.js +76 -0
  11. package/dist/model-adaptation/source.js +49 -0
  12. package/dist/model-optimization/optimize.js +281 -0
  13. package/dist/model-preparation/bindings.js +35 -0
  14. package/dist/model-preparation/commands.js +28 -0
  15. package/dist/model-preparation/geometry.js +241 -0
  16. package/dist/model-preparation/glb.js +176 -0
  17. package/dist/model-preparation/pipeline-engine.js +157 -0
  18. package/dist/model-preparation/pipeline-files.js +108 -0
  19. package/dist/model-preparation/pipeline-worker.js +32 -0
  20. package/dist/model-preparation/split-rigid.js +302 -0
  21. package/dist/model-preparation/vertex-repairs.js +63 -0
  22. package/dist/workspace-runtime/batch/deliver.js +2 -2
  23. package/dist/workspace-runtime/codex/app-server.js +11 -1
  24. package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
  25. package/dist/workspace-runtime/codex/model-broker.js +19 -1
  26. package/dist/workspace-runtime/previews/publish.js +2 -2
  27. package/dist/workspace-runtime/serve.js +5 -0
  28. package/dist/workspace-runtime/server/directory-access.js +1 -1
  29. package/dist/workspace-runtime/server/dispatch.js +27 -47
  30. package/dist/workspace-runtime/server/http.js +12 -0
  31. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  32. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  33. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  34. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  35. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  36. package/package.json +6 -4
  37. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  38. package/project-templates/empty/AGENTS.md +1 -1
  39. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  40. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  41. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
@@ -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;
@@ -0,0 +1,75 @@
1
+ import { constants } from 'node:fs';
2
+ import { copyFile, cp, lstat, mkdir, readdir, readlink, symlink } from 'node:fs/promises';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ import { listWorkspaceBuildInputs } from './git.js';
5
+ import { ensureSharedDirectory } from './shared-write.js';
6
+ /** Staging under the workspace from earlier runtimes' sandbox delivery builds; never a build input. */
7
+ const STAGING_DIRECTORY = '.playcraft-delivery';
8
+ /** Never copied into the build copy: history, outputs and caches that the build neither reads nor owns. */
9
+ const ISOLATE_SKIP = new Set(['.git', 'dist', '.turbo', STAGING_DIRECTORY]);
10
+ /** Copy-on-write clone where the filesystem supports it (APFS, btrfs, XFS); a plain copy elsewhere. */
11
+ const CLONE = constants.COPYFILE_FICLONE;
12
+ const COPY_CONCURRENCY = 16;
13
+ function skipped(path) {
14
+ const parts = path.split(/[\\/]/);
15
+ return ISOLATE_SKIP.has(parts[0]) || parts.includes('node_modules');
16
+ }
17
+ async function copyInput(root, destination, path) {
18
+ const source = join(root, path);
19
+ const target = join(destination, path);
20
+ let entry;
21
+ try {
22
+ entry = await lstat(source);
23
+ }
24
+ catch (error) {
25
+ // A tracked file deleted in the work tree is not a build input.
26
+ if (error.code === 'ENOENT')
27
+ return;
28
+ throw error;
29
+ }
30
+ await mkdir(dirname(target), { recursive: true });
31
+ if (entry.isSymbolicLink())
32
+ await symlink(await readlink(source), target);
33
+ else if (entry.isDirectory())
34
+ await cp(source, target, { recursive: true, verbatimSymlinks: true, mode: CLONE });
35
+ else
36
+ await copyFile(source, target, CLONE);
37
+ }
38
+ /**
39
+ * Copies the workspace into `destination` for one build, like the platform export runner, with the
40
+ * top-level `node_modules` linked (the build only reads it). Builders rewrite project files in place
41
+ * (`builds.config.js` and its `.bak`, `package.json` scripts, migrated imports), so the build must
42
+ * never run in the workspace itself, where autosave would commit those rewrites.
43
+ *
44
+ * Only the files Git would carry are copied: tracked ones and untracked ones `.gitignore` keeps, so
45
+ * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
46
+ */
47
+ export async function isolateWorkspaceForBuild(root, destination) {
48
+ // The build runs as the development identity and writes its scratch folder inside this copy.
49
+ await ensureSharedDirectory(root, destination);
50
+ const modules = join(root, 'node_modules');
51
+ const modulesEntry = await lstat(modules).catch(() => undefined);
52
+ if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
53
+ await symlink(modulesEntry.isSymbolicLink() ? await readlink(modules) : modules, join(destination, 'node_modules'), 'dir');
54
+ }
55
+ const inputs = await listWorkspaceBuildInputs(root).catch(() => undefined);
56
+ const paths = inputs
57
+ ?? (await readdir(root)).filter((name) => name !== 'node_modules');
58
+ const queue = paths.filter((path) => !skipped(path));
59
+ const workers = Array.from({ length: Math.min(COPY_CONCURRENCY, queue.length) }, async () => {
60
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
61
+ if (inputs)
62
+ await copyInput(root, destination, path);
63
+ else {
64
+ await cp(join(root, path), join(destination, path), {
65
+ recursive: true,
66
+ verbatimSymlinks: true,
67
+ mode: CLONE,
68
+ // Nested dependency folders stay out as well; the build resolves through the linked root.
69
+ filter: (source) => !relative(join(root, path), source).split(sep).includes('node_modules'),
70
+ });
71
+ }
72
+ }
73
+ });
74
+ await Promise.all(workers);
75
+ }
@@ -118,6 +118,16 @@ export async function readWorkspaceModelBuildInputs(root, stateDir) {
118
118
  throw new Error('model_build_resource_manifest_mismatch');
119
119
  for (const file of source.files)
120
120
  inputs.push((await read(file.path, file.contentHash)).file);
121
+ // Resolve the actual installed dependency inputs even when a generated declaration predates installation.
122
+ for (const path of ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
123
+ try {
124
+ inputs.push((await read(path)).file);
125
+ }
126
+ catch (error) {
127
+ if (error.code !== 'ENOENT')
128
+ throw error;
129
+ }
130
+ }
121
131
  }
122
132
  else {
123
133
  const candidate = candidates?.candidates.find(item => item.runId === source.runId && item.candidateId === source.candidateId);
@@ -174,6 +184,23 @@ export async function verifyWorkspaceModelBuildInputs(root, snapshot) {
174
184
  if (model.source.kind === 'procedural' &&
175
185
  await dependencyHash(root) !== model.source.dependencyHash)
176
186
  throw new Error('model_build_input_changed');
187
+ for (const model of snapshot.models)
188
+ if (model.source.kind === 'resource') {
189
+ for (const path of ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
190
+ const frozen = model.inputs.find(file => file.path === path);
191
+ try {
192
+ const bytes = await checkedFile(root, path, 64 * 1024 * 1024);
193
+ if (!frozen || digest(bytes) !== frozen.contentHash)
194
+ throw new Error('model_build_input_changed');
195
+ }
196
+ catch (error) {
197
+ if (error.code !== 'ENOENT')
198
+ throw error;
199
+ if (frozen)
200
+ throw new Error('model_build_input_changed');
201
+ }
202
+ }
203
+ }
177
204
  }
178
205
  /** The marker makes otherwise identical HTML from different input declarations distinct artifacts. */
179
206
  export function markWorkspaceModelBuild(bytes, snapshot) {
@@ -0,0 +1,134 @@
1
+ import { lstat, mkdir, readdir, realpath, readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { modelPreparationReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { hash, hashData, preparationPath, readPreparationFile, readPreparationJson, writePreparationJson } from '../../model-preparation/pipeline-files.js';
5
+ import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
6
+ /** Control-owned step truth. Workspace metadata is never accepted as an execution receipt. */
7
+ export class ModelPreparationStore {
8
+ workspaceId;
9
+ directoryName;
10
+ pipelineId;
11
+ stepId;
12
+ root;
13
+ constructor(stateDir, workspaceId, directoryName, pipelineId, stepId) {
14
+ this.workspaceId = workspaceId;
15
+ this.directoryName = directoryName;
16
+ this.pipelineId = pipelineId;
17
+ this.stepId = stepId;
18
+ if (!/^[a-f0-9-]{36}$/.test(workspaceId) || !/^[a-f0-9-]{36}$/.test(pipelineId) || stepId.length < 1 || stepId.length > 128)
19
+ throw Error('model_prepare_scope_invalid');
20
+ this.root = join(stateDir, 'model-preparations', hashData({ workspaceId, directoryName }), pipelineId, hash(stepId));
21
+ }
22
+ async read() {
23
+ const value = await readPreparationJson(this.root, 'record.json');
24
+ if (value && (value.schemaVersion !== 1 || value.workspaceId !== this.workspaceId || value.directoryName !== this.directoryName ||
25
+ value.pipelineId !== this.pipelineId || value.stepId !== this.stepId))
26
+ throw Error('model_prepare_scope_mismatch');
27
+ if (value?.receipt) {
28
+ const parsed = modelPreparationReceiptSchema.parse(value.receipt), { receiptHash, ...body } = parsed;
29
+ if (hashData(body) !== receiptHash)
30
+ throw Error('model_prepare_receipt_changed');
31
+ }
32
+ return value;
33
+ }
34
+ async write(value) {
35
+ if (value.workspaceId !== this.workspaceId || value.directoryName !== this.directoryName || value.pipelineId !== this.pipelineId || value.stepId !== this.stepId)
36
+ throw Error('model_prepare_scope_mismatch');
37
+ if (value.receipt)
38
+ modelPreparationReceiptSchema.parse(value.receipt);
39
+ await writePreparationJson(join(this.root, 'record.json'), value);
40
+ }
41
+ }
42
+ /** New standalone template baseline, including preserved instructions; never traverse repository internals. */
43
+ export async function targetPreparationSnapshot(root) {
44
+ const result = [];
45
+ let size = 0;
46
+ async function walk(prefix = '') {
47
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
48
+ if (['.git', 'node_modules'].includes(entry.name))
49
+ continue;
50
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
51
+ preparationPath(path);
52
+ if (entry.isSymbolicLink())
53
+ throw Error('model_prepare_target_unsafe');
54
+ if (entry.isDirectory())
55
+ await walk(path);
56
+ else {
57
+ const file = await readPreparationFile(root, path);
58
+ size += file.bytes.length;
59
+ if (result.length >= 1024 || size > 256 * 1024 * 1024)
60
+ throw Error('model_prepare_target_bound');
61
+ result.push({ path, contentHash: file.contentHash, bytes: file.bytes.length });
62
+ }
63
+ }
64
+ }
65
+ await walk();
66
+ return result.sort((a, b) => a.path.localeCompare(b.path, 'en'));
67
+ }
68
+ export function preparationWriteFiles(result) {
69
+ return result.files.filter(file => !['AGENTS.md', 'README.md'].includes(file.path));
70
+ }
71
+ /** Every output is either its frozen preimage or this exact transaction's postimage. */
72
+ export async function promotePreparationProject(store, target, record, options) {
73
+ if (!record.result || !record.targetSnapshot)
74
+ throw Error('model_prepare_transaction_missing');
75
+ if (await realpath(target) !== target || !(await lstat(target)).isDirectory())
76
+ throw Error('model_prepare_target_unsafe');
77
+ const expected = new Map(record.targetSnapshot.map(file => [file.path, file.contentHash]));
78
+ if (!record.transaction) {
79
+ const actual = await targetPreparationSnapshot(target);
80
+ if (hashData(actual) !== hashData(record.targetSnapshot))
81
+ throw Error('model_prepare_target_changed');
82
+ record.transaction = { files: preparationWriteFiles(record.result).map(file => ({ ...file, before: expected.get(file.path) ?? null })), published: [] };
83
+ record.phase = 'promoting';
84
+ await store.write(record);
85
+ }
86
+ const transaction = record.transaction;
87
+ const expectedPaths = new Set([...record.targetSnapshot.map(file => file.path), ...transaction.files.map(file => file.path)]);
88
+ for (const file of await targetPreparationSnapshot(target)) {
89
+ if (!expectedPaths.has(file.path))
90
+ throw Error('model_prepare_target_changed');
91
+ const write = transaction.files.find(item => item.path === file.path);
92
+ if (file.contentHash !== (expected.get(file.path) ?? null) && (!write || file.contentHash !== write.contentHash))
93
+ throw Error('model_prepare_target_changed');
94
+ }
95
+ // A removed non-output instruction is also a conflict, rather than a reason to recreate it.
96
+ for (const file of record.targetSnapshot)
97
+ if (!transaction.files.some(item => item.path === file.path) &&
98
+ (await observeWorkspaceBinaryFile(target, file.path))?.contentHash !== file.contentHash)
99
+ throw Error('model_prepare_target_changed');
100
+ for (const file of transaction.files) {
101
+ options.signal?.throwIfAborted();
102
+ await options.assertCurrent();
103
+ const current = await observeWorkspaceBinaryFile(target, file.path);
104
+ if (current?.contentHash !== file.contentHash) {
105
+ if ((current?.contentHash ?? null) !== file.before)
106
+ throw Error('model_prepare_target_changed');
107
+ const source = await readPreparationFile(record.result.projectRoot, file.path);
108
+ if (source.contentHash !== file.contentHash || source.bytes.length !== file.bytes)
109
+ throw Error('model_prepare_worker_output_changed');
110
+ if (current) {
111
+ const backup = join(store.root, 'preimages', file.path);
112
+ await mkdir(dirname(backup), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
113
+ const old = await readPreparationFile(target, file.path);
114
+ if (old.contentHash !== file.before)
115
+ throw Error('model_prepare_target_changed');
116
+ const { writeFile } = await import('node:fs/promises');
117
+ await writeFile(backup, old.bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => {
118
+ if (error.code !== 'EEXIST' || hash(await readFile(backup)) !== file.before)
119
+ throw error;
120
+ });
121
+ }
122
+ await writeWorkspaceBinaryFile(target, { path: file.path, contentBase64: source.bytes.toString('base64'), expectedContentHash: file.before });
123
+ }
124
+ if (!transaction.published.includes(file.path)) {
125
+ const actual = await observeWorkspaceBinaryFile(target, file.path);
126
+ if (!actual || actual.contentHash !== file.contentHash)
127
+ throw Error('model_prepare_target_changed');
128
+ await options.onFile({ path: file.path, ...actual });
129
+ transaction.published.push(file.path);
130
+ await store.write(record);
131
+ }
132
+ }
133
+ await options.assertCurrent();
134
+ }