@playcraft/cli 0.1.56-beta → 0.1.58-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 (101) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -6,6 +6,7 @@ import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
9
10
  const credentialSchema = z.object({
10
11
  token: z.string().min(1), expiresAt: z.string().datetime(),
11
12
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -25,8 +26,10 @@ export class WorkspaceModelBroker {
25
26
  pending = new Map();
26
27
  requests = new Set();
27
28
  sessionRequests = new Map();
29
+ generation;
28
30
  constructor(state) {
29
31
  this.state = state;
32
+ this.generation = new WorkspaceGenerationProvenanceBroker(state);
30
33
  }
31
34
  async start() {
32
35
  if (this.server)
@@ -67,8 +70,10 @@ export class WorkspaceModelBroker {
67
70
  }
68
71
  // Resolve current model metadata and permission at every native turn boundary.
69
72
  const credential = await this.credential(entry, true);
73
+ const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
70
74
  if (!route || route.epoch !== this.state.runtimeEpoch) {
71
- route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
75
+ route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
76
+ generationCapability: randomBytes(32).toString('base64url') };
72
77
  this.routes.set(routeKey, route);
73
78
  }
74
79
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -77,12 +82,16 @@ export class WorkspaceModelBroker {
77
82
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
78
83
  throw new Error('model_access_denied');
79
84
  route.selected = { entry, model: credential.model,
80
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
85
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
81
86
  },
82
87
  model: credential.model,
83
88
  modelConfigId: credential.configId,
84
89
  modelProvider: 'playcraft_workspace',
85
90
  config: {
91
+ ...(sessionId ? { 'shell_environment_policy.set': {
92
+ PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
93
+ PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
94
+ } } : {}),
86
95
  'model_providers.playcraft_workspace': {
87
96
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
88
97
  wire_api: 'responses', requires_openai_auth: false,
@@ -142,18 +151,28 @@ export class WorkspaceModelBroker {
142
151
  const cancel = () => abort.abort();
143
152
  response.once('close', cancel);
144
153
  try {
145
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
154
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
146
155
  const route = match ? this.routes.get(match[1]) : undefined;
147
156
  const selected = route?.selected;
148
157
  const entry = selected?.entry;
149
158
  const presented = Buffer.from(request.headers.authorization ?? '');
150
- const expected = Buffer.from(route ? `Bearer ${route.capability}` : '');
159
+ const generationRequest = match?.[2] === 'generation-provenance';
160
+ const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
151
161
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
152
162
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
153
163
  response.writeHead(403);
154
164
  response.end();
155
165
  return;
156
166
  }
167
+ if (generationRequest) {
168
+ if (!selected?.feedback) {
169
+ response.writeHead(403);
170
+ response.end();
171
+ return;
172
+ }
173
+ await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
174
+ return;
175
+ }
157
176
  activeRoute = route;
158
177
  const active = this.sessionRequests.get(route) ?? new Set();
159
178
  active.add(abort);
@@ -11,6 +11,7 @@ export class RuntimeJournal {
11
11
  legacyPath;
12
12
  client;
13
13
  listeners = new Set();
14
+ changeListeners = new Set();
14
15
  maintenance;
15
16
  engineInfo;
16
17
  epochs = [];
@@ -40,11 +41,13 @@ export class RuntimeJournal {
40
41
  }
41
42
  }
42
43
  async load() {
44
+ // private
43
45
  await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
44
46
  const legacy = await readLegacy(this.legacyPath, this.filePath);
45
47
  const started = await SqliteWorkerClient.open(this.filePath, legacy?.value ?? null);
46
48
  this.client = started.client;
47
49
  this.apply(started.opened);
50
+ // private
48
51
  if (legacy?.readable)
49
52
  await writeFile(this.legacyPath, LEGACY_JOURNAL_SENTINEL, { mode: 0o600 });
50
53
  this.maintenance = setInterval(() => {
@@ -76,6 +79,33 @@ export class RuntimeJournal {
76
79
  this.listeners.add(listener);
77
80
  return () => this.listeners.delete(listener);
78
81
  }
82
+ onChange(listener) {
83
+ this.changeListeners.add(listener);
84
+ return () => this.changeListeners.delete(listener);
85
+ }
86
+ directoryBarriers() { return Object.values(this.snapshot.directoryBarriers); }
87
+ async putDirectoryBarrier(barrier) {
88
+ await this.run('upsertRecord', { bucket: 'directory_barriers', key: barrier.owner,
89
+ body: { ...barrier, queuedAt: this.snapshot.directoryBarriers[barrier.owner]?.queuedAt ?? barrier.queuedAt ?? new Date().toISOString() } });
90
+ }
91
+ async deleteDirectoryBarrier(owner) {
92
+ await this.run('deleteRecord', { bucket: 'directory_barriers', key: owner });
93
+ }
94
+ deferredDirectoryOperations() { return Object.values(this.snapshot.deferredDirectoryOperations); }
95
+ async deferDirectoryOperation(input) {
96
+ await this.run('upsertRecord', { bucket: 'directory_deferred_operations', key: input.operationId,
97
+ body: { ...input, queuedAt: this.snapshot.deferredDirectoryOperations[input.operationId]?.queuedAt ?? input.queuedAt ?? new Date().toISOString() } });
98
+ }
99
+ /** The accepted command and its replayable input commit together before any writer can start. */
100
+ async putPendingOperation(scopeKey, operation, payload, recovery) {
101
+ const parsed = operationRecordSchema.parse(operation);
102
+ await this.run('putPendingOperation', { scopeKey, operation: parsed,
103
+ pending: { operationId: parsed.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}), queuedAt: parsed.createdAt } });
104
+ return this.snapshot.operations[scopeKey];
105
+ }
106
+ async deleteDeferredDirectoryOperation(operationId) {
107
+ await this.run('deleteRecord', { bucket: 'directory_deferred_operations', key: operationId });
108
+ }
79
109
  getWorkspace(workspaceId) {
80
110
  return this.snapshot.workspaces[workspaceId];
81
111
  }
@@ -288,6 +318,8 @@ export class RuntimeJournal {
288
318
  throw new Error('sqlite_not_open');
289
319
  const result = await this.client.request(type, payload);
290
320
  this.apply(result);
321
+ for (const listener of this.changeListeners)
322
+ listener();
291
323
  for (const event of result.committedEvents ?? []) {
292
324
  for (const listener of this.listeners)
293
325
  listener(event);
@@ -356,6 +388,8 @@ function emptySnapshot() {
356
388
  fileRevisions: {},
357
389
  approvals: {},
358
390
  managedWrites: {},
391
+ directoryBarriers: {},
392
+ deferredDirectoryOperations: {},
359
393
  workspaceDependencies: {},
360
394
  executionGroup: { stopConfirmed: false, replaced: false },
361
395
  lastSourceSequence: 0,
@@ -64,6 +64,15 @@ function dispatch(type, payload) {
64
64
  case 'putOperation':
65
65
  putOperation(database, String(body.scopeKey), body.operation);
66
66
  return;
67
+ case 'putPendingOperation': {
68
+ const operation = operationRecordSchema.parse(body.operation);
69
+ const pending = asRecord(body.pending);
70
+ if (operation.status !== 'queued' || pending.operationId !== operation.operationId || pending.scopeKey !== body.scopeKey)
71
+ throw new Error('invalid_pending_operation');
72
+ putOperation(database, String(body.scopeKey), operation);
73
+ upsertRecord(database, 'directory_deferred_operations', operation.operationId, pending);
74
+ return;
75
+ }
67
76
  case 'setAcceptedFence':
68
77
  setMeta(database, 'accepted_lease_fence', String(Math.max(metaNumber(database, 'accepted_lease_fence'), Number(body.value) || 0)));
69
78
  return;
@@ -158,6 +167,8 @@ function openDatabase(payload) {
158
167
  ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
159
168
  CREATE INDEX IF NOT EXISTS records_native_session_thread
160
169
  ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
170
+ CREATE INDEX IF NOT EXISTS operation_intents_sandbox_operation
171
+ ON operation_intents(sandbox_operation_id);
161
172
  CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
162
173
  ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
163
174
  json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
@@ -353,7 +364,7 @@ function allocateEvent(database, body) {
353
364
  WHERE runtime_id = ? AND generation = ? AND runtime_epoch = ?
354
365
  RETURNING next_sequence`)
355
366
  .get(runtimeId, generation, runtimeEpoch);
356
- const payload = body.payload ?? {};
367
+ const payload = operationEventPayload(database, body);
357
368
  const event = pendingSourceEventSchema.parse({
358
369
  source: { runtimeId, generation, runtimeEpoch, sourceSequence: allocated.next_sequence },
359
370
  payloadHash: hashOperationPayload(payload),
@@ -832,6 +843,8 @@ function dump(database, includeEvents = true) {
832
843
  fileRevisions: bucketObject(database, 'file_revisions'),
833
844
  approvals: bucketObject(database, 'approvals'),
834
845
  managedWrites: bucketObject(database, 'managed_writes'),
846
+ directoryBarriers: bucketObject(database, 'directory_barriers'),
847
+ deferredDirectoryOperations: bucketObject(database, 'directory_deferred_operations'),
835
848
  workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
836
849
  executionGroup: executionGroup(database),
837
850
  stoppedRuntimeEpochs: stoppedExecutionEpochs(database, metaString(database, 'runtime_id'), metaNumber(database, 'generation')),
@@ -878,7 +891,16 @@ function operationsObject(database) {
878
891
  return operations;
879
892
  }
880
893
  function putOperation(database, scopeKey, value) {
881
- const operation = operationRecordSchema.parse(value);
894
+ let operation = operationRecordSchema.parse(value);
895
+ if (operation.status !== 'queued')
896
+ database.prepare("DELETE FROM records WHERE bucket = 'directory_deferred_operations' AND record_key = ?").run(operation.operationId);
897
+ const existing = database.prepare('SELECT gateway_operation_id FROM operation_intents WHERE scope_key = ?')
898
+ .get(scopeKey);
899
+ if (existing?.gateway_operation_id) {
900
+ if (operation.gatewayOperationId && operation.gatewayOperationId !== existing.gateway_operation_id)
901
+ throw new Error('gateway_operation_mismatch');
902
+ operation = { ...operation, gatewayOperationId: existing.gateway_operation_id };
903
+ }
882
904
  if (metaString(database, 'runtime_id') && operation.generation === metaNumber(database, 'generation') && operation.runtimeEpoch === metaNumber(database, 'runtime_epoch')) {
883
905
  const key = JSON.stringify([scopeKey, operation.operationId]);
884
906
  database.prepare("INSERT INTO records (bucket, record_key, body_json) VALUES ('operation_execution_scopes', ?, ?) ON CONFLICT DO NOTHING")
@@ -898,7 +920,7 @@ function putOperation(database, scopeKey, value) {
898
920
  thread_id = excluded.thread_id,
899
921
  turn_id = excluded.turn_id,
900
922
  reconcile_status = excluded.reconcile_status`)
901
- .run(scopeKey, JSON.stringify(operation), null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
923
+ .run(scopeKey, JSON.stringify(operation), operation.gatewayOperationId ?? null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
902
924
  }
903
925
  function insertEventRow(database, event, links) {
904
926
  database
@@ -1017,17 +1039,57 @@ function operationByTurn(database, turnId, threadId) {
1017
1039
  }
1018
1040
  function linkedPayload(params, session, subAgent) {
1019
1041
  const payload = { ...asRecord(params) };
1020
- delete payload.workspaceId;
1021
- delete payload.sessionId;
1022
- delete payload.subAgent;
1042
+ // Native notifications cannot assert the platform identity or override durable ownership.
1043
+ for (const key of ['operationId', 'gatewayOperationId', 'platformOperationId', 'sandboxOperationId',
1044
+ 'workspaceId', 'sessionId', 'directoryName', 'runtimeId', 'generation', 'runtimeEpoch', 'userId', 'environment', 'execution', 'subAgent',
1045
+ 'directoryAccessMode', 'dispatchAttempted'])
1046
+ delete payload[key];
1023
1047
  if (typeof session?.workspaceId === 'string')
1024
1048
  payload.workspaceId = session.workspaceId;
1025
1049
  if (typeof session?.sessionId === 'string')
1026
1050
  payload.sessionId = session.sessionId;
1051
+ if (session)
1052
+ payload.directoryName = optionalString(session.directoryName) ?? 'basic';
1027
1053
  if (subAgent)
1028
1054
  payload.subAgent = subAgent;
1029
1055
  return payload;
1030
1056
  }
1057
+ /** Identity enters the immutable event body before hashing, including synthetic stop events. */
1058
+ function operationEventPayload(database, body) {
1059
+ const operationId = optionalString(body.operationId);
1060
+ if (!operationId)
1061
+ return body.payload ?? {};
1062
+ const row = database.prepare('SELECT operation_json FROM operation_intents WHERE sandbox_operation_id = ? LIMIT 1')
1063
+ .get(operationId);
1064
+ if (!row)
1065
+ return body.payload ?? {};
1066
+ const operation = operationRecordSchema.parse(JSON.parse(row.operation_json));
1067
+ if ((operation.generation !== undefined && operation.generation !== Number(body.generation)) ||
1068
+ (operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== Number(body.runtimeEpoch)))
1069
+ return body.payload ?? {};
1070
+ const payload = { ...asRecord(body.payload) };
1071
+ delete payload.gatewayOperationId;
1072
+ delete payload.platformOperationId;
1073
+ payload.operationId = operation.gatewayOperationId ?? operation.operationId;
1074
+ payload.sandboxOperationId = operation.operationId;
1075
+ if (operation.gatewayOperationId)
1076
+ payload.gatewayOperationId = operation.gatewayOperationId;
1077
+ if (operation.workspaceId)
1078
+ payload.workspaceId = operation.workspaceId;
1079
+ if (operation.sessionId)
1080
+ payload.sessionId = operation.sessionId;
1081
+ payload.directoryName = operation.directoryName ?? 'basic';
1082
+ const subAgent = asRecord(payload.subAgent);
1083
+ const threadId = optionalString(body.threadId) ?? nativeIds(payload).threadId ??
1084
+ (Object.keys(subAgent).length ? undefined : operation.nativeThreadId);
1085
+ const turnId = optionalString(body.turnId) ?? nativeIds(payload).turnId ??
1086
+ (Object.keys(subAgent).length ? undefined : operation.nativeTurnId);
1087
+ if (threadId)
1088
+ payload.threadId = threadId;
1089
+ if (turnId)
1090
+ payload.turnId = turnId;
1091
+ return payload;
1092
+ }
1031
1093
  function nativeIds(params) {
1032
1094
  const record = asRecord(params);
1033
1095
  const turn = asRecord(record.turn);
@@ -8,6 +8,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
8
8
  let directory = workspace;
9
9
  for (const segment of dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH).split('/')) {
10
10
  directory = join(directory, segment);
11
+ // shared
11
12
  const created = await mkdir(directory, { mode: 0o2775 }).then(() => true, error => {
12
13
  if (error.code !== 'EEXIST')
13
14
  throw error;
@@ -22,6 +23,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
22
23
  const target = join(workspace, WORKSPACE_PLAYABLE_OUTPUT_PATH);
23
24
  const temporary = join(directory, `.playable-${randomUUID()}.tmp`);
24
25
  try {
26
+ // shared
25
27
  await writeFile(temporary, bytes, { flag: 'wx', mode: 0o664 });
26
28
  await chmod(temporary, 0o664);
27
29
  const stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
@@ -17,6 +17,7 @@ import { openScratchToDevelopment } from '../workspaces/directory-access.js';
17
17
  import { injectPreviewDiagnostics } from './diagnostics.js';
18
18
  import { OperationLogStore } from '../workspaces/operation-log.js';
19
19
  import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
20
+ import { driftScope, permissionDriftFromOutput, repairWorkspaceShares, } from '../workspaces/workspace-shares.js';
20
21
  const BUILD_TIMEOUT_MS = 10 * 60_000;
21
22
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
22
23
  const OUTPUT_FOLDER = '.playcraft-preview-out';
@@ -60,12 +61,12 @@ export function trackPreviewPublish(state, task) {
60
61
  export async function settlePreviewPublishes(state) {
61
62
  await Promise.all([...(running.get(state) ?? [])]);
62
63
  }
63
- /** A publish cut off by a restart has no build left running; it is closed rather than left accepted. */
64
+ /** Restart alone does not prove the old builder stopped; retain its exclusive directory claim. */
64
65
  export async function recoverInterruptedPreviews(journal) {
65
66
  for (const { scopeKey, operation } of journal.listOperations()) {
66
67
  if (operation.kind !== 'preview.publish' || operation.status !== 'accepted')
67
68
  continue;
68
- await journal.putOperation(scopeKey, transitionOperation(operation, 'interrupted', { error: 'preview_interrupted' }));
69
+ await journal.putOperation(scopeKey, transitionOperation(operation, 'uncertain', { error: 'preview_interrupted' }));
69
70
  }
70
71
  }
71
72
  export async function runPreviewPublish(state, operation, root) {
@@ -215,7 +216,7 @@ async function ensureDependencies(state, workspaceId, root, output) {
215
216
  async function buildPreview(state, root, outputDir, operationId, output, onLog, runtimeCommand) {
216
217
  // Tests replace the build; the real one needs a project and the published CLI.
217
218
  const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
218
- const outcome = await runDeveloperCommand(state, {
219
+ const run = () => runDeveloperCommand(state, {
219
220
  processId: `preview-${operationId}`,
220
221
  cwd: root,
221
222
  command: runtimeCommand ?? `${planned} --output ${shellQuote(outputDir)} .`,
@@ -226,8 +227,22 @@ async function buildPreview(state, root, outputDir, operationId, output, onLog,
226
227
  onLog?.(text);
227
228
  },
228
229
  });
229
- if (outcome.code !== 0)
230
- throw new PreviewFailure('preview_build_failed');
230
+ let outcome = await run();
231
+ if (outcome.code === undefined)
232
+ throw new PreviewFailure('preview_process_unknown');
233
+ if (outcome.code !== 0) {
234
+ // The build runs as the development identity; a directory only that identity can repair makes
235
+ // it fail on its own cwd. Repair the shared subtree as its owner and retry exactly once.
236
+ const drift = permissionDriftFromOutput(output.log);
237
+ if (drift) {
238
+ await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
239
+ outcome = await run();
240
+ if (outcome.code === undefined)
241
+ throw new PreviewFailure('preview_process_unknown');
242
+ }
243
+ if (outcome.code !== 0)
244
+ throw new PreviewFailure(drift ? 'workspace_permission_drift' : 'preview_build_failed');
245
+ }
231
246
  }
232
247
  /** Reads the one HTML file without following links, so the uploaded bytes are what was checked. */
233
248
  async function readSingleHtml(outputDir) {
@@ -166,6 +166,8 @@ export async function prepareRuntimePreview(state, operation, root) {
166
166
  dataRevision: before.dataRevision ?? 0,
167
167
  schemaRevision: before.schemaRevision ?? 0,
168
168
  dataHash: before.dataHash,
169
+ ...(before.runtimeI18n ? { runtimeI18nRevision: before.runtimeI18n.themeRevision,
170
+ ...(before.runtimeI18n.themeHash ? { runtimeI18nHash: before.runtimeI18n.themeHash } : {}) } : {}),
169
171
  },
170
172
  };
171
173
  const context = { buildId, ...profileBase };
@@ -187,6 +189,8 @@ export async function prepareRuntimePreview(state, operation, root) {
187
189
  after.schemaHash !== before.schemaHash ||
188
190
  after.dataRevision !== before.dataRevision ||
189
191
  after.schemaRevision !== before.schemaRevision ||
192
+ after.runtimeI18n?.themeHash !== before.runtimeI18n?.themeHash ||
193
+ after.runtimeI18n?.themeRevision !== before.runtimeI18n?.themeRevision ||
190
194
  digest(await insideFile(root, manifestPath)) !== digest(manifest))
191
195
  throw new RuntimePreviewBuildError('runtime_preview_source_changed');
192
196
  for (const [path, hash] of gameHashes) {
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
+ import { holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
3
4
  export const MANAGED_WRITE_SOURCES = ['manual', 'agent', 'background'];
4
5
  const BACKGROUND_WRITER = `
5
6
  const fs = require('node:fs');
@@ -23,16 +24,48 @@ export function probeProcess(pid) {
23
24
  return undefined;
24
25
  }
25
26
  }
26
- export function spawnBackgroundWriter(absolutePath) {
27
+ export function spawnBackgroundWriter(absolutePath, stopped) {
27
28
  const child = spawn(process.execPath, ['-e', BACKGROUND_WRITER, absolutePath], {
28
29
  detached: true,
29
30
  stdio: 'ignore',
30
31
  });
31
32
  child.unref();
33
+ child.once('error', () => undefined);
34
+ child.once('exit', () => stopped?.());
32
35
  if (!child.pid)
33
36
  throw new Error('background_write_failed');
34
37
  return child.pid;
35
38
  }
39
+ /** A writer keeps its directory claim after the request that created it has finished. */
40
+ export async function startManagedBackgroundWriter(state, record, absolutePath) {
41
+ if (state.journal.listManagedWrites().some(item => item.writeId === record.writeId && !item.stoppedAt))
42
+ throw new Error('write_already_active');
43
+ const release = await holdDirectoryAccess(state, managedDirectoryScope(state, record.workspaceId), `managed:${record.writeId}`, 'shared');
44
+ let pid;
45
+ let persisted = Promise.resolve();
46
+ const finish = async () => {
47
+ await persisted;
48
+ const current = state.journal.listManagedWrites().find(item => item.writeId === record.writeId);
49
+ if (current && current.startedAt === record.startedAt && !current.stoppedAt) {
50
+ await state.journal.putManagedWrite({ ...current, stoppedAt: new Date().toISOString() });
51
+ }
52
+ release();
53
+ };
54
+ try {
55
+ await state.journal.putManagedWrite(record);
56
+ pid = spawnBackgroundWriter(absolutePath, () => { void finish().catch(() => undefined); });
57
+ persisted = state.journal.putManagedWrite({ ...record, pid });
58
+ await persisted;
59
+ return pid;
60
+ }
61
+ catch (error) {
62
+ if (pid === undefined || await stopBackgroundWriter(pid)) {
63
+ persisted = Promise.resolve();
64
+ await finish();
65
+ }
66
+ throw error;
67
+ }
68
+ }
36
69
  export async function stopBackgroundWriter(pid) {
37
70
  signal(pid, 'SIGTERM');
38
71
  if (await waitUntilStopped(pid, 2_000))
@@ -58,12 +91,21 @@ function signal(pid, name) {
58
91
  async function waitUntilStopped(pid, timeoutMs) {
59
92
  const deadline = Date.now() + timeoutMs;
60
93
  while (Date.now() < deadline) {
61
- const alive = probeProcess(pid);
94
+ const alive = probeProcessGroup(pid);
62
95
  if (alive === false)
63
96
  return true;
64
- if (alive === undefined)
65
- return false;
66
97
  await delay(50);
67
98
  }
68
- return probeProcess(pid) === false;
99
+ return probeProcessGroup(pid) === false;
100
+ }
101
+ function probeProcessGroup(pid) {
102
+ try {
103
+ process.kill(-pid, 0);
104
+ return true;
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'ESRCH')
108
+ return undefined;
109
+ return probeProcess(pid);
110
+ }
69
111
  }
@@ -2,14 +2,14 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
- import { createPlatformAdapter } from '@playcraft/build';
6
5
  import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
7
6
  import { RuntimeJournal } from './persistence/journal.js';
8
7
  import { createCodexAppServerAdapter } from './codex/app-server.js';
9
8
  import { trustedMemberPolicy } from './batch/policy.js';
10
9
  import { batchDirectoryHeld } from './batch/occupancy.js';
11
10
  import { createLoopbackNativeAdapter } from './codex/loopback.js';
12
- import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
11
+ import { attachNativeCollector, executeOperation, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
12
+ import { restoreDeferredDirectoryOperations } from './server/directory-access.js';
13
13
  import { handleRuntimeRequest } from './server/http.js';
14
14
  import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
15
15
  import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
@@ -32,7 +32,7 @@ import { revisionScope } from './workspaces/registry.js';
32
32
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
33
33
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
34
34
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
35
- import { submitTrustedOperation } from './server/submit-operation.js';
35
+ import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
36
36
  export async function startWorkspaceRuntime(config, options = {}) {
37
37
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
38
38
  const negotiated = negotiateProtocol(config);
@@ -58,7 +58,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
58
58
  await recoverInterruptedPreviews(journal);
59
59
  await recoverInterruptedParameterVerifications(journal);
60
60
  await recoverInterruptedDeliveries(config.stateDir);
61
- await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
61
+ // shared: in multi-workspace mode this is /workspaces, the container both identities traverse.
62
+ await mkdir(config.workspaceRoot, { recursive: true, mode: 0o2770 });
62
63
  const state = runtimeState = {
63
64
  config: { ...config, protocolMinor: negotiated.protocolMinor },
64
65
  configPath: options.configPath,
@@ -100,6 +101,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
100
101
  for (const { scopeKey, operation } of journal.listOperations()) {
101
102
  if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
102
103
  continue;
104
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
105
+ continue;
103
106
  const published = recoveredImports.get(operation.operationId);
104
107
  const result = operation.result && 'paths' in operation.result ? { ...operation.result,
105
108
  paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
@@ -151,6 +154,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
151
154
  for (const { scopeKey, operation } of journal.listOperations()) {
152
155
  if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
153
156
  continue;
157
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
158
+ continue;
154
159
  const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
155
160
  let next = operation;
156
161
  if (receipt) {
@@ -182,6 +187,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
182
187
  if (!['directory.rename', 'directory.delete'].includes(operation.kind)
183
188
  || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
184
189
  continue;
190
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
191
+ continue;
185
192
  const kind = operation.kind;
186
193
  let next = operation;
187
194
  try {
@@ -273,6 +280,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
273
280
  state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
274
281
  console.error(`[workspace-runtime] ${state.nativeError}`);
275
282
  }
283
+ await restoreDeferredDirectoryOperations(state, (scopeKey, operation, payload, recovery) => executeOperation(state, scopeKey, operation, payload, recovery));
276
284
  const server = createServer((request, response) => {
277
285
  void handleRuntimeRequest(state, request, response);
278
286
  });
@@ -355,6 +363,7 @@ function closeServer(server) {
355
363
  function formatHost(host) {
356
364
  return host.includes(':') ? `[${host}]` : host;
357
365
  }
366
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
358
367
  function deliveryToolHandlers(state) {
359
368
  const contextFor = async (input) => {
360
369
  if (input.workspaceId)
@@ -368,37 +377,15 @@ function deliveryToolHandlers(state) {
368
377
  const { workspaceId, directoryName } = await contextFor(input);
369
378
  if (directoryName === 'root')
370
379
  throw new Error('invalid_workspace_directory');
371
- const builds = [];
372
- for (const channel of input.channels) {
373
- const operation = await submitTrustedOperation(state, {
374
- kind: 'delivery.build',
375
- workspaceId,
376
- directoryName,
377
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
378
- clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
379
- payload: {
380
- channel,
381
- format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
382
- actor: 'agent',
383
- },
384
- });
385
- let record;
386
- for (let attempt = 0; attempt < 40 && !record; attempt++) {
387
- record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
388
- if (!record)
389
- await new Promise(resolve => setTimeout(resolve, 25));
390
- }
391
- builds.push({
392
- buildId: operation.operationId,
393
- channel,
394
- status: record?.status ?? operation.status,
395
- ...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
396
- });
397
- }
398
- return { builds };
380
+ // Exports go through the platform; a standalone workspace becomes a project first.
381
+ return startPlatformExport(state, { ...input, workspaceId, directoryName });
399
382
  },
400
383
  async get(input) {
401
384
  const { workspaceId, directoryName } = await contextFor(input);
385
+ const sandboxBuild = input.buildId !== undefined && UUID.test(input.buildId);
386
+ if (!sandboxBuild && await platformExportTarget(state, { workspaceId, directoryName })) {
387
+ return readPlatformExport(state, { ...input, workspaceId, directoryName });
388
+ }
402
389
  const store = new DeliveryStore(state.config.stateDir, workspaceId);
403
390
  const record = input.buildId
404
391
  ? await store.read(input.buildId)
@@ -1,4 +1,4 @@
1
- import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
1
+ import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
2
2
  import { canExecuteKind, executeOperation } from './dispatch.js';
3
3
  import { buildOperationRecord } from './operation-record.js';
4
4
  /**
@@ -29,6 +29,18 @@ export async function acceptOperationCore(state, parsed, options) {
29
29
  operationId: decision.operation.operationId,
30
30
  }));
31
31
  }
32
+ if (decision.action === 'reuse' && parsed.gatewayOperationId) {
33
+ if (decision.operation.gatewayOperationId && decision.operation.gatewayOperationId !== parsed.gatewayOperationId) {
34
+ return rejected(409, runtimeError('idempotency_conflict', 'gateway_operation_mismatch', {
35
+ operationId: decision.operation.operationId,
36
+ }));
37
+ }
38
+ if (!decision.operation.gatewayOperationId) {
39
+ decision.operation = await state.journal.putOperation(scopeKey, {
40
+ ...decision.operation, gatewayOperationId: parsed.gatewayOperationId,
41
+ });
42
+ }
43
+ }
32
44
  const retryWithRecovery = decision.action === 'reuse' && parsed.kind === 'session.turn'
33
45
  && Boolean(parsed.sessionRecovery) && decision.operation.status === 'failed'
34
46
  && isLostThreadError(decision.operation.error);
@@ -38,6 +50,12 @@ export async function acceptOperationCore(state, parsed, options) {
38
50
  operationId: decision.operation.operationId,
39
51
  }));
40
52
  }
53
+ if (decision.operation.status === 'queued' && takesWorkspaceDirectory(decision.operation.kind) &&
54
+ !isTurnControl(decision.operation.kind) && !state.journal.deferredDirectoryOperations().some(item => item.operationId === decision.operation.operationId)) {
55
+ const queued = await state.journal.putPendingOperation(scopeKey, decision.operation, options.executionPayload, parsed.sessionRecovery);
56
+ const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
57
+ return { rejected: false, operation: executed, executed, reused: true };
58
+ }
41
59
  return { rejected: false, operation: decision.operation, executed: decision.operation, reused: true };
42
60
  }
43
61
  const validation = await options.validate?.(scopeKey);
@@ -51,7 +69,9 @@ export async function acceptOperationCore(state, parsed, options) {
51
69
  }
52
70
  state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
53
71
  await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
54
- const queued = await state.journal.putOperation(scopeKey, operation);
72
+ const queued = takesWorkspaceDirectory(operation.kind) && !isTurnControl(operation.kind) && operation.kind !== 'runtime.destroy'
73
+ ? await state.journal.putPendingOperation(scopeKey, operation, options.executionPayload, parsed.sessionRecovery)
74
+ : await state.journal.putOperation(scopeKey, operation);
55
75
  const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
56
76
  return { rejected: false, operation: queued, executed, reused: false };
57
77
  });