@playcraft/cli 0.1.57-beta → 0.1.58-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/workspace.js +43 -0
- 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 +108 -93
- 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 +1 -1
- 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 +11 -2
- 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 +90 -70
- 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 +5 -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
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
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:
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
250
|
+
state: writersStopped ? 'failed' : 'stopping',
|
|
251
|
+
writersStopped,
|
|
252
|
+
holding: !writersStopped,
|
|
253
|
+
error: code,
|
|
214
254
|
});
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
260
|
-
holding:
|
|
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
|
|
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: '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.
|
|
8
|
+
description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
|
|
9
9
|
inputSchema: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
@@ -11,6 +11,7 @@ export class RuntimeJournal {
|
|
|
11
11
|
legacyPath;
|
|
12
12
|
client;
|
|
13
13
|
listeners = new Set();
|
|
14
|
+
changeListeners = new Set();
|
|
14
15
|
maintenance;
|
|
15
16
|
engineInfo;
|
|
16
17
|
epochs = [];
|
|
@@ -40,11 +41,13 @@ export class RuntimeJournal {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
async load() {
|
|
44
|
+
// private
|
|
43
45
|
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
44
46
|
const legacy = await readLegacy(this.legacyPath, this.filePath);
|
|
45
47
|
const started = await SqliteWorkerClient.open(this.filePath, legacy?.value ?? null);
|
|
46
48
|
this.client = started.client;
|
|
47
49
|
this.apply(started.opened);
|
|
50
|
+
// private
|
|
48
51
|
if (legacy?.readable)
|
|
49
52
|
await writeFile(this.legacyPath, LEGACY_JOURNAL_SENTINEL, { mode: 0o600 });
|
|
50
53
|
this.maintenance = setInterval(() => {
|
|
@@ -76,6 +79,33 @@ export class RuntimeJournal {
|
|
|
76
79
|
this.listeners.add(listener);
|
|
77
80
|
return () => this.listeners.delete(listener);
|
|
78
81
|
}
|
|
82
|
+
onChange(listener) {
|
|
83
|
+
this.changeListeners.add(listener);
|
|
84
|
+
return () => this.changeListeners.delete(listener);
|
|
85
|
+
}
|
|
86
|
+
directoryBarriers() { return Object.values(this.snapshot.directoryBarriers); }
|
|
87
|
+
async putDirectoryBarrier(barrier) {
|
|
88
|
+
await this.run('upsertRecord', { bucket: 'directory_barriers', key: barrier.owner,
|
|
89
|
+
body: { ...barrier, queuedAt: this.snapshot.directoryBarriers[barrier.owner]?.queuedAt ?? barrier.queuedAt ?? new Date().toISOString() } });
|
|
90
|
+
}
|
|
91
|
+
async deleteDirectoryBarrier(owner) {
|
|
92
|
+
await this.run('deleteRecord', { bucket: 'directory_barriers', key: owner });
|
|
93
|
+
}
|
|
94
|
+
deferredDirectoryOperations() { return Object.values(this.snapshot.deferredDirectoryOperations); }
|
|
95
|
+
async deferDirectoryOperation(input) {
|
|
96
|
+
await this.run('upsertRecord', { bucket: 'directory_deferred_operations', key: input.operationId,
|
|
97
|
+
body: { ...input, queuedAt: this.snapshot.deferredDirectoryOperations[input.operationId]?.queuedAt ?? input.queuedAt ?? new Date().toISOString() } });
|
|
98
|
+
}
|
|
99
|
+
/** The accepted command and its replayable input commit together before any writer can start. */
|
|
100
|
+
async putPendingOperation(scopeKey, operation, payload, recovery) {
|
|
101
|
+
const parsed = operationRecordSchema.parse(operation);
|
|
102
|
+
await this.run('putPendingOperation', { scopeKey, operation: parsed,
|
|
103
|
+
pending: { operationId: parsed.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}), queuedAt: parsed.createdAt } });
|
|
104
|
+
return this.snapshot.operations[scopeKey];
|
|
105
|
+
}
|
|
106
|
+
async deleteDeferredDirectoryOperation(operationId) {
|
|
107
|
+
await this.run('deleteRecord', { bucket: 'directory_deferred_operations', key: operationId });
|
|
108
|
+
}
|
|
79
109
|
getWorkspace(workspaceId) {
|
|
80
110
|
return this.snapshot.workspaces[workspaceId];
|
|
81
111
|
}
|
|
@@ -288,6 +318,8 @@ export class RuntimeJournal {
|
|
|
288
318
|
throw new Error('sqlite_not_open');
|
|
289
319
|
const result = await this.client.request(type, payload);
|
|
290
320
|
this.apply(result);
|
|
321
|
+
for (const listener of this.changeListeners)
|
|
322
|
+
listener();
|
|
291
323
|
for (const event of result.committedEvents ?? []) {
|
|
292
324
|
for (const listener of this.listeners)
|
|
293
325
|
listener(event);
|
|
@@ -356,6 +388,8 @@ function emptySnapshot() {
|
|
|
356
388
|
fileRevisions: {},
|
|
357
389
|
approvals: {},
|
|
358
390
|
managedWrites: {},
|
|
391
|
+
directoryBarriers: {},
|
|
392
|
+
deferredDirectoryOperations: {},
|
|
359
393
|
workspaceDependencies: {},
|
|
360
394
|
executionGroup: { stopConfirmed: false, replaced: false },
|
|
361
395
|
lastSourceSequence: 0,
|
|
@@ -64,6 +64,15 @@ function dispatch(type, payload) {
|
|
|
64
64
|
case 'putOperation':
|
|
65
65
|
putOperation(database, String(body.scopeKey), body.operation);
|
|
66
66
|
return;
|
|
67
|
+
case 'putPendingOperation': {
|
|
68
|
+
const operation = operationRecordSchema.parse(body.operation);
|
|
69
|
+
const pending = asRecord(body.pending);
|
|
70
|
+
if (operation.status !== 'queued' || pending.operationId !== operation.operationId || pending.scopeKey !== body.scopeKey)
|
|
71
|
+
throw new Error('invalid_pending_operation');
|
|
72
|
+
putOperation(database, String(body.scopeKey), operation);
|
|
73
|
+
upsertRecord(database, 'directory_deferred_operations', operation.operationId, pending);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
67
76
|
case 'setAcceptedFence':
|
|
68
77
|
setMeta(database, 'accepted_lease_fence', String(Math.max(metaNumber(database, 'accepted_lease_fence'), Number(body.value) || 0)));
|
|
69
78
|
return;
|
|
@@ -158,6 +167,8 @@ function openDatabase(payload) {
|
|
|
158
167
|
ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
|
|
159
168
|
CREATE INDEX IF NOT EXISTS records_native_session_thread
|
|
160
169
|
ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
|
|
170
|
+
CREATE INDEX IF NOT EXISTS operation_intents_sandbox_operation
|
|
171
|
+
ON operation_intents(sandbox_operation_id);
|
|
161
172
|
CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
|
|
162
173
|
ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
|
|
163
174
|
json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
|
|
@@ -353,7 +364,7 @@ function allocateEvent(database, body) {
|
|
|
353
364
|
WHERE runtime_id = ? AND generation = ? AND runtime_epoch = ?
|
|
354
365
|
RETURNING next_sequence`)
|
|
355
366
|
.get(runtimeId, generation, runtimeEpoch);
|
|
356
|
-
const payload = body
|
|
367
|
+
const payload = operationEventPayload(database, body);
|
|
357
368
|
const event = pendingSourceEventSchema.parse({
|
|
358
369
|
source: { runtimeId, generation, runtimeEpoch, sourceSequence: allocated.next_sequence },
|
|
359
370
|
payloadHash: hashOperationPayload(payload),
|
|
@@ -832,6 +843,8 @@ function dump(database, includeEvents = true) {
|
|
|
832
843
|
fileRevisions: bucketObject(database, 'file_revisions'),
|
|
833
844
|
approvals: bucketObject(database, 'approvals'),
|
|
834
845
|
managedWrites: bucketObject(database, 'managed_writes'),
|
|
846
|
+
directoryBarriers: bucketObject(database, 'directory_barriers'),
|
|
847
|
+
deferredDirectoryOperations: bucketObject(database, 'directory_deferred_operations'),
|
|
835
848
|
workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
|
|
836
849
|
executionGroup: executionGroup(database),
|
|
837
850
|
stoppedRuntimeEpochs: stoppedExecutionEpochs(database, metaString(database, 'runtime_id'), metaNumber(database, 'generation')),
|
|
@@ -878,7 +891,16 @@ function operationsObject(database) {
|
|
|
878
891
|
return operations;
|
|
879
892
|
}
|
|
880
893
|
function putOperation(database, scopeKey, value) {
|
|
881
|
-
|
|
894
|
+
let operation = operationRecordSchema.parse(value);
|
|
895
|
+
if (operation.status !== 'queued')
|
|
896
|
+
database.prepare("DELETE FROM records WHERE bucket = 'directory_deferred_operations' AND record_key = ?").run(operation.operationId);
|
|
897
|
+
const existing = database.prepare('SELECT gateway_operation_id FROM operation_intents WHERE scope_key = ?')
|
|
898
|
+
.get(scopeKey);
|
|
899
|
+
if (existing?.gateway_operation_id) {
|
|
900
|
+
if (operation.gatewayOperationId && operation.gatewayOperationId !== existing.gateway_operation_id)
|
|
901
|
+
throw new Error('gateway_operation_mismatch');
|
|
902
|
+
operation = { ...operation, gatewayOperationId: existing.gateway_operation_id };
|
|
903
|
+
}
|
|
882
904
|
if (metaString(database, 'runtime_id') && operation.generation === metaNumber(database, 'generation') && operation.runtimeEpoch === metaNumber(database, 'runtime_epoch')) {
|
|
883
905
|
const key = JSON.stringify([scopeKey, operation.operationId]);
|
|
884
906
|
database.prepare("INSERT INTO records (bucket, record_key, body_json) VALUES ('operation_execution_scopes', ?, ?) ON CONFLICT DO NOTHING")
|
|
@@ -898,7 +920,7 @@ function putOperation(database, scopeKey, value) {
|
|
|
898
920
|
thread_id = excluded.thread_id,
|
|
899
921
|
turn_id = excluded.turn_id,
|
|
900
922
|
reconcile_status = excluded.reconcile_status`)
|
|
901
|
-
.run(scopeKey, JSON.stringify(operation), null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
923
|
+
.run(scopeKey, JSON.stringify(operation), operation.gatewayOperationId ?? null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
902
924
|
}
|
|
903
925
|
function insertEventRow(database, event, links) {
|
|
904
926
|
database
|
|
@@ -1017,17 +1039,57 @@ function operationByTurn(database, turnId, threadId) {
|
|
|
1017
1039
|
}
|
|
1018
1040
|
function linkedPayload(params, session, subAgent) {
|
|
1019
1041
|
const payload = { ...asRecord(params) };
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1042
|
+
// Native notifications cannot assert the platform identity or override durable ownership.
|
|
1043
|
+
for (const key of ['operationId', 'gatewayOperationId', 'platformOperationId', 'sandboxOperationId',
|
|
1044
|
+
'workspaceId', 'sessionId', 'directoryName', 'runtimeId', 'generation', 'runtimeEpoch', 'userId', 'environment', 'execution', 'subAgent',
|
|
1045
|
+
'directoryAccessMode', 'dispatchAttempted'])
|
|
1046
|
+
delete payload[key];
|
|
1023
1047
|
if (typeof session?.workspaceId === 'string')
|
|
1024
1048
|
payload.workspaceId = session.workspaceId;
|
|
1025
1049
|
if (typeof session?.sessionId === 'string')
|
|
1026
1050
|
payload.sessionId = session.sessionId;
|
|
1051
|
+
if (session)
|
|
1052
|
+
payload.directoryName = optionalString(session.directoryName) ?? 'basic';
|
|
1027
1053
|
if (subAgent)
|
|
1028
1054
|
payload.subAgent = subAgent;
|
|
1029
1055
|
return payload;
|
|
1030
1056
|
}
|
|
1057
|
+
/** Identity enters the immutable event body before hashing, including synthetic stop events. */
|
|
1058
|
+
function operationEventPayload(database, body) {
|
|
1059
|
+
const operationId = optionalString(body.operationId);
|
|
1060
|
+
if (!operationId)
|
|
1061
|
+
return body.payload ?? {};
|
|
1062
|
+
const row = database.prepare('SELECT operation_json FROM operation_intents WHERE sandbox_operation_id = ? LIMIT 1')
|
|
1063
|
+
.get(operationId);
|
|
1064
|
+
if (!row)
|
|
1065
|
+
return body.payload ?? {};
|
|
1066
|
+
const operation = operationRecordSchema.parse(JSON.parse(row.operation_json));
|
|
1067
|
+
if ((operation.generation !== undefined && operation.generation !== Number(body.generation)) ||
|
|
1068
|
+
(operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== Number(body.runtimeEpoch)))
|
|
1069
|
+
return body.payload ?? {};
|
|
1070
|
+
const payload = { ...asRecord(body.payload) };
|
|
1071
|
+
delete payload.gatewayOperationId;
|
|
1072
|
+
delete payload.platformOperationId;
|
|
1073
|
+
payload.operationId = operation.gatewayOperationId ?? operation.operationId;
|
|
1074
|
+
payload.sandboxOperationId = operation.operationId;
|
|
1075
|
+
if (operation.gatewayOperationId)
|
|
1076
|
+
payload.gatewayOperationId = operation.gatewayOperationId;
|
|
1077
|
+
if (operation.workspaceId)
|
|
1078
|
+
payload.workspaceId = operation.workspaceId;
|
|
1079
|
+
if (operation.sessionId)
|
|
1080
|
+
payload.sessionId = operation.sessionId;
|
|
1081
|
+
payload.directoryName = operation.directoryName ?? 'basic';
|
|
1082
|
+
const subAgent = asRecord(payload.subAgent);
|
|
1083
|
+
const threadId = optionalString(body.threadId) ?? nativeIds(payload).threadId ??
|
|
1084
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeThreadId);
|
|
1085
|
+
const turnId = optionalString(body.turnId) ?? nativeIds(payload).turnId ??
|
|
1086
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeTurnId);
|
|
1087
|
+
if (threadId)
|
|
1088
|
+
payload.threadId = threadId;
|
|
1089
|
+
if (turnId)
|
|
1090
|
+
payload.turnId = turnId;
|
|
1091
|
+
return payload;
|
|
1092
|
+
}
|
|
1031
1093
|
function nativeIds(params) {
|
|
1032
1094
|
const record = asRecord(params);
|
|
1033
1095
|
const turn = asRecord(record.turn);
|
|
@@ -8,6 +8,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
8
8
|
let directory = workspace;
|
|
9
9
|
for (const segment of dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH).split('/')) {
|
|
10
10
|
directory = join(directory, segment);
|
|
11
|
+
// shared
|
|
11
12
|
const created = await mkdir(directory, { mode: 0o2775 }).then(() => true, error => {
|
|
12
13
|
if (error.code !== 'EEXIST')
|
|
13
14
|
throw error;
|
|
@@ -22,6 +23,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
22
23
|
const target = join(workspace, WORKSPACE_PLAYABLE_OUTPUT_PATH);
|
|
23
24
|
const temporary = join(directory, `.playable-${randomUUID()}.tmp`);
|
|
24
25
|
try {
|
|
26
|
+
// shared
|
|
25
27
|
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o664 });
|
|
26
28
|
await chmod(temporary, 0o664);
|
|
27
29
|
const stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
|