@playcraft/cli 0.1.57-beta → 0.1.59-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -1,4 +1,4 @@
1
- import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  const memory = new WeakMap();
@@ -29,38 +29,87 @@ function parsePolicy(raw) {
29
29
  return undefined;
30
30
  return value;
31
31
  }
32
- export async function bindMemberPolicy(state, sessionId, policy) {
32
+ export async function bindMemberPolicy(state, sessionId, policy, owner) {
33
33
  const path = policyPath(state, sessionId);
34
34
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
35
  const temporary = `${path}.${randomUUID()}.tmp`;
36
- writeFileSync(temporary, JSON.stringify(policy), { mode: 0o600 });
36
+ const binding = { ...owner, policy };
37
+ writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
37
38
  renameSync(temporary, path);
38
39
  const map = memory.get(state) ?? new Map();
39
- map.set(sessionId, policy);
40
+ map.set(sessionId, binding);
40
41
  memory.set(state, map);
41
42
  }
42
- /** Synchronous: the shared session adapter reapplies this on every thread start and resume. */
43
+ function readBinding(raw) {
44
+ const value = JSON.parse(raw);
45
+ if (!('policy' in value)) {
46
+ const policy = parsePolicy(raw);
47
+ return policy ? { policy } : undefined;
48
+ }
49
+ const policy = parsePolicy(JSON.stringify(value.policy));
50
+ return policy && typeof value.runId === 'string' && /^[0-9a-f-]{36}$/i.test(value.runId)
51
+ && Number.isInteger(value.ownerFence) && value.ownerFence >= 0 ? { ...value, policy } : undefined;
52
+ }
53
+ function memberRecord(state, runId, sessionId) {
54
+ if (typeof runId !== 'string' || !/^[0-9a-f-]{36}$/i.test(runId))
55
+ throw new Error('member_policy_record_invalid');
56
+ const record = JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', `${runId}.json`), 'utf8'));
57
+ if (record.runId !== runId || record.sessionId !== sessionId || !Number.isInteger(record.ownerFence)
58
+ || typeof record.holding !== 'boolean' || typeof record.writersStopped !== 'boolean')
59
+ throw new Error('member_policy_record_invalid');
60
+ return record;
61
+ }
62
+ function sessionMembers(state, sessionId) {
63
+ return readdirSync(join(state.config.stateDir, 'batch-members')).filter(name => name.endsWith('.json'))
64
+ .map(name => JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', name), 'utf8')))
65
+ .filter(record => record.sessionId === sessionId)
66
+ .map(record => memberRecord(state, record.runId, sessionId));
67
+ }
68
+ /** Re-read the owning Run on every lookup; a cached policy is never proof of an active member. */
43
69
  export function trustedMemberPolicy(state, sessionId) {
44
- const cached = memory.get(state)?.get(sessionId);
45
- if (cached)
46
- return cached;
47
70
  try {
48
- const parsed = parsePolicy(readFileSync(policyPath(state, sessionId), 'utf8'));
49
- if (!parsed) {
50
- return {
51
- cwd: state.config.workspaceRoot,
52
- approvalPolicy: 'never',
53
- sandboxPolicy: { type: 'readOnly', networkAccess: false },
54
- };
71
+ let binding = memory.get(state)?.get(sessionId);
72
+ if (!binding) {
73
+ let raw;
74
+ try {
75
+ raw = readFileSync(policyPath(state, sessionId), 'utf8');
76
+ }
77
+ catch (error) {
78
+ if (error.code === 'ENOENT')
79
+ return undefined;
80
+ throw error;
81
+ }
82
+ binding = readBinding(raw);
83
+ if (!binding)
84
+ throw new Error('member_policy_invalid');
85
+ const map = memory.get(state) ?? new Map();
86
+ map.set(sessionId, binding);
87
+ memory.set(state, map);
55
88
  }
56
- const map = memory.get(state) ?? new Map();
57
- map.set(sessionId, parsed);
58
- memory.set(state, map);
59
- return parsed;
60
- }
61
- catch (error) {
62
- if (error.code === 'ENOENT')
89
+ if (binding.runId) {
90
+ const record = memberRecord(state, binding.runId, sessionId);
91
+ if (record.holding || !record.writersStopped) {
92
+ if (record.ownerFence !== binding.ownerFence)
93
+ throw new Error('member_policy_owner_mismatch');
94
+ return binding.policy;
95
+ }
96
+ if (sessionMembers(state, sessionId).some(member => member.holding || !member.writersStopped))
97
+ throw new Error('member_policy_owner_mismatch');
98
+ return undefined;
99
+ }
100
+ // Profiles written before Run ownership was recorded still use durable member facts.
101
+ const records = sessionMembers(state, sessionId);
102
+ if (!records.length)
103
+ throw new Error('member_policy_record_missing');
104
+ const active = records.filter(record => record.holding || !record.writersStopped);
105
+ if (!active.length)
63
106
  return undefined;
107
+ if (active.length !== 1 || binding.policy.sandboxPolicy.type !== 'workspaceWrite'
108
+ || !binding.policy.sandboxPolicy.writableRoots.includes(join(state.config.stateDir, 'batch-temp', active[0].runId)))
109
+ throw new Error('member_policy_owner_mismatch');
110
+ return binding.policy;
111
+ }
112
+ catch {
64
113
  return {
65
114
  cwd: state.config.workspaceRoot,
66
115
  approvalPolicy: 'never',
@@ -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
+ }