@playcraft/cli 0.1.61-beta → 0.1.63
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/dist/commands/build.js +16 -7
- package/dist/utils/agent-api-client.js +22 -0
- package/dist/workspace-runtime/codex/model-broker.js +13 -3
- package/dist/workspace-runtime/previews/publish.js +28 -4
- 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 +31 -2
- package/dist/workspace-runtime/server/http.js +36 -0
- package/dist/workspace-runtime/workspaces/artifacts.js +17 -3
- package/dist/workspace-runtime/workspaces/model-build-inputs.js +201 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
- package/package.json +4 -3
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +13 -3
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +12 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +1 -0
- package/project-template-v2/.playcraft/skills.lock.json +2 -2
- package/project-templates/empty/AGENTS.md +1 -1
- package/templates/preview-runtime/v1/README.md +20 -0
- package/templates/preview-runtime/v1/manifest.json +1 -1
package/dist/commands/build.js
CHANGED
|
@@ -9,7 +9,8 @@ import inquirer from 'inquirer';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
|
|
11
11
|
import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/delivery-build.js';
|
|
12
|
-
import { finalizeWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
|
|
12
|
+
import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
|
|
13
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
13
14
|
/**
|
|
14
15
|
* 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
|
|
15
16
|
*/
|
|
@@ -268,13 +269,21 @@ export async function buildCommand(projectPath, options) {
|
|
|
268
269
|
if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
|
|
269
270
|
throw new Error('preview_not_single_file');
|
|
270
271
|
await finalizeWorkspacePlayable(workspace.root, await fs.readFile(path.join(output, entries[0].name)), 'playcraft build', { expectedSignature: signature });
|
|
272
|
+
console.log(path.join(workspace.root, 'dist/playable/index.html'));
|
|
273
|
+
try {
|
|
274
|
+
const { bytes } = await readWorkspacePlayable(workspace.root);
|
|
275
|
+
const { previewUrl } = await new AgentApiClient().publishWorkspacePlayable({ workspaceId: workspace.workspaceId, directoryName: path.basename(workspace.root) }, bytes);
|
|
276
|
+
console.log(`Public URL: ${previewUrl}`);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
console.warn(`Public URL unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
271
282
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
console.log(canonicalOutput ? path.join(workspace.root, 'dist/playable/index.html') : requestedOutput);
|
|
283
|
+
if (options.clean === true)
|
|
284
|
+
await fs.rm(requestedOutput, { recursive: true, force: true });
|
|
285
|
+
await fs.cp(output, requestedOutput, { recursive: true });
|
|
286
|
+
console.log(requestedOutput);
|
|
278
287
|
return;
|
|
279
288
|
}
|
|
280
289
|
finally {
|
|
@@ -11,6 +11,7 @@ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
|
11
11
|
import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
|
|
12
12
|
import { beginToolOperation, findToolOperation } from './tool-operation-journal.js';
|
|
13
13
|
import { businessEndpoint } from './environment-endpoints.js';
|
|
14
|
+
import { PLATFORM_ROUTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
14
15
|
import { cloudRuntimeCredentialHint, isWorkspaceGenerationPath, withGenerationWorkspaceScope } from './generation-workspace-scope.js';
|
|
15
16
|
import { agentGenerationProvenanceHeaders } from './agent-generation-provenance.js';
|
|
16
17
|
import { redactDiagnosticText } from './error-diagnostics.js';
|
|
@@ -301,6 +302,27 @@ export class AgentApiClient {
|
|
|
301
302
|
}
|
|
302
303
|
await response.body?.cancel();
|
|
303
304
|
}
|
|
305
|
+
/** Publish a workspace's single-file playable through the runtime with the sandbox credential. */
|
|
306
|
+
async publishWorkspacePlayable(query, html, signal = AbortSignal.timeout(2 * 60_000)) {
|
|
307
|
+
const url = businessEndpoint(this.apiUrl, PLATFORM_ROUTES.cliPlayablePublications);
|
|
308
|
+
url.searchParams.set('workspaceId', query.workspaceId);
|
|
309
|
+
url.searchParams.set('directoryName', query.directoryName);
|
|
310
|
+
const response = await fetch(url, {
|
|
311
|
+
method: 'POST', redirect: 'error', signal,
|
|
312
|
+
headers: { ...this.authHeaders(), 'content-type': 'application/octet-stream' },
|
|
313
|
+
body: html,
|
|
314
|
+
});
|
|
315
|
+
const text = await response.text().catch(() => response.statusText);
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const reason = responseErrorReason(text) || redactDiagnosticText(response.statusText);
|
|
318
|
+
throw new Error(`playable_publication_failed:${response.status}${reason ? `: ${reason}` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
const previewUrl = JSON.parse(text).previewUrl;
|
|
321
|
+
if (typeof previewUrl !== 'string' || !/^https?:$/.test(new URL(previewUrl).protocol)) {
|
|
322
|
+
throw new Error('playable_publication_failed: invalid_public_url');
|
|
323
|
+
}
|
|
324
|
+
return { previewUrl };
|
|
325
|
+
}
|
|
304
326
|
/**
|
|
305
327
|
* 按顺序搜索本地 .playcraft.json 文件(沙箱内或项目目录)。
|
|
306
328
|
* 与全局 login 配置(~/.playcraft/config.json)不同,前者专用于沙箱 token。
|
|
@@ -17,6 +17,7 @@ const credentialSchema = z.object({
|
|
|
17
17
|
generation: z.number().int(), runtimeEpoch: z.number().int(),
|
|
18
18
|
}),
|
|
19
19
|
});
|
|
20
|
+
const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
|
|
20
21
|
/** Keeps renewable execution credentials in control memory, outside Codex and project files. */
|
|
21
22
|
export class WorkspaceModelBroker {
|
|
22
23
|
state;
|
|
@@ -213,6 +214,16 @@ export class WorkspaceModelBroker {
|
|
|
213
214
|
await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
217
|
+
const rejectOversizedBody = () => {
|
|
218
|
+
response.writeHead(413, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
219
|
+
response.end(JSON.stringify({ error: { message: 'model_request_too_large',
|
|
220
|
+
type: 'invalid_request_error', code: 'model_request_too_large', maxBytes: MAX_MODEL_REQUEST_BYTES } }));
|
|
221
|
+
};
|
|
222
|
+
const declaredLength = request.headers['content-length'];
|
|
223
|
+
if (declaredLength !== undefined && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
|
|
224
|
+
rejectOversizedBody();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
216
227
|
activeRoute = route;
|
|
217
228
|
const active = this.sessionRequests.get(route) ?? new Set();
|
|
218
229
|
active.add(abort);
|
|
@@ -222,9 +233,8 @@ export class WorkspaceModelBroker {
|
|
|
222
233
|
let size = 0;
|
|
223
234
|
for await (const chunk of request) {
|
|
224
235
|
size += chunk.length;
|
|
225
|
-
if (size >
|
|
226
|
-
|
|
227
|
-
response.end();
|
|
236
|
+
if (size > MAX_MODEL_REQUEST_BYTES) {
|
|
237
|
+
rejectOversizedBody();
|
|
228
238
|
return;
|
|
229
239
|
}
|
|
230
240
|
chunks.push(Buffer.from(chunk));
|
|
@@ -5,18 +5,19 @@ import { constants } from 'node:fs';
|
|
|
5
5
|
import { lstat, mkdtemp, open, readdir, rm } from 'node:fs/promises';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
|
+
import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, WORKSPACE_PLAYABLE_OUTPUT_PATH, workspaceModelBuildSchema, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
9
|
import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
|
|
10
10
|
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
11
11
|
import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
|
|
12
12
|
import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
|
|
13
|
-
import { finalizeWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
|
|
13
|
+
import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
|
|
14
14
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
15
15
|
import { isolateWorkspaceForBuild } from '../workspaces/delivery-build.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';
|
|
19
19
|
import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
|
|
20
|
+
import { readWorkspaceModelBuildInputs, verifyWorkspaceModelBuildInputs, markWorkspaceModelBuild } from '../workspaces/model-build-inputs.js';
|
|
20
21
|
import { driftScope, permissionDriftFromOutput, repairWorkspaceShares, } from '../workspaces/workspace-shares.js';
|
|
21
22
|
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
22
23
|
/** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
|
|
@@ -86,7 +87,10 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
86
87
|
await operationLog.append(operation.operationId, `$ preview build\n`).catch(() => undefined);
|
|
87
88
|
try {
|
|
88
89
|
await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
|
|
90
|
+
const modelInputs = await readWorkspaceModelBuildInputs(root, state.config.stateDir);
|
|
89
91
|
const runtimePreview = await prepareRuntimePreview(state, operation, root);
|
|
92
|
+
if (modelInputs && runtimePreview)
|
|
93
|
+
throw new PreviewFailure('model_build_runtime_preview_unsupported');
|
|
90
94
|
remotePreview = Boolean(runtimePreview);
|
|
91
95
|
if (runtimePreview)
|
|
92
96
|
await excludeFromGit(root, '/.playcraft-editor-preview/');
|
|
@@ -100,11 +104,31 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
100
104
|
await openScratchToDevelopment(buildCopy, (await lstat(root)).gid);
|
|
101
105
|
const project = join(buildCopy, 'project'), staged = join(buildCopy, 'output');
|
|
102
106
|
await isolateWorkspaceForBuild(root, project);
|
|
107
|
+
if (modelInputs)
|
|
108
|
+
await verifyWorkspaceModelBuildInputs(project, modelInputs);
|
|
103
109
|
await buildPreview(state, project, staged, operation.operationId, output, appendLog);
|
|
104
|
-
|
|
110
|
+
if (modelInputs)
|
|
111
|
+
await verifyWorkspaceModelBuildInputs(project, modelInputs);
|
|
112
|
+
if (modelInputs)
|
|
113
|
+
await verifyWorkspaceModelBuildInputs(root, modelInputs);
|
|
114
|
+
const built = await readSingleHtml(staged);
|
|
115
|
+
const finalBytes = modelInputs ? markWorkspaceModelBuild(built, modelInputs) : built;
|
|
116
|
+
await finalizeWorkspacePlayable(root, finalBytes, 'preview.publish', {
|
|
105
117
|
expectedSignature: signature, source: { operationId: operation.operationId, ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) },
|
|
106
118
|
});
|
|
107
|
-
|
|
119
|
+
if (modelInputs) {
|
|
120
|
+
await verifyWorkspaceModelBuildInputs(root, modelInputs);
|
|
121
|
+
const finalized = await readWorkspacePlayable(root);
|
|
122
|
+
if (!finalized.bytes.equals(injectPreviewDiagnostics(finalBytes)))
|
|
123
|
+
throw new PreviewFailure('model_build_output_changed');
|
|
124
|
+
const modelBuild = workspaceModelBuildSchema.parse({ ...modelInputs, buildOperationId: operation.gatewayOperationId ?? operation.operationId,
|
|
125
|
+
outputHash: createHash('sha256').update(finalized.bytes).digest('hex') });
|
|
126
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source: { kind: 'generated', tool: 'preview.publish', operationId: operation.operationId,
|
|
127
|
+
...(operation.sessionId ? { sessionId: operation.sessionId } : {}) }, paths: [WORKSPACE_PLAYABLE_OUTPUT_PATH],
|
|
128
|
+
playable: { mode: 'success', modelBuild } }, operation.directoryName ?? 'basic');
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
|
|
108
132
|
return;
|
|
109
133
|
}
|
|
110
134
|
await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview.command);
|
|
@@ -35,6 +35,7 @@ import { startSourceEventUploader, stopSourceEventUploader } from './server/sour
|
|
|
35
35
|
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
36
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
37
37
|
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
38
|
+
import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
|
|
38
39
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
39
40
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
40
41
|
const negotiated = negotiateProtocol(config);
|
|
@@ -79,6 +80,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
79
80
|
instanceToken: randomUUID(),
|
|
80
81
|
};
|
|
81
82
|
const recoveredImports = new Map();
|
|
83
|
+
await recoverModelPreviewOperations(state, { identityMatches: previousIdentityMatches,
|
|
84
|
+
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
85
|
+
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
86
|
+
? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
|
|
82
87
|
const recoveredChanges = [];
|
|
83
88
|
const recoveredWrites = [];
|
|
84
89
|
const recoverLibraryImports = async (root) => {
|
|
@@ -8,7 +8,7 @@ const instances = new WeakMap();
|
|
|
8
8
|
const executing = new WeakMap();
|
|
9
9
|
const freezing = new WeakMap();
|
|
10
10
|
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
-
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'directory.create',
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'model.preview', 'directory.create',
|
|
12
12
|
'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
|
|
13
13
|
'checkpoint.create', 'checkpoint.restore']);
|
|
14
14
|
export function managedDirectoryScope(state, value) {
|
|
@@ -6,7 +6,7 @@ import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
|
6
6
|
import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } 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, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, 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';
|
|
@@ -43,6 +43,7 @@ import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.j
|
|
|
43
43
|
import { publishAssetTransfer } from '../workspaces/asset-transfer-publish.js';
|
|
44
44
|
import { AssetTransferError } from '../workspaces/asset-transfer-storage.js';
|
|
45
45
|
import { runDeliveryBuild, cancelDeliveryBuild, trackDeliveryBuild, DeliveryBuildError } from '../workspaces/delivery-build.js';
|
|
46
|
+
import { runModelPreviewBuild, cancelModelPreview, modelPreviewError } from '../workspaces/procedural-3d/model-preview-build.js';
|
|
46
47
|
const CONVERSATION_KINDS = new Set([
|
|
47
48
|
'session.create',
|
|
48
49
|
'session.turn',
|
|
@@ -77,6 +78,8 @@ const LOCAL_KINDS = new Set([
|
|
|
77
78
|
'preview.publish',
|
|
78
79
|
'delivery.build',
|
|
79
80
|
'delivery.cancel',
|
|
81
|
+
'model.preview',
|
|
82
|
+
'model.preview.cancel',
|
|
80
83
|
'skills.update',
|
|
81
84
|
]);
|
|
82
85
|
const parameterVerifications = new WeakMap();
|
|
@@ -120,7 +123,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
120
123
|
return executeScopedOperation(state, scopeKey, operation, payload, recovery);
|
|
121
124
|
}, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
|
|
122
125
|
};
|
|
123
|
-
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
|
|
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)) {
|
|
124
127
|
return run();
|
|
125
128
|
}
|
|
126
129
|
return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
|
|
@@ -202,6 +205,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
202
205
|
if (operation.kind === 'delivery.cancel') {
|
|
203
206
|
return executeDeliveryCancel(state, scopeKey, current, payload);
|
|
204
207
|
}
|
|
208
|
+
if (operation.kind === 'model.preview' || operation.kind === 'model.preview.cancel') {
|
|
209
|
+
return executeModelPreview(state, scopeKey, current, payload);
|
|
210
|
+
}
|
|
205
211
|
if (!state.native?.ready()) {
|
|
206
212
|
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
|
|
207
213
|
}
|
|
@@ -1504,6 +1510,29 @@ async function executePreviewPublish(state, scopeKey, operation) {
|
|
|
1504
1510
|
}).catch(() => undefined));
|
|
1505
1511
|
return accepted;
|
|
1506
1512
|
}
|
|
1513
|
+
async function executeModelPreview(state, scopeKey, operation, payload) {
|
|
1514
|
+
if (!operation.workspaceId || operation.directoryName === 'root')
|
|
1515
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_preview_scope_invalid' }));
|
|
1516
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1517
|
+
if (operation.kind === 'model.preview.cancel') {
|
|
1518
|
+
try {
|
|
1519
|
+
const input = workspaceModelPreviewCancelSchema.parse(payload);
|
|
1520
|
+
await cancelModelPreview(state, operation.workspaceId, operation.directoryName ?? 'basic', input.previewId);
|
|
1521
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: modelPreviewError(error) }));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const parsed = workspaceModelPreviewBuildSchema.safeParse(payload);
|
|
1528
|
+
if (!parsed.success)
|
|
1529
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: 'model_preview_invalid_request' }));
|
|
1530
|
+
trackPreviewPublish(state, runModelPreviewBuild(state, accepted, parsed.data).then(async () => { await persist(state, scopeKey, transitionOperation(accepted, 'completed')); }, async (error) => {
|
|
1531
|
+
const code = modelPreviewError(error);
|
|
1532
|
+
await persist(state, scopeKey, transitionOperation(accepted, code === 'model_preview_process_unknown' ? 'uncertain' : code === 'model_preview_cancelled' ? 'interrupted' : 'failed', { error: code }));
|
|
1533
|
+
}).catch(() => undefined));
|
|
1534
|
+
return accepted;
|
|
1535
|
+
}
|
|
1507
1536
|
async function executeDeliveryBuild(state, scopeKey, operation, payload) {
|
|
1508
1537
|
const parsed = deliveryBuildPayloadSchema.safeParse(payload);
|
|
1509
1538
|
if (!parsed.success) {
|
|
@@ -47,6 +47,8 @@ import { restoreNativeSession, SessionRestoreError } from './session-restore.js'
|
|
|
47
47
|
import { handleAssetTransferRequest } from './asset-transfers.js';
|
|
48
48
|
import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktrees.js';
|
|
49
49
|
import { withDirectoryWrite } from './directory-access.js';
|
|
50
|
+
import { readProceduralModelCandidates } from '../workspaces/procedural-3d/read-candidates.js';
|
|
51
|
+
import { ModelPreviewStore } from '../workspaces/procedural-3d/model-preview-store.js';
|
|
50
52
|
export async function handleRuntimeRequest(state, request, response) {
|
|
51
53
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
52
54
|
const workspaceId = url.searchParams.get('workspaceId');
|
|
@@ -531,6 +533,40 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
531
533
|
writeJson(response, 200, deliveryBuildDetailSchema.parse({ record, ...(report ? { report } : {}) }));
|
|
532
534
|
return;
|
|
533
535
|
}
|
|
536
|
+
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/model-preview-content') {
|
|
537
|
+
if (writeDestroyed(state, response))
|
|
538
|
+
return;
|
|
539
|
+
const workspaceId = url.searchParams.get('workspaceId');
|
|
540
|
+
if (!workspaceId || directoryName === 'root') {
|
|
541
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
545
|
+
try {
|
|
546
|
+
const output = await new ModelPreviewStore(state.config.stateDir, root).content(url.searchParams.get('previewId') ?? '');
|
|
547
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': output.bytes.length,
|
|
548
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-content-sha256': output.record.contentHash,
|
|
549
|
+
'content-security-policy': "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'",
|
|
550
|
+
'referrer-policy': 'no-referrer' });
|
|
551
|
+
response.end(request.method === 'HEAD' ? undefined : output.bytes);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
writeJson(response, 404, { error: 'model_preview_unavailable' });
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (request.method === 'GET' && url.pathname === '/runtime/model-candidates') {
|
|
559
|
+
if (writeDestroyed(state, response))
|
|
560
|
+
return;
|
|
561
|
+
const workspaceId = url.searchParams.get('workspaceId');
|
|
562
|
+
if (!workspaceId || directoryName === 'root') {
|
|
563
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
567
|
+
writeJson(response, 200, await readProceduralModelCandidates(root, state.config.stateDir));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
534
570
|
if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
|
|
535
571
|
if (writeDestroyed(state, response))
|
|
536
572
|
return;
|
|
@@ -3,12 +3,13 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import sharp from 'sharp';
|
|
6
|
-
import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, workspaceModelBuildSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
|
|
8
8
|
import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
|
|
9
9
|
import { ensureSharedDirectory } from './shared-write.js';
|
|
10
10
|
import { validateWorkspacePlayable } from '../previews/finalize.js';
|
|
11
11
|
import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
|
|
12
|
+
import { applyWorkspaceModelBuildStates, sameWorkspaceModelBuild } from './model-build-inputs.js';
|
|
12
13
|
const serial = new Map();
|
|
13
14
|
function digest(text) { return createHash('sha256').update(text).digest('hex'); }
|
|
14
15
|
function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
|
|
@@ -86,7 +87,8 @@ export class WorkspaceArtifactStore {
|
|
|
86
87
|
const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths, playable));
|
|
87
88
|
serial.set(key, task);
|
|
88
89
|
try {
|
|
89
|
-
|
|
90
|
+
const result = await task;
|
|
91
|
+
return { ...result, list: await applyWorkspaceModelBuildStates(root, this.stateDir, result.list) };
|
|
90
92
|
}
|
|
91
93
|
finally {
|
|
92
94
|
if (serial.get(key) === task)
|
|
@@ -214,6 +216,17 @@ export class WorkspaceArtifactStore {
|
|
|
214
216
|
}
|
|
215
217
|
catch { /* Files being written or temporarily missing are retried, never published as complete. */ }
|
|
216
218
|
}
|
|
219
|
+
if (playable.modelBuild) {
|
|
220
|
+
const modelBuild = workspaceModelBuildSchema.parse(playable.modelBuild);
|
|
221
|
+
const files = await snapshotArtifactBundle(root, WORKSPACE_PLAYABLE_OUTPUT_PATH, objects);
|
|
222
|
+
const primary = files[0];
|
|
223
|
+
if (primary.sha256 !== modelBuild.outputHash || source.kind !== 'generated' || !source.operationId)
|
|
224
|
+
throw new Error('artifact_model_build_mismatch');
|
|
225
|
+
const version = { id: `version_${digest(`${modelBuild.buildOperationId}\0${modelBuild.inputHash}\0${primary.sha256}`).slice(0, 48)}`,
|
|
226
|
+
createdAt: new Date().toISOString(), path: WORKSPACE_PLAYABLE_OUTPUT_PATH, mimeType: primary.mimeType,
|
|
227
|
+
size: primary.size, sha256: primary.sha256, files, source, modelBuild };
|
|
228
|
+
await this.add(catalog, version, await this.versionKind(objects, version), objects);
|
|
229
|
+
}
|
|
217
230
|
catalog.artifacts = catalog.artifacts.filter(artifact => {
|
|
218
231
|
const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
|
|
219
232
|
return !isWorkspaceArtifactOutputVersion(current) || current.source.kind !== 'discovered' || !members.has(current.path);
|
|
@@ -267,7 +280,8 @@ export class WorkspaceArtifactStore {
|
|
|
267
280
|
if (this.directoryName !== 'basic')
|
|
268
281
|
version.directoryName = this.directoryName;
|
|
269
282
|
const current = artifact.versions.find(item => item.id === artifact.currentVersionId);
|
|
270
|
-
if (kind === 'playable' && current && artifactBundleDigest(current.files) === artifactBundleDigest(version.files)
|
|
283
|
+
if (kind === 'playable' && current && artifactBundleDigest(current.files) === artifactBundleDigest(version.files) &&
|
|
284
|
+
(!version.modelBuild || sameWorkspaceModelBuild(current.modelBuild, version.modelBuild)))
|
|
271
285
|
return;
|
|
272
286
|
if (!artifact.versions.some(item => item.id === version.id)) {
|
|
273
287
|
// Display ordering is monotonic; the original generation time stays in source.generatedAt.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { WORKSPACE_MODEL_BUILD_INPUTS_PATH, workspaceModelBuildInputsSchema, workspaceRelativePathSchema, workspaceModelPreviewParametersSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { checkedFile, dependencyHash } from './procedural-3d/files.js';
|
|
5
|
+
import { canonicalJson } from './procedural-3d/review-contracts.js';
|
|
6
|
+
import { readProceduralModelCandidates } from './procedural-3d/read-candidates.js';
|
|
7
|
+
import { ModelPreviewStore } from './procedural-3d/model-preview-store.js';
|
|
8
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const sorted = (files) => [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
10
|
+
const fileHash = (files) => digest(canonicalJson(sorted(files)));
|
|
11
|
+
const MAX_INPUT_BYTES = 256 * 1024 * 1024;
|
|
12
|
+
const MAX_INPUT_FILES = 512;
|
|
13
|
+
function unique(files) {
|
|
14
|
+
const byPath = new Map();
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const previous = byPath.get(file.path);
|
|
17
|
+
if (previous && previous.contentHash !== file.contentHash)
|
|
18
|
+
throw new Error('model_build_input_conflict');
|
|
19
|
+
byPath.set(file.path, file);
|
|
20
|
+
}
|
|
21
|
+
if (byPath.size > 256)
|
|
22
|
+
throw new Error('model_build_input_limit');
|
|
23
|
+
return sorted([...byPath.values()]);
|
|
24
|
+
}
|
|
25
|
+
function resourcePaths(path, bytes) {
|
|
26
|
+
let json = bytes;
|
|
27
|
+
if (/\.glb$/i.test(path)) {
|
|
28
|
+
if (bytes.length < 20 || bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 ||
|
|
29
|
+
bytes.readUInt32LE(8) !== bytes.length || bytes.readUInt32LE(16) !== 0x4e4f534a)
|
|
30
|
+
throw new Error('model_build_resource_invalid');
|
|
31
|
+
const size = bytes.readUInt32LE(12);
|
|
32
|
+
if (size < 2 || size > 8 * 1024 * 1024 || size + 20 > bytes.length)
|
|
33
|
+
throw new Error('model_build_resource_invalid');
|
|
34
|
+
json = bytes.subarray(20, 20 + size);
|
|
35
|
+
}
|
|
36
|
+
if (json.length > 8 * 1024 * 1024)
|
|
37
|
+
throw new Error('model_build_resource_invalid');
|
|
38
|
+
let document;
|
|
39
|
+
try {
|
|
40
|
+
document = JSON.parse(json.toString('utf8').replace(/\0+$/, ''));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error('model_build_resource_invalid');
|
|
44
|
+
}
|
|
45
|
+
if (!document || document.asset?.version !== '2.0' ||
|
|
46
|
+
document.buffers !== undefined && !Array.isArray(document.buffers) || document.images !== undefined && !Array.isArray(document.images))
|
|
47
|
+
throw new Error('model_build_resource_invalid');
|
|
48
|
+
const paths = new Set([path]);
|
|
49
|
+
for (const item of [...document.buffers ?? [], ...document.images ?? []]) {
|
|
50
|
+
if (item?.uri === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (typeof item.uri !== 'string')
|
|
53
|
+
throw new Error('model_build_resource_invalid');
|
|
54
|
+
if (item.uri.startsWith('data:'))
|
|
55
|
+
continue;
|
|
56
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(item.uri) || /[\\?#]/.test(item.uri) || item.uri.startsWith('/'))
|
|
57
|
+
throw new Error('model_build_external_resource');
|
|
58
|
+
let decoded;
|
|
59
|
+
try {
|
|
60
|
+
decoded = decodeURIComponent(item.uri);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error('model_build_resource_invalid');
|
|
64
|
+
}
|
|
65
|
+
const relative = posix.normalize(posix.join(posix.dirname(path), decoded));
|
|
66
|
+
if (!workspaceRelativePathSchema.safeParse(relative).success || /^[a-z][a-z\d+.-]*:/i.test(decoded) || /[\\?#]/.test(decoded) || decoded.startsWith('/'))
|
|
67
|
+
throw new Error('model_build_resource_invalid');
|
|
68
|
+
paths.add(relative);
|
|
69
|
+
}
|
|
70
|
+
return [...paths].sort();
|
|
71
|
+
}
|
|
72
|
+
/** Capture declared inputs from stable files; project declarations cannot assert host verification. */
|
|
73
|
+
export async function readWorkspaceModelBuildInputs(root, stateDir) {
|
|
74
|
+
let manifest;
|
|
75
|
+
try {
|
|
76
|
+
manifest = await checkedFile(root, WORKSPACE_MODEL_BUILD_INPUTS_PATH, 512 * 1024);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT')
|
|
80
|
+
return undefined;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
let declaration;
|
|
84
|
+
try {
|
|
85
|
+
declaration = workspaceModelBuildInputsSchema.parse(JSON.parse(manifest.toString('utf8')));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error('model_build_manifest_invalid');
|
|
89
|
+
}
|
|
90
|
+
const cache = new Map();
|
|
91
|
+
let bytesRead = manifest.length;
|
|
92
|
+
const read = async (path, expected) => {
|
|
93
|
+
let bytes = cache.get(path);
|
|
94
|
+
if (!bytes) {
|
|
95
|
+
if (cache.size >= MAX_INPUT_FILES || bytesRead >= MAX_INPUT_BYTES)
|
|
96
|
+
throw new Error('model_build_input_limit');
|
|
97
|
+
bytes = await checkedFile(root, path, Math.min(64 * 1024 * 1024, MAX_INPUT_BYTES - bytesRead));
|
|
98
|
+
cache.set(path, bytes);
|
|
99
|
+
bytesRead += bytes.length;
|
|
100
|
+
}
|
|
101
|
+
const contentHash = digest(bytes);
|
|
102
|
+
if (expected !== undefined && expected !== contentHash)
|
|
103
|
+
throw new Error('model_build_input_changed');
|
|
104
|
+
return { bytes, file: { path, contentHash } };
|
|
105
|
+
};
|
|
106
|
+
const candidates = declaration.models.some(model => model.source.kind === 'procedural')
|
|
107
|
+
? await readProceduralModelCandidates(root, stateDir) : undefined;
|
|
108
|
+
const models = [];
|
|
109
|
+
for (const model of declaration.models) {
|
|
110
|
+
const inputs = [];
|
|
111
|
+
const adaptations = [];
|
|
112
|
+
for (const file of model.adaptationFiles)
|
|
113
|
+
adaptations.push((await read(file.path, file.contentHash)).file);
|
|
114
|
+
const source = model.source;
|
|
115
|
+
if (source.kind === 'resource') {
|
|
116
|
+
const primary = await read(source.path, source.contentHash);
|
|
117
|
+
if (JSON.stringify(resourcePaths(source.path, primary.bytes)) !== JSON.stringify(source.files.map(file => file.path).sort()))
|
|
118
|
+
throw new Error('model_build_resource_manifest_mismatch');
|
|
119
|
+
for (const file of source.files)
|
|
120
|
+
inputs.push((await read(file.path, file.contentHash)).file);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const candidate = candidates?.candidates.find(item => item.runId === source.runId && item.candidateId === source.candidateId);
|
|
124
|
+
if (!candidate || candidate.integrity !== 'verified' || candidate.sourceHash !== source.sourceHash ||
|
|
125
|
+
candidate.dependencyHash !== source.dependencyHash)
|
|
126
|
+
throw new Error('model_build_source_unavailable');
|
|
127
|
+
const preview = await new ModelPreviewStore(stateDir, root).content(source.previewId);
|
|
128
|
+
if (preview.record.runId !== source.runId || preview.record.candidateId !== source.candidateId ||
|
|
129
|
+
preview.record.sourceHash !== source.sourceHash || preview.record.dependencyHash !== source.dependencyHash ||
|
|
130
|
+
preview.record.contentHash !== source.contentHash || preview.record.parameterHash !== source.parameterHash || !preview.record.parameters)
|
|
131
|
+
throw new Error('model_build_source_changed');
|
|
132
|
+
const prefix = `.playcraft/procedural-3d/runs/${source.runId}/candidates/${source.candidateId}/source/`;
|
|
133
|
+
for (const file of candidate.files) {
|
|
134
|
+
if (!file.path.startsWith(prefix))
|
|
135
|
+
throw new Error('model_build_source_changed');
|
|
136
|
+
const path = file.path.slice(prefix.length);
|
|
137
|
+
if (path.startsWith('__playcraft-review/'))
|
|
138
|
+
continue;
|
|
139
|
+
inputs.push((await read(posix.join(source.sourceRoot, path), file.contentHash)).file);
|
|
140
|
+
}
|
|
141
|
+
const parameters = await read(source.parametersPath);
|
|
142
|
+
try {
|
|
143
|
+
const values = workspaceModelPreviewParametersSchema.parse(JSON.parse(parameters.bytes.toString('utf8')));
|
|
144
|
+
if (canonicalJson(values) !== canonicalJson(preview.record.parameters))
|
|
145
|
+
throw new Error('mismatch');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new Error('model_build_parameters_changed');
|
|
149
|
+
}
|
|
150
|
+
adaptations.push(parameters.file);
|
|
151
|
+
}
|
|
152
|
+
const adaptationFiles = unique(adaptations);
|
|
153
|
+
models.push({ source, adaptationFiles, adaptationHash: fileHash(adaptationFiles), inputs: unique([...inputs, ...adaptationFiles]) });
|
|
154
|
+
}
|
|
155
|
+
const manifestHash = digest(manifest);
|
|
156
|
+
return { schemaVersion: 1, manifestPath: WORKSPACE_MODEL_BUILD_INPUTS_PATH, manifestHash,
|
|
157
|
+
inputHash: digest(canonicalJson({ manifestHash, models })), hostVerified: true, behaviorValidation: 'pending', models };
|
|
158
|
+
}
|
|
159
|
+
/** Validate the isolated copy and later the live inputs against this build's frozen snapshot. */
|
|
160
|
+
export async function verifyWorkspaceModelBuildInputs(root, snapshot) {
|
|
161
|
+
if (digest(await checkedFile(root, snapshot.manifestPath, 512 * 1024)) !== snapshot.manifestHash)
|
|
162
|
+
throw new Error('model_build_input_changed');
|
|
163
|
+
const files = unique(snapshot.models.flatMap(model => model.inputs));
|
|
164
|
+
let bytesRead = 0;
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
if (bytesRead >= MAX_INPUT_BYTES)
|
|
167
|
+
throw new Error('model_build_input_limit');
|
|
168
|
+
const bytes = await checkedFile(root, file.path, Math.min(64 * 1024 * 1024, MAX_INPUT_BYTES - bytesRead));
|
|
169
|
+
bytesRead += bytes.length;
|
|
170
|
+
if (digest(bytes) !== file.contentHash)
|
|
171
|
+
throw new Error('model_build_input_changed');
|
|
172
|
+
}
|
|
173
|
+
for (const model of snapshot.models)
|
|
174
|
+
if (model.source.kind === 'procedural' &&
|
|
175
|
+
await dependencyHash(root) !== model.source.dependencyHash)
|
|
176
|
+
throw new Error('model_build_input_changed');
|
|
177
|
+
}
|
|
178
|
+
/** The marker makes otherwise identical HTML from different input declarations distinct artifacts. */
|
|
179
|
+
export function markWorkspaceModelBuild(bytes, snapshot) {
|
|
180
|
+
const html = bytes.toString('utf8').replace(/^<!--playcraft-model-inputs:v1:[a-f0-9]{64}:[a-f0-9]{64}-->\n/, '');
|
|
181
|
+
return Buffer.from(`<!--playcraft-model-inputs:v1:${snapshot.manifestHash}:${snapshot.inputHash}-->\n${html}`);
|
|
182
|
+
}
|
|
183
|
+
/** A current claim is calculated for this live read, never persisted as historical truth. */
|
|
184
|
+
export async function applyWorkspaceModelBuildStates(root, stateDir, list) {
|
|
185
|
+
if (!list.artifacts.some(artifact => artifact.versions.some(version => version.modelBuild)))
|
|
186
|
+
return list;
|
|
187
|
+
let current, unavailable = false;
|
|
188
|
+
try {
|
|
189
|
+
current = await readWorkspaceModelBuildInputs(root, stateDir);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
unavailable = error instanceof Error && /unavailable|limit|EACCES|EPERM/.test(error.message);
|
|
193
|
+
}
|
|
194
|
+
return { ...list, artifacts: list.artifacts.map(artifact => ({ ...artifact, versions: artifact.versions.map(version => version.modelBuild
|
|
195
|
+
? { ...version, modelBuildState: unavailable ? 'unavailable' : current &&
|
|
196
|
+
current.manifestHash === version.modelBuild.manifestHash && current.inputHash === version.modelBuild.inputHash ? 'current' : 'stale' }
|
|
197
|
+
: version) })) };
|
|
198
|
+
}
|
|
199
|
+
export function sameWorkspaceModelBuild(left, right) {
|
|
200
|
+
return left?.manifestHash === right?.manifestHash && left?.inputHash === right?.inputHash && left?.outputHash === right?.outputHash;
|
|
201
|
+
}
|