@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
|
@@ -1,13 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
|
-
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
|
+
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
5
8
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
6
9
|
import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
10
|
+
import { stopMemberWriters } from './writer-proof.js';
|
|
7
11
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
8
12
|
/** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
|
|
9
13
|
function buildCommand() {
|
|
10
|
-
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
14
|
+
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
15
|
+
?? 'umask 002 && CI=1 playcraft build --mode base --output dist .');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The batch build runs as the runtime identity while the agent wrote the tree as the development
|
|
19
|
+
* identity. Directories the agent created private (browser profiles, tool caches) are unreadable to
|
|
20
|
+
* the build user, and the build's pre-build scan fails hard on the first one — which is how a
|
|
21
|
+
* delivered-looking member used to end as `batch_build_failed`. Only the owning identity may chmod,
|
|
22
|
+
* so the repair has to run through the launcher rather than the runtime user.
|
|
23
|
+
*/
|
|
24
|
+
const PERMISSION_REPAIR_COMMAND = [
|
|
25
|
+
'find . \\( -path ./node_modules -o -path ./.git \\) -prune -o',
|
|
26
|
+
'\\( -type d \\( -not -perm -040 -o -not -perm -010 \\) -o -type f -not -perm -040 \\)',
|
|
27
|
+
'-exec chmod g+rX {} +',
|
|
28
|
+
].join(' ');
|
|
29
|
+
const BUILD_LOG_LIMIT = 64 * 1024;
|
|
30
|
+
/** Best effort: a failed repair is recorded in the log and never hides the build's own error. */
|
|
31
|
+
async function repairGroupAccess(state, root, runId) {
|
|
32
|
+
let output = '';
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await runDeveloperCommand(state, {
|
|
35
|
+
processId: `batch-deliver-repair-${runId}-${randomUUID()}`,
|
|
36
|
+
cwd: root,
|
|
37
|
+
command: PERMISSION_REPAIR_COMMAND,
|
|
38
|
+
timeoutMs: 30_000,
|
|
39
|
+
onOutput: (chunk) => {
|
|
40
|
+
output = (output + String(chunk)).slice(-BUILD_LOG_LIMIT);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (outcome.code !== 0)
|
|
44
|
+
output += `\n[repair] exit ${outcome.code ?? 'unknown'}${outcome.timedOut ? ' (timeout)' : ''}`;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
output += `\n[repair] failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
48
|
+
}
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
/** Keeps the build's own stderr next to the run so a failure is diagnosable without a re-run. */
|
|
52
|
+
async function writeBuildLog(state, runId, text) {
|
|
53
|
+
const directory = join(state.config.stateDir, 'batch-temp', runId);
|
|
54
|
+
await mkdir(directory, { recursive: true });
|
|
55
|
+
await writeFile(join(directory, 'build.log'), text.slice(-BUILD_LOG_LIMIT));
|
|
56
|
+
}
|
|
57
|
+
/** A build command that writes nothing must not inherit an earlier run's index.html as success. */
|
|
58
|
+
async function clearBuiltHtml(root) {
|
|
59
|
+
await rm(join(root, 'dist', 'index.html'), { force: true }).catch(() => undefined);
|
|
11
60
|
}
|
|
12
61
|
async function readBuiltHtml(root) {
|
|
13
62
|
const htmlPath = join(root, 'dist', 'index.html');
|
|
@@ -67,86 +116,97 @@ export async function deliverBatchMember(state, body) {
|
|
|
67
116
|
existing.state === 'completed') {
|
|
68
117
|
throw new Error('batch_command_conflict');
|
|
69
118
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (await readStopMarker(state, command.runId))
|
|
98
|
-
return memberStatus(await markStopped(state, command, record));
|
|
99
|
-
const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
|
|
100
|
-
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
101
|
-
});
|
|
102
|
-
record = await writeMember(state, {
|
|
103
|
-
...record,
|
|
104
|
-
processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
|
|
119
|
+
await claimBatchDirectory(state, command);
|
|
120
|
+
return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
|
|
121
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
122
|
+
let record = await writeMember(state, {
|
|
123
|
+
runId: command.runId,
|
|
124
|
+
ownerFence: command.ownerFence,
|
|
125
|
+
occupancyId: command.occupancyId,
|
|
126
|
+
workspaceId: command.workspaceId,
|
|
127
|
+
directoryName: command.directoryName,
|
|
128
|
+
phase: 'deliver',
|
|
129
|
+
state: 'running',
|
|
130
|
+
writersStopped: false,
|
|
131
|
+
holding: true,
|
|
132
|
+
stopRequested: false,
|
|
133
|
+
processes: existing?.processes ?? [],
|
|
134
|
+
sessionId: existing?.sessionId,
|
|
135
|
+
operationId: existing?.operationId,
|
|
136
|
+
commandHash: { ...existing?.commandHash, deliver: hash },
|
|
137
|
+
snapshotDigest: existing?.snapshotDigest,
|
|
138
|
+
branchName: existing?.branchName,
|
|
139
|
+
updatedAt: new Date().toISOString(),
|
|
140
|
+
sourceCommit: existing?.sourceCommit ??
|
|
141
|
+
state.journal
|
|
142
|
+
.getWorkspace(command.workspaceId)
|
|
143
|
+
?.directories?.find((directory) => directory.directoryName === command.directoryName)
|
|
144
|
+
?.sourceCommit,
|
|
145
|
+
beforeTree: existing?.beforeTree,
|
|
105
146
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
147
|
+
try {
|
|
148
|
+
if (await readStopMarker(state, command.runId))
|
|
149
|
+
return memberStatus(await markStopped(state, command, record));
|
|
150
|
+
const repairOutput = await repairGroupAccess(state, root, command.runId);
|
|
151
|
+
await clearBuiltHtml(root);
|
|
152
|
+
const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
|
|
153
|
+
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
154
|
+
});
|
|
155
|
+
record = await writeMember(state, {
|
|
114
156
|
...record,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
|
|
158
|
+
});
|
|
159
|
+
if (await readStopMarker(state, command.runId))
|
|
160
|
+
return memberStatus(await markStopped(state, command, record));
|
|
161
|
+
const buildLog = [repairOutput, built.output].filter(Boolean).join('\n');
|
|
162
|
+
if (built.code !== 0) {
|
|
163
|
+
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
164
|
+
const tail = built.output.trim().split('\n').slice(-3).join(' | ').slice(0, 400);
|
|
165
|
+
if (tail)
|
|
166
|
+
console.warn(`[workspace-runtime] batch build failed run=${command.runId} code=${built.code}: ${tail}`);
|
|
167
|
+
throw new Error('batch_build_failed');
|
|
168
|
+
}
|
|
169
|
+
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
170
|
+
await readBuiltHtml(root);
|
|
171
|
+
const writersStopped = await prove(record);
|
|
172
|
+
if (!writersStopped)
|
|
173
|
+
return memberStatus(await writeMember(state, {
|
|
174
|
+
...record,
|
|
175
|
+
state: 'stopping',
|
|
176
|
+
writersStopped: false,
|
|
177
|
+
holding: true,
|
|
178
|
+
}));
|
|
179
|
+
if (await readStopMarker(state, command.runId))
|
|
180
|
+
return memberStatus(await markStopped(state, command, record));
|
|
181
|
+
record = await writeMember(state, {
|
|
182
|
+
...record,
|
|
183
|
+
state: 'completed',
|
|
184
|
+
writersStopped: true,
|
|
185
|
+
holding: false,
|
|
186
|
+
error: undefined,
|
|
187
|
+
evidence: { verified: true },
|
|
188
|
+
});
|
|
189
|
+
return memberStatus(record);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
193
|
+
? error.message
|
|
194
|
+
: 'batch_deliver_failed';
|
|
195
|
+
const writersStopped = await prove(record);
|
|
196
|
+
record = await writeMember(state, {
|
|
197
|
+
...record,
|
|
198
|
+
state: writersStopped ? 'failed' : 'stopping',
|
|
199
|
+
writersStopped,
|
|
200
|
+
holding: !writersStopped,
|
|
201
|
+
error: code,
|
|
202
|
+
});
|
|
203
|
+
return memberStatus(record);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
145
206
|
});
|
|
146
207
|
}
|
|
147
208
|
async function markStopped(state, command, existing) {
|
|
148
|
-
|
|
149
|
-
await prove(existing);
|
|
209
|
+
const writersStopped = await stopMemberWriters(state, existing);
|
|
150
210
|
return writeMember(state, {
|
|
151
211
|
runId: command.runId,
|
|
152
212
|
ownerFence: command.ownerFence,
|
|
@@ -154,9 +214,9 @@ async function markStopped(state, command, existing) {
|
|
|
154
214
|
workspaceId: command.workspaceId,
|
|
155
215
|
directoryName: command.directoryName,
|
|
156
216
|
phase: 'deliver',
|
|
157
|
-
state: 'stopped',
|
|
158
|
-
writersStopped
|
|
159
|
-
holding:
|
|
217
|
+
state: writersStopped ? 'stopped' : 'stopping',
|
|
218
|
+
writersStopped,
|
|
219
|
+
holding: !writersStopped,
|
|
160
220
|
stopRequested: true,
|
|
161
221
|
processes: existing?.processes ?? [],
|
|
162
222
|
sessionId: existing?.sessionId,
|
|
@@ -9,6 +9,7 @@ import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, w
|
|
|
9
9
|
import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from './source-candidate.js';
|
|
10
10
|
import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
|
|
11
11
|
import { publishSourceCandidate } from './source-publish.js';
|
|
12
|
+
import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
|
|
12
13
|
async function usagePercent(path) {
|
|
13
14
|
const disk = await statfs(path);
|
|
14
15
|
if (disk.blocks <= 0)
|
|
@@ -21,11 +22,8 @@ async function stopManagedWriters(state, workspaceId, directoryName) {
|
|
|
21
22
|
for (const write of state.journal.listManagedWrites()) {
|
|
22
23
|
if (write.stoppedAt || write.workspaceId !== scope)
|
|
23
24
|
continue;
|
|
24
|
-
if (write.pid
|
|
25
|
-
|
|
26
|
-
if (!stopped)
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
25
|
+
if (write.pid === undefined || !await stopBackgroundWriter(write.pid))
|
|
26
|
+
continue;
|
|
29
27
|
await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
|
|
30
28
|
}
|
|
31
29
|
}
|
|
@@ -34,7 +32,14 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
34
32
|
if (!parsed.success)
|
|
35
33
|
throw new Error('invalid_batch_request');
|
|
36
34
|
const request = parsed.data;
|
|
37
|
-
|
|
35
|
+
const frozen = await readSnapshot(state, request.batchId);
|
|
36
|
+
if (frozen) {
|
|
37
|
+
assertSourceIdentity(state, request, frozen, await readSourceCandidate(state, request.batchId));
|
|
38
|
+
await clearDirectoryFreeze(state, request.batchId);
|
|
39
|
+
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
|
|
40
|
+
}
|
|
41
|
+
return withDirectoryFreeze(state, { workspaceId: request.workspaceId,
|
|
42
|
+
directoryName: request.sourceDirectoryName, batchId: request.batchId }, () => serializeFreeze(async () => {
|
|
38
43
|
let candidate;
|
|
39
44
|
let source;
|
|
40
45
|
try {
|
|
@@ -60,6 +65,7 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
60
65
|
const cache = sourceCacheDir(state, request.batchId);
|
|
61
66
|
await rm(cache, { recursive: true, force: true });
|
|
62
67
|
const captured = await captureIndependentSource(source, cache);
|
|
68
|
+
// private
|
|
63
69
|
await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
|
|
64
70
|
const binding = state.journal.getWorkspace(request.workspaceId);
|
|
65
71
|
const snapshot = {
|
|
@@ -97,5 +103,11 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
97
103
|
throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
|
|
98
104
|
}
|
|
99
105
|
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: candidate.snapshot });
|
|
106
|
+
}), () => stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName), {
|
|
107
|
+
signal: options?.signal, deadline: 'deadline' in request && typeof request.deadline === 'string' ? request.deadline : undefined,
|
|
108
|
+
}).catch(async (error) => {
|
|
109
|
+
if (error instanceof Error && error.message === 'directory_access_cancelled')
|
|
110
|
+
throw await sourceFreezeError(state, request.batchId, { code: 'source_freeze_timeout', stage: 'capture', retryable: false, outcome: 'not_published' });
|
|
111
|
+
throw error;
|
|
100
112
|
});
|
|
101
113
|
}
|
|
@@ -4,12 +4,14 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
|
|
7
|
+
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
8
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
8
9
|
import { snapshotWorkspaceTree } from '../workspaces/git.js';
|
|
9
10
|
import { requireBatchContentChanges } from './content-changes.js';
|
|
10
11
|
import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
|
|
11
12
|
import { groupExited, stopProcessGroup } from './processes.js';
|
|
12
13
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
|
|
14
|
+
import { stopMemberWriters } from './writer-proof.js';
|
|
13
15
|
function hashCommand(value) {
|
|
14
16
|
return hashOperationPayload(value);
|
|
15
17
|
}
|
|
@@ -74,63 +76,69 @@ export async function generateBatchMember(state, body) {
|
|
|
74
76
|
}
|
|
75
77
|
if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.generate && existing.commandHash.generate !== hash)
|
|
76
78
|
throw new Error('batch_command_conflict');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!threadId || !terminal)
|
|
111
|
-
return;
|
|
112
|
-
if (terminals.get(threadId) === 'needs_user')
|
|
113
|
-
return;
|
|
114
|
-
terminals.set(threadId, terminal);
|
|
115
|
-
});
|
|
116
|
-
try {
|
|
117
|
-
if (!state.journal.getSession(sessionId)) {
|
|
118
|
-
const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
|
|
119
|
-
if (!state.journal.getOperation(created.scopeKey))
|
|
120
|
-
await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
|
|
79
|
+
if (existing && !existing.holding && existing.writersStopped)
|
|
80
|
+
return memberStatus(existing);
|
|
81
|
+
await claimBatchDirectory(state, command);
|
|
82
|
+
return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
|
|
83
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
84
|
+
const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
|
|
85
|
+
const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
|
|
86
|
+
// private
|
|
87
|
+
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
const policy = memberExecutionPolicy(root, tempDir);
|
|
89
|
+
await bindMemberPolicy(state, sessionId, policy);
|
|
90
|
+
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
91
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
|
|
92
|
+
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
93
|
+
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
94
|
+
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
95
|
+
throw new Error('batch_command_conflict');
|
|
96
|
+
const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
|
|
97
|
+
const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
|
|
98
|
+
let record = await writeMember(state, {
|
|
99
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
100
|
+
directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
|
|
101
|
+
writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
|
|
102
|
+
operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
|
|
103
|
+
snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
|
|
104
|
+
sourceCommit, beforeTree,
|
|
105
|
+
updatedAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
if (prior)
|
|
108
|
+
return memberStatus(await finishGenerate(state, record, prior, root));
|
|
109
|
+
if (!state.native?.ready()) {
|
|
110
|
+
record = await writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: 'capability_unavailable' });
|
|
111
|
+
return memberStatus(record);
|
|
121
112
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
113
|
+
const terminals = new Map();
|
|
114
|
+
const unsubscribe = state.native.onCollected?.((event) => {
|
|
115
|
+
const threadId = threadIdOf(event);
|
|
116
|
+
const terminal = turnTerminal(event);
|
|
117
|
+
if (!threadId || !terminal)
|
|
118
|
+
return;
|
|
119
|
+
if (terminals.get(threadId) === 'needs_user')
|
|
120
|
+
return;
|
|
121
|
+
terminals.set(threadId, terminal);
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
if (!state.journal.getSession(sessionId)) {
|
|
125
|
+
const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
|
|
126
|
+
if (!state.journal.getOperation(created.scopeKey))
|
|
127
|
+
await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
|
|
128
|
+
}
|
|
129
|
+
if (await readStopMarker(state, command.runId))
|
|
130
|
+
return memberStatus(await stoppedRecord(state, command, record));
|
|
131
|
+
const started = await executeOperation(state, turnKey.scopeKey, turnKey.record, turnPayload);
|
|
132
|
+
record = await writeMember(state, { ...record, operationId: started.operationId, sessionId });
|
|
133
|
+
return memberStatus(await finishGenerate(state, record, started, root, () => {
|
|
134
|
+
const threadId = state.journal.getSession(sessionId)?.threadId;
|
|
135
|
+
return threadId ? terminals.get(threadId) : undefined;
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
unsubscribe?.();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
async function finishGenerate(state, record, started, root, early) {
|
|
@@ -142,7 +150,7 @@ async function finishGenerate(state, record, started, root, early) {
|
|
|
142
150
|
return writeMember(state, { ...record, state: 'unknown', writersStopped: false, holding: true, operationId: started.operationId, error: 'native_receipt_lost' });
|
|
143
151
|
}
|
|
144
152
|
if (!terminal && started.status === 'failed') {
|
|
145
|
-
const writersStopped = await
|
|
153
|
+
const writersStopped = await stopMemberWriters(state, record);
|
|
146
154
|
return writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, operationId: started.operationId, error: started.error ?? 'batch_turn_failed' });
|
|
147
155
|
}
|
|
148
156
|
const deadline = Date.now() + 5_000;
|
|
@@ -161,10 +169,7 @@ async function finishGenerate(state, record, started, root, early) {
|
|
|
161
169
|
await delay(15);
|
|
162
170
|
}
|
|
163
171
|
if (terminal === 'needs_user') {
|
|
164
|
-
const
|
|
165
|
-
if (session?.threadId)
|
|
166
|
-
await state.native?.interrupt?.({ sessionId, threadId: session.threadId }).catch(() => undefined);
|
|
167
|
-
const writersStopped = await proveStopped(record);
|
|
172
|
+
const writersStopped = await stopMemberWriters(state, record);
|
|
168
173
|
if (await readStopMarker(state, record.runId))
|
|
169
174
|
return stoppedRecord(state, record, record);
|
|
170
175
|
return writeMember(state, {
|
|
@@ -236,11 +241,10 @@ async function completeGeneration(state, record, root) {
|
|
|
236
241
|
return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, headCommit: head, error: undefined });
|
|
237
242
|
}
|
|
238
243
|
async function stoppedRecord(state, command, existing) {
|
|
239
|
-
|
|
240
|
-
await proveStopped(existing);
|
|
244
|
+
const writersStopped = await stopMemberWriters(state, existing);
|
|
241
245
|
return writeMember(state, {
|
|
242
246
|
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
243
|
-
directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: 'stopped'
|
|
247
|
+
directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: writersStopped ? 'stopped' : 'stopping', writersStopped, holding: !writersStopped,
|
|
244
248
|
stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
|
|
245
249
|
headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
|
|
246
250
|
branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|