@playcraft/cli 0.1.48-beta → 0.1.49-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 +16 -4
- package/dist/workspace-runtime/serve.js +60 -0
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/dispatch.js +47 -2
- package/dist/workspace-runtime/server/http.js +161 -129
- 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/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/operation-log.js +23 -0
- package/package.json +3 -3
|
@@ -4,7 +4,7 @@ import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../work
|
|
|
4
4
|
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
8
|
import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
9
9
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
10
10
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -31,6 +31,7 @@ import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
|
31
31
|
import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
|
|
32
32
|
import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
|
|
33
33
|
import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
|
|
34
|
+
import { runDeliveryBuild, cancelDeliveryBuild, trackDeliveryBuild, DeliveryBuildError } from '../workspaces/delivery-build.js';
|
|
34
35
|
const CONVERSATION_KINDS = new Set([
|
|
35
36
|
'session.create',
|
|
36
37
|
'session.turn',
|
|
@@ -59,6 +60,8 @@ const LOCAL_KINDS = new Set([
|
|
|
59
60
|
'terminal.create',
|
|
60
61
|
'parameter.patch',
|
|
61
62
|
'preview.publish',
|
|
63
|
+
'delivery.build',
|
|
64
|
+
'delivery.cancel',
|
|
62
65
|
'skills.update',
|
|
63
66
|
]);
|
|
64
67
|
export function canExecuteKind(state, kind) {
|
|
@@ -75,7 +78,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
75
78
|
}
|
|
76
79
|
return executeScopedOperation(state, scopeKey, operation, payload, recovery);
|
|
77
80
|
};
|
|
78
|
-
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish'].includes(operation.kind)) {
|
|
81
|
+
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
|
|
79
82
|
return run();
|
|
80
83
|
}
|
|
81
84
|
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
|
|
@@ -140,6 +143,12 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
140
143
|
if (operation.kind === 'preview.publish') {
|
|
141
144
|
return executePreviewPublish(state, scopeKey, current);
|
|
142
145
|
}
|
|
146
|
+
if (operation.kind === 'delivery.build') {
|
|
147
|
+
return executeDeliveryBuild(state, scopeKey, current, payload);
|
|
148
|
+
}
|
|
149
|
+
if (operation.kind === 'delivery.cancel') {
|
|
150
|
+
return executeDeliveryCancel(state, scopeKey, current, payload);
|
|
151
|
+
}
|
|
143
152
|
if (!state.native?.ready()) {
|
|
144
153
|
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
|
|
145
154
|
}
|
|
@@ -1165,6 +1174,42 @@ async function executePreviewPublish(state, scopeKey, operation) {
|
|
|
1165
1174
|
}).catch(() => undefined));
|
|
1166
1175
|
return accepted;
|
|
1167
1176
|
}
|
|
1177
|
+
async function executeDeliveryBuild(state, scopeKey, operation, payload) {
|
|
1178
|
+
const parsed = deliveryBuildPayloadSchema.safeParse(payload);
|
|
1179
|
+
if (!parsed.success) {
|
|
1180
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_build' }));
|
|
1181
|
+
}
|
|
1182
|
+
if (!operation.workspaceId) {
|
|
1183
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace' }));
|
|
1184
|
+
}
|
|
1185
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1186
|
+
const task = runDeliveryBuild(state, {
|
|
1187
|
+
buildId: operation.operationId,
|
|
1188
|
+
workspaceId: operation.workspaceId,
|
|
1189
|
+
directoryName: operation.directoryName ?? 'basic',
|
|
1190
|
+
payload: parsed.data,
|
|
1191
|
+
actor: parsed.data.actor ?? 'user',
|
|
1192
|
+
}).then(async (result) => {
|
|
1193
|
+
const error = result.record.error
|
|
1194
|
+
?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
|
|
1195
|
+
await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : 'failed', error ? { error } : {}));
|
|
1196
|
+
}, async (error) => {
|
|
1197
|
+
await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
|
|
1198
|
+
error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
|
|
1199
|
+
}));
|
|
1200
|
+
}).catch(() => undefined);
|
|
1201
|
+
trackDeliveryBuild(state, task);
|
|
1202
|
+
return accepted;
|
|
1203
|
+
}
|
|
1204
|
+
async function executeDeliveryCancel(state, scopeKey, operation, payload) {
|
|
1205
|
+
const parsed = deliveryCancelPayloadSchema.safeParse(payload);
|
|
1206
|
+
if (!parsed.success || !operation.workspaceId) {
|
|
1207
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_cancel' }));
|
|
1208
|
+
}
|
|
1209
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1210
|
+
const canceled = await cancelDeliveryBuild(state, operation.workspaceId, operation.directoryName ?? 'basic', parsed.data.buildId);
|
|
1211
|
+
return persist(state, scopeKey, transitionOperation(accepted, canceled ? 'completed' : 'failed', canceled ? {} : { error: 'delivery_build_not_found' }));
|
|
1212
|
+
}
|
|
1168
1213
|
async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
1169
1214
|
const parsed = terminalCreatePayloadSchema.safeParse(payload ?? {});
|
|
1170
1215
|
if (!parsed.success) {
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
3
|
import { constants } from 'node:fs';
|
|
5
4
|
import { access as accessDirectory, stat } from 'node:fs/promises';
|
|
6
|
-
import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema,
|
|
5
|
+
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
6
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
8
7
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
9
8
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
10
|
-
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
11
9
|
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
12
10
|
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
13
11
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
@@ -17,6 +15,8 @@ import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
|
17
15
|
import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
|
|
18
16
|
import { serveWorkspaceAttachment } from '../workspaces/attachment-content.js';
|
|
19
17
|
import { serveWorkspaceFileContent } from '../workspaces/file-content.js';
|
|
18
|
+
import { validateDeliveryArtifact, DeliveryValidationError } from '../workspaces/delivery.js';
|
|
19
|
+
import { DeliveryStore } from '../workspaces/delivery-store.js';
|
|
20
20
|
import { importWorkspaceLibraryAttachment, listWorkspaceLibraryAttachments, listWorkspaceLibraryImportReceipts, readLibraryAttachmentUpload } from '../workspaces/library-attachments.js';
|
|
21
21
|
import { beginWorkspaceDirectoryAttachment, uploadWorkspaceDirectoryFile, completeWorkspaceDirectoryAttachment, cancelWorkspaceDirectoryAttachment } from '../workspaces/directory-attachments.js';
|
|
22
22
|
import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
@@ -32,6 +32,8 @@ import { readWorkspaceDirectoryStats } from '../workspaces/directory-stats.js';
|
|
|
32
32
|
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
33
33
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
34
34
|
import { WorkspaceArtifactStore } from '../workspaces/artifacts.js';
|
|
35
|
+
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
36
|
+
import { acceptOperationCore } from './accept-operation.js';
|
|
35
37
|
import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
36
38
|
import { searchWorkspaceFiles } from '../workspaces/files-search.js';
|
|
37
39
|
import { serveWorkspaceArchive } from '../workspaces/archive.js';
|
|
@@ -360,6 +362,82 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
360
362
|
await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response, url.searchParams.get('filePath') ?? undefined);
|
|
361
363
|
return;
|
|
362
364
|
}
|
|
365
|
+
if (request.method === 'POST' && url.pathname === '/runtime/deliveries/validate') {
|
|
366
|
+
if (writeDestroyed(state, response))
|
|
367
|
+
return;
|
|
368
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
369
|
+
if (!workspaceId || directoryName === 'root') {
|
|
370
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const parsed = deliveryValidatePayloadSchema.safeParse(await readJson(request, 64 * 1024));
|
|
374
|
+
if (!parsed.success) {
|
|
375
|
+
writeJson(response, 400, { error: 'invalid_delivery_validate' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
379
|
+
try {
|
|
380
|
+
writeJson(response, 200, await validateDeliveryArtifact(root, parsed.data));
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const code = error instanceof DeliveryValidationError ? error.code : 'delivery_validation_failed';
|
|
384
|
+
writeJson(response, code === 'delivery_validation_failed' ? 500 : 400, { error: code });
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (request.method === 'GET' && url.pathname === '/runtime/deliveries') {
|
|
389
|
+
if (writeDestroyed(state, response))
|
|
390
|
+
return;
|
|
391
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
392
|
+
if (!workspaceId || directoryName === 'root') {
|
|
393
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const parsed = deliveryBuildListQuerySchema.safeParse({
|
|
397
|
+
...(url.searchParams.has('channel') ? { channel: url.searchParams.get('channel') } : {}),
|
|
398
|
+
...(url.searchParams.has('limit') ? { limit: url.searchParams.get('limit') } : {}),
|
|
399
|
+
});
|
|
400
|
+
if (!parsed.success) {
|
|
401
|
+
writeJson(response, 400, { error: 'invalid_delivery_query' });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
405
|
+
const items = await new DeliveryStore(state.config.stateDir, workspaceId)
|
|
406
|
+
.list(directoryName, parsed.data.channel, parsed.data.limit);
|
|
407
|
+
writeJson(response, 200, deliveryBuildListSchema.parse({ items, scannedAt: new Date().toISOString() }));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const deliveryBuild = /^\/runtime\/deliveries\/([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})(?:\/(log))?$/.exec(url.pathname);
|
|
411
|
+
if (request.method === 'GET' && deliveryBuild) {
|
|
412
|
+
if (writeDestroyed(state, response))
|
|
413
|
+
return;
|
|
414
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
415
|
+
if (!workspaceId) {
|
|
416
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
420
|
+
const store = new DeliveryStore(state.config.stateDir, workspaceId);
|
|
421
|
+
const buildId = deliveryBuild[1];
|
|
422
|
+
const record = await store.read(buildId);
|
|
423
|
+
if (!record || record.directoryName !== directoryName) {
|
|
424
|
+
writeJson(response, 404, { error: 'delivery_build_not_found' });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (deliveryBuild[2] === 'log') {
|
|
428
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
429
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
430
|
+
writeJson(response, 400, { error: 'invalid_delivery_offset' });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const log = await store.readLog(buildId, offset);
|
|
434
|
+
writeJson(response, 200, deliveryBuildLogSchema.parse({ ...log, offset }));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const report = await store.readReport(buildId);
|
|
438
|
+
writeJson(response, 200, deliveryBuildDetailSchema.parse({ record, ...(report ? { report } : {}) }));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
363
441
|
if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
|
|
364
442
|
if (writeDestroyed(state, response))
|
|
365
443
|
return;
|
|
@@ -596,6 +674,30 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
596
674
|
writeJson(response, operation ? 200 : 404, operation ?? { error: 'operation_not_found' });
|
|
597
675
|
return;
|
|
598
676
|
}
|
|
677
|
+
const operationLog = /^\/runtime\/operations\/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\/log$/.exec(url.pathname);
|
|
678
|
+
if (request.method === 'GET' && operationLog) {
|
|
679
|
+
if (writeDestroyed(state, response))
|
|
680
|
+
return;
|
|
681
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
682
|
+
if (!workspaceId) {
|
|
683
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const operation = state.journal.getOperationById(operationLog[1]);
|
|
687
|
+
if (!operation || operation.workspaceId !== workspaceId
|
|
688
|
+
|| (operation.directoryName ?? 'basic') !== directoryName) {
|
|
689
|
+
writeJson(response, 404, { error: 'operation_not_found' });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
693
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
694
|
+
writeJson(response, 400, { error: 'invalid_operation_log_offset' });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const log = await new OperationLogStore(state.config.stateDir).read(operationLog[1], offset);
|
|
698
|
+
writeJson(response, 200, operationLogSchema.parse({ ...log, offset }));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
599
701
|
if (request.method === 'GET' && url.pathname.startsWith('/runtime/operations/')) {
|
|
600
702
|
const operationId = url.pathname.slice('/runtime/operations/'.length);
|
|
601
703
|
const operation = state.journal.getOperationById(operationId);
|
|
@@ -758,135 +860,65 @@ async function acceptOperation(state, request, response) {
|
|
|
758
860
|
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
759
861
|
return;
|
|
760
862
|
}
|
|
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
|
-
|
|
863
|
+
const executionPayload = parsed.data.kind === 'workspace.references.import'
|
|
864
|
+
? { snapshot: parsed.data.remixReferenceSnapshot }
|
|
865
|
+
: parsed.data.kind === 'asset.library.import'
|
|
866
|
+
? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
|
|
867
|
+
: parsed.data.payload;
|
|
868
|
+
const accept = async () => {
|
|
869
|
+
const result = await acceptOperationCore(state, parsed.data, {
|
|
870
|
+
leaseFence: incomingFence,
|
|
871
|
+
executionPayload,
|
|
872
|
+
validate: async (scopeKey) => {
|
|
873
|
+
if (parsed.data.kind.startsWith('session.') && parsed.data.sessionId && productionSessionAttemptId(parsed.data.sessionId)) {
|
|
874
|
+
return { status: 409, body: runtimeError('capability_unavailable', 'production_session_readonly') };
|
|
875
|
+
}
|
|
876
|
+
if (parsed.data.kind === 'session.create') {
|
|
877
|
+
const creation = state.journal.findSessionCreation(parsed.data.sessionId);
|
|
878
|
+
const session = state.journal.getSession(parsed.data.sessionId);
|
|
879
|
+
if (creation || session)
|
|
880
|
+
return { status: 409, body: { error: 'session_already_bound' } };
|
|
881
|
+
}
|
|
882
|
+
else if (parsed.data.kind === 'terminal.create') {
|
|
883
|
+
const payload = asRecord(parsed.data.payload);
|
|
884
|
+
const terminalId = typeof payload.terminalId === 'string' ? payload.terminalId.trim() : '';
|
|
885
|
+
if (!terminalId)
|
|
886
|
+
return { status: 400, body: { error: 'invalid_operation', reason: 'terminalId_required' } };
|
|
887
|
+
const creation = state.journal.findTerminalCreation(terminalId);
|
|
888
|
+
const terminal = state.journal.getTerminal(terminalId);
|
|
889
|
+
if (creation && operationScopeKey(creation) !== scopeKey) {
|
|
890
|
+
return { status: 409, body: { error: 'terminal_already_bound' } };
|
|
891
|
+
}
|
|
892
|
+
if (terminal && !creation)
|
|
893
|
+
return { status: 409, body: { error: 'terminal_already_bound' } };
|
|
894
|
+
}
|
|
895
|
+
else if (parsed.data.kind.startsWith('session.') && state.config.workspaceMode === 'multi') {
|
|
896
|
+
const session = state.journal.getSession(parsed.data.sessionId);
|
|
897
|
+
// A replaced sandbox has no binding yet; the recovering turn creates it.
|
|
898
|
+
const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
|
|
899
|
+
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
|
|
900
|
+
|| (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
|
|
901
|
+
return { status: 409, body: { error: 'session_workspace_mismatch' } };
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (parsed.data.kind === 'workspace.references.import' && !parsed.data.remixReferenceSnapshot) {
|
|
905
|
+
return { status: 400, body: { error: 'remix_reference_snapshot_required' } };
|
|
906
|
+
}
|
|
907
|
+
if (parsed.data.kind === 'asset.library.import' && !parsed.data.libraryAssetImports) {
|
|
908
|
+
return { status: 400, body: { error: 'library_asset_snapshot_required' } };
|
|
909
|
+
}
|
|
910
|
+
return undefined;
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
if (result.rejected) {
|
|
914
|
+
writeJson(response, result.refusal.status, result.refusal.body);
|
|
802
915
|
return;
|
|
803
916
|
}
|
|
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
|
-
}));
|
|
917
|
+
if (result.reused) {
|
|
918
|
+
writeJson(response, 200, result.operation);
|
|
881
919
|
return;
|
|
882
920
|
}
|
|
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);
|
|
921
|
+
const executed = result.executed;
|
|
890
922
|
if (executed.status === 'uncertain') {
|
|
891
923
|
writeError(response, runtimeError('uncertain_operation', executed.error ?? 'native_receipt_lost', {
|
|
892
924
|
operationId: executed.operationId,
|
|
@@ -894,7 +926,7 @@ async function acceptOperation(state, request, response) {
|
|
|
894
926
|
return;
|
|
895
927
|
}
|
|
896
928
|
writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
|
|
897
|
-
}
|
|
929
|
+
};
|
|
898
930
|
const acceptBound = async () => {
|
|
899
931
|
if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
|
|
900
932
|
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
@@ -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
|
+
}
|