@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.
- package/dist/assets/client.js +6 -4
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/commands/workspace.js +43 -0
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +109 -137
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -4
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +19 -32
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/templates/preview-runtime/v1/README.md +4 -0
- package/templates/preview-runtime/v1/manifest.json +6 -6
- package/templates/preview-runtime/v1/runtime.mjs +368 -22
- package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
- 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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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:
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
250
|
+
state: writersStopped ? 'failed' : 'stopping',
|
|
251
|
+
writersStopped,
|
|
252
|
+
holding: !writersStopped,
|
|
253
|
+
error: code,
|
|
257
254
|
});
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
303
|
-
holding:
|
|
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
|
|
40
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|| !/^[
|
|
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();
|