@playcraft/cli 0.1.61-beta → 0.1.62-beta
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/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/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-templates/empty/AGENTS.md +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));
|
|
@@ -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;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, writeFile, lstat, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { workspaceRoot, revisionScope } from '../registry.js';
|
|
7
|
+
import { openDirectoryToDevelopment, openScratchToDevelopment } from '../directory-access.js';
|
|
8
|
+
import { runDeveloperCommand } from '../developer-command.js';
|
|
9
|
+
import { artifactMime } from '../artifact-files.js';
|
|
10
|
+
import { checkedFile, dependencyHash, digest } from './files.js';
|
|
11
|
+
import { readProceduralModelCandidates } from './read-candidates.js';
|
|
12
|
+
import { createHostReviewFiles } from './review-page.js';
|
|
13
|
+
import { canonicalJson, REVIEW_DIRECTORY, REVIEW_PAGE, reviewProfileSchema } from './review-contracts.js';
|
|
14
|
+
import { ModelPreviewStore, MODEL_PREVIEW_MAX_BYTES } from './model-preview-store.js';
|
|
15
|
+
import { recoverModelPreviewOperations } from './model-preview-recovery.js';
|
|
16
|
+
import { cancelPendingDirectoryAccess } from '../../server/directory-access.js';
|
|
17
|
+
const active = new WeakMap();
|
|
18
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
19
|
+
export const modelPreviewError = (error) => error instanceof Error && /^model_[a-z_]+$/.test(error.message)
|
|
20
|
+
? error.message : 'model_preview_build_failed';
|
|
21
|
+
/** Parse declarative fields and require exact equality with the host's own page generator. */
|
|
22
|
+
export function parseFrozenModelReview(bytes, helpers) {
|
|
23
|
+
const page = bytes.toString('utf8');
|
|
24
|
+
const string = '"(?:\\\\.|[^"\\\\])*"';
|
|
25
|
+
const match = (expression) => {
|
|
26
|
+
const value = new RegExp(expression).exec(page)?.[1];
|
|
27
|
+
if (!value)
|
|
28
|
+
throw new Error('model_preview_custom_page_unsupported');
|
|
29
|
+
return JSON.parse(value);
|
|
30
|
+
};
|
|
31
|
+
const path = match(`import \\* as factory from (${string});`);
|
|
32
|
+
const exportName = match(`const createModel = factory\\[(${string})\\];`);
|
|
33
|
+
const parameters = JSON.parse(match(`const parameters = JSON.parse\\((${string})\\);`));
|
|
34
|
+
const profile = reviewProfileSchema.parse(JSON.parse(match(`const profile = JSON.parse\\((${string})\\);`)));
|
|
35
|
+
if (!path.startsWith('/') || !/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/.test(exportName) ||
|
|
36
|
+
!parameters || typeof parameters !== 'object' || Array.isArray(parameters))
|
|
37
|
+
throw new Error('model_preview_custom_page_unsupported');
|
|
38
|
+
canonicalJson(parameters);
|
|
39
|
+
const factoryPath = path.slice(1).split('/').map(decodeURIComponent).join('/');
|
|
40
|
+
const review = { factoryPath, exportName, parameters, profile };
|
|
41
|
+
if (!createHostReviewFiles(review, helpers)[0].bytes.equals(bytes))
|
|
42
|
+
throw new Error('model_preview_custom_page_unsupported');
|
|
43
|
+
return review;
|
|
44
|
+
}
|
|
45
|
+
export async function cancelModelPreview(state, workspaceId, directoryName, previewId) {
|
|
46
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
47
|
+
const cancelled = await new ModelPreviewStore(state.config.stateDir, root).cancel(previewId);
|
|
48
|
+
const running = active.get(state)?.get(previewId);
|
|
49
|
+
if (cancelled && running && running.workspaceId === workspaceId && running.directoryName === directoryName)
|
|
50
|
+
running.controller.abort();
|
|
51
|
+
else if (cancelled) {
|
|
52
|
+
const storedTarget = state.journal.listOperations().find(item => (item.operation.gatewayOperationId ?? item.operation.operationId) === previewId);
|
|
53
|
+
const operation = storedTarget?.operation;
|
|
54
|
+
if (operation && operation.workspaceId === workspaceId && (operation.directoryName ?? 'basic') === directoryName) {
|
|
55
|
+
if (operation.status === 'queued') {
|
|
56
|
+
await cancelPendingDirectoryAccess(state, `operation:${operation.operationId}`);
|
|
57
|
+
const queued = state.journal.getOperationById(operation.operationId);
|
|
58
|
+
const stored = state.journal.listOperations().find(item => item.operation.operationId === operation.operationId);
|
|
59
|
+
if (queued?.status === 'queued' && stored) {
|
|
60
|
+
await state.journal.putOperation(stored.scopeKey, transitionOperation(queued, 'interrupted', { error: 'model_preview_cancelled' }));
|
|
61
|
+
await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await recoverModelPreviewOperations(state, { identityMatches: operation.generation === state.config.generation,
|
|
66
|
+
...(process.env.PLAYCRAFT_LAUNCHER_SOCKET ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) }, previewId);
|
|
67
|
+
if (state.journal.getOperationById(operation.operationId)?.status === 'uncertain')
|
|
68
|
+
throw new Error('model_preview_process_unknown');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function runModelPreviewBuild(state, operation, input, options = {}) {
|
|
73
|
+
if (!operation.workspaceId || operation.directoryName === 'root')
|
|
74
|
+
throw new Error('model_preview_scope_invalid');
|
|
75
|
+
const directoryName = operation.directoryName ?? 'basic';
|
|
76
|
+
const root = await workspaceRoot(state, operation.workspaceId, directoryName);
|
|
77
|
+
const store = new ModelPreviewStore(state.config.stateDir, root), previewId = operation.gatewayOperationId ?? operation.operationId;
|
|
78
|
+
if (await store.cancelled(previewId))
|
|
79
|
+
throw new Error('model_preview_cancelled');
|
|
80
|
+
const previous = await store.read(previewId);
|
|
81
|
+
if (previous) {
|
|
82
|
+
if (previous.runId !== input.runId || previous.candidateId !== input.candidateId || previous.sourceHash !== input.sourceHash ||
|
|
83
|
+
previous.dependencyHash !== input.dependencyHash || canonicalJson(previous.parameterOverrides ?? {}) !== canonicalJson(input.parameterOverrides ?? {}))
|
|
84
|
+
throw new Error('model_preview_binding_changed');
|
|
85
|
+
if (previous.status === 'ready') {
|
|
86
|
+
await store.content(previewId);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(previous.status === 'cancelled' ? 'model_preview_cancelled' : 'model_preview_recovery_required');
|
|
90
|
+
}
|
|
91
|
+
const listing = await readProceduralModelCandidates(root, state.config.stateDir);
|
|
92
|
+
const candidate = listing.candidates.find(item => item.runId === input.runId && item.candidateId === input.candidateId);
|
|
93
|
+
if (!candidate || candidate.integrity !== 'verified')
|
|
94
|
+
throw new Error('model_preview_candidate_unavailable');
|
|
95
|
+
if (candidate.sourceHash !== input.sourceHash || candidate.dependencyHash !== input.dependencyHash)
|
|
96
|
+
throw new Error('model_preview_binding_changed');
|
|
97
|
+
const prefix = `.playcraft/procedural-3d/runs/${input.runId}/candidates/${input.candidateId}/source/`;
|
|
98
|
+
if (candidate.preview.kind !== 'scene-preview' || candidate.preview.entryPath !== `${prefix}${REVIEW_PAGE}`)
|
|
99
|
+
throw new Error('model_preview_custom_page_unsupported');
|
|
100
|
+
const scene = await checkedFile(root, `${prefix}${REVIEW_DIRECTORY}/review-scene.mjs`);
|
|
101
|
+
const profile = await checkedFile(root, `${prefix}${REVIEW_DIRECTORY}/review-profile.mjs`);
|
|
102
|
+
const review = parseFrozenModelReview(await checkedFile(root, `${prefix}${REVIEW_PAGE}`), { scene, profile });
|
|
103
|
+
const parameters = { ...(review.parameters ?? {}) };
|
|
104
|
+
for (const [key, value] of Object.entries(input.parameterOverrides ?? {})) {
|
|
105
|
+
if (!Object.hasOwn(parameters, key) || typeof parameters[key] !== typeof value)
|
|
106
|
+
throw new Error('model_preview_parameter_unsupported');
|
|
107
|
+
parameters[key] = value;
|
|
108
|
+
}
|
|
109
|
+
const publicParameters = Object.fromEntries(Object.entries(parameters).filter(([, value]) => typeof value === 'boolean' || typeof value === 'string' && value.length <= 256 || typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 100000)
|
|
110
|
+
.filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key) && !['__proto__', 'prototype', 'constructor'].includes(key)).slice(0, 64));
|
|
111
|
+
if (!candidate.files.some(file => file.path === `${prefix}${review.factoryPath}` && file.source))
|
|
112
|
+
throw new Error('model_preview_factory_missing');
|
|
113
|
+
const record = { previewId, root, ...input, parameters: publicParameters,
|
|
114
|
+
parameterHash: digest(canonicalJson(publicParameters)), status: 'running', createdAt: new Date().toISOString() };
|
|
115
|
+
await store.write(record);
|
|
116
|
+
const controller = new AbortController(), known = active.get(state) ?? new Map();
|
|
117
|
+
active.set(state, known);
|
|
118
|
+
known.set(previewId, { workspaceId: operation.workspaceId, directoryName, controller });
|
|
119
|
+
let scratch, stopped = true;
|
|
120
|
+
const writeId = `model-preview-${previewId}`;
|
|
121
|
+
try {
|
|
122
|
+
if (await store.cancelled(previewId))
|
|
123
|
+
controller.abort();
|
|
124
|
+
if (controller.signal.aborted)
|
|
125
|
+
throw new Error('model_preview_cancelled');
|
|
126
|
+
scratch = await mkdtemp(join(tmpdir(), 'playcraft-model-preview-'));
|
|
127
|
+
await openScratchToDevelopment(scratch, (await lstat(root)).gid);
|
|
128
|
+
const sourceRoot = join(scratch, 'source');
|
|
129
|
+
await mkdir(sourceRoot);
|
|
130
|
+
const resources = {};
|
|
131
|
+
for (const file of candidate.files) {
|
|
132
|
+
const path = file.path.slice(prefix.length), bytes = await checkedFile(root, file.path);
|
|
133
|
+
if (digest(bytes) !== file.contentHash)
|
|
134
|
+
throw new Error('model_preview_binding_changed');
|
|
135
|
+
const target = join(sourceRoot, path);
|
|
136
|
+
await mkdir(dirname(target), { recursive: true });
|
|
137
|
+
await writeFile(target, bytes);
|
|
138
|
+
if (!/\.[cm]?[jt]sx?$|\.html$/i.test(path))
|
|
139
|
+
resources[path] = `data:${artifactMime(path)};base64,${bytes.toString('base64')}`;
|
|
140
|
+
}
|
|
141
|
+
const outputPath = join(scratch, 'output/index.html');
|
|
142
|
+
const config = { projectRoot: root, sourceRoot, outputPath,
|
|
143
|
+
factoryPath: review.factoryPath, exportName: review.exportName, parameters, profile: review.profile,
|
|
144
|
+
resources, previewId, runId: input.runId, candidateId: input.candidateId, sourceHash: input.sourceHash };
|
|
145
|
+
const configPath = join(scratch, 'input.json');
|
|
146
|
+
await writeFile(configPath, JSON.stringify(config));
|
|
147
|
+
await openDirectoryToDevelopment(scratch, (await lstat(root)).gid);
|
|
148
|
+
const worker = options.workerPath ?? fileURLToPath(new URL('./model-preview-worker.js', import.meta.url));
|
|
149
|
+
const outcome = await runDeveloperCommand(state, { processId: writeId, cwd: scratch,
|
|
150
|
+
command: `${quote(process.execPath)} ${quote(worker)} ${quote(configPath)}`, timeoutMs: 120_000, signal: controller.signal,
|
|
151
|
+
onOutput: () => undefined, onStarted: async (identity) => {
|
|
152
|
+
stopped = false;
|
|
153
|
+
await state.journal.putManagedWrite({ writeId, workspaceId: revisionScope(state, operation.workspaceId, directoryName) ?? 'single',
|
|
154
|
+
source: 'manual', directoryAccessMode: 'exclusive', startedAt: new Date().toISOString(), pid: identity.pid,
|
|
155
|
+
...(identity.launcher ? { executionMode: 'launcher', launcher: identity.launcher, runtimeEpoch: state.runtimeEpoch } : { executionMode: 'local' }) });
|
|
156
|
+
} });
|
|
157
|
+
stopped = outcome.code !== undefined;
|
|
158
|
+
if (!stopped)
|
|
159
|
+
throw new Error('model_preview_process_unknown');
|
|
160
|
+
const managed = state.journal.listManagedWrites().find(item => item.writeId === writeId);
|
|
161
|
+
if (managed)
|
|
162
|
+
await state.journal.putManagedWrite({ ...managed, stoppedAt: new Date().toISOString() });
|
|
163
|
+
if (outcome.canceled || await store.cancelled(previewId))
|
|
164
|
+
throw new Error('model_preview_cancelled');
|
|
165
|
+
if (outcome.timedOut)
|
|
166
|
+
throw new Error('model_preview_timeout');
|
|
167
|
+
if (outcome.code !== 0)
|
|
168
|
+
throw new Error('model_preview_build_failed');
|
|
169
|
+
if (await dependencyHash(root) !== input.dependencyHash)
|
|
170
|
+
throw new Error('model_preview_binding_changed');
|
|
171
|
+
const bytes = await checkedFile(scratch, 'output/index.html', MODEL_PREVIEW_MAX_BYTES);
|
|
172
|
+
await store.publish(record, bytes);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const code = stopped ? modelPreviewError(error) : 'model_preview_process_unknown';
|
|
176
|
+
await store.write({ ...record, status: code === 'model_preview_cancelled' ? 'cancelled' : 'failed', error: code });
|
|
177
|
+
throw new Error(code);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
known.delete(previewId);
|
|
181
|
+
if (scratch && stopped)
|
|
182
|
+
await rm(scratch, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { stopLauncherExecution } from '../../codex/launcher-client.js';
|
|
3
|
+
import { reconcileDirectoryAccess } from '../../server/directory-access.js';
|
|
4
|
+
import { workspaceRoot } from '../registry.js';
|
|
5
|
+
import { ModelPreviewStore } from './model-preview-store.js';
|
|
6
|
+
function absent(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return error.code === 'ESRCH';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function stopRecoveredWriter(writer, recovery) {
|
|
16
|
+
if (writer.stoppedAt)
|
|
17
|
+
return true;
|
|
18
|
+
if (recovery.confirmStopped)
|
|
19
|
+
return recovery.confirmStopped(writer);
|
|
20
|
+
if (writer.executionMode === 'launcher') {
|
|
21
|
+
if (!recovery.identityMatches || !recovery.launcherSocket || !writer.launcher ||
|
|
22
|
+
writer.launcher.processId !== writer.writeId || writer.launcher.runtimeEpoch !== writer.runtimeEpoch)
|
|
23
|
+
return false;
|
|
24
|
+
if (recovery.previousRuntimeEpoch !== undefined && recovery.runtimeEpoch !== undefined &&
|
|
25
|
+
recovery.runtimeEpoch > recovery.previousRuntimeEpoch && writer.launcher.runtimeEpoch < recovery.runtimeEpoch)
|
|
26
|
+
return true;
|
|
27
|
+
try {
|
|
28
|
+
await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// A recycled local process id is not authority to signal a process after restart.
|
|
36
|
+
return Boolean(writer.pid && absent(-writer.pid) && absent(writer.pid));
|
|
37
|
+
}
|
|
38
|
+
/** Recovered work is observed/stopped, never rebuilt or re-inferred automatically. */
|
|
39
|
+
export async function recoverModelPreviewOperations(state, recovery, onlyId) {
|
|
40
|
+
for (const { scopeKey, operation: snapshot } of state.journal.listOperations()) {
|
|
41
|
+
if (snapshot.kind !== 'model.preview' || onlyId && (snapshot.gatewayOperationId ?? snapshot.operationId) !== onlyId ||
|
|
42
|
+
!['dispatched', 'accepted', 'uncertain'].includes(snapshot.status))
|
|
43
|
+
continue;
|
|
44
|
+
const operation = state.journal.getOperationById(snapshot.operationId) ?? snapshot;
|
|
45
|
+
const previewId = operation.gatewayOperationId ?? operation.operationId;
|
|
46
|
+
if (!['dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
47
|
+
continue;
|
|
48
|
+
const writer = state.journal.listManagedWrites().find(item => item.writeId === `model-preview-${previewId}`);
|
|
49
|
+
const stopped = writer ? await stopRecoveredWriter(writer, recovery) : false;
|
|
50
|
+
let store, ready = false;
|
|
51
|
+
try {
|
|
52
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName ?? 'basic');
|
|
53
|
+
store = new ModelPreviewStore(state.config.stateDir, root);
|
|
54
|
+
ready = (await store.read(previewId))?.status === 'ready';
|
|
55
|
+
if (ready)
|
|
56
|
+
await store.content(previewId);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
ready = false;
|
|
60
|
+
}
|
|
61
|
+
const current = state.journal.getOperationById(operation.operationId) ?? operation;
|
|
62
|
+
if (!['dispatched', 'accepted', 'uncertain'].includes(current.status))
|
|
63
|
+
continue;
|
|
64
|
+
if (!stopped) {
|
|
65
|
+
await state.journal.putOperation(scopeKey, transitionOperation(current, 'uncertain', { error: 'model_preview_process_unknown' }));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (writer && !writer.stoppedAt)
|
|
69
|
+
await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
|
|
70
|
+
const record = await store?.read(previewId).catch(() => undefined);
|
|
71
|
+
if (record && !ready)
|
|
72
|
+
await store.write({ ...record,
|
|
73
|
+
status: await store.cancelled(previewId) ? 'cancelled' : 'failed', error: 'model_preview_interrupted' }).catch(() => undefined);
|
|
74
|
+
await state.journal.putOperation(scopeKey, transitionOperation(current, ready ? 'completed' : 'interrupted', ready ? {} : { error: 'model_preview_interrupted' }));
|
|
75
|
+
}
|
|
76
|
+
reconcileDirectoryAccess(state);
|
|
77
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readdir, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { PREVIEW_HTML_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { checkedFile, digest, writeControlJson } from './files.js';
|
|
7
|
+
export const MODEL_PREVIEW_MAX_BYTES = PREVIEW_HTML_MAX_BYTES;
|
|
8
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
9
|
+
const parameterKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}$/)
|
|
10
|
+
.refine(value => !['__proto__', 'prototype', 'constructor'].includes(value));
|
|
11
|
+
const parameters = z.unknown().superRefine((value, context) => {
|
|
12
|
+
if (value && typeof value === 'object' && Object.keys(value).some(key => !parameterKey.safeParse(key).success))
|
|
13
|
+
context.addIssue({ code: 'custom', message: 'invalid_model_parameter_key' });
|
|
14
|
+
}).pipe(z.record(parameterKey, z.union([z.string().max(256), z.number().finite().min(-100000).max(100000), z.boolean()]))
|
|
15
|
+
.refine(value => Object.keys(value).length <= 64));
|
|
16
|
+
const recordSchema = z.object({ previewId: z.string().uuid(), root: z.string(), runId: z.string().uuid(),
|
|
17
|
+
candidateId: z.string().uuid(), sourceHash: hash, dependencyHash: hash,
|
|
18
|
+
status: z.enum(['running', 'ready', 'failed', 'cancelled']), createdAt: z.string().datetime(),
|
|
19
|
+
contentHash: hash.optional(), error: z.string().regex(/^[a-z_]+$/).optional(),
|
|
20
|
+
parameters: parameters.optional(), parameterHash: hash.optional(),
|
|
21
|
+
parameterOverrides: parameters.optional() }).strict().superRefine((record, context) => {
|
|
22
|
+
const normalizedParameters = record.parameters && JSON.stringify(Object.fromEntries(Object.keys(record.parameters).sort().map(key => [key, record.parameters[key]])));
|
|
23
|
+
if (record.status === 'ready' && !record.contentHash || Boolean(record.parameters) !== Boolean(record.parameterHash) ||
|
|
24
|
+
normalizedParameters && digest(normalizedParameters) !== record.parameterHash)
|
|
25
|
+
context.addIssue({ code: 'custom', message: 'model_preview_receipt_invalid' });
|
|
26
|
+
});
|
|
27
|
+
const transactions = new Map();
|
|
28
|
+
async function exclusive(key, action) {
|
|
29
|
+
const previous = transactions.get(key) ?? Promise.resolve();
|
|
30
|
+
let release;
|
|
31
|
+
const current = new Promise(done => { release = done; });
|
|
32
|
+
transactions.set(key, current);
|
|
33
|
+
await previous;
|
|
34
|
+
try {
|
|
35
|
+
return await action();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
release();
|
|
39
|
+
if (transactions.get(key) === current)
|
|
40
|
+
transactions.delete(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class ModelPreviewStore {
|
|
44
|
+
stateDir;
|
|
45
|
+
root;
|
|
46
|
+
relative;
|
|
47
|
+
constructor(stateDir, root) {
|
|
48
|
+
this.stateDir = stateDir;
|
|
49
|
+
this.root = root;
|
|
50
|
+
this.relative = `model-previews/${digest(root)}`;
|
|
51
|
+
}
|
|
52
|
+
async cancel(previewId) {
|
|
53
|
+
if (!z.string().uuid().safeParse(previewId).success)
|
|
54
|
+
throw new Error('model_preview_invalid_id');
|
|
55
|
+
return exclusive(join(this.stateDir, this.relative, previewId), async () => {
|
|
56
|
+
if ((await this.read(previewId))?.status === 'ready')
|
|
57
|
+
return false;
|
|
58
|
+
await writeControlJson(join(this.stateDir, this.relative, 'cancelled', `${previewId}.json`), { previewId, root: this.root });
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async cancelled(previewId) {
|
|
63
|
+
if (!z.string().uuid().safeParse(previewId).success)
|
|
64
|
+
throw new Error('model_preview_invalid_id');
|
|
65
|
+
try {
|
|
66
|
+
const receipt = JSON.parse((await checkedFile(this.stateDir, `${this.relative}/cancelled/${previewId}.json`, 4096)).toString('utf8'));
|
|
67
|
+
if (receipt.previewId !== previewId || receipt.root !== this.root)
|
|
68
|
+
throw new Error('model_preview_scope_mismatch');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT')
|
|
73
|
+
return false;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async write(record) {
|
|
78
|
+
recordSchema.parse(record);
|
|
79
|
+
if (record.root !== this.root)
|
|
80
|
+
throw new Error('model_preview_scope_mismatch');
|
|
81
|
+
if (record.status === 'ready') {
|
|
82
|
+
const index = `${record.createdAt.replace(/\D/g, '')}-${record.previewId}.json`;
|
|
83
|
+
await writeControlJson(join(this.stateDir, this.relative, 'bindings', this.binding(record), index), { previewId: record.previewId });
|
|
84
|
+
}
|
|
85
|
+
await writeControlJson(join(this.stateDir, this.relative, 'records', `${record.previewId}.json`), record);
|
|
86
|
+
}
|
|
87
|
+
binding(input) {
|
|
88
|
+
return digest(JSON.stringify([input.runId, input.candidateId, input.sourceHash, input.dependencyHash]));
|
|
89
|
+
}
|
|
90
|
+
async read(previewId) {
|
|
91
|
+
if (!z.string().uuid().safeParse(previewId).success)
|
|
92
|
+
throw new Error('model_preview_invalid_id');
|
|
93
|
+
try {
|
|
94
|
+
const record = recordSchema.parse(JSON.parse((await checkedFile(this.stateDir, `${this.relative}/records/${previewId}.json`, 16 * 1024)).toString('utf8')));
|
|
95
|
+
if (record.root !== this.root || record.previewId !== previewId)
|
|
96
|
+
throw new Error('model_preview_scope_mismatch');
|
|
97
|
+
return record;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT')
|
|
101
|
+
return undefined;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async publish(record, bytes) {
|
|
106
|
+
return exclusive(join(this.stateDir, this.relative, record.previewId), async () => {
|
|
107
|
+
if (await this.cancelled(record.previewId))
|
|
108
|
+
throw new Error('model_preview_cancelled');
|
|
109
|
+
if (!bytes.length || bytes.length > MODEL_PREVIEW_MAX_BYTES)
|
|
110
|
+
throw new Error('model_preview_size_limit');
|
|
111
|
+
const previous = await this.read(record.previewId);
|
|
112
|
+
if (previous?.status === 'ready') {
|
|
113
|
+
if (previous.runId !== record.runId || previous.candidateId !== record.candidateId || previous.sourceHash !== record.sourceHash ||
|
|
114
|
+
previous.dependencyHash !== record.dependencyHash || previous.contentHash !== digest(bytes))
|
|
115
|
+
throw new Error('model_preview_binding_changed');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const directory = join(this.stateDir, this.relative, 'content');
|
|
119
|
+
await mkdir(directory, { recursive: true, mode: 0o700 }); // private
|
|
120
|
+
const target = join(directory, `${record.previewId}.html`), temporary = `${target}.${randomUUID()}.pending`;
|
|
121
|
+
try {
|
|
122
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); // private
|
|
123
|
+
await rename(temporary, target);
|
|
124
|
+
await this.write({ ...record, status: 'ready', contentHash: digest(bytes) });
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await rm(temporary, { force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async content(previewId) {
|
|
132
|
+
const record = await this.read(previewId);
|
|
133
|
+
if (!record || record.status !== 'ready' || !record.contentHash)
|
|
134
|
+
throw new Error('model_preview_not_ready');
|
|
135
|
+
if (await this.cancelled(previewId))
|
|
136
|
+
throw new Error('model_preview_cancelled');
|
|
137
|
+
const bytes = await checkedFile(this.stateDir, `${this.relative}/content/${previewId}.html`, MODEL_PREVIEW_MAX_BYTES);
|
|
138
|
+
if (digest(bytes) !== record.contentHash)
|
|
139
|
+
throw new Error('model_preview_content_changed');
|
|
140
|
+
return { bytes, record };
|
|
141
|
+
}
|
|
142
|
+
async latest(input) {
|
|
143
|
+
const records = await this.versions(input);
|
|
144
|
+
if (records[0])
|
|
145
|
+
return records[0];
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
async versions(input) {
|
|
149
|
+
let names;
|
|
150
|
+
try {
|
|
151
|
+
names = (await readdir(join(this.stateDir, this.relative, 'bindings', this.binding(input))))
|
|
152
|
+
.filter(name => /^\d{17}-[a-f0-9-]{36}\.json$/i.test(name)).sort().reverse().slice(0, 32);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code === 'ENOENT')
|
|
156
|
+
return [];
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const records = [];
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
const record = await this.read(name.slice(18, -5));
|
|
162
|
+
if (record && record.runId === input.runId && record.candidateId === input.candidateId &&
|
|
163
|
+
record.sourceHash === input.sourceHash && record.dependencyHash === input.dependencyHash && record.status === 'ready' && record.contentHash &&
|
|
164
|
+
!await this.cancelled(record.previewId))
|
|
165
|
+
records.push(record);
|
|
166
|
+
}
|
|
167
|
+
records.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
168
|
+
return records.slice(0, 32);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
4
|
+
import { readFile, writeFile, mkdir, realpath } from 'node:fs/promises';
|
|
5
|
+
import { PREVIEW_HTML_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
/** Bundles only the frozen factory and the installed Three.js; no project config or plugin runs. */
|
|
7
|
+
export async function buildModelPreviewHtml(input) {
|
|
8
|
+
const sourceRoot = await realpath(input.sourceRoot);
|
|
9
|
+
const project = createRequire(join(input.projectRoot, 'package.json'));
|
|
10
|
+
const vite = await import(pathToFileURL(project.resolve('vite')).href);
|
|
11
|
+
const threeEntry = project.resolve('three');
|
|
12
|
+
const threeRoot = resolve(dirname(threeEntry), '..');
|
|
13
|
+
const literal = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
14
|
+
const entry = join(sourceRoot, '__model-preview-entry.mjs');
|
|
15
|
+
const code = `import * as THREE from 'three';
|
|
16
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
17
|
+
import * as factory from ${literal(`./${input.factoryPath}`)};
|
|
18
|
+
const binding=${literal({ previewId: input.previewId, runId: input.runId, candidateId: input.candidateId, sourceHash: input.sourceHash })};
|
|
19
|
+
const profile=${literal(input.profile)}, resources=${literal(input.resources)};
|
|
20
|
+
let parameters=${literal(input.parameters)}, model, renderer, scene, camera, controls, stopped=false, revision=0,lastState,pendingParameters,rebuilding=false,resourceHung=false,renderFailed=false;
|
|
21
|
+
let viewportWidth=0,viewportHeight=0,viewportRatio=0,viewportRevision=0,renderReadyRevision=0,resumeFrames=0,viewportObserver;
|
|
22
|
+
const validKey=key=>/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key)&&!['__proto__','prototype','constructor'].includes(key);
|
|
23
|
+
const primitiveParameters=()=>Object.fromEntries(Object.entries(parameters).filter(([key,value])=>validKey(key)&&(typeof value==='boolean'||typeof value==='string'&&value.length<=256||typeof value==='number'&&Number.isFinite(value)&&Math.abs(value)<=100000)).slice(0,64));
|
|
24
|
+
const capabilities=['orbit','fixed-views',...(Object.keys(primitiveParameters()).length?['factory-parameters']:[])];
|
|
25
|
+
const send=state=>{lastState=state;parent.postMessage({type:'playcraft:model-preview',schemaVersion:1,...binding,state,capabilities,parameters:primitiveParameters()},'*');};
|
|
26
|
+
const syncViewport=()=>{if(stopped)return false;const width=Math.floor(document.documentElement?.clientWidth||innerWidth||0),height=Math.floor(document.documentElement?.clientHeight||innerHeight||0);if(!Number.isFinite(width)||!Number.isFinite(height)||width<1||height<1){resumeFrames=0;if(lastState==='ready')send('loading');return false;}const ratio=Math.min(devicePixelRatio||1,2),canvas=renderer.domElement,box=canvas.getBoundingClientRect?.();if(width!==viewportWidth||height!==viewportHeight||ratio!==viewportRatio||canvas.width!==Math.floor(width*ratio)||canvas.height!==Math.floor(height*ratio)||box&&(box.width<1||box.height<1)){renderer.setPixelRatio(ratio);renderer.setSize(width,height);camera.aspect=width/height;camera.updateProjectionMatrix();viewportWidth=width;viewportHeight=height;viewportRatio=ratio;viewportRevision++;resumeFrames=0;}const actual=canvas.getBoundingClientRect?.(),positive=canvas.width>0&&canvas.height>0&&(!actual||actual.width>0&&actual.height>0);if(!positive){resumeFrames=0;if(lastState==='ready')send('loading');}return positive;};
|
|
27
|
+
const drawFrame=()=>{if(renderFailed)throw new Error('model_preview_frame_failed');if(!syncViewport())return false;try{const context=renderer.getContext();if(context.isContextLost())throw new Error('model_preview_context_lost');controls.update();renderer.render(scene,camera);if(renderFailed||context.isContextLost()||context.getError()!==context.NO_ERROR)throw new Error('model_preview_frame_failed');return renderer.domElement.width>0&&renderer.domElement.height>0;}catch(error){renderFailed=true;throw error;}};
|
|
28
|
+
const waitForVisibleFrames=async current=>{let frames=0,sizeRevision=-1;while(!stopped&¤t===revision&&!pendingParameters){await new Promise(resolve=>requestAnimationFrame(resolve));if(stopped||current!==revision||pendingParameters)return false;if(renderer.getContext().isContextLost())throw new Error('model_preview_context_lost');if(!drawFrame()){frames=0;continue;}if(renderer.info.render.calls<1)throw new Error('model_preview_empty_frame');if(sizeRevision!==viewportRevision){sizeRevision=viewportRevision;frames=0;}if(++frames>=2)return true;}return false;};
|
|
29
|
+
const disposeModel=()=>{if(!model)return;const owned=new Set();model.traverse(o=>{if(o.geometry)owned.add(o.geometry);for(const m of [o.material].flat().filter(Boolean)){owned.add(m);for(const v of Object.values(m))if(v?.isTexture)owned.add(v);}});for(const resource of owned)resource.dispose();scene.remove(model);model=undefined;};
|
|
30
|
+
const rebuildOnce=async()=>{const current=++revision;disposeModel();let loading=false,loadSettled=false,loadFailed=false,done,failed;const ready=new Promise((resolve,reject)=>{done=resolve;failed=reject;});void ready.catch(()=>{});
|
|
31
|
+
const manager=THREE.DefaultLoadingManager;manager.setURLModifier(url=>{if(url.startsWith('data:'))return url;const key=decodeURIComponent(url).replace(/^\\.?\\//,'').split('?')[0];if(!Object.hasOwn(resources,key))throw new Error('model_preview_resource_unavailable');return resources[key];});
|
|
32
|
+
manager.onStart=()=>{loading=true;};manager.onLoad=()=>{loadSettled=true;loadFailed?failed(new Error('model_preview_resource_failed')):done();};manager.onError=()=>{loadFailed=true;};
|
|
33
|
+
try{const create=factory[${literal(input.exportName)}];if(typeof create!=='function')throw new Error('model_preview_factory_missing');model=await create(parameters);if(!model?.isObject3D)throw new Error('model_preview_model_invalid');if(stopped){disposeModel();return;}scene.add(model);model.updateMatrixWorld(true);if(new THREE.Box3().setFromObject(model).isEmpty())throw new Error('model_preview_model_empty');if(!loading){loadSettled=true;done();}let timeout;try{await Promise.race([ready,new Promise((_,reject)=>{timeout=setTimeout(()=>{resourceHung=true;reject(new Error('model_preview_resource_timeout'));},30000);})]);}finally{clearTimeout(timeout);}
|
|
34
|
+
if(stopped||current!==revision||pendingParameters)return;renderReadyRevision=current;if(await waitForVisibleFrames(current)&&!stopped&¤t===revision&&!pendingParameters)send('ready');}catch(error){if(loading&&!loadSettled)resourceHung=true;throw error;}};
|
|
35
|
+
const queueRebuild=next=>{if(stopped||resourceHung||renderFailed)return;pendingParameters=structuredClone(next);if(rebuilding)return;rebuilding=true;void(async()=>{while(pendingParameters&&!stopped&&!resourceHung&&!renderFailed){parameters=pendingParameters;pendingParameters=undefined;send('loading');try{await rebuildOnce();}catch{if(!stopped)send('error');}}})().finally(()=>{rebuilding=false;});};
|
|
36
|
+
try{scene=new THREE.Scene();scene.background=new THREE.Color(profile.background);camera=new THREE.PerspectiveCamera(profile.fovDegrees,1,profile.near,profile.far);camera.position.fromArray(profile.views.hero);renderer=new THREE.WebGLRenderer({antialias:true});renderer.debug.checkShaderErrors=true;renderer.debug.onShaderError=()=>{renderFailed=true;renderReadyRevision=0;if(lastState!=='error')send('error');};renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;document.body.append(renderer.domElement);controls=new OrbitControls(camera,renderer.domElement);controls.target.fromArray(profile.target);controls.update();scene.add(new THREE.HemisphereLight(0xffffff,0x6b6a68,2));const light=new THREE.DirectionalLight(0xffffff,3);light.position.fromArray(profile.keyLightPosition);scene.add(light);syncViewport();renderer.setAnimationLoop(()=>{if(stopped||renderFailed)return;try{if(renderer.getContext().isContextLost())throw new Error('model_preview_context_lost');if(!drawFrame())return;if(!rebuilding&&renderReadyRevision===revision&&!pendingParameters&&!resourceHung&&lastState==='loading'&&renderer.info.render.calls>0){if(++resumeFrames>=2)send('ready');}else resumeFrames=0;}catch{renderFailed=true;renderReadyRevision=0;if(lastState!=='error')send('error');}});
|
|
37
|
+
addEventListener('resize',syncViewport);if(typeof ResizeObserver==='function'){viewportObserver=new ResizeObserver(syncViewport);viewportObserver.observe(document.documentElement);}
|
|
38
|
+
addEventListener('message',event=>{const m=event.data;if(event.source!==parent||!m||m.schemaVersion!==1||m.runId!==binding.runId||m.candidateId!==binding.candidateId||m.previewId!==binding.previewId||m.sourceHash!==binding.sourceHash)return;if(m.type==='playcraft:model-preview:status'){if(lastState)send(lastState);return;}if(m.type!=='playcraft:model-preview-command')return;if(m.action==='view'&&Object.hasOwn(profile.views,m.view)){camera.position.fromArray(profile.views[m.view]);controls.target.fromArray(profile.target);controls.update();}if(m.action==='parameters'&&m.parameters&&typeof m.parameters==='object'&&!Array.isArray(m.parameters)){const next={...(pendingParameters??parameters)};for(const [key,value]of Object.entries(m.parameters)){if(!validKey(key)||!Object.hasOwn(parameters,key)||!['string','number','boolean'].includes(typeof value)||typeof value!==typeof parameters[key]||typeof value==='number'&&(!Number.isFinite(value)||Math.abs(value)>100000)||typeof value==='string'&&value.length>256)return;next[key]=value;}queueRebuild(next);}});
|
|
39
|
+
addEventListener('pagehide',()=>{stopped=true;revision++;viewportObserver?.disconnect();renderer.setAnimationLoop(null);controls.dispose();disposeModel();renderer.dispose();},{once:true});
|
|
40
|
+
Object.defineProperty(window,'__PLAYCRAFT_MODEL_PREVIEW__',{value:Object.freeze({...binding,capabilities}),writable:false});queueRebuild(parameters);
|
|
41
|
+
}catch{send('error');}`;
|
|
42
|
+
await writeFile(entry, code);
|
|
43
|
+
const allowed = (path) => {
|
|
44
|
+
const candidate = resolve(path);
|
|
45
|
+
return [sourceRoot, threeRoot].some(root => { const value = relative(resolve(root), candidate); return value === '' || !value.startsWith('..') && !value.startsWith('/'); });
|
|
46
|
+
};
|
|
47
|
+
const built = await vite.build({
|
|
48
|
+
configFile: false, envFile: false, publicDir: false, root: sourceRoot, logLevel: 'silent',
|
|
49
|
+
cacheDir: join(sourceRoot, '.vite-cache'),
|
|
50
|
+
resolve: { alias: [{ find: /^three$/, replacement: join(threeRoot, 'build/three.module.js') },
|
|
51
|
+
{ find: /^three\/addons\//, replacement: `${threeRoot}/examples/jsm/` }] },
|
|
52
|
+
plugins: [{ name: 'playcraft-model-preview-boundary', enforce: 'pre', resolveId(id, importer) {
|
|
53
|
+
if (id.startsWith('\0') || id === 'three' || id.startsWith('three/addons/'))
|
|
54
|
+
return null;
|
|
55
|
+
if (id.startsWith('.') && importer && allowed(resolve(dirname(importer), id)))
|
|
56
|
+
return null;
|
|
57
|
+
if (id.startsWith('/') && allowed(id))
|
|
58
|
+
return null;
|
|
59
|
+
throw new Error('model_preview_import_denied');
|
|
60
|
+
} }],
|
|
61
|
+
build: { write: false, minify: false, sourcemap: false, emptyOutDir: false,
|
|
62
|
+
rollupOptions: { input: entry, output: { format: 'iife', inlineDynamicImports: true } } },
|
|
63
|
+
});
|
|
64
|
+
const output = (Array.isArray(built) ? built[0] : built).output;
|
|
65
|
+
if (output.length !== 1 || output[0].type !== 'chunk')
|
|
66
|
+
throw new Error('model_preview_bundle_invalid');
|
|
67
|
+
const javascript = output[0].code.replace(/<\/script/gi, '\\u003c/script');
|
|
68
|
+
return Buffer.from(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}canvas{display:block;touch-action:none}</style></head><body><script>${javascript}</script></body></html>`);
|
|
69
|
+
}
|
|
70
|
+
async function main() {
|
|
71
|
+
const path = process.argv[2];
|
|
72
|
+
if (!path)
|
|
73
|
+
throw new Error('model_preview_input_missing');
|
|
74
|
+
const raw = await readFile(path);
|
|
75
|
+
if (raw.length > 96 * 1024 * 1024)
|
|
76
|
+
throw new Error('model_preview_size_limit');
|
|
77
|
+
const input = JSON.parse(raw.toString('utf8'));
|
|
78
|
+
const bytes = await buildModelPreviewHtml(input);
|
|
79
|
+
if (bytes.length > PREVIEW_HTML_MAX_BYTES)
|
|
80
|
+
throw new Error('model_preview_size_limit');
|
|
81
|
+
await mkdir(dirname(input.outputPath), { recursive: true });
|
|
82
|
+
await writeFile(input.outputPath, bytes, { flag: 'wx' });
|
|
83
|
+
}
|
|
84
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
85
|
+
main().catch(() => { process.stderr.write('model_preview_build_failed\n'); process.exitCode = 1; });
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { workspaceModelCandidateListSchema, workspaceModelCandidateSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { checkedFile, dependencyHash, digest, relativeFile } from './files.js';
|
|
6
|
+
import { ModelPreviewStore } from './model-preview-store.js';
|
|
7
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
8
|
+
const fileSchema = z.object({ path: z.string().refine(value => {
|
|
9
|
+
try {
|
|
10
|
+
relativeFile(value);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}), hash });
|
|
17
|
+
const candidateSchema = z.object({ id: z.string().uuid(), files: z.array(fileSchema).min(1).max(256),
|
|
18
|
+
sourcePaths: z.array(z.string()).min(1).max(128), sourceHash: hash, dependencyHash: hash,
|
|
19
|
+
pagePath: z.string(), specHash: hash, passId: z.string().min(1).max(128),
|
|
20
|
+
captures: z.array(fileSchema.extend({ view: z.string().min(1).max(64) })).max(16).optional(),
|
|
21
|
+
hostReview: z.object({ profileHash: hash }).optional(),
|
|
22
|
+
review: z.object({ decision: z.enum(['accept', 'revise']), sourceHash: hash }).optional(),
|
|
23
|
+
adoptedPath: z.string().optional() });
|
|
24
|
+
const runSchema = z.object({ schemaVersion: z.literal(1), runId: z.string().uuid(),
|
|
25
|
+
revision: z.number().int().nonnegative(), root: z.string(), artifactHash: hash,
|
|
26
|
+
referenceHash: hash, referencePath: z.string(), candidates: z.array(z.unknown()) });
|
|
27
|
+
const MAX_RUNS = 64;
|
|
28
|
+
const MAX_CANDIDATES = 128;
|
|
29
|
+
const MAX_BYTES = 256 * 1024 * 1024;
|
|
30
|
+
/** Read control-owned snapshots without invoking the Skill or changing a completed run. */
|
|
31
|
+
export async function readProceduralModelCandidates(root, stateDir) {
|
|
32
|
+
const result = { schemaVersion: 1, candidates: [], diagnostics: [],
|
|
33
|
+
truncated: false, scannedAt: new Date().toISOString() };
|
|
34
|
+
const controlRelative = `procedural-3d/${digest(root)}`;
|
|
35
|
+
const controlDirectory = join(stateDir, controlRelative);
|
|
36
|
+
const diagnose = (code, runId, candidateId) => {
|
|
37
|
+
if (result.diagnostics.length < 256)
|
|
38
|
+
result.diagnostics.push({ code, ...(runId ? { runId } : {}), ...(candidateId ? { candidateId } : {}) });
|
|
39
|
+
else
|
|
40
|
+
result.truncated = true;
|
|
41
|
+
};
|
|
42
|
+
let names;
|
|
43
|
+
try {
|
|
44
|
+
const info = await lstat(controlDirectory);
|
|
45
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
46
|
+
throw new Error('model_candidates_control_invalid');
|
|
47
|
+
names = (await readdir(controlDirectory)).filter(name => /^[a-f0-9-]{36}\.json$/i.test(name)).sort();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== 'ENOENT')
|
|
51
|
+
diagnose('model_candidates_control_unavailable');
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
if (names.length > MAX_RUNS) {
|
|
55
|
+
result.truncated = true;
|
|
56
|
+
diagnose('model_candidates_run_limit');
|
|
57
|
+
}
|
|
58
|
+
let inspectedBytes = 0;
|
|
59
|
+
let inspectedCandidates = 0;
|
|
60
|
+
const seenCandidates = new Set();
|
|
61
|
+
const readBounded = async (base, path) => {
|
|
62
|
+
if (inspectedBytes >= MAX_BYTES)
|
|
63
|
+
throw new Error('model_candidates_byte_limit');
|
|
64
|
+
const bytes = await checkedFile(base, path, Math.min(16 * 1024 * 1024, MAX_BYTES - inspectedBytes));
|
|
65
|
+
inspectedBytes += bytes.length;
|
|
66
|
+
return bytes;
|
|
67
|
+
};
|
|
68
|
+
let currentDependencyHash;
|
|
69
|
+
try {
|
|
70
|
+
currentDependencyHash = await dependencyHash(root);
|
|
71
|
+
}
|
|
72
|
+
catch { /* Each candidate remains visible with a diagnostic. */ }
|
|
73
|
+
for (const name of names.slice(0, MAX_RUNS)) {
|
|
74
|
+
const runId = name.slice(0, -5);
|
|
75
|
+
if (!z.string().uuid().safeParse(runId).success) {
|
|
76
|
+
diagnose('model_candidates_run_invalid');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let run;
|
|
80
|
+
try {
|
|
81
|
+
run = runSchema.parse(JSON.parse((await checkedFile(stateDir, `${controlRelative}/${name}`, 4 * 1024 * 1024)).toString('utf8')));
|
|
82
|
+
if (run.root !== root || run.runId !== runId)
|
|
83
|
+
throw new Error('model_candidates_scope_mismatch');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
diagnose('model_candidates_run_invalid', runId);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const jobRelative = `.playcraft/procedural-3d/runs/${runId}`;
|
|
90
|
+
const jobRoot = join(root, jobRelative);
|
|
91
|
+
let referenceVerified = false;
|
|
92
|
+
try {
|
|
93
|
+
referenceVerified = digest(await readBounded(jobRoot, run.referencePath)) === run.referenceHash;
|
|
94
|
+
}
|
|
95
|
+
catch { /* Reported against every affected candidate, without exposing filesystem errors. */ }
|
|
96
|
+
for (const raw of run.candidates) {
|
|
97
|
+
if (inspectedCandidates++ >= MAX_CANDIDATES) {
|
|
98
|
+
result.truncated = true;
|
|
99
|
+
diagnose('model_candidates_candidate_limit', runId);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (result.candidates.length >= MAX_CANDIDATES) {
|
|
103
|
+
result.truncated = true;
|
|
104
|
+
diagnose('model_candidates_candidate_limit', runId);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const parsed = candidateSchema.safeParse(raw);
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
diagnose('model_candidates_candidate_invalid', runId);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const candidate = parsed.data;
|
|
113
|
+
if (seenCandidates.has(`${runId}:${candidate.id}`)) {
|
|
114
|
+
diagnose('model_candidates_candidate_duplicate', runId, candidate.id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
seenCandidates.add(`${runId}:${candidate.id}`);
|
|
118
|
+
const candidatePrefix = `${jobRelative}/candidates/${candidate.id}`;
|
|
119
|
+
const source = new Set(candidate.sourcePaths);
|
|
120
|
+
try {
|
|
121
|
+
relativeFile(candidate.pagePath);
|
|
122
|
+
if (candidate.adoptedPath)
|
|
123
|
+
relativeFile(candidate.adoptedPath);
|
|
124
|
+
if (new Set(candidate.files.map(file => file.path)).size !== candidate.files.length ||
|
|
125
|
+
new Set(candidate.captures?.map(file => file.path)).size !== (candidate.captures?.length ?? 0) ||
|
|
126
|
+
candidate.sourcePaths.some(path => !candidate.files.some(file => file.path === path)) ||
|
|
127
|
+
!candidate.files.some(file => file.path === candidate.pagePath) ||
|
|
128
|
+
candidate.captures?.some(file => !file.path.startsWith(`candidates/${candidate.id}/captures/`)))
|
|
129
|
+
throw new Error('model_candidates_manifest_invalid');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
diagnose('model_candidates_manifest_invalid', runId, candidate.id);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const item = {
|
|
136
|
+
runId, candidateId: candidate.id, revision: run.revision, passId: candidate.passId,
|
|
137
|
+
sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
|
|
138
|
+
referenceHash: run.referenceHash, skillArtifactHash: run.artifactHash,
|
|
139
|
+
files: candidate.files.map(file => ({ path: `${candidatePrefix}/source/${file.path}`, contentHash: file.hash, source: source.has(file.path) })),
|
|
140
|
+
captures: (candidate.captures ?? []).map(file => ({ path: `${jobRelative}/${file.path}`, contentHash: file.hash, view: file.view })),
|
|
141
|
+
preview: { kind: 'scene-preview', status: 'needs-build', entryPath: `${candidatePrefix}/source/${candidate.pagePath}`,
|
|
142
|
+
sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
|
|
143
|
+
...(candidate.hostReview ? { profileHash: candidate.hostReview.profileHash } : {}), reason: 'candidate_scene_not_built' },
|
|
144
|
+
integrity: 'verified', ...(candidate.review ? { review: candidate.review } : {}),
|
|
145
|
+
...(candidate.adoptedPath ? { adoptedPath: candidate.adoptedPath } : {}),
|
|
146
|
+
};
|
|
147
|
+
const invalidate = (code) => {
|
|
148
|
+
item.integrity = 'invalid';
|
|
149
|
+
if (item.preview.kind === 'scene-preview') {
|
|
150
|
+
item.preview.status = 'unavailable';
|
|
151
|
+
item.preview.reason = code;
|
|
152
|
+
}
|
|
153
|
+
diagnose(code, runId, candidate.id);
|
|
154
|
+
};
|
|
155
|
+
if (!workspaceModelCandidateSchema.safeParse(item).success) {
|
|
156
|
+
diagnose('model_candidates_manifest_invalid', runId, candidate.id);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!referenceVerified)
|
|
160
|
+
invalidate('model_candidates_reference_changed');
|
|
161
|
+
if (currentDependencyHash !== candidate.dependencyHash)
|
|
162
|
+
invalidate('model_candidates_dependencies_changed');
|
|
163
|
+
if (digest(JSON.stringify(candidate.files.map(({ path, hash }) => ({ path, hash })))) !== candidate.sourceHash)
|
|
164
|
+
invalidate('model_candidates_source_hash_changed');
|
|
165
|
+
if (!candidate.captures?.length)
|
|
166
|
+
invalidate('model_candidates_captures_missing');
|
|
167
|
+
if (candidate.review && candidate.review.sourceHash !== candidate.sourceHash)
|
|
168
|
+
invalidate('model_candidates_review_changed');
|
|
169
|
+
try {
|
|
170
|
+
if (digest(await readBounded(root, `${candidatePrefix}/spec.json`)) !== candidate.specHash)
|
|
171
|
+
invalidate('model_candidates_spec_changed');
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
invalidate('model_candidates_spec_unavailable');
|
|
175
|
+
}
|
|
176
|
+
for (const file of [...item.files, ...item.captures]) {
|
|
177
|
+
try {
|
|
178
|
+
const bytes = await readBounded(root, file.path);
|
|
179
|
+
file.size = bytes.length;
|
|
180
|
+
if (digest(bytes) !== file.contentHash)
|
|
181
|
+
throw new Error('model_candidates_file_changed');
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const limit = inspectedBytes >= MAX_BYTES || (error instanceof Error && error.message === 'model_candidates_byte_limit');
|
|
185
|
+
invalidate(limit ? 'model_candidates_byte_limit' : 'model_candidates_file_unavailable');
|
|
186
|
+
if (limit) {
|
|
187
|
+
result.truncated = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
result.candidates.push(item);
|
|
193
|
+
if (item.integrity === 'verified' && item.preview.kind === 'scene-preview') {
|
|
194
|
+
try {
|
|
195
|
+
const versions = await new ModelPreviewStore(stateDir, root).versions(item), preview = versions[0];
|
|
196
|
+
item.previewVersions = versions.map(version => ({ previewId: version.previewId, contentHash: version.contentHash,
|
|
197
|
+
createdAt: version.createdAt, ...(version.parameters ? { parameters: version.parameters } : {}),
|
|
198
|
+
...(version.parameterHash ? { parameterHash: version.parameterHash } : {}) }));
|
|
199
|
+
if (preview)
|
|
200
|
+
Object.assign(item.preview, { status: 'ready', reason: 'candidate_scene_built',
|
|
201
|
+
previewId: preview.previewId, contentHash: preview.contentHash,
|
|
202
|
+
...(preview.parameters ? { parameters: preview.parameters } : {}),
|
|
203
|
+
...(preview.parameterHash ? { parameterHash: preview.parameterHash } : {}) });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
invalidate('model_preview_receipt_invalid');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (inspectedBytes >= MAX_BYTES)
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (inspectedBytes >= MAX_BYTES || inspectedCandidates >= MAX_CANDIDATES) {
|
|
213
|
+
result.truncated = true;
|
|
214
|
+
diagnose(inspectedBytes >= MAX_BYTES ? 'model_candidates_byte_limit' : 'model_candidates_candidate_limit', runId);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return workspaceModelCandidateListSchema.parse(result);
|
|
219
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@gltf-transform/core": "^4.3.0",
|
|
54
54
|
"@gltf-transform/extensions": "^4.3.0",
|
|
55
55
|
"@gltf-transform/functions": "^4.3.0",
|
|
56
|
-
"@playcraft/build": "0.1.
|
|
57
|
-
"@playcraft/common": "0.1.
|
|
56
|
+
"@playcraft/build": "0.1.62-beta",
|
|
57
|
+
"@playcraft/common": "0.1.62-beta",
|
|
58
58
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
59
59
|
"chokidar": "^4.0.3",
|
|
60
60
|
"commander": "^13.1.0",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"@types/update-notifier": "^6.0.8",
|
|
92
92
|
"@types/ws": "^8.18.1",
|
|
93
93
|
"esbuild": "^0.28.1",
|
|
94
|
+
"three": "^0.183.2",
|
|
94
95
|
"typescript": "^5.9.3",
|
|
95
96
|
"vitest": "^3.2.4"
|
|
96
97
|
}
|
|
@@ -65,7 +65,7 @@ The artifact panel shows final deliverables under `dist/` at the current project
|
|
|
65
65
|
|
|
66
66
|
When a generated asset is ready for delivery, save or copy it into `dist/` with a stable relative path. Keep the resources required by a model or document within `dist/` and preserve their relative links. Reuse the same delivery path for a revised artifact so its versions remain grouped; the platform retains its history. Use different paths for independent candidates or deliverables.
|
|
67
67
|
|
|
68
|
-
Run playable builds from the project root (`playcraft build --mode full --platform playcraft --format html .`). In a workspace this command builds in an isolated copy, validates the result, and retains the playable at `dist/playable/index.html` while preserving other deliverables. A successful agent command or turn that writes a valid self-contained HTML (HyperText Markup Language, 超文本标记语言) file at this same path is collected as the same playable preview. No separate publish action is required to view it. A base build's `dist/index.html` can still require resource files and remains a document. The page's generate-preview action rebuilds the current project through the same validation. Public
|
|
68
|
+
Run playable builds from the project root (`playcraft build --mode full --platform playcraft --format html .`). In a workspace this command builds in an isolated copy, validates the result, and retains the playable at `dist/playable/index.html` while preserving other deliverables. A successful agent command or turn that writes a valid self-contained HTML (HyperText Markup Language, 超文本标记语言) file at this same path is collected as the same playable preview. No separate publish action is required to view it. A base build's `dist/index.html` can still require resource files and remains a document. The page's generate-preview action rebuilds the current project through the same validation. After a successful build the command also publishes the playable and prints `Public URL: <address>`; when you report the build, link that address as the playable instead of the sandbox path, since it opens directly and can be shared. If the command prints `Public URL unavailable`, report that the build succeeded without a public address and give the reason it printed. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
|
|
69
69
|
|
|
70
70
|
Keep this convention in `AGENTS.md`, the project's standard instruction filename. Do not create a separate `AGENT.md`.
|
|
71
71
|
|