@playcraft/cli 0.1.57-beta → 0.1.59-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- 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/policy.js +71 -22
- 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 +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -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 +13 -3
- 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 +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- 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 +55 -3
- 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/storage-snapshot.js +235 -0
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const memory = new WeakMap();
|
|
@@ -29,38 +29,87 @@ function parsePolicy(raw) {
|
|
|
29
29
|
return undefined;
|
|
30
30
|
return value;
|
|
31
31
|
}
|
|
32
|
-
export async function bindMemberPolicy(state, sessionId, policy) {
|
|
32
|
+
export async function bindMemberPolicy(state, sessionId, policy, owner) {
|
|
33
33
|
const path = policyPath(state, sessionId);
|
|
34
34
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
35
35
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
36
|
-
|
|
36
|
+
const binding = { ...owner, policy };
|
|
37
|
+
writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
|
|
37
38
|
renameSync(temporary, path);
|
|
38
39
|
const map = memory.get(state) ?? new Map();
|
|
39
|
-
map.set(sessionId,
|
|
40
|
+
map.set(sessionId, binding);
|
|
40
41
|
memory.set(state, map);
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
function readBinding(raw) {
|
|
44
|
+
const value = JSON.parse(raw);
|
|
45
|
+
if (!('policy' in value)) {
|
|
46
|
+
const policy = parsePolicy(raw);
|
|
47
|
+
return policy ? { policy } : undefined;
|
|
48
|
+
}
|
|
49
|
+
const policy = parsePolicy(JSON.stringify(value.policy));
|
|
50
|
+
return policy && typeof value.runId === 'string' && /^[0-9a-f-]{36}$/i.test(value.runId)
|
|
51
|
+
&& Number.isInteger(value.ownerFence) && value.ownerFence >= 0 ? { ...value, policy } : undefined;
|
|
52
|
+
}
|
|
53
|
+
function memberRecord(state, runId, sessionId) {
|
|
54
|
+
if (typeof runId !== 'string' || !/^[0-9a-f-]{36}$/i.test(runId))
|
|
55
|
+
throw new Error('member_policy_record_invalid');
|
|
56
|
+
const record = JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', `${runId}.json`), 'utf8'));
|
|
57
|
+
if (record.runId !== runId || record.sessionId !== sessionId || !Number.isInteger(record.ownerFence)
|
|
58
|
+
|| typeof record.holding !== 'boolean' || typeof record.writersStopped !== 'boolean')
|
|
59
|
+
throw new Error('member_policy_record_invalid');
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
function sessionMembers(state, sessionId) {
|
|
63
|
+
return readdirSync(join(state.config.stateDir, 'batch-members')).filter(name => name.endsWith('.json'))
|
|
64
|
+
.map(name => JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', name), 'utf8')))
|
|
65
|
+
.filter(record => record.sessionId === sessionId)
|
|
66
|
+
.map(record => memberRecord(state, record.runId, sessionId));
|
|
67
|
+
}
|
|
68
|
+
/** Re-read the owning Run on every lookup; a cached policy is never proof of an active member. */
|
|
43
69
|
export function trustedMemberPolicy(state, sessionId) {
|
|
44
|
-
const cached = memory.get(state)?.get(sessionId);
|
|
45
|
-
if (cached)
|
|
46
|
-
return cached;
|
|
47
70
|
try {
|
|
48
|
-
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
let binding = memory.get(state)?.get(sessionId);
|
|
72
|
+
if (!binding) {
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFileSync(policyPath(state, sessionId), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === 'ENOENT')
|
|
79
|
+
return undefined;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
binding = readBinding(raw);
|
|
83
|
+
if (!binding)
|
|
84
|
+
throw new Error('member_policy_invalid');
|
|
85
|
+
const map = memory.get(state) ?? new Map();
|
|
86
|
+
map.set(sessionId, binding);
|
|
87
|
+
memory.set(state, map);
|
|
55
88
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
if (binding.runId) {
|
|
90
|
+
const record = memberRecord(state, binding.runId, sessionId);
|
|
91
|
+
if (record.holding || !record.writersStopped) {
|
|
92
|
+
if (record.ownerFence !== binding.ownerFence)
|
|
93
|
+
throw new Error('member_policy_owner_mismatch');
|
|
94
|
+
return binding.policy;
|
|
95
|
+
}
|
|
96
|
+
if (sessionMembers(state, sessionId).some(member => member.holding || !member.writersStopped))
|
|
97
|
+
throw new Error('member_policy_owner_mismatch');
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
// Profiles written before Run ownership was recorded still use durable member facts.
|
|
101
|
+
const records = sessionMembers(state, sessionId);
|
|
102
|
+
if (!records.length)
|
|
103
|
+
throw new Error('member_policy_record_missing');
|
|
104
|
+
const active = records.filter(record => record.holding || !record.writersStopped);
|
|
105
|
+
if (!active.length)
|
|
63
106
|
return undefined;
|
|
107
|
+
if (active.length !== 1 || binding.policy.sandboxPolicy.type !== 'workspaceWrite'
|
|
108
|
+
|| !binding.policy.sandboxPolicy.writableRoots.includes(join(state.config.stateDir, 'batch-temp', active[0].runId)))
|
|
109
|
+
throw new Error('member_policy_owner_mismatch');
|
|
110
|
+
return binding.policy;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
64
113
|
return {
|
|
65
114
|
cwd: state.config.workspaceRoot,
|
|
66
115
|
approvalPolicy: 'never',
|
|
@@ -3,12 +3,15 @@ import { lstat, mkdir, readFile, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { sandboxBatchMemberPrepareSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
|
+
import { writeWorkspaceCliContext } from '../workspaces/cli-context.js';
|
|
6
8
|
import { registerWorkspaceDirectory } from '../workspaces/directories.js';
|
|
7
9
|
import { workspaceContainerRoot } from '../workspaces/registry.js';
|
|
8
10
|
import { dependencyInstallCommand, planInstall } from '../workspaces/dependencies.js';
|
|
9
11
|
import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
|
|
10
12
|
import { restoreArchive } from './source-capture.js';
|
|
11
13
|
import { groupExited, runProcessGroup } from './processes.js';
|
|
14
|
+
import { stopMemberWriters } from './writer-proof.js';
|
|
12
15
|
import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
|
|
13
16
|
const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
|
|
14
17
|
function hashCommand(value) {
|
|
@@ -143,111 +146,123 @@ export async function prepareBatchMember(state, body, options) {
|
|
|
143
146
|
if (existing?.processes.some((process) => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
|
|
144
147
|
return memberStatus(existing);
|
|
145
148
|
const snapshot = command.snapshot;
|
|
146
|
-
|
|
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
|
+
}
|