@playcraft/cli 0.1.57-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 (82) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/workspace/messages.js +10 -0
  3. package/dist/workspace-runtime/batch/deliver.js +140 -80
  4. package/dist/workspace-runtime/batch/freeze.js +18 -6
  5. package/dist/workspace-runtime/batch/generate.js +68 -64
  6. package/dist/workspace-runtime/batch/prepare.js +108 -93
  7. package/dist/workspace-runtime/batch/records.js +4 -0
  8. package/dist/workspace-runtime/batch/routes.js +10 -2
  9. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  10. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  11. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  12. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  13. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  14. package/dist/workspace-runtime/codex/app-server.js +1 -0
  15. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  16. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  17. package/dist/workspace-runtime/persistence/journal.js +34 -0
  18. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  19. package/dist/workspace-runtime/previews/output.js +2 -0
  20. package/dist/workspace-runtime/previews/publish.js +20 -5
  21. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  22. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  23. package/dist/workspace-runtime/serve.js +11 -2
  24. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  25. package/dist/workspace-runtime/server/directory-access.js +414 -0
  26. package/dist/workspace-runtime/server/dispatch.js +61 -23
  27. package/dist/workspace-runtime/server/endpoints.js +1 -0
  28. package/dist/workspace-runtime/server/http.js +52 -10
  29. package/dist/workspace-runtime/server/operation-record.js +2 -0
  30. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  31. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  32. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  33. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  34. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  35. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  36. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  37. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  38. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  39. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  40. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  41. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  42. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  43. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  44. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  45. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  46. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  47. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  48. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  49. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  50. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  51. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  52. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  53. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  54. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  55. package/dist/workspace-runtime/workspaces/files.js +4 -0
  56. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  57. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  58. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  59. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  60. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  61. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  62. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  63. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  64. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  65. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  66. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  67. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  68. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  69. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  70. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  71. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  72. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  73. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  74. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  75. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  76. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  77. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  78. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  79. package/package.json +5 -4
  80. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  81. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  82. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -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
  }
@@ -8,7 +8,8 @@ import { createCodexAppServerAdapter } from './codex/app-server.js';
8
8
  import { trustedMemberPolicy } from './batch/policy.js';
9
9
  import { batchDirectoryHeld } from './batch/occupancy.js';
10
10
  import { createLoopbackNativeAdapter } from './codex/loopback.js';
11
- 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';
12
13
  import { handleRuntimeRequest } from './server/http.js';
13
14
  import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
14
15
  import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
@@ -57,7 +58,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
57
58
  await recoverInterruptedPreviews(journal);
58
59
  await recoverInterruptedParameterVerifications(journal);
59
60
  await recoverInterruptedDeliveries(config.stateDir);
60
- 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 });
61
63
  const state = runtimeState = {
62
64
  config: { ...config, protocolMinor: negotiated.protocolMinor },
63
65
  configPath: options.configPath,
@@ -99,6 +101,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
99
101
  for (const { scopeKey, operation } of journal.listOperations()) {
100
102
  if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
101
103
  continue;
104
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
105
+ continue;
102
106
  const published = recoveredImports.get(operation.operationId);
103
107
  const result = operation.result && 'paths' in operation.result ? { ...operation.result,
104
108
  paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
@@ -150,6 +154,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
150
154
  for (const { scopeKey, operation } of journal.listOperations()) {
151
155
  if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
152
156
  continue;
157
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
158
+ continue;
153
159
  const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
154
160
  let next = operation;
155
161
  if (receipt) {
@@ -181,6 +187,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
181
187
  if (!['directory.rename', 'directory.delete'].includes(operation.kind)
182
188
  || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
183
189
  continue;
190
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
191
+ continue;
184
192
  const kind = operation.kind;
185
193
  let next = operation;
186
194
  try {
@@ -272,6 +280,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
272
280
  state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
273
281
  console.error(`[workspace-runtime] ${state.nativeError}`);
274
282
  }
283
+ await restoreDeferredDirectoryOperations(state, (scopeKey, operation, payload, recovery) => executeOperation(state, scopeKey, operation, payload, recovery));
275
284
  const server = createServer((request, response) => {
276
285
  void handleRuntimeRequest(state, request, response);
277
286
  });
@@ -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
  });