@playcraft/cli 0.1.47-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.
Files changed (28) hide show
  1. package/dist/commands/tools-batch.js +12 -2
  2. package/dist/workspace-runtime/batch/control.js +6 -0
  3. package/dist/workspace-runtime/batch/deliver.js +105 -87
  4. package/dist/workspace-runtime/batch/generate.js +26 -3
  5. package/dist/workspace-runtime/batch/prepare.js +169 -56
  6. package/dist/workspace-runtime/batch/source-capture.js +7 -20
  7. package/dist/workspace-runtime/batch/source-publish.js +2 -4
  8. package/dist/workspace-runtime/codex/app-server.js +29 -4
  9. package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
  10. package/dist/workspace-runtime/codex/tool-response.js +10 -0
  11. package/dist/workspace-runtime/codex/ui-tools.js +22 -5
  12. package/dist/workspace-runtime/previews/diagnostics.js +44 -0
  13. package/dist/workspace-runtime/previews/publish.js +16 -4
  14. package/dist/workspace-runtime/serve.js +60 -0
  15. package/dist/workspace-runtime/server/accept-operation.js +61 -0
  16. package/dist/workspace-runtime/server/dispatch.js +47 -2
  17. package/dist/workspace-runtime/server/http.js +163 -130
  18. package/dist/workspace-runtime/server/operation-record.js +33 -0
  19. package/dist/workspace-runtime/server/submit-operation.js +41 -0
  20. package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
  21. package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
  22. package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
  23. package/dist/workspace-runtime/workspaces/delivery.js +64 -0
  24. package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
  25. package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
  26. package/dist/workspace-runtime/workspaces/directories.js +9 -4
  27. package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
  28. package/package.json +3 -3
@@ -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, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, } from '@playcraft/common/cloud-workspace/runtime';
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);
@@ -752,140 +854,71 @@ async function acceptOperation(state, request, response) {
752
854
  if (state.config.workspaceMode === 'multi')
753
855
  await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
754
856
  const mutation = isWorkspaceMutation(parsed.data.kind);
755
- const occupiesDirectory = mutation || parsed.data.kind === 'session.compact';
857
+ // A new conversation does not change workspace files, so a production hold must not refuse it.
858
+ const occupiesDirectory = (mutation && parsed.data.kind !== 'session.create') || parsed.data.kind === 'session.compact';
756
859
  if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
757
860
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
758
861
  return;
759
862
  }
760
- const payloadHash = hashOperationPayload(parsed.data.payload);
761
- const scopeKey = operationScopeKey({
762
- environment: state.config.environment,
763
- userId: state.config.userId,
764
- kind: parsed.data.kind,
765
- clientRequestId: parsed.data.clientRequestId,
766
- workspaceId: parsed.data.workspaceId,
767
- directoryName: parsed.data.directoryName,
768
- sessionId: parsed.data.sessionId,
769
- });
770
- const accept = () => state.journal.exclusive(scopeKey, async () => {
771
- // Recheck after a concurrent dispatcher may have advanced the fence while this request waited.
772
- if (assertLeaseFence(incomingFence, state.acceptedLeaseFence) !== 'ok') {
773
- writeError(response, runtimeError('stale_lease', 'leaseFence is behind the accepted dispatcher'));
774
- return;
775
- }
776
- const existing = state.journal.getOperation(scopeKey);
777
- const decision = resolveIdempotentAccept(existing, payloadHash);
778
- if (decision.action === 'conflict') {
779
- writeError(response, runtimeError('idempotency_conflict', 'clientRequestId reused with a different payload', {
780
- operationId: decision.operation.operationId,
781
- }));
782
- return;
783
- }
784
- // A turn refused for a lost thread never reached Codex. Sent again with the conversation, it
785
- // runs afresh under the same request, so the message is shown and answered only once.
786
- const retryWithRecovery = decision.action === 'reuse' && parsed.data.kind === 'session.turn'
787
- && Boolean(parsed.data.sessionRecovery) && decision.operation.status === 'failed'
788
- && isLostThreadError(decision.operation.error);
789
- if (decision.action === 'reuse' && !retryWithRecovery) {
790
- if (decision.operation.status === 'uncertain') {
791
- writeError(response, runtimeError('uncertain_operation', decision.operation.error ?? 'native_receipt_lost', {
792
- operationId: decision.operation.operationId,
793
- }));
794
- return;
795
- }
796
- writeJson(response, 200, decision.operation);
797
- return;
798
- }
799
- if (parsed.data.kind.startsWith('session.') && parsed.data.sessionId && productionSessionAttemptId(parsed.data.sessionId)) {
800
- writeError(response, runtimeError('capability_unavailable', 'production_session_readonly'));
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);
801
915
  return;
802
916
  }
803
- if (parsed.data.kind === 'session.create') {
804
- const creation = state.journal.findSessionCreation(parsed.data.sessionId);
805
- const session = state.journal.getSession(parsed.data.sessionId);
806
- if (creation || session) {
807
- writeJson(response, 409, { error: 'session_already_bound' });
808
- return;
809
- }
810
- }
811
- else if (parsed.data.kind === 'terminal.create') {
812
- const payload = asRecord(parsed.data.payload);
813
- const terminalId = typeof payload.terminalId === 'string' ? payload.terminalId.trim() : '';
814
- if (!terminalId) {
815
- writeJson(response, 400, { error: 'invalid_operation', reason: 'terminalId_required' });
816
- return;
817
- }
818
- const creation = state.journal.findTerminalCreation(terminalId);
819
- const terminal = state.journal.getTerminal(terminalId);
820
- if (creation && operationScopeKey(creation) !== scopeKey) {
821
- writeJson(response, 409, { error: 'terminal_already_bound' });
822
- return;
823
- }
824
- if (terminal && !creation) {
825
- writeJson(response, 409, { error: 'terminal_already_bound' });
826
- return;
827
- }
828
- }
829
- else if (parsed.data.kind.startsWith('session.') && state.config.workspaceMode === 'multi') {
830
- const session = state.journal.getSession(parsed.data.sessionId);
831
- // A replaced sandbox has no binding yet; the recovering turn creates it.
832
- const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
833
- if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
834
- || (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
835
- writeJson(response, 409, { error: 'session_workspace_mismatch' });
836
- return;
837
- }
838
- }
839
- if (parsed.data.kind === 'workspace.references.import' && !parsed.data.remixReferenceSnapshot) {
840
- writeJson(response, 400, { error: 'remix_reference_snapshot_required' });
841
- return;
842
- }
843
- if (parsed.data.kind === 'asset.library.import' && !parsed.data.libraryAssetImports) {
844
- writeJson(response, 400, { error: 'library_asset_snapshot_required' });
845
- return;
846
- }
847
- const now = new Date().toISOString();
848
- const executable = canExecuteKind(state, parsed.data.kind);
849
- const payloadRecord = asRecord(parsed.data.payload);
850
- const operation = {
851
- operationId: randomUUID(),
852
- environment: state.config.environment,
853
- userId: state.config.userId,
854
- clientRequestId: parsed.data.clientRequestId,
855
- kind: parsed.data.kind,
856
- status: executable ? 'queued' : 'failed',
857
- payloadHash,
858
- workspaceId: parsed.data.workspaceId,
859
- directoryName: parsed.data.directoryName,
860
- sessionId: parsed.data.sessionId,
861
- expectedRevision: parsed.data.expectedRevision,
862
- generation: state.config.generation,
863
- runtimeEpoch: state.runtimeEpoch,
864
- leaseFence: incomingFence,
865
- terminalId: parsed.data.kind === 'terminal.create' && typeof payloadRecord.terminalId === 'string'
866
- ? payloadRecord.terminalId.trim()
867
- : undefined,
868
- cwd: parsed.data.kind === 'terminal.create' && typeof payloadRecord.cwd === 'string'
869
- ? payloadRecord.cwd
870
- : undefined,
871
- error: executable ? undefined : 'capability_unavailable',
872
- createdAt: now,
873
- updatedAt: now,
874
- };
875
- if (!executable) {
876
- const saved = await state.journal.putOperation(scopeKey, operation);
877
- writeError(response, runtimeError('capability_unavailable', unavailableReason(state, parsed.data.kind), {
878
- operationId: saved.operationId,
879
- }));
917
+ if (result.reused) {
918
+ writeJson(response, 200, result.operation);
880
919
  return;
881
920
  }
882
- state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, incomingFence);
883
- await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
884
- const queued = await state.journal.putOperation(scopeKey, operation);
885
- const executionPayload = parsed.data.kind === 'workspace.references.import'
886
- ? { snapshot: parsed.data.remixReferenceSnapshot } : parsed.data.kind === 'asset.library.import'
887
- ? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports } : parsed.data.payload;
888
- const executed = await executeOperation(state, scopeKey, queued, executionPayload, parsed.data.sessionRecovery);
921
+ const executed = result.executed;
889
922
  if (executed.status === 'uncertain') {
890
923
  writeError(response, runtimeError('uncertain_operation', executed.error ?? 'native_receipt_lost', {
891
924
  operationId: executed.operationId,
@@ -893,7 +926,7 @@ async function acceptOperation(state, request, response) {
893
926
  return;
894
927
  }
895
928
  writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
896
- });
929
+ };
897
930
  const acceptBound = async () => {
898
931
  if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
899
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
+ }