@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
@@ -3,25 +3,17 @@ 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';
10
+ import { dependencyInstallCommand, planInstall } from '../workspaces/dependencies.js';
8
11
  import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
9
12
  import { restoreArchive } from './source-capture.js';
10
13
  import { groupExited, runProcessGroup } from './processes.js';
14
+ import { stopMemberWriters } from './writer-proof.js';
11
15
  import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
12
16
  const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
13
- const LOCKS = [
14
- 'pnpm-lock.yaml',
15
- 'package-lock.json',
16
- 'npm-shrinkwrap.json',
17
- 'yarn.lock',
18
- 'bun.lock',
19
- 'bun.lockb',
20
- ];
21
- export function frozenInstallCommand() {
22
- return (process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ??
23
- 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
24
- }
25
17
  function hashCommand(value) {
26
18
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
27
19
  }
@@ -117,43 +109,6 @@ async function resumeBranch(repositoryUrl, branchName, commit, destination, sour
117
109
  }
118
110
  await gitWrite(destination, ['checkout', '-B', branchName, commit]);
119
111
  }
120
- export async function assertMemberDependencies(root) {
121
- let manifest;
122
- try {
123
- manifest = await readFile(join(root, 'package.json'), 'utf8');
124
- }
125
- catch (error) {
126
- if (error.code === 'ENOENT')
127
- return 'skip';
128
- throw error;
129
- }
130
- let names = [];
131
- try {
132
- const pkg = JSON.parse(manifest);
133
- names = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
134
- }
135
- catch {
136
- throw new Error('dependency_lock_mismatch');
137
- }
138
- const present = [];
139
- for (const lock of LOCKS)
140
- if (await lstat(join(root, lock)).catch(() => undefined))
141
- present.push(lock);
142
- if (present.length > 1)
143
- throw new Error('dependency_locks_conflict');
144
- if (present.length === 0)
145
- return 'skip';
146
- if (present[0] !== 'pnpm-lock.yaml')
147
- throw new Error('dependency_lock_mismatch');
148
- const lock = await readFile(join(root, 'pnpm-lock.yaml'), 'utf8');
149
- if (names.some((name) => !lock.includes(`${name}:`) &&
150
- !lock.includes(`/${name}@`) &&
151
- !lock.includes(`'${name}'`) &&
152
- !lock.includes(`"${name}"`))) {
153
- throw new Error('dependency_lock_mismatch');
154
- }
155
- return 'install';
156
- }
157
112
  export async function prepareBatchMember(state, body, options) {
158
113
  if (!body ||
159
114
  typeof body !== 'object' ||
@@ -191,106 +146,123 @@ export async function prepareBatchMember(state, body, options) {
191
146
  if (existing?.processes.some((process) => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
192
147
  return memberStatus(existing);
193
148
  const snapshot = command.snapshot;
194
- if ((await readSnapshot(state, snapshot.batchId))?.digest === snapshot.digest) {
195
- /* The local snapshot record is an accelerator; the command snapshot remains authoritative. */
196
- }
197
- let record = await writeMember(state, {
198
- runId: command.runId,
199
- ownerFence: command.ownerFence,
200
- occupancyId: command.occupancyId,
201
- workspaceId: command.workspaceId,
202
- directoryName: command.directoryName,
203
- phase: 'prepare',
204
- state: 'running',
205
- writersStopped: false,
206
- holding: true,
207
- stopRequested: false,
208
- processes: existing?.processes ?? [],
209
- commandHash: { ...existing?.commandHash, prepare: hash },
210
- snapshotDigest: snapshot.digest,
211
- branchName: command.branchName,
212
- sourceCommit: snapshot.commitHash,
213
- beforeTree: existing?.beforeTree,
214
- updatedAt: new Date().toISOString(),
215
- });
216
- let staging;
217
- try {
218
- const parent = await workspaceContainerRoot(state, command.workspaceId);
219
- const root = join(parent, command.directoryName);
220
- staging = join(parent, `.batch-member-${randomUUID()}`);
221
- const binding = state.journal.getWorkspace(command.workspaceId);
222
- if (!binding)
223
- throw new Error('workspace_not_registered');
224
- const repositoryUrl = binding.repositoryUrl ?? undefined;
225
- const sourceRoot = join(parent, snapshot.sourceDirectoryName);
226
- if (!(await lstat(root).catch(() => undefined))) {
227
- await mkdir(parent, { recursive: true });
228
- if (command.resumeFromCommit)
229
- await resumeBranch(repositoryUrl, command.branchName, command.resumeFromCommit, staging, sourceRoot);
230
- else
231
- await materialize(state, snapshot, staging, options?.objectStore ?? filesystemObjectStore(state), sourceRoot);
232
- const commit = command.resumeFromCommit ?? snapshot.commitHash;
233
- await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
234
- const { rename } = await import('node:fs/promises');
235
- 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. */
236
153
  }
237
- await registerWorkspaceDirectory(state, {
154
+ let record = await writeMember(state, {
155
+ runId: command.runId,
156
+ ownerFence: command.ownerFence,
157
+ occupancyId: command.occupancyId,
238
158
  workspaceId: command.workspaceId,
239
159
  directoryName: command.directoryName,
240
- 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,
241
168
  branchName: command.branchName,
242
- sourceCommit: command.resumeFromCommit ?? snapshot.commitHash,
243
- }, state.acceptedLeaseFence);
244
- const plan = await assertMemberDependencies(root);
245
- if (plan === 'install') {
246
- const installed = await runProcessGroup(root, frozenInstallCommand(), '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
+ });
247
218
  record = await writeMember(state, {
248
219
  ...record,
249
- processes: [...record.processes, process],
220
+ processes: record.processes.map((process) => process.pid === installed.process.pid
221
+ ? { ...installed.process, role: 'install' }
222
+ : process),
250
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,
251
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);
252
248
  record = await writeMember(state, {
253
249
  ...record,
254
- processes: record.processes.map((process) => process.pid === installed.process.pid
255
- ? { ...installed.process, role: 'install' }
256
- : process),
250
+ state: writersStopped ? 'failed' : 'stopping',
251
+ writersStopped,
252
+ holding: !writersStopped,
253
+ error: code,
257
254
  });
258
- if (await readStopMarker(state, command.runId))
259
- return memberStatus(await sealStopped(state, command, record));
260
- if (installed.code !== 0)
261
- throw new Error('batch_install_failed');
255
+ return memberStatus(record);
262
256
  }
263
- const head = (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
264
- record = await writeMember(state, {
265
- ...record,
266
- state: 'completed',
267
- writersStopped: true,
268
- holding: true,
269
- headCommit: head,
270
- error: undefined,
271
- });
272
- return memberStatus(record);
273
- }
274
- catch (error) {
275
- const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
276
- ? error.message
277
- : 'batch_prepare_failed';
278
- record = await writeMember(state, {
279
- ...record,
280
- state: 'failed',
281
- writersStopped: true,
282
- holding: false,
283
- error: code,
284
- });
285
- return memberStatus(record);
286
- }
287
- finally {
288
- if (staging)
289
- await rm(staging, { recursive: true, force: true });
290
- }
257
+ finally {
258
+ if (staging)
259
+ await rm(staging, { recursive: true, force: true });
260
+ }
261
+ });
291
262
  });
292
263
  }
293
264
  async function sealStopped(state, command, existing) {
265
+ const writersStopped = await stopMemberWriters(state, existing);
294
266
  return writeMember(state, {
295
267
  runId: command.runId,
296
268
  ownerFence: Math.max(command.ownerFence, existing?.ownerFence ?? 0),
@@ -298,9 +270,9 @@ async function sealStopped(state, command, existing) {
298
270
  workspaceId: command.workspaceId,
299
271
  directoryName: command.directoryName,
300
272
  phase: existing?.phase ?? 'prepare',
301
- state: 'stopped',
302
- writersStopped: true,
303
- holding: false,
273
+ state: writersStopped ? 'stopped' : 'stopping',
274
+ writersStopped,
275
+ holding: !writersStopped,
304
276
  stopRequested: true,
305
277
  processes: existing?.processes ?? [],
306
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: 'Start one formal channel delivery build. Returns build identifiers immediately; poll playcraft_get_delivery for status and validation results. This writes a delivery record under dist/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. 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: {
@@ -24,7 +24,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
24
24
  {
25
25
  type: 'function',
26
26
  name: 'playcraft_get_delivery',
27
- description: 'Read the latest delivery build, or one build by id, including status, validation summary, error and a truncated build log.',
27
+ description: 'Read one export by build id, or the latest export of a channel (or of any channel) on this branch: status, current stage while running, channel check findings, and for a failed export the failed stage, command, exit code and the end of its stderr/stdout.',
28
28
  inputSchema: {
29
29
  type: 'object',
30
30
  properties: {
@@ -109,8 +109,9 @@ export class WorkspaceDeliveryTools {
109
109
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
110
110
  return;
111
111
  }
112
+ // Sandbox builds are UUIDs; platform exports use the build queue's ids.
112
113
  if (input.buildId !== undefined && (typeof input.buildId !== 'string'
113
- || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(input.buildId))) {
114
+ || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input.buildId))) {
114
115
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
115
116
  return;
116
117
  }
@@ -138,7 +139,8 @@ export class WorkspaceDeliveryTools {
138
139
  }
139
140
  }
140
141
  function errorReason(cause) {
141
- if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string')
142
- return cause.code;
142
+ if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
143
+ return cause.message.startsWith(cause.code) ? cause.message.slice(0, 500) : cause.code;
144
+ }
143
145
  return 'delivery_tool_failed';
144
146
  }
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { agentGenerationProvenanceRequestSchema, PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { prepareLauncherOrigin, verifyLauncherOrigin } from './launcher-client.js';
4
+ const requestSchema = agentGenerationProvenanceRequestSchema.pick({ toolPath: true, toolRequestId: true, payloadHash: true });
5
+ /** Only an unambiguous, newly launched shell in a controlled turn receives attribution. */
6
+ export class WorkspaceGenerationProvenanceBroker {
7
+ state;
8
+ constructor(state) {
9
+ this.state = state;
10
+ }
11
+ async prepare() {
12
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
13
+ const root = this.state.native?.executionProcess?.();
14
+ return socketPath && root ? prepareLauncherOrigin({ socketPath, rootPid: root.pid, runtimeEpoch: this.state.runtimeEpoch }) : undefined;
15
+ }
16
+ operation(context) {
17
+ const active = this.state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
18
+ && ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
19
+ && operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch);
20
+ if (active.length !== 1)
21
+ return undefined;
22
+ const operation = active[0];
23
+ const session = this.state.journal.getSession(context.sessionId);
24
+ if (operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId || operation.clientRequestId !== context.clientRequestId
25
+ || !operation.nativeThreadId || !operation.nativeTurnId || !operation.workspaceId || !operation.directoryName
26
+ || !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
27
+ || session.cwd !== context.cwd || !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
28
+ return undefined;
29
+ return operation;
30
+ }
31
+ async forward(request, response, context, valid) {
32
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
33
+ const deny = () => { response.writeHead(403, { 'cache-control': 'no-store' }); response.end(); };
34
+ try {
35
+ const operation = this.operation(context);
36
+ if (!valid() || !socketPath || !context.boundaryId || !operation || request.socket.remoteAddress !== '127.0.0.1'
37
+ || !request.socket.localPort || !request.socket.remotePort)
38
+ return deny();
39
+ let text = '';
40
+ for await (const chunk of request) {
41
+ text += chunk.toString();
42
+ if (text.length > 2048)
43
+ return deny();
44
+ }
45
+ const parsed = requestSchema.safeParse(JSON.parse(text));
46
+ if (!parsed.success || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId,
47
+ runtimeEpoch: this.state.runtimeEpoch, serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.cwd }))
48
+ return deny();
49
+ // The socket lookup may take time. Never attest a turn which ended or changed meanwhile.
50
+ if (!valid() || this.operation(context)?.operationId !== operation.operationId)
51
+ return deny();
52
+ const config = this.state.config;
53
+ const input = agentGenerationProvenanceRequestSchema.parse({ ...parsed.data,
54
+ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
55
+ workspaceId: operation.workspaceId, directoryName: operation.directoryName,
56
+ sessionId: context.sessionId, sessionClientRequestId: context.clientRequestId,
57
+ nativeThreadId: operation.nativeThreadId, nativeTurnId: operation.nativeTurnId });
58
+ const upstream = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.agentGenerationProvenance), {
59
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
60
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
61
+ body: JSON.stringify(input),
62
+ });
63
+ if (!upstream.ok) {
64
+ await upstream.body?.cancel();
65
+ return deny();
66
+ }
67
+ const value = z.object({ proof: z.string().min(1).max(8192) }).strict().parse(await upstream.json());
68
+ if (!valid() || this.operation(context)?.operationId !== operation.operationId)
69
+ return deny();
70
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
71
+ response.end(JSON.stringify(value));
72
+ }
73
+ catch {
74
+ if (!response.headersSent)
75
+ deny();
76
+ else
77
+ response.destroy();
78
+ }
79
+ }
80
+ }
@@ -33,6 +33,24 @@ function request(socketPath, message) {
33
33
  });
34
34
  });
35
35
  }
36
+ /** The private launcher owns the boundary; development shells never receive its identity. */
37
+ export async function prepareLauncherOrigin(input) {
38
+ try {
39
+ const reply = await request(input.socketPath, { action: 'origin-boundary', rootPid: input.rootPid, runtimeEpoch: input.runtimeEpoch });
40
+ return typeof reply.boundaryId === 'string' ? reply.boundaryId : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ export async function verifyLauncherOrigin(input) {
47
+ try {
48
+ return (await request(input.socketPath, { ...input, action: 'origin-verify', socketPath: undefined })).verified === true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
36
54
  export async function startLauncherProcess(input) {
37
55
  const socket = createConnection(input.socketPath);
38
56
  const stdout = new PassThrough();