@playcraft/cli 0.1.57-beta → 0.1.59-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 (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -2,6 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
4
4
  import { shareWorkspaceGit } from '../workspaces/shared-git.js';
5
+ import { holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
5
6
  import { join } from 'node:path';
6
7
  import { z } from 'zod';
7
8
  import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -11,6 +12,7 @@ import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
11
12
  import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
12
13
  import { importWorkspaceRemixReference, RemixReferenceError } from '../workspaces/remix-references.js';
13
14
  import { ComposerContextError, resolveComposerContext } from '../workspaces/composer-context.js';
15
+ import { ensureParameterInitializationSkill } from '../workspaces/parameter-initialization-skill.js';
14
16
  import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
15
17
  import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from '../workspaces/skill-artifact-loader.js';
16
18
  import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-skills/local-store.js';
@@ -28,9 +30,10 @@ import { ThemeAssetIndexError } from '../workspaces/theme-asset-index.js';
28
30
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
29
31
  import { prepareParameterAgentTask, finishParameterAgentTask, verifyParameterAgentTask, } from '../workspaces/parameter-agent.js';
30
32
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
31
- import { spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
33
+ import { startManagedBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
32
34
  import { wakeSourceEventUploader } from './source-uploader.js';
33
35
  import { batchDirectoryHeld } from '../batch/occupancy.js';
36
+ import { trustedMemberPolicy } from '../batch/policy.js';
34
37
  import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
35
38
  import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
36
39
  import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
@@ -90,6 +93,8 @@ export async function recoverInterruptedParameterVerifications(journal) {
90
93
  for (const { scopeKey, operation } of journal.listOperations()) {
91
94
  if (operation.kind !== 'parameter.verify' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
92
95
  continue;
96
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
97
+ continue;
93
98
  await journal.putOperation(scopeKey, transitionOperation(operation, 'interrupted', { error: 'parameter_verification_interrupted' }));
94
99
  }
95
100
  }
@@ -105,12 +110,21 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
105
110
  if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed()) {
106
111
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
107
112
  }
108
- return executeScopedOperation(state, scopeKey, operation, payload, recovery);
113
+ if (operation.kind === 'session.turn' && !recovery && operation.sessionId &&
114
+ state.journal.getSession(operation.sessionId)?.recoverability === 'history_only')
115
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'native_not_recoverable' }));
116
+ return withOperationDirectoryAccess(state, operation, () => {
117
+ if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
118
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
119
+ return executeScopedOperation(state, scopeKey, operation, payload, recovery);
120
+ }, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
109
121
  };
110
122
  if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
111
123
  return run();
112
124
  }
113
- return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
125
+ return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
126
+ ? `session-execution:${operation.sessionId}`
127
+ : `workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
114
128
  }
115
129
  async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
116
130
  let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
@@ -204,7 +218,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
204
218
  if (operation.kind === 'session.steer')
205
219
  return executeSteer(state, scopeKey, current, payload);
206
220
  if (operation.kind === 'session.interrupt') {
207
- return executeInterrupt(state, scopeKey, current);
221
+ return executeInterrupt(state, scopeKey, current, payload);
208
222
  }
209
223
  if (operation.kind === 'session.approval.respond') {
210
224
  return executeApproval(state, scopeKey, current, payload);
@@ -381,12 +395,12 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
381
395
  }
382
396
  found = rebound;
383
397
  }
384
- const binding = found;
385
398
  // Also covers a new thread whose first turn failed before starting: the next turn still carries it.
386
- const context = recovery && binding?.recoveryContextPending ? recovery.context : '';
387
- if (!binding) {
399
+ const context = recovery && found?.recoveryContextPending ? recovery.context : '';
400
+ if (!found) {
388
401
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
389
402
  }
403
+ let binding = found;
390
404
  if ((binding.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic')) {
391
405
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'context_directory_mismatch' }));
392
406
  }
@@ -398,17 +412,30 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
398
412
  }
399
413
  const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
400
414
  let composer;
415
+ let initializationSkill;
401
416
  try {
417
+ let turnContext = selection.data.context;
418
+ if (parameterTask.data?.action === 'initialize') {
419
+ const selectedSkill = await ensureParameterInitializationSkill(root);
420
+ initializationSkill = { ...selectedSkill,
421
+ path: join(root, '.playcraft/skills', selectedSkill.lockHash, selectedSkill.entryKey, 'SKILL.md') };
422
+ turnContext = { ...turnContext, files: turnContext?.files ?? [], skills: [
423
+ ...(turnContext?.skills ?? []).filter(skill => skill.entryKey !== selectedSkill.entryKey), selectedSkill,
424
+ ] };
425
+ }
402
426
  composer = await resolveComposerContext({
403
427
  root,
404
- directoryName: binding.directoryName ?? 'basic', context: selection.data.context,
428
+ directoryName: binding.directoryName ?? 'basic', context: turnContext,
405
429
  skillCache: join(state.config.stateDir, 'skill-artifacts'),
406
430
  loadSkillArtifact: entry => loadWorkspaceSkillArtifact(state, operation.workspaceId ?? 'workspace', entry),
407
431
  });
408
432
  }
409
433
  catch (error) {
410
434
  return persist(state, scopeKey, transitionOperation(operation, 'failed', {
411
- error: error instanceof ComposerContextError ? error.message : 'invalid_composer_context',
435
+ error: error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize'
436
+ ? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
437
+ ? error.message : parameterAgentError(error)
438
+ : 'invalid_composer_context',
412
439
  }));
413
440
  }
414
441
  let attachments;
@@ -429,7 +456,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
429
456
  ...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}\n\n`
430
457
  : '';
431
458
  const engineeringBatchContext = state.config.workspaceMode === 'multi' && operation.workspaceId
432
- && operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId)
459
+ && operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId) && !trustedMemberPolicy(state, sessionId)
433
460
  ? `PlayCraft batch request context (trusted runtime identity): ${JSON.stringify({ workspaceId: operation.workspaceId,
434
461
  clientRequestId: operation.clientRequestId, sourceDirectoryName: binding.directoryName ?? 'basic',
435
462
  ...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}`
@@ -446,7 +473,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
446
473
  let parameterInstructions = '';
447
474
  if (parameterTask.data) {
448
475
  try {
449
- parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root)).instructions;
476
+ parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root, { initializationSkill })).instructions;
450
477
  }
451
478
  catch (error) {
452
479
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterAgentError(error) }));
@@ -458,7 +485,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
458
485
  });
459
486
  let result;
460
487
  try {
461
- result = await native(state).startTurn({
488
+ const startTurn = () => native(state).startTurn({
462
489
  sessionId,
463
490
  threadId: binding.threadId,
464
491
  text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions].filter(Boolean).join('\n\n')),
@@ -490,6 +517,18 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
490
517
  await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
491
518
  },
492
519
  });
520
+ result = await startTurn();
521
+ // The native runtime cannot persist an empty thread before its first turn. Adding the
522
+ // selected skill can therefore require replacing that empty thread, before any model work.
523
+ if (initializationSkill && result.outcome === 'failed' && result.error === 'workspace_skills_changed'
524
+ && !(state.journal.getOperation(scopeKey) ?? operation).nativeRequestId) {
525
+ const started = await native(state).startThread({ sessionId, cwd: root,
526
+ modelConfigId: selected.data ?? binding.modelConfigId, reasoningEffort: requestedEffort, permissionMode: requestedMode });
527
+ binding = { ...binding, threadId: started.threadId, modelConfigId: started.modelConfigId ?? binding.modelConfigId,
528
+ reasoningEffort: started.reasoningEffort ?? requestedEffort, permissionMode: started.permissionMode ?? requestedMode };
529
+ await state.journal.putSession(binding);
530
+ result = await startTurn();
531
+ }
493
532
  }
494
533
  catch (error) {
495
534
  if (!parameterTask.data)
@@ -811,13 +850,18 @@ async function executeSteer(state, scopeKey, operation, payload) {
811
850
  } }]);
812
851
  return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
813
852
  }
814
- async function executeInterrupt(state, scopeKey, operation) {
853
+ async function executeInterrupt(state, scopeKey, operation, payload) {
815
854
  const sessionId = required(operation.sessionId, 'sessionId');
816
855
  const binding = state.journal.getSession(sessionId);
817
856
  if (!binding) {
818
857
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
819
858
  }
820
- const result = await native(state).interrupt({ sessionId, threadId: binding.threadId });
859
+ const expectedTurnId = asRecord(payload).expectedTurnId;
860
+ if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
861
+ || binding.activeTurnId !== expectedTurnId))
862
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
863
+ const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
864
+ ...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
821
865
  if (result.outcome === 'failed') {
822
866
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: result.error ?? 'turn_not_running' }));
823
867
  }
@@ -1113,7 +1157,7 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
1113
1157
  : 'invalid_workspace_path';
1114
1158
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error }));
1115
1159
  }
1116
- const workspaceId = operation.workspaceId ?? 'workspace';
1160
+ const workspaceId = revisionScope(state, operation.workspaceId, operation.directoryName) ?? operation.workspaceId ?? 'workspace';
1117
1161
  const writer = parsed.data.writer ?? 'manual';
1118
1162
  if (parsed.data.release) {
1119
1163
  const lease = state.journal.getManagedWrite(parsed.data.writeId);
@@ -1147,17 +1191,14 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
1147
1191
  contentHash: written.contentHash,
1148
1192
  }, revisionScope(state, operation.workspaceId, operation.directoryName));
1149
1193
  if (parsed.data.hold && parsed.data.writeId) {
1150
- let pid;
1194
+ const record = { writeId: parsed.data.writeId, workspaceId, source: writer, startedAt: new Date().toISOString() };
1151
1195
  if (writer === 'background') {
1152
- pid = spawnBackgroundWriter(await resolveWorkspaceFile(root, parsed.data.path));
1196
+ await startManagedBackgroundWriter(state, record, await resolveWorkspaceFile(root, parsed.data.path));
1197
+ }
1198
+ else {
1199
+ await holdDirectoryAccess(state, operation, `managed:${parsed.data.writeId}`, 'shared');
1200
+ await state.journal.putManagedWrite(record);
1153
1201
  }
1154
- await state.journal.putManagedWrite({
1155
- writeId: parsed.data.writeId,
1156
- workspaceId,
1157
- source: writer,
1158
- startedAt: new Date().toISOString(),
1159
- pid,
1160
- });
1161
1202
  }
1162
1203
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { expectedRevision: revision }));
1163
1204
  if (artifactFormat(parsed.data.path) && operation.workspaceId) {
@@ -1238,11 +1279,12 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
1238
1279
  }
1239
1280
  const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
1240
1281
  try {
1241
- const saved = await saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
1282
+ // Saves share the directory, and a deferred one runs outside the workspace queue; commit one at a time.
1283
+ const saved = await state.journal.exclusive(`workspace-save:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, async () => saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
1242
1284
  message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
1243
1285
  push: savePushesBranch(binding?.targetKind, operation.directoryName),
1244
1286
  boundBranch: boundBranch(state, operation),
1245
- });
1287
+ }));
1246
1288
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1247
1289
  await recordNativeEvents(state, accepted, [
1248
1290
  {
@@ -1349,7 +1391,9 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
1349
1391
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
1350
1392
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_data_missing' }));
1351
1393
  }
1352
- const dataPath = document.dataPath;
1394
+ const dataPath = 'documentId' in parsed.data ? document.runtimeI18n?.themePath : document.dataPath;
1395
+ if (!dataPath)
1396
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_i18n_global_invalid' }));
1353
1397
  return withObservedFile(state, operation.workspaceId, dataPath, async ({ revision: currentRevision, file }) => {
1354
1398
  if (parsed.data.expectedRevision !== currentRevision) {
1355
1399
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'revision_conflict' }));
@@ -1386,6 +1430,7 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
1386
1430
  revision,
1387
1431
  contentHash: written.contentHash,
1388
1432
  size: written.size,
1433
+ ...('documentId' in parsed.data ? { documentId: parsed.data.documentId } : {}),
1389
1434
  ...('changes' in parsed.data
1390
1435
  ? { fieldPaths: parsed.data.changes.map((change) => change.fieldPath) }
1391
1436
  : { fieldPath: parsed.data.fieldPath }),
@@ -1416,7 +1461,7 @@ async function executePreviewPublish(state, scopeKey, operation) {
1416
1461
  trackPreviewPublish(state, runPreviewPublish(state, accepted, root).then(async () => {
1417
1462
  await persist(state, scopeKey, transitionOperation(accepted, 'completed'));
1418
1463
  }, async (error) => {
1419
- await persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: previewFailureCode(error) }));
1464
+ await persist(state, scopeKey, transitionOperation(accepted, previewFailureCode(error) === 'preview_process_unknown' ? 'uncertain' : 'failed', { error: previewFailureCode(error) }));
1420
1465
  }).catch(() => undefined));
1421
1466
  return accepted;
1422
1467
  }
@@ -1438,7 +1483,7 @@ async function executeDeliveryBuild(state, scopeKey, operation, payload) {
1438
1483
  }).then(async (result) => {
1439
1484
  const error = result.record.error
1440
1485
  ?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
1441
- await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : 'failed', error ? { error } : {}));
1486
+ await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : error === 'delivery_process_unknown' ? 'uncertain' : 'failed', error ? { error } : {}));
1442
1487
  }, async (error) => {
1443
1488
  await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
1444
1489
  error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
@@ -12,6 +12,7 @@ export async function applyRuntimeEndpointPatch(state, patch) {
12
12
  throw new Error('endpoint_config_path_missing');
13
13
  }
14
14
  const temporary = `${state.configPath}.${process.pid}.tmp`;
15
+ // private
15
16
  await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600 });
16
17
  await chmod(temporary, 0o600);
17
18
  await rename(temporary, state.configPath);
@@ -3,7 +3,7 @@ import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.j
3
3
  import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
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, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, 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, workspaceDirectoryNameSchema, attachmentIdsSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, 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, workspaceDirectoryNameSchema, workspaceDirectoryRegistrationSchema, attachmentIdsSchema, } 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';
@@ -12,6 +12,7 @@ import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWork
12
12
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
13
13
  import { listParameterAgentTasks } from '../workspaces/parameter-agent.js';
14
14
  import { readDependencies } from '../workspaces/dependencies.js';
15
+ import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
15
16
  import { desktopHost, desktopRoute } from './desktop-host.js';
16
17
  import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
17
18
  import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
@@ -40,11 +41,37 @@ import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-wor
40
41
  import { searchWorkspaceFiles } from '../workspaces/files-search.js';
41
42
  import { serveWorkspaceArchive } from '../workspaces/archive.js';
42
43
  import { handleWorkspaceTransfer } from './workspace-transfer.js';
44
+ import { handleWorkspaceStorage } from './workspace-storage.js';
43
45
  import { serveSessionImage } from '../workspaces/session-image.js';
44
46
  import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
45
47
  import { handleAssetTransferRequest } from './asset-transfers.js';
46
48
  import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktrees.js';
49
+ import { withDirectoryWrite } from './directory-access.js';
47
50
  export async function handleRuntimeRequest(state, request, response) {
51
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
52
+ const workspaceId = url.searchParams.get('workspaceId');
53
+ // Body-scoped operations, members and terminal control take their own lifetime-aware leases.
54
+ const ownLease = url.pathname.startsWith('/runtime/operations') || url.pathname.startsWith('/runtime/terminals/') ||
55
+ url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname) ||
56
+ /^\/runtime\/sessions\/[^/]+\/prediction$/.test(url.pathname);
57
+ if (authorizeRuntime(state, request) === 'control' && workspaceId &&
58
+ (!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') || url.pathname === '/runtime/git-bundle') && !ownLease) {
59
+ try {
60
+ return await withDirectoryWrite(state, { workspaceId, directoryName: url.searchParams.get('directoryName') ?? 'basic', wait: false,
61
+ sessionId: url.searchParams.get('sessionId') ?? undefined,
62
+ ...(url.pathname === '/runtime/git-bundle' ? { mode: 'exclusive' } : {}) }, () => dispatchRuntimeRequest(state, request, response));
63
+ }
64
+ catch (error) {
65
+ if (!response.headersSent)
66
+ writeJson(response, error instanceof Error && error.message === 'directory_busy' ? 409 : 500, { error: error instanceof Error && error.message === 'directory_busy' ? 'directory_busy' : 'runtime_request_failed' });
67
+ else
68
+ response.destroy(error instanceof Error ? error : undefined);
69
+ return;
70
+ }
71
+ }
72
+ return dispatchRuntimeRequest(state, request, response);
73
+ }
74
+ async function dispatchRuntimeRequest(state, request, response) {
48
75
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
49
76
  const directoryName = url.searchParams.get('directoryName') ?? 'basic';
50
77
  const access = authorizeRuntime(state, request);
@@ -57,6 +84,8 @@ export async function handleRuntimeRequest(state, request, response) {
57
84
  return;
58
85
  }
59
86
  try {
87
+ if (await handleWorkspaceStorage(state, request, response, url))
88
+ return;
60
89
  const transfer = await handleAssetTransferRequest(state, request, url);
61
90
  if (transfer) {
62
91
  writeJson(response, transfer.status, transfer.body);
@@ -103,12 +132,17 @@ export async function handleRuntimeRequest(state, request, response) {
103
132
  instanceToken: state.instanceToken,
104
133
  permissionsVersion: 1,
105
134
  composerContextVersion: 1,
135
+ scheduledTaskIdentityVersion: 1,
136
+ scheduledTaskCancellationVersion: 1,
106
137
  parameterAgentVersion: 1,
138
+ directoryConcurrencyVersion: 1,
139
+ parameterInitializationVersion: 1,
107
140
  remixReferencesVersion: 1,
108
141
  batchSourceFreezeRevision: 2,
109
142
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
110
143
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
111
144
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
145
+ ...(state.native?.startPrediction ? { predictionVersion: 1 } : {}),
112
146
  sourceReadToken: state.journal.sourceReadToken(),
113
147
  workspaceMode: state.config.workspaceMode,
114
148
  executionGroup: {
@@ -181,6 +215,27 @@ export async function handleRuntimeRequest(state, request, response) {
181
215
  writeJson(response, result.outcome === 'accepted' ? 200 : 409, result);
182
216
  return;
183
217
  }
218
+ const predictionRoute = /^\/runtime\/sessions\/([^/]+)\/prediction$/.exec(url.pathname);
219
+ if (request.method === 'POST' && predictionRoute) {
220
+ if (writeDestroyed(state, response))
221
+ return;
222
+ const sessionId = decodeURIComponent(predictionRoute[1]);
223
+ const session = state.journal.getSession(sessionId);
224
+ if (!session || productionSessionAttemptId(sessionId) || session.workspaceId !== url.searchParams.get('workspaceId')
225
+ || (session.directoryName ?? 'basic') !== directoryName) {
226
+ writeJson(response, 404, { error: 'session_not_found' });
227
+ return;
228
+ }
229
+ if (!state.native?.startPrediction) {
230
+ writeJson(response, 409, { error: 'prediction_unavailable' });
231
+ return;
232
+ }
233
+ // Best-effort and read-only: no instruction/journal reservation, and the ephemeral
234
+ // fork never becomes a user-visible thread.
235
+ const result = await state.native.startPrediction({ sessionId, threadId: session.threadId });
236
+ writeJson(response, result.outcome === 'ok' ? 200 : 409, result);
237
+ return;
238
+ }
184
239
  if (request.method === 'POST' && url.pathname === '/runtime/workspaces/register') {
185
240
  if (writeDestroyed(state, response))
186
241
  return;
@@ -189,7 +244,7 @@ export async function handleRuntimeRequest(state, request, response) {
189
244
  writeJson(response, 409, { error: 'invalid_workspace_registration' });
190
245
  return;
191
246
  }
192
- const binding = await registerWorkspace(state, parsed.data.binding, parsed.data.leaseFence, parsed.data.preparation, parsed.data.cliCredentials === false, parsed.data.sourceSnapshot);
247
+ const binding = await withDirectoryWrite(state, { workspaceId: parsed.data.binding.workspaceId, directoryName: 'basic', wait: false }, () => registerWorkspace(state, parsed.data.binding, parsed.data.leaseFence, parsed.data.preparation, parsed.data.cliCredentials === false, parsed.data.sourceSnapshot));
193
248
  if (parsed.data.cliCredentials)
194
249
  await ensureCliCredentials(state);
195
250
  writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
@@ -210,10 +265,13 @@ export async function handleRuntimeRequest(state, request, response) {
210
265
  writeJson(response, 400, { error: 'invalid_workspace_registration' });
211
266
  return;
212
267
  }
213
- writeJson(response, 200, await registerChatWorktree(state, body.directory, Number(body.leaseFence), source.data, body.sourceBranchName, attachments.data));
268
+ const directory = workspaceDirectoryRegistrationSchema.parse(body.directory);
269
+ writeJson(response, 200, await withDirectoryWrite(state, { ...directory, mode: 'exclusive', wait: false }, () => withDirectoryWrite(state, { workspaceId: directory.workspaceId, directoryName: source.data, mode: 'exclusive', wait: false }, () => registerChatWorktree(state, directory, Number(body.leaseFence), source.data, body.sourceBranchName, attachments.data))));
270
+ }
271
+ else {
272
+ const directory = workspaceDirectoryRegistrationSchema.parse(body.directory);
273
+ writeJson(response, 200, await withDirectoryWrite(state, { ...directory, mode: 'exclusive', wait: false }, () => registerWorkspaceDirectory(state, directory, Number(body.leaseFence))));
214
274
  }
215
- else
216
- writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
217
275
  return;
218
276
  }
219
277
  if (request.method === 'POST' && url.pathname === '/runtime/workspaces/directories/worktree-source') {
@@ -226,7 +284,7 @@ export async function handleRuntimeRequest(state, request, response) {
226
284
  writeJson(response, 409, { error: 'invalid_workspace_registration' });
227
285
  return;
228
286
  }
229
- writeJson(response, 200, await readWorktreeSource(state, body.workspaceId, source.data, Number(body.leaseFence), body.sourceBranchName));
287
+ writeJson(response, 200, await withDirectoryWrite(state, { workspaceId: body.workspaceId, directoryName: source.data, mode: 'exclusive', wait: false }, () => readWorktreeSource(state, body.workspaceId, source.data, Number(body.leaseFence), body.sourceBranchName)));
230
288
  return;
231
289
  }
232
290
  const maintenance = /^\/runtime\/workspaces\/([a-f0-9-]{36})\/directories\/(cleanup|sync|stats)$/.exec(url.pathname);
@@ -763,6 +821,10 @@ export async function handleRuntimeRequest(state, request, response) {
763
821
  writeJson(response, 404, { error: 'not_found' });
764
822
  }
765
823
  catch (error) {
824
+ if (error instanceof Error && error.message === 'directory_busy') {
825
+ writeJson(response, 409, { error: 'directory_busy' });
826
+ return;
827
+ }
766
828
  if (request.method === 'GET' && ['/runtime/connectivity', '/runtime/files'].includes(url.pathname)) {
767
829
  const cause = error instanceof Error && error.cause ? error.cause : error;
768
830
  if (['EACCES', 'EPERM'].includes(String(cause?.code))) {
@@ -778,6 +840,12 @@ export async function handleRuntimeRequest(state, request, response) {
778
840
  writeJson(response, 413, { error: 'request_body_too_large' });
779
841
  return;
780
842
  }
843
+ // A shared path without development-group access is a repair, not an internal failure.
844
+ const drift = permissionDriftFromError(error);
845
+ if (drift) {
846
+ writeJson(response, 409, { error: 'workspace_permission_drift', ...drift });
847
+ return;
848
+ }
781
849
  if (error instanceof WorkspaceContextError) {
782
850
  writeJson(response, 409, { error: error.message });
783
851
  return;
@@ -858,7 +926,7 @@ async function acceptOperation(state, request, response) {
858
926
  return;
859
927
  }
860
928
  // leaseFence 由 Runtime Server 派发,本控制面单独校验;其余代次/身份/根目录字段仍只来自引导配置。
861
- const untrusted = findUntrustedRuntimeFields(body).filter((field) => field !== 'leaseFence');
929
+ const untrusted = findUntrustedRuntimeFields(body).filter((field) => field !== 'leaseFence' && field !== 'gatewayOperationId');
862
930
  if (untrusted.length > 0) {
863
931
  writeError(response, runtimeError('untrusted_runtime_field', untrusted.join(',')));
864
932
  return;
@@ -896,7 +964,7 @@ async function acceptOperation(state, request, response) {
896
964
  return;
897
965
  }
898
966
  if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
899
- await restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName);
967
+ await withDirectoryWrite(state, { workspaceId: parsed.data.workspaceId, directoryName: parsed.data.directoryName, mode: 'exclusive', wait: false }, () => restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName));
900
968
  if (state.config.workspaceMode === 'multi')
901
969
  await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
902
970
  // Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
@@ -995,7 +1063,9 @@ async function acceptOperation(state, request, response) {
995
1063
  };
996
1064
  // Directory maintenance uses this same lock before checking durable occupancy.
997
1065
  if (occupiesDirectory && state.config.workspaceMode === 'multi')
998
- await state.journal.exclusive(`directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
1066
+ await state.journal.exclusive(parsed.data.kind.startsWith('session.') && parsed.data.sessionId
1067
+ ? `session-admission:${parsed.data.sessionId}`
1068
+ : `directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
999
1069
  else
1000
1070
  await acceptBound();
1001
1071
  }
@@ -1152,7 +1222,7 @@ async function readObservedParameters(state, workspaceId, directoryName = 'basic
1152
1222
  const scope = revisionScope(state, workspaceId, directoryName);
1153
1223
  return state.journal.exclusive(`workspace:${scope ?? 'single'}`, async () => {
1154
1224
  const first = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
1155
- for (const path of [first.schemaPath, first.dataPath]) {
1225
+ for (const path of [first.schemaPath, first.dataPath, first.runtimeI18n?.globalPath, first.runtimeI18n?.themePath]) {
1156
1226
  if (path)
1157
1227
  await withObservedFile(state, workspaceId, path, async () => undefined, directoryName);
1158
1228
  }
@@ -7,6 +7,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
7
7
  : {};
8
8
  return {
9
9
  operationId: randomUUID(),
10
+ ...(parsed.gatewayOperationId ? { gatewayOperationId: parsed.gatewayOperationId } : {}),
11
+ ...(parsed.kind === 'session.turn' && payload.parameterTask !== undefined ? { directoryAccessMode: 'exclusive' } : {}),
10
12
  environment: state.config.environment,
11
13
  userId: state.config.userId,
12
14
  clientRequestId: parsed.clientRequestId,