@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
@@ -3,12 +3,15 @@ import { lstat, mkdir, readFile, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { sandboxBatchMemberPrepareSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
7
+ import { writeWorkspaceCliContext } from '../workspaces/cli-context.js';
6
8
  import { registerWorkspaceDirectory } from '../workspaces/directories.js';
7
9
  import { workspaceContainerRoot } from '../workspaces/registry.js';
8
10
  import { dependencyInstallCommand, planInstall } from '../workspaces/dependencies.js';
9
11
  import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
10
12
  import { restoreArchive } from './source-capture.js';
11
13
  import { groupExited, runProcessGroup } from './processes.js';
14
+ import { stopMemberWriters } from './writer-proof.js';
12
15
  import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
13
16
  const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
14
17
  function hashCommand(value) {
@@ -143,111 +146,123 @@ export async function prepareBatchMember(state, body, options) {
143
146
  if (existing?.processes.some((process) => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
144
147
  return memberStatus(existing);
145
148
  const snapshot = command.snapshot;
146
- if ((await readSnapshot(state, snapshot.batchId))?.digest === snapshot.digest) {
147
- /* The local snapshot record is an accelerator; the command snapshot remains authoritative. */
148
- }
149
- let record = await writeMember(state, {
150
- runId: command.runId,
151
- ownerFence: command.ownerFence,
152
- occupancyId: command.occupancyId,
153
- workspaceId: command.workspaceId,
154
- directoryName: command.directoryName,
155
- phase: 'prepare',
156
- state: 'running',
157
- writersStopped: false,
158
- holding: true,
159
- stopRequested: false,
160
- processes: existing?.processes ?? [],
161
- commandHash: { ...existing?.commandHash, prepare: hash },
162
- snapshotDigest: snapshot.digest,
163
- branchName: command.branchName,
164
- sourceCommit: snapshot.commitHash,
165
- beforeTree: existing?.beforeTree,
166
- updatedAt: new Date().toISOString(),
167
- });
168
- let staging;
169
- try {
170
- const parent = await workspaceContainerRoot(state, command.workspaceId);
171
- const root = join(parent, command.directoryName);
172
- staging = join(parent, `.batch-member-${randomUUID()}`);
173
- const binding = state.journal.getWorkspace(command.workspaceId);
174
- if (!binding)
175
- throw new Error('workspace_not_registered');
176
- const repositoryUrl = binding.repositoryUrl ?? undefined;
177
- const sourceRoot = join(parent, snapshot.sourceDirectoryName);
178
- if (!(await lstat(root).catch(() => undefined))) {
179
- await mkdir(parent, { recursive: true });
180
- if (command.resumeFromCommit)
181
- await resumeBranch(repositoryUrl, command.branchName, command.resumeFromCommit, staging, sourceRoot);
182
- else
183
- await materialize(state, snapshot, staging, options?.objectStore ?? filesystemObjectStore(state), sourceRoot);
184
- const commit = command.resumeFromCommit ?? snapshot.commitHash;
185
- await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
186
- const { rename } = await import('node:fs/promises');
187
- await rename(staging, root);
149
+ await claimBatchDirectory(state, command);
150
+ return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
151
+ if ((await readSnapshot(state, snapshot.batchId))?.digest === snapshot.digest) {
152
+ /* The local snapshot record is an accelerator; the command snapshot remains authoritative. */
188
153
  }
189
- await registerWorkspaceDirectory(state, {
154
+ let record = await writeMember(state, {
155
+ runId: command.runId,
156
+ ownerFence: command.ownerFence,
157
+ occupancyId: command.occupancyId,
190
158
  workspaceId: command.workspaceId,
191
159
  directoryName: command.directoryName,
192
- remixId: command.remixId,
160
+ phase: 'prepare',
161
+ state: 'running',
162
+ writersStopped: false,
163
+ holding: true,
164
+ stopRequested: false,
165
+ processes: existing?.processes ?? [],
166
+ commandHash: { ...existing?.commandHash, prepare: hash },
167
+ snapshotDigest: snapshot.digest,
193
168
  branchName: command.branchName,
194
- sourceCommit: command.resumeFromCommit ?? snapshot.commitHash,
195
- }, state.acceptedLeaseFence);
196
- const plan = await planInstall(root);
197
- if (plan.kind === 'install') {
198
- // Keep control-user installs script-free. pnpm validates the frozen manifest and lock.
199
- const frozen = `${dependencyInstallCommand(state, { frozenLockfile: true })} --ignore-scripts`;
200
- const installCommand = process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ?? (plan.hasLockfile
201
- ? frozen
202
- : `${dependencyInstallCommand(state)} --lockfile-only --ignore-scripts && ${frozen}`);
203
- const installed = await runProcessGroup(root, installCommand, 'install', async (process) => {
169
+ sourceCommit: snapshot.commitHash,
170
+ beforeTree: existing?.beforeTree,
171
+ updatedAt: new Date().toISOString(),
172
+ });
173
+ let staging;
174
+ try {
175
+ const parent = await workspaceContainerRoot(state, command.workspaceId);
176
+ const root = join(parent, command.directoryName);
177
+ staging = join(parent, `.batch-member-${randomUUID()}`);
178
+ const binding = state.journal.getWorkspace(command.workspaceId);
179
+ if (!binding)
180
+ throw new Error('workspace_not_registered');
181
+ const repositoryUrl = binding.repositoryUrl ?? undefined;
182
+ const sourceRoot = join(parent, snapshot.sourceDirectoryName);
183
+ if (!(await lstat(root).catch(() => undefined))) {
184
+ await mkdir(parent, { recursive: true });
185
+ if (command.resumeFromCommit)
186
+ await resumeBranch(repositoryUrl, command.branchName, command.resumeFromCommit, staging, sourceRoot);
187
+ else
188
+ await materialize(state, snapshot, staging, options?.objectStore ?? filesystemObjectStore(state), sourceRoot);
189
+ const commit = command.resumeFromCommit ?? snapshot.commitHash;
190
+ await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
191
+ const { rename } = await import('node:fs/promises');
192
+ await rename(staging, root);
193
+ }
194
+ await registerWorkspaceDirectory(state, {
195
+ workspaceId: command.workspaceId,
196
+ directoryName: command.directoryName,
197
+ remixId: command.remixId,
198
+ branchName: command.branchName,
199
+ sourceCommit: command.resumeFromCommit ?? snapshot.commitHash,
200
+ }, state.acceptedLeaseFence);
201
+ // Workspace-level registration writes this for `basic` only. A batch member is a workspace
202
+ // directory in its own right: without the hint `generatedWorkspace()` returns nothing and the
203
+ // agent's deliverables are never captured as artifacts.
204
+ await writeWorkspaceCliContext(root, command.workspaceId);
205
+ const plan = await planInstall(root);
206
+ if (plan.kind === 'install') {
207
+ // Keep control-user installs script-free. pnpm validates the frozen manifest and lock.
208
+ const frozen = `${dependencyInstallCommand(state, { frozenLockfile: true })} --ignore-scripts`;
209
+ const installCommand = process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ?? (plan.hasLockfile
210
+ ? frozen
211
+ : `${dependencyInstallCommand(state)} --lockfile-only --ignore-scripts && ${frozen}`);
212
+ const installed = await runProcessGroup(root, installCommand, 'install', async (process) => {
213
+ record = await writeMember(state, {
214
+ ...record,
215
+ processes: [...record.processes, process],
216
+ });
217
+ });
204
218
  record = await writeMember(state, {
205
219
  ...record,
206
- processes: [...record.processes, process],
220
+ processes: record.processes.map((process) => process.pid === installed.process.pid
221
+ ? { ...installed.process, role: 'install' }
222
+ : process),
207
223
  });
224
+ if (await readStopMarker(state, command.runId))
225
+ return memberStatus(await sealStopped(state, command, record));
226
+ if (installed.code !== 0)
227
+ throw new Error('batch_install_failed');
228
+ }
229
+ const writersStopped = await stopMemberWriters(state, record);
230
+ if (!writersStopped)
231
+ return memberStatus(await writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true }));
232
+ const head = (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
233
+ record = await writeMember(state, {
234
+ ...record,
235
+ state: 'completed',
236
+ writersStopped: true,
237
+ holding: true,
238
+ headCommit: head,
239
+ error: undefined,
208
240
  });
241
+ return memberStatus(record);
242
+ }
243
+ catch (error) {
244
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
245
+ ? error.message
246
+ : 'batch_prepare_failed';
247
+ const writersStopped = await stopMemberWriters(state, record);
209
248
  record = await writeMember(state, {
210
249
  ...record,
211
- processes: record.processes.map((process) => process.pid === installed.process.pid
212
- ? { ...installed.process, role: 'install' }
213
- : process),
250
+ state: writersStopped ? 'failed' : 'stopping',
251
+ writersStopped,
252
+ holding: !writersStopped,
253
+ error: code,
214
254
  });
215
- if (await readStopMarker(state, command.runId))
216
- return memberStatus(await sealStopped(state, command, record));
217
- if (installed.code !== 0)
218
- throw new Error('batch_install_failed');
255
+ return memberStatus(record);
219
256
  }
220
- const head = (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
221
- record = await writeMember(state, {
222
- ...record,
223
- state: 'completed',
224
- writersStopped: true,
225
- holding: true,
226
- headCommit: head,
227
- error: undefined,
228
- });
229
- return memberStatus(record);
230
- }
231
- catch (error) {
232
- const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
233
- ? error.message
234
- : 'batch_prepare_failed';
235
- record = await writeMember(state, {
236
- ...record,
237
- state: 'failed',
238
- writersStopped: true,
239
- holding: false,
240
- error: code,
241
- });
242
- return memberStatus(record);
243
- }
244
- finally {
245
- if (staging)
246
- await rm(staging, { recursive: true, force: true });
247
- }
257
+ finally {
258
+ if (staging)
259
+ await rm(staging, { recursive: true, force: true });
260
+ }
261
+ });
248
262
  });
249
263
  }
250
264
  async function sealStopped(state, command, existing) {
265
+ const writersStopped = await stopMemberWriters(state, existing);
251
266
  return writeMember(state, {
252
267
  runId: command.runId,
253
268
  ownerFence: Math.max(command.ownerFence, existing?.ownerFence ?? 0),
@@ -255,9 +270,9 @@ async function sealStopped(state, command, existing) {
255
270
  workspaceId: command.workspaceId,
256
271
  directoryName: command.directoryName,
257
272
  phase: existing?.phase ?? 'prepare',
258
- state: 'stopped',
259
- writersStopped: true,
260
- holding: false,
273
+ state: writersStopped ? 'stopped' : 'stopping',
274
+ writersStopped,
275
+ holding: !writersStopped,
261
276
  stopRequested: true,
262
277
  processes: existing?.processes ?? [],
263
278
  sessionId: existing?.sessionId,
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { batchSourceSnapshotRefSchema, sandboxBatchMemberStatusSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { releaseBatchDirectory } from '../server/directory-access.js';
5
6
  const runs = new Map();
6
7
  let freezeChain = Promise.resolve();
7
8
  export function serializeFreeze(action) {
@@ -33,8 +34,10 @@ export function objectFile(state, key) {
33
34
  return join(state.config.stateDir, 'batch-objects', key);
34
35
  }
35
36
  async function writeAtomic(path, body) {
37
+ // private
36
38
  await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
37
39
  const temporary = `${path}.${randomUUID()}.tmp`;
40
+ // private
38
41
  await writeFile(temporary, body, { mode: 0o600 });
39
42
  await rename(temporary, path);
40
43
  }
@@ -62,6 +65,7 @@ export async function readMember(state, runId) {
62
65
  export async function writeMember(state, record) {
63
66
  const next = { ...record, updatedAt: new Date().toISOString() };
64
67
  await writeAtomic(memberFile(state, record.runId), JSON.stringify(next));
68
+ releaseBatchDirectory(state, next);
65
69
  return next;
66
70
  }
67
71
  export async function listMembers(state) {
@@ -36,8 +36,16 @@ export async function dispatchBatchRoute(state, request, url) {
36
36
  return undefined;
37
37
  try {
38
38
  if (path === '/runtime/batch-sources/freeze') {
39
- const result = batchSourceFreezeResultSchema.parse(await freezeBatchSource(state, await readBody(request)));
40
- return { status: 200, body: result };
39
+ const controller = new AbortController();
40
+ const disconnected = () => controller.abort();
41
+ request.socket.once('close', disconnected);
42
+ try {
43
+ const result = batchSourceFreezeResultSchema.parse(await freezeBatchSource(state, await readBody(request), { signal: controller.signal }));
44
+ return { status: 200, body: result };
45
+ }
46
+ finally {
47
+ request.socket.off('close', disconnected);
48
+ }
41
49
  }
42
50
  if (path === '/runtime/batch-members/prepare')
43
51
  return { status: 200, body: await prepareBatchMember(state, await readBody(request)) };
@@ -39,8 +39,10 @@ export async function readSourceCandidate(state, batchId) {
39
39
  export async function writeSourceCandidate(state, candidate) {
40
40
  const path = sourceCandidateFile(state, candidate.snapshot.batchId);
41
41
  const directory = join(path, '..');
42
+ // private
42
43
  await mkdir(directory, { recursive: true, mode: 0o700 });
43
44
  const temporary = `${path}.${randomUUID()}.tmp`;
45
+ // private
44
46
  const handle = await open(temporary, 'wx', 0o600);
45
47
  try {
46
48
  await handle.writeFile(JSON.stringify(candidate));
@@ -129,6 +129,7 @@ export async function restoreArchive(body, destination, expectedDigest) {
129
129
  await mkdir(temporary, { recursive: true });
130
130
  try {
131
131
  const bundle = join(temporary, 'source.bundle');
132
+ // private
132
133
  await writeFile(bundle, Buffer.from(archive.bundle, 'base64'), { mode: 0o600 });
133
134
  await gitWrite(temporary, ['clone', '--no-checkout', '--', bundle, destination]);
134
135
  await gitWrite(destination, ['checkout', '--detach', archive.commitHash]);
@@ -29,7 +29,9 @@ export async function sourceFreezeError(state, batchId, failure, cause) {
29
29
  const directory = join(state.config.stateDir, 'batch-source-diagnostics');
30
30
  const detail = cause;
31
31
  try {
32
+ // private
32
33
  await mkdir(directory, { recursive: true, mode: 0o700 });
34
+ // private
33
35
  await writeFile(join(directory, `${diagnosticId}.json`), JSON.stringify({
34
36
  diagnosticId, batchId, ...failure, recordedAt: new Date().toISOString(), reason: redactedReason(cause),
35
37
  exitCode: typeof detail?.code === 'number' ? detail.code : undefined,
@@ -25,6 +25,7 @@ async function publishRemote(state, candidate, source) {
25
25
  const cache = sourceCacheDir(state, snapshot.batchId);
26
26
  if (!remote || !snapshot.remoteRef)
27
27
  return reject(state, candidate, 'batch_source_push_failed', 'publish', false, 'not_published');
28
+ // private
28
29
  await mkdir(cache, { recursive: true, mode: 0o700 });
29
30
  if (!await lstat(join(cache, '.git')).catch(() => undefined))
30
31
  await gitWrite(cache, ['init', '--initial-branch', 'source']);
@@ -85,6 +86,7 @@ async function publishRemote(state, candidate, source) {
85
86
  if (!await verifyCache(cache, candidate))
86
87
  return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
87
88
  }
89
+ // private
88
90
  await writeFile(`${cache}.digest`, snapshot.digest, { mode: 0o600 });
89
91
  }
90
92
  async function publishArtifact(state, candidate, store) {
@@ -113,6 +115,7 @@ async function publishArtifact(state, candidate, store) {
113
115
  return reject(state, candidate, 'batch_source_upload_failed', 'publish', true, 'unknown', error);
114
116
  }
115
117
  }
118
+ // private
116
119
  await writeFile(`${cache}.digest`, snapshot.digest, { mode: 0o600 });
117
120
  }
118
121
  export async function publishSourceCandidate(state, candidate, source, store) {
@@ -0,0 +1,37 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { groupExited, stopProcessGroup } from './processes.js';
3
+ /** An interrupt acknowledgement is not proof that the native turn and its writers have stopped. */
4
+ export async function stopMemberWriters(state, record) {
5
+ if (!record)
6
+ return true;
7
+ let stopped = true;
8
+ for (const process of record.processes) {
9
+ if (process.stopped || groupExited(process.pid))
10
+ process.stopped = true;
11
+ else
12
+ process.stopped = await stopProcessGroup(process);
13
+ if (!process.stopped)
14
+ stopped = false;
15
+ }
16
+ if (record.sessionId) {
17
+ const terminal = () => {
18
+ const session = state.journal.getSession(record.sessionId);
19
+ return Boolean(session && !session.turnBlock && ['completed', 'failed', 'interrupted'].includes(session.turnStatus ?? ''));
20
+ };
21
+ if (!terminal()) {
22
+ const session = state.journal.getSession(record.sessionId);
23
+ if (session?.threadId)
24
+ await state.native?.interrupt?.({ sessionId: record.sessionId, threadId: session.threadId }).catch(() => undefined);
25
+ const deadline = Date.now() + 2_000;
26
+ while (!terminal() && Date.now() < deadline)
27
+ await delay(15);
28
+ if (!terminal())
29
+ stopped = false;
30
+ }
31
+ }
32
+ const scope = `${record.workspaceId}:${record.directoryName}`;
33
+ if (state.journal.listManagedWrites(scope).some(writer => !writer.stoppedAt) ||
34
+ state.journal.listTerminals(record.workspaceId).some(terminal => terminal.directoryName === record.directoryName && terminal.status !== 'destroyed'))
35
+ stopped = false;
36
+ return stopped;
37
+ }
@@ -372,6 +372,7 @@ export function createCodexAppServerAdapter(options) {
372
372
  }
373
373
  else {
374
374
  // Local execution owns its fresh profile; managed launcher profiles are prepared for the development identity by the image.
375
+ // private
375
376
  await mkdir(homeDir, { recursive: true, mode: 0o700 });
376
377
  if (closed)
377
378
  throw new Error('codex_not_started');
@@ -77,7 +77,9 @@ export async function writePortableCodexCatalog(input) {
77
77
  }
78
78
  await mkdir(input.directory, { recursive: true });
79
79
  const path = join(input.directory, 'portable-models.json');
80
+ // private
80
81
  await writeFile(path, body, { encoding: 'utf8', mode: 0o644 });
82
+ // private
81
83
  await writeFile(join(input.directory, 'portable-models.version.json'), JSON.stringify({
82
84
  version,
83
85
  sha256: createHash('sha256').update(body).digest('hex'),
@@ -85,6 +87,7 @@ export async function writePortableCodexCatalog(input) {
85
87
  return path;
86
88
  }
87
89
  export async function writePlaycraftCodexConfig(input) {
90
+ // private
88
91
  await mkdir(input.homeDir, { recursive: true, mode: 0o700 });
89
92
  const path = join(input.homeDir, 'config.toml');
90
93
  const body = [
@@ -102,6 +105,7 @@ export async function writePlaycraftCodexConfig(input) {
102
105
  'requires_openai_auth = false',
103
106
  '',
104
107
  ].join('\n');
108
+ // private
105
109
  await writeFile(path, body, { encoding: 'utf8', mode: 0o600 });
106
110
  return path;
107
111
  }
@@ -5,7 +5,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. Returns build ids immediately; poll playcraft_get_delivery for status, checks and failure details.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -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')