@playcraft/cli 0.1.48-beta → 0.1.50-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/workspace-runtime/batch/deliver.js +105 -87
- package/dist/workspace-runtime/batch/prepare.js +169 -56
- package/dist/workspace-runtime/batch/source-capture.js +7 -20
- package/dist/workspace-runtime/batch/source-publish.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +19 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
- package/dist/workspace-runtime/codex/tool-response.js +10 -0
- package/dist/workspace-runtime/codex/ui-tools.js +22 -5
- package/dist/workspace-runtime/previews/diagnostics.js +44 -0
- package/dist/workspace-runtime/previews/publish.js +29 -8
- package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
- package/dist/workspace-runtime/serve.js +162 -4
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/asset-transfers.js +63 -0
- package/dist/workspace-runtime/server/dispatch.js +328 -44
- package/dist/workspace-runtime/server/http.js +193 -137
- package/dist/workspace-runtime/server/operation-record.js +33 -0
- package/dist/workspace-runtime/server/submit-operation.js +41 -0
- package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
- package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
- package/dist/workspace-runtime/workspaces/delivery.js +64 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
- package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
- package/dist/workspace-runtime/workspaces/directories.js +9 -4
- package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
- package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
- package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
- package/dist/workspace-runtime/workspaces/parameters.js +77 -54
- package/package.json +3 -3
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
|
+
import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.js';
|
|
2
3
|
import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { access as accessDirectory, stat } from 'node:fs/promises';
|
|
6
|
-
import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema,
|
|
6
|
+
import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, isWorkspaceMutation, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, deliveryValidatePayloadSchema, deliveryBuildListQuerySchema, deliveryBuildListSchema, deliveryBuildDetailSchema, deliveryBuildLogSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
8
8
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
9
9
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
10
|
-
import {
|
|
11
|
-
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
10
|
+
import { InvalidWorkspacePathError, } from '../workspaces/files.js';
|
|
12
11
|
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
13
12
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
13
|
+
import { listParameterAgentTasks } from '../workspaces/parameter-agent.js';
|
|
14
14
|
import { readDependencies } from '../workspaces/dependencies.js';
|
|
15
15
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
16
16
|
import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
17
17
|
import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
|
|
18
18
|
import { serveWorkspaceAttachment } from '../workspaces/attachment-content.js';
|
|
19
19
|
import { serveWorkspaceFileContent } from '../workspaces/file-content.js';
|
|
20
|
+
import { validateDeliveryArtifact, DeliveryValidationError } from '../workspaces/delivery.js';
|
|
21
|
+
import { DeliveryStore } from '../workspaces/delivery-store.js';
|
|
20
22
|
import { importWorkspaceLibraryAttachment, listWorkspaceLibraryAttachments, listWorkspaceLibraryImportReceipts, readLibraryAttachmentUpload } from '../workspaces/library-attachments.js';
|
|
21
23
|
import { beginWorkspaceDirectoryAttachment, uploadWorkspaceDirectoryFile, completeWorkspaceDirectoryAttachment, cancelWorkspaceDirectoryAttachment } from '../workspaces/directory-attachments.js';
|
|
22
24
|
import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
@@ -32,12 +34,15 @@ import { readWorkspaceDirectoryStats } from '../workspaces/directory-stats.js';
|
|
|
32
34
|
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
33
35
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
34
36
|
import { WorkspaceArtifactStore } from '../workspaces/artifacts.js';
|
|
37
|
+
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
38
|
+
import { acceptOperationCore } from './accept-operation.js';
|
|
35
39
|
import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
36
40
|
import { searchWorkspaceFiles } from '../workspaces/files-search.js';
|
|
37
41
|
import { serveWorkspaceArchive } from '../workspaces/archive.js';
|
|
38
42
|
import { handleWorkspaceTransfer } from './workspace-transfer.js';
|
|
39
43
|
import { serveSessionImage } from '../workspaces/session-image.js';
|
|
40
44
|
import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
|
|
45
|
+
import { handleAssetTransferRequest } from './asset-transfers.js';
|
|
41
46
|
export async function handleRuntimeRequest(state, request, response) {
|
|
42
47
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
43
48
|
const directoryName = url.searchParams.get('directoryName') ?? 'basic';
|
|
@@ -51,6 +56,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
51
56
|
return;
|
|
52
57
|
}
|
|
53
58
|
try {
|
|
59
|
+
const transfer = await handleAssetTransferRequest(state, request, url);
|
|
60
|
+
if (transfer) {
|
|
61
|
+
writeJson(response, transfer.status, transfer.body);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
54
64
|
const batch = await dispatchBatchRoute(state, request, url);
|
|
55
65
|
if (batch) {
|
|
56
66
|
writeJson(response, batch.status, batch.body);
|
|
@@ -92,6 +102,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
92
102
|
instanceToken: state.instanceToken,
|
|
93
103
|
permissionsVersion: 1,
|
|
94
104
|
composerContextVersion: 1,
|
|
105
|
+
parameterAgentVersion: 1,
|
|
95
106
|
remixReferencesVersion: 1,
|
|
96
107
|
batchSourceFreezeRevision: 2,
|
|
97
108
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
@@ -360,6 +371,82 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
360
371
|
await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response, url.searchParams.get('filePath') ?? undefined);
|
|
361
372
|
return;
|
|
362
373
|
}
|
|
374
|
+
if (request.method === 'POST' && url.pathname === '/runtime/deliveries/validate') {
|
|
375
|
+
if (writeDestroyed(state, response))
|
|
376
|
+
return;
|
|
377
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
378
|
+
if (!workspaceId || directoryName === 'root') {
|
|
379
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const parsed = deliveryValidatePayloadSchema.safeParse(await readJson(request, 64 * 1024));
|
|
383
|
+
if (!parsed.success) {
|
|
384
|
+
writeJson(response, 400, { error: 'invalid_delivery_validate' });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
388
|
+
try {
|
|
389
|
+
writeJson(response, 200, await validateDeliveryArtifact(root, parsed.data));
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
const code = error instanceof DeliveryValidationError ? error.code : 'delivery_validation_failed';
|
|
393
|
+
writeJson(response, code === 'delivery_validation_failed' ? 500 : 400, { error: code });
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (request.method === 'GET' && url.pathname === '/runtime/deliveries') {
|
|
398
|
+
if (writeDestroyed(state, response))
|
|
399
|
+
return;
|
|
400
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
401
|
+
if (!workspaceId || directoryName === 'root') {
|
|
402
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const parsed = deliveryBuildListQuerySchema.safeParse({
|
|
406
|
+
...(url.searchParams.has('channel') ? { channel: url.searchParams.get('channel') } : {}),
|
|
407
|
+
...(url.searchParams.has('limit') ? { limit: url.searchParams.get('limit') } : {}),
|
|
408
|
+
});
|
|
409
|
+
if (!parsed.success) {
|
|
410
|
+
writeJson(response, 400, { error: 'invalid_delivery_query' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
414
|
+
const items = await new DeliveryStore(state.config.stateDir, workspaceId)
|
|
415
|
+
.list(directoryName, parsed.data.channel, parsed.data.limit);
|
|
416
|
+
writeJson(response, 200, deliveryBuildListSchema.parse({ items, scannedAt: new Date().toISOString() }));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const deliveryBuild = /^\/runtime\/deliveries\/([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})(?:\/(log))?$/.exec(url.pathname);
|
|
420
|
+
if (request.method === 'GET' && deliveryBuild) {
|
|
421
|
+
if (writeDestroyed(state, response))
|
|
422
|
+
return;
|
|
423
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
424
|
+
if (!workspaceId) {
|
|
425
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
429
|
+
const store = new DeliveryStore(state.config.stateDir, workspaceId);
|
|
430
|
+
const buildId = deliveryBuild[1];
|
|
431
|
+
const record = await store.read(buildId);
|
|
432
|
+
if (!record || record.directoryName !== directoryName) {
|
|
433
|
+
writeJson(response, 404, { error: 'delivery_build_not_found' });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (deliveryBuild[2] === 'log') {
|
|
437
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
438
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
439
|
+
writeJson(response, 400, { error: 'invalid_delivery_offset' });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const log = await store.readLog(buildId, offset);
|
|
443
|
+
writeJson(response, 200, deliveryBuildLogSchema.parse({ ...log, offset }));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const report = await store.readReport(buildId);
|
|
447
|
+
writeJson(response, 200, deliveryBuildDetailSchema.parse({ record, ...(report ? { report } : {}) }));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
363
450
|
if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
|
|
364
451
|
if (writeDestroyed(state, response))
|
|
365
452
|
return;
|
|
@@ -427,7 +514,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
427
514
|
return;
|
|
428
515
|
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
429
516
|
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
430
|
-
const
|
|
517
|
+
const scope = revisionScope(state, workspaceId, directoryName);
|
|
518
|
+
const listed = await state.journal.exclusive(`workspace:${scope ?? 'single'}`, () => observeWorkspaceDirectories(root, state.journal, scope));
|
|
431
519
|
console.log(`[workspace-files] GET /runtime/files workspaceId=${workspaceId ?? '-'} root=${root} files=${listed.files.length}`);
|
|
432
520
|
writeJson(response, 200, listed);
|
|
433
521
|
return;
|
|
@@ -561,6 +649,18 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
561
649
|
writeJson(response, 200, await readObservedParameters(state, url.searchParams.get('workspaceId') ?? undefined, directoryName));
|
|
562
650
|
return;
|
|
563
651
|
}
|
|
652
|
+
if (request.method === 'GET' && url.pathname === '/runtime/parameter-tasks') {
|
|
653
|
+
if (writeDestroyed(state, response))
|
|
654
|
+
return;
|
|
655
|
+
if (directoryName === 'root') {
|
|
656
|
+
writeJson(response, 400, { error: 'invalid_workspace_directory' });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
660
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
661
|
+
writeJson(response, 200, { tasks: await listParameterAgentTasks(state, workspaceId, directoryName, root) });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
564
664
|
if (request.method === 'GET' && url.pathname === '/runtime/terminals') {
|
|
565
665
|
if (writeDestroyed(state, response))
|
|
566
666
|
return;
|
|
@@ -596,6 +696,30 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
596
696
|
writeJson(response, operation ? 200 : 404, operation ?? { error: 'operation_not_found' });
|
|
597
697
|
return;
|
|
598
698
|
}
|
|
699
|
+
const operationLog = /^\/runtime\/operations\/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\/log$/.exec(url.pathname);
|
|
700
|
+
if (request.method === 'GET' && operationLog) {
|
|
701
|
+
if (writeDestroyed(state, response))
|
|
702
|
+
return;
|
|
703
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
704
|
+
if (!workspaceId) {
|
|
705
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const operation = state.journal.getOperationById(operationLog[1]);
|
|
709
|
+
if (!operation || operation.workspaceId !== workspaceId
|
|
710
|
+
|| (operation.directoryName ?? 'basic') !== directoryName) {
|
|
711
|
+
writeJson(response, 404, { error: 'operation_not_found' });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
715
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
716
|
+
writeJson(response, 400, { error: 'invalid_operation_log_offset' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const log = await new OperationLogStore(state.config.stateDir).read(operationLog[1], offset);
|
|
720
|
+
writeJson(response, 200, operationLogSchema.parse({ ...log, offset }));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
599
723
|
if (request.method === 'GET' && url.pathname.startsWith('/runtime/operations/')) {
|
|
600
724
|
const operationId = url.pathname.slice('/runtime/operations/'.length);
|
|
601
725
|
const operation = state.journal.getOperationById(operationId);
|
|
@@ -758,135 +882,65 @@ async function acceptOperation(state, request, response) {
|
|
|
758
882
|
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
759
883
|
return;
|
|
760
884
|
}
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
885
|
+
const executionPayload = parsed.data.kind === 'workspace.references.import'
|
|
886
|
+
? { snapshot: parsed.data.remixReferenceSnapshot }
|
|
887
|
+
: parsed.data.kind === 'asset.library.import'
|
|
888
|
+
? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
|
|
889
|
+
: parsed.data.payload;
|
|
890
|
+
const accept = async () => {
|
|
891
|
+
const result = await acceptOperationCore(state, parsed.data, {
|
|
892
|
+
leaseFence: incomingFence,
|
|
893
|
+
executionPayload,
|
|
894
|
+
validate: async (scopeKey) => {
|
|
895
|
+
if (parsed.data.kind.startsWith('session.') && parsed.data.sessionId && productionSessionAttemptId(parsed.data.sessionId)) {
|
|
896
|
+
return { status: 409, body: runtimeError('capability_unavailable', 'production_session_readonly') };
|
|
897
|
+
}
|
|
898
|
+
if (parsed.data.kind === 'session.create') {
|
|
899
|
+
const creation = state.journal.findSessionCreation(parsed.data.sessionId);
|
|
900
|
+
const session = state.journal.getSession(parsed.data.sessionId);
|
|
901
|
+
if (creation || session)
|
|
902
|
+
return { status: 409, body: { error: 'session_already_bound' } };
|
|
903
|
+
}
|
|
904
|
+
else if (parsed.data.kind === 'terminal.create') {
|
|
905
|
+
const payload = asRecord(parsed.data.payload);
|
|
906
|
+
const terminalId = typeof payload.terminalId === 'string' ? payload.terminalId.trim() : '';
|
|
907
|
+
if (!terminalId)
|
|
908
|
+
return { status: 400, body: { error: 'invalid_operation', reason: 'terminalId_required' } };
|
|
909
|
+
const creation = state.journal.findTerminalCreation(terminalId);
|
|
910
|
+
const terminal = state.journal.getTerminal(terminalId);
|
|
911
|
+
if (creation && operationScopeKey(creation) !== scopeKey) {
|
|
912
|
+
return { status: 409, body: { error: 'terminal_already_bound' } };
|
|
913
|
+
}
|
|
914
|
+
if (terminal && !creation)
|
|
915
|
+
return { status: 409, body: { error: 'terminal_already_bound' } };
|
|
916
|
+
}
|
|
917
|
+
else if (parsed.data.kind.startsWith('session.') && state.config.workspaceMode === 'multi') {
|
|
918
|
+
const session = state.journal.getSession(parsed.data.sessionId);
|
|
919
|
+
// A replaced sandbox has no binding yet; the recovering turn creates it.
|
|
920
|
+
const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
|
|
921
|
+
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
|
|
922
|
+
|| (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
|
|
923
|
+
return { status: 409, body: { error: 'session_workspace_mismatch' } };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (parsed.data.kind === 'workspace.references.import' && !parsed.data.remixReferenceSnapshot) {
|
|
927
|
+
return { status: 400, body: { error: 'remix_reference_snapshot_required' } };
|
|
928
|
+
}
|
|
929
|
+
if (parsed.data.kind === 'asset.library.import' && !parsed.data.libraryAssetImports) {
|
|
930
|
+
return { status: 400, body: { error: 'library_asset_snapshot_required' } };
|
|
931
|
+
}
|
|
932
|
+
return undefined;
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
if (result.rejected) {
|
|
936
|
+
writeJson(response, result.refusal.status, result.refusal.body);
|
|
802
937
|
return;
|
|
803
938
|
}
|
|
804
|
-
if (
|
|
805
|
-
|
|
806
|
-
const session = state.journal.getSession(parsed.data.sessionId);
|
|
807
|
-
if (creation || session) {
|
|
808
|
-
writeJson(response, 409, { error: 'session_already_bound' });
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
else if (parsed.data.kind === 'terminal.create') {
|
|
813
|
-
const payload = asRecord(parsed.data.payload);
|
|
814
|
-
const terminalId = typeof payload.terminalId === 'string' ? payload.terminalId.trim() : '';
|
|
815
|
-
if (!terminalId) {
|
|
816
|
-
writeJson(response, 400, { error: 'invalid_operation', reason: 'terminalId_required' });
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
const creation = state.journal.findTerminalCreation(terminalId);
|
|
820
|
-
const terminal = state.journal.getTerminal(terminalId);
|
|
821
|
-
if (creation && operationScopeKey(creation) !== scopeKey) {
|
|
822
|
-
writeJson(response, 409, { error: 'terminal_already_bound' });
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
if (terminal && !creation) {
|
|
826
|
-
writeJson(response, 409, { error: 'terminal_already_bound' });
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
else if (parsed.data.kind.startsWith('session.') && state.config.workspaceMode === 'multi') {
|
|
831
|
-
const session = state.journal.getSession(parsed.data.sessionId);
|
|
832
|
-
// A replaced sandbox has no binding yet; the recovering turn creates it.
|
|
833
|
-
const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
|
|
834
|
-
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
|
|
835
|
-
|| (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
|
|
836
|
-
writeJson(response, 409, { error: 'session_workspace_mismatch' });
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
if (parsed.data.kind === 'workspace.references.import' && !parsed.data.remixReferenceSnapshot) {
|
|
841
|
-
writeJson(response, 400, { error: 'remix_reference_snapshot_required' });
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (parsed.data.kind === 'asset.library.import' && !parsed.data.libraryAssetImports) {
|
|
845
|
-
writeJson(response, 400, { error: 'library_asset_snapshot_required' });
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
const now = new Date().toISOString();
|
|
849
|
-
const executable = canExecuteKind(state, parsed.data.kind);
|
|
850
|
-
const payloadRecord = asRecord(parsed.data.payload);
|
|
851
|
-
const operation = {
|
|
852
|
-
operationId: randomUUID(),
|
|
853
|
-
environment: state.config.environment,
|
|
854
|
-
userId: state.config.userId,
|
|
855
|
-
clientRequestId: parsed.data.clientRequestId,
|
|
856
|
-
kind: parsed.data.kind,
|
|
857
|
-
status: executable ? 'queued' : 'failed',
|
|
858
|
-
payloadHash,
|
|
859
|
-
workspaceId: parsed.data.workspaceId,
|
|
860
|
-
directoryName: parsed.data.directoryName,
|
|
861
|
-
sessionId: parsed.data.sessionId,
|
|
862
|
-
expectedRevision: parsed.data.expectedRevision,
|
|
863
|
-
generation: state.config.generation,
|
|
864
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
865
|
-
leaseFence: incomingFence,
|
|
866
|
-
terminalId: parsed.data.kind === 'terminal.create' && typeof payloadRecord.terminalId === 'string'
|
|
867
|
-
? payloadRecord.terminalId.trim()
|
|
868
|
-
: undefined,
|
|
869
|
-
cwd: parsed.data.kind === 'terminal.create' && typeof payloadRecord.cwd === 'string'
|
|
870
|
-
? payloadRecord.cwd
|
|
871
|
-
: undefined,
|
|
872
|
-
error: executable ? undefined : 'capability_unavailable',
|
|
873
|
-
createdAt: now,
|
|
874
|
-
updatedAt: now,
|
|
875
|
-
};
|
|
876
|
-
if (!executable) {
|
|
877
|
-
const saved = await state.journal.putOperation(scopeKey, operation);
|
|
878
|
-
writeError(response, runtimeError('capability_unavailable', unavailableReason(state, parsed.data.kind), {
|
|
879
|
-
operationId: saved.operationId,
|
|
880
|
-
}));
|
|
939
|
+
if (result.reused) {
|
|
940
|
+
writeJson(response, 200, result.operation);
|
|
881
941
|
return;
|
|
882
942
|
}
|
|
883
|
-
|
|
884
|
-
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
885
|
-
const queued = await state.journal.putOperation(scopeKey, operation);
|
|
886
|
-
const executionPayload = parsed.data.kind === 'workspace.references.import'
|
|
887
|
-
? { snapshot: parsed.data.remixReferenceSnapshot } : parsed.data.kind === 'asset.library.import'
|
|
888
|
-
? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports } : parsed.data.payload;
|
|
889
|
-
const executed = await executeOperation(state, scopeKey, queued, executionPayload, parsed.data.sessionRecovery);
|
|
943
|
+
const executed = result.executed;
|
|
890
944
|
if (executed.status === 'uncertain') {
|
|
891
945
|
writeError(response, runtimeError('uncertain_operation', executed.error ?? 'native_receipt_lost', {
|
|
892
946
|
operationId: executed.operationId,
|
|
@@ -894,7 +948,7 @@ async function acceptOperation(state, request, response) {
|
|
|
894
948
|
return;
|
|
895
949
|
}
|
|
896
950
|
writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
|
|
897
|
-
}
|
|
951
|
+
};
|
|
898
952
|
const acceptBound = async () => {
|
|
899
953
|
if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
|
|
900
954
|
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
@@ -1073,12 +1127,14 @@ async function handleSessionHistory(state, response, workspaceId, sessionId) {
|
|
|
1073
1127
|
async function readObservedParameters(state, workspaceId, directoryName = 'basic') {
|
|
1074
1128
|
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
1075
1129
|
const scope = revisionScope(state, workspaceId, directoryName);
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1130
|
+
return state.journal.exclusive(`workspace:${scope ?? 'single'}`, async () => {
|
|
1131
|
+
const first = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
1132
|
+
for (const path of [first.schemaPath, first.dataPath]) {
|
|
1133
|
+
if (path)
|
|
1134
|
+
await withObservedFile(state, workspaceId, path, async () => undefined, directoryName);
|
|
1135
|
+
}
|
|
1136
|
+
return readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
1137
|
+
});
|
|
1082
1138
|
}
|
|
1083
1139
|
function writeError(response, error) {
|
|
1084
1140
|
writeJson(response, HTTP_STATUS_BY_ERROR[error.code], error);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/** One construction point for operation records so HTTP and in-sandbox callers cannot drift. */
|
|
3
|
+
export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true) {
|
|
4
|
+
const now = new Date().toISOString();
|
|
5
|
+
const payload = parsed.payload && typeof parsed.payload === 'object' && !Array.isArray(parsed.payload)
|
|
6
|
+
? parsed.payload
|
|
7
|
+
: {};
|
|
8
|
+
return {
|
|
9
|
+
operationId: randomUUID(),
|
|
10
|
+
environment: state.config.environment,
|
|
11
|
+
userId: state.config.userId,
|
|
12
|
+
clientRequestId: parsed.clientRequestId,
|
|
13
|
+
kind: parsed.kind,
|
|
14
|
+
status: executable ? 'queued' : 'failed',
|
|
15
|
+
payloadHash,
|
|
16
|
+
workspaceId: parsed.workspaceId,
|
|
17
|
+
directoryName: parsed.directoryName,
|
|
18
|
+
sessionId: parsed.sessionId,
|
|
19
|
+
expectedRevision: parsed.expectedRevision,
|
|
20
|
+
generation: state.config.generation,
|
|
21
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
22
|
+
leaseFence,
|
|
23
|
+
terminalId: parsed.kind === 'terminal.create' && typeof payload.terminalId === 'string'
|
|
24
|
+
? payload.terminalId.trim()
|
|
25
|
+
: undefined,
|
|
26
|
+
cwd: parsed.kind === 'terminal.create' && typeof payload.cwd === 'string'
|
|
27
|
+
? payload.cwd
|
|
28
|
+
: undefined,
|
|
29
|
+
error: executable ? undefined : 'capability_unavailable',
|
|
30
|
+
createdAt: now,
|
|
31
|
+
updatedAt: now,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { operationAcceptRequestSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
3
|
+
import { acceptOperationCore } from './accept-operation.js';
|
|
4
|
+
export class SubmitOperationError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code) {
|
|
7
|
+
super(code);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'SubmitOperationError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Accepts an operation from a trusted in-sandbox caller (an Agent tool or a future runtime
|
|
14
|
+
* feature) with the same idempotency and lease semantics as the HTTP control plane.
|
|
15
|
+
*/
|
|
16
|
+
export async function submitTrustedOperation(state, input) {
|
|
17
|
+
const directoryName = input.directoryName ?? 'basic';
|
|
18
|
+
if (directoryName === 'root')
|
|
19
|
+
throw new SubmitOperationError('invalid_workspace_directory');
|
|
20
|
+
const parsed = operationAcceptRequestSchema.safeParse({
|
|
21
|
+
clientRequestId: input.clientRequestId,
|
|
22
|
+
kind: input.kind,
|
|
23
|
+
workspaceId: input.workspaceId,
|
|
24
|
+
directoryName,
|
|
25
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
26
|
+
...(input.expectedRevision !== undefined ? { expectedRevision: input.expectedRevision } : {}),
|
|
27
|
+
payload: input.payload,
|
|
28
|
+
});
|
|
29
|
+
if (!parsed.success)
|
|
30
|
+
throw new SubmitOperationError('invalid_operation');
|
|
31
|
+
if (await batchDirectoryHeld(state, input.workspaceId, directoryName)) {
|
|
32
|
+
throw new SubmitOperationError('batch_workspace_readonly');
|
|
33
|
+
}
|
|
34
|
+
const result = await acceptOperationCore(state, parsed.data, {
|
|
35
|
+
leaseFence: state.acceptedLeaseFence,
|
|
36
|
+
executionPayload: parsed.data.payload,
|
|
37
|
+
});
|
|
38
|
+
if (result.rejected)
|
|
39
|
+
throw new SubmitOperationError(String(result.refusal.body.error ?? 'operation_failed'));
|
|
40
|
+
return result.reused ? result.operation : result.executed;
|
|
41
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* One append-only log file with a hard byte cap. When the cap is crossed the file is atomically
|
|
7
|
+
* replaced with a truncation notice plus the newest bytes, so readers never see a half write.
|
|
8
|
+
*/
|
|
9
|
+
export class AppendOnlyLog {
|
|
10
|
+
path;
|
|
11
|
+
maxBytes;
|
|
12
|
+
truncatedNotice;
|
|
13
|
+
constructor(path, maxBytes, truncatedNotice = '[playcraft] log truncated; showing the tail\n') {
|
|
14
|
+
this.path = path;
|
|
15
|
+
this.maxBytes = maxBytes;
|
|
16
|
+
this.truncatedNotice = truncatedNotice;
|
|
17
|
+
}
|
|
18
|
+
async append(chunk) {
|
|
19
|
+
if (!chunk)
|
|
20
|
+
return;
|
|
21
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
22
|
+
const handle = await open(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
23
|
+
try {
|
|
24
|
+
await handle.write(Buffer.from(chunk));
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await handle.close();
|
|
28
|
+
}
|
|
29
|
+
const info = await stat(this.path);
|
|
30
|
+
if (info.size <= this.maxBytes)
|
|
31
|
+
return;
|
|
32
|
+
const content = await readFile(this.path);
|
|
33
|
+
const notice = Buffer.from(this.truncatedNotice);
|
|
34
|
+
const tail = content.subarray(Math.max(0, content.length - (this.maxBytes - notice.length)));
|
|
35
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(temporary, Buffer.concat([notice, tail]), { mode: 0o600 });
|
|
38
|
+
await rename(temporary, this.path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async read(offset = 0) {
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = await readFile(this.path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return { text: '', size: 0, offset: 0 };
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const start = Math.min(Math.max(0, offset), content.length);
|
|
56
|
+
return { text: content.subarray(start).toString('utf8'), size: content.length, offset: start };
|
|
57
|
+
}
|
|
58
|
+
async size() {
|
|
59
|
+
const info = await stat(this.path).catch(() => undefined);
|
|
60
|
+
return info?.isFile() ? info.size : 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, posix } from 'node:path';
|
|
4
|
+
import { AssetTransferError, safeAssetTransferDirectory } from './asset-transfer-storage.js';
|
|
5
|
+
const METADATA_MAX_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
/** Parse only bounded model metadata; all external dependencies must be explicit package members. */
|
|
7
|
+
export async function validateAssetTransferDependencies(transfer) {
|
|
8
|
+
const paths = new Set(transfer.record.manifest.files.map(file => file.path));
|
|
9
|
+
for (const [index, file] of transfer.record.manifest.files.entries()) {
|
|
10
|
+
const extension = extname(file.path).toLowerCase();
|
|
11
|
+
if (extension !== '.gltf' && extension !== '.glb')
|
|
12
|
+
continue;
|
|
13
|
+
await safeAssetTransferDirectory(transfer.directory, 'incoming');
|
|
14
|
+
const handle = await open(join(transfer.directory, 'incoming', String(index)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
15
|
+
try {
|
|
16
|
+
let start = 0, size = file.size;
|
|
17
|
+
if (extension === '.glb') {
|
|
18
|
+
const header = Buffer.alloc(20);
|
|
19
|
+
const read = await handle.read(header, 0, header.length, 0);
|
|
20
|
+
if (read.bytesRead !== 20 || header.readUInt32LE(0) !== 0x46546c67 || header.readUInt32LE(4) !== 2
|
|
21
|
+
|| header.readUInt32LE(8) !== file.size || header.readUInt32LE(16) !== 0x4e4f534a)
|
|
22
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
23
|
+
size = header.readUInt32LE(12);
|
|
24
|
+
start = 20;
|
|
25
|
+
}
|
|
26
|
+
if (size > METADATA_MAX_BYTES)
|
|
27
|
+
throw new AssetTransferError('asset_transfer_metadata_too_large', 413);
|
|
28
|
+
if (size < 2 || size + start > file.size)
|
|
29
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
30
|
+
const bytes = Buffer.alloc(size);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
while (offset < size) {
|
|
33
|
+
const read = await handle.read(bytes, offset, size - offset, start + offset);
|
|
34
|
+
if (!read.bytesRead)
|
|
35
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
36
|
+
offset += read.bytesRead;
|
|
37
|
+
}
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = JSON.parse(bytes.toString('utf8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
44
|
+
}
|
|
45
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
46
|
+
|| !Array.isArray(body.buffers ?? []) || !Array.isArray(body.images ?? []))
|
|
47
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
48
|
+
for (const resource of [...(body.buffers ?? []), ...(body.images ?? [])]) {
|
|
49
|
+
if (!resource || typeof resource !== 'object' || Array.isArray(resource))
|
|
50
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
51
|
+
if (resource.uri === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (typeof resource.uri !== 'string')
|
|
54
|
+
throw new AssetTransferError('asset_transfer_invalid_model');
|
|
55
|
+
const target = assetTransferDependencyPath(file, resource.uri);
|
|
56
|
+
if (target && !paths.has(target))
|
|
57
|
+
throw new AssetTransferError('asset_transfer_missing_dependency');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function assetTransferDependencyPath(file, uri) {
|
|
66
|
+
if (/^data:/i.test(uri))
|
|
67
|
+
return undefined;
|
|
68
|
+
let decoded;
|
|
69
|
+
try {
|
|
70
|
+
decoded = decodeURIComponent(uri);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new AssetTransferError('asset_transfer_invalid_dependency');
|
|
74
|
+
}
|
|
75
|
+
if (!decoded || /^[a-z][a-z\d+.-]*:/i.test(decoded) || /^[\/\\]/.test(decoded) || /[\\:\x00-\x1f\x7f?#]/.test(decoded))
|
|
76
|
+
throw new AssetTransferError('asset_transfer_invalid_dependency');
|
|
77
|
+
const target = posix.normalize(posix.join(posix.dirname(file.path), decoded));
|
|
78
|
+
if (target === '..' || target.startsWith('../'))
|
|
79
|
+
throw new AssetTransferError('asset_transfer_invalid_dependency');
|
|
80
|
+
return target;
|
|
81
|
+
}
|