@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.
Files changed (23) hide show
  1. package/dist/commands/build.js +16 -7
  2. package/dist/utils/agent-api-client.js +22 -0
  3. package/dist/workspace-runtime/codex/model-broker.js +13 -3
  4. package/dist/workspace-runtime/previews/publish.js +28 -4
  5. package/dist/workspace-runtime/serve.js +5 -0
  6. package/dist/workspace-runtime/server/directory-access.js +1 -1
  7. package/dist/workspace-runtime/server/dispatch.js +31 -2
  8. package/dist/workspace-runtime/server/http.js +36 -0
  9. package/dist/workspace-runtime/workspaces/artifacts.js +17 -3
  10. package/dist/workspace-runtime/workspaces/model-build-inputs.js +201 -0
  11. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
  12. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
  13. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
  14. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
  15. package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
  16. package/package.json +4 -3
  17. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +13 -3
  18. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +12 -0
  19. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +1 -0
  20. package/project-template-v2/.playcraft/skills.lock.json +2 -2
  21. package/project-templates/empty/AGENTS.md +1 -1
  22. package/templates/preview-runtime/v1/README.md +20 -0
  23. package/templates/preview-runtime/v1/manifest.json +1 -1
@@ -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
- else {
273
- if (options.clean === true)
274
- await fs.rm(requestedOutput, { recursive: true, force: true });
275
- await fs.cp(output, requestedOutput, { recursive: true });
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 > 16 * 1024 * 1024) {
226
- response.writeHead(413);
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
- await finalizeWorkspacePlayable(root, await readSingleHtml(staged), 'preview.publish', {
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
- await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
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
- return await task;
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
+ }