@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.
- package/README.md +24 -31
- package/SKILLS_REGISTRY.md +1 -1
- package/dist/commands/3d.js +41 -33
- 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 +194 -0
- package/dist/model-adaptation/project.js +76 -0
- package/dist/model-adaptation/source.js +49 -0
- package/dist/model-optimization/optimize.js +281 -0
- package/dist/model-preparation/bindings.js +35 -0
- package/dist/model-preparation/commands.js +28 -0
- package/dist/model-preparation/geometry.js +241 -0
- package/dist/model-preparation/glb.js +176 -0
- 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/model-preparation/split-rigid.js +302 -0
- package/dist/model-preparation/vertex-repairs.js +63 -0
- package/dist/workspace-runtime/batch/deliver.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +11 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
- package/dist/workspace-runtime/codex/model-broker.js +19 -1
- 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 +6 -4
- package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/empty/AGENTS.md +1 -1
- 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
|
@@ -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;
|
|
@@ -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
|
+
}
|