@playcraft/cli 0.1.80 → 0.1.82
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/apk-replication/client.js +285 -0
- package/dist/apk-replication/messages.js +75 -0
- package/dist/apk-replication/summary.js +122 -0
- package/dist/commands/apk.js +67 -0
- package/dist/index.js +7 -3
- package/dist/recording/project-digest.js +1 -1
- package/dist/utils/agent-api-client.js +15 -0
- package/dist/utils/video-evidence-git.js +12 -7
- package/dist/workspace-runtime/batch/deliver.js +110 -38
- package/dist/workspace-runtime/batch/generate.js +42 -10
- package/dist/workspace-runtime/batch/policy.js +4 -2
- package/dist/workspace-runtime/batch/processes.js +1 -1
- package/dist/workspace-runtime/batch/records.js +4 -1
- package/dist/workspace-runtime/batch/source-capture.js +104 -4
- package/dist/workspace-runtime/codex/app-server.js +4 -2
- package/dist/workspace-runtime/codex/model-broker.js +1 -21
- package/dist/workspace-runtime/codex/permissions.js +8 -1
- package/dist/workspace-runtime/codex/remix-references-broker.js +2 -1
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +1 -1
- package/dist/workspace-runtime/workspaces/archive.js +1 -1
- package/dist/workspace-runtime/workspaces/cli-context.js +1 -0
- package/dist/workspace-runtime/workspaces/transfer.js +1 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +1 -0
- package/package.json +3 -3
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { lstat, mkdir, open, realpath } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
|
|
4
|
+
export function ensureVideoEvidenceGitExcluded(directory) {
|
|
5
|
+
return ensureGitExcluded(directory, '/.playcraft/video-replication/');
|
|
6
|
+
}
|
|
7
|
+
export function ensureApkEvidenceGitExcluded(directory) {
|
|
8
|
+
return ensureGitExcluded(directory, '/.playcraft/apk-replication/');
|
|
9
|
+
}
|
|
5
10
|
/** Keep local analysis evidence out of a repository without changing its tracked ignore rules. */
|
|
6
|
-
|
|
11
|
+
async function ensureGitExcluded(directory, entry) {
|
|
7
12
|
let root = await realpath(directory);
|
|
8
13
|
while (true) {
|
|
9
14
|
const marker = await lstat(join(root, '.git')).catch((error) => {
|
|
@@ -16,7 +21,7 @@ export async function ensureVideoEvidenceGitExcluded(directory) {
|
|
|
16
21
|
if (!marker.isDirectory() || marker.isSymbolicLink())
|
|
17
22
|
return false;
|
|
18
23
|
try {
|
|
19
|
-
await appendExclude(root);
|
|
24
|
+
await appendExclude(root, entry);
|
|
20
25
|
return true;
|
|
21
26
|
}
|
|
22
27
|
catch (error) {
|
|
@@ -31,7 +36,7 @@ export async function ensureVideoEvidenceGitExcluded(directory) {
|
|
|
31
36
|
root = parent;
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
|
-
async function appendExclude(root) {
|
|
39
|
+
async function appendExclude(root, entry) {
|
|
35
40
|
const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
36
41
|
const handles = [];
|
|
37
42
|
let file;
|
|
@@ -57,7 +62,7 @@ async function appendExclude(root) {
|
|
|
57
62
|
const stat = await readable.stat();
|
|
58
63
|
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
|
|
59
64
|
throw new Error('invalid_git_exclude');
|
|
60
|
-
if ((await readable.readFile('utf8')).split('\n').some(line => line.trim() ===
|
|
65
|
+
if ((await readable.readFile('utf8')).split('\n').some(line => line.trim() === entry))
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
68
|
finally {
|
|
@@ -69,8 +74,8 @@ async function appendExclude(root) {
|
|
|
69
74
|
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
|
|
70
75
|
throw new Error('invalid_git_exclude');
|
|
71
76
|
const current = await file.readFile('utf8');
|
|
72
|
-
if (!current.split('\n').some(line => line.trim() ===
|
|
73
|
-
await file.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}${
|
|
77
|
+
if (!current.split('\n').some(line => line.trim() === entry)) {
|
|
78
|
+
await file.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
81
|
finally {
|
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { mkdir, mkdtemp, open, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { hashOperationPayload, PREVIEW_HTML_MAX_BYTES, sandboxBatchMemberDeliverSchema, WORKSPACE_PLAYABLE_OUTPUT_PATH, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { finalizeWorkspacePlayable, validateWorkspacePlayable } from '../previews/finalize.js';
|
|
6
8
|
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
|
-
import {
|
|
9
|
+
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
10
|
+
import { artifactPath } from '../workspaces/artifact-files.js';
|
|
11
|
+
import { markAudioBuildInputs, verifyAudioBuildInputs } from '../workspaces/audio-assets/build-inputs.js';
|
|
12
|
+
import { isolateWorkspaceForBuild } from '../workspaces/build-isolation.js';
|
|
13
|
+
import { launcherSocket, runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
14
|
+
import { openDirectoryToDevelopment, openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
8
15
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
16
|
+
import { workspaceGroup, writeSharedFile } from '../workspaces/shared-write.js';
|
|
17
|
+
import { withShareRepair } from '../workspaces/workspace-shares.js';
|
|
9
18
|
import { processHasExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
10
19
|
import { stopMemberWriters } from './writer-proof.js';
|
|
11
20
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
12
|
-
|
|
21
|
+
const DELIVERY_OUTPUT_PATH = 'dist/index.html';
|
|
22
|
+
/** Compile the review playable once in a fresh copy. Keep the existing custom builder contract. */
|
|
13
23
|
function buildCommand() {
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
const custom = process.env.PLAYCRAFT_BATCH_BUILD_COMMAND;
|
|
25
|
+
const outputPath = custom ? DELIVERY_OUTPUT_PATH : WORKSPACE_PLAYABLE_OUTPUT_PATH;
|
|
26
|
+
const build = custom || 'umask 002 && CI=1 PLAYCRAFT_BUILD_ISOLATED=1 playcraft build .';
|
|
27
|
+
const directory = dirname(outputPath);
|
|
28
|
+
return {
|
|
29
|
+
outputPath,
|
|
30
|
+
command: [
|
|
31
|
+
`(${build})`,
|
|
32
|
+
`test ! -L dist && test ! -L ${directory} && test ! -L ${outputPath}`,
|
|
33
|
+
`chmod g+rX dist ${directory} ${outputPath}`,
|
|
34
|
+
].join(' && '),
|
|
35
|
+
};
|
|
16
36
|
}
|
|
17
37
|
/**
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
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.
|
|
38
|
+
* The controller also reads the member tree. Only the development identity can grant group access
|
|
39
|
+
* to the private files and directories it created.
|
|
23
40
|
*/
|
|
24
41
|
const PERMISSION_REPAIR_COMMAND = [
|
|
25
42
|
'find . \\( -path ./node_modules -o -path ./.git \\) -prune -o',
|
|
@@ -32,6 +49,16 @@ const BUILD_LOG_LIMIT = 64 * 1024;
|
|
|
32
49
|
async function repairGroupAccess(state, root, runId) {
|
|
33
50
|
let output = '';
|
|
34
51
|
try {
|
|
52
|
+
if (launcherSocket(state)) {
|
|
53
|
+
// Earlier deliveries built as the controller, leaving these caches owned by it.
|
|
54
|
+
const group = await workspaceGroup(root);
|
|
55
|
+
for (const directory of ['.cache', 'temp', 'library', 'build', 'dist']) {
|
|
56
|
+
await openDirectoryToDevelopment(join(root, directory), group).catch(error => {
|
|
57
|
+
if (error.code !== 'ENOENT')
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
35
62
|
const outcome = await runDeveloperCommand(state, {
|
|
36
63
|
processId: `batch-deliver-repair-${runId}-${randomUUID()}`,
|
|
37
64
|
cwd: root,
|
|
@@ -55,19 +82,24 @@ async function writeBuildLog(state, runId, text) {
|
|
|
55
82
|
await mkdir(directory, { recursive: true });
|
|
56
83
|
await writeFile(join(directory, 'build.log'), text.slice(-BUILD_LOG_LIMIT));
|
|
57
84
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
async function readBuiltHtml(root, path) {
|
|
86
|
+
try {
|
|
87
|
+
const handle = await open(await artifactPath(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
88
|
+
try {
|
|
89
|
+
const stat = await handle.stat();
|
|
90
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > PREVIEW_HTML_MAX_BYTES)
|
|
91
|
+
throw new Error('preview_not_single_file');
|
|
92
|
+
const bytes = await handle.readFile();
|
|
93
|
+
validateWorkspacePlayable(bytes);
|
|
94
|
+
return bytes;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await handle.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
68
101
|
throw new Error('batch_build_failed');
|
|
69
|
-
}
|
|
70
|
-
await handle.close();
|
|
102
|
+
}
|
|
71
103
|
}
|
|
72
104
|
async function prove(record) {
|
|
73
105
|
let stopped = true;
|
|
@@ -97,9 +129,9 @@ export async function deliverBatchMember(state, body) {
|
|
|
97
129
|
const existing = await readMember(state, command.runId);
|
|
98
130
|
if (existing && command.ownerFence < existing.ownerFence)
|
|
99
131
|
throw new Error('stale_owner');
|
|
100
|
-
if (existing?.
|
|
101
|
-
existing.
|
|
102
|
-
existing.evidence &&
|
|
132
|
+
if (existing?.phase === 'deliver' &&
|
|
133
|
+
existing.writersStopped &&
|
|
134
|
+
(existing.state === 'failed' || (existing.state === 'completed' && existing.evidence)) &&
|
|
103
135
|
existing.commandHash?.deliver === hash &&
|
|
104
136
|
existing.ownerFence === command.ownerFence) {
|
|
105
137
|
return memberStatus(existing);
|
|
@@ -110,6 +142,10 @@ export async function deliverBatchMember(state, body) {
|
|
|
110
142
|
return memberStatus(existing);
|
|
111
143
|
if (await readStopMarker(state, command.runId))
|
|
112
144
|
return memberStatus(await markStopped(state, command, existing));
|
|
145
|
+
if (existing?.phase === 'generate' && !existing.writersStopped)
|
|
146
|
+
return memberStatus(existing);
|
|
147
|
+
if (existing?.processes.some(process => process.role === 'build' && !processHasExited(process)))
|
|
148
|
+
return memberStatus(existing);
|
|
113
149
|
if (existing &&
|
|
114
150
|
existing.ownerFence === command.ownerFence &&
|
|
115
151
|
existing.commandHash?.deliver &&
|
|
@@ -133,6 +169,7 @@ export async function deliverBatchMember(state, body) {
|
|
|
133
169
|
stopRequested: false,
|
|
134
170
|
processes: existing?.processes ?? [],
|
|
135
171
|
sessionId: existing?.sessionId,
|
|
172
|
+
scratchDir: existing?.scratchDir,
|
|
136
173
|
operationId: existing?.operationId,
|
|
137
174
|
evidence: existing?.evidence,
|
|
138
175
|
commandHash: { ...existing?.commandHash, deliver: hash },
|
|
@@ -146,30 +183,51 @@ export async function deliverBatchMember(state, body) {
|
|
|
146
183
|
?.sourceCommit,
|
|
147
184
|
beforeTree: existing?.beforeTree,
|
|
148
185
|
});
|
|
186
|
+
let buildLog = '';
|
|
149
187
|
try {
|
|
150
188
|
if (await readStopMarker(state, command.runId))
|
|
151
189
|
return memberStatus(await markStopped(state, command, record));
|
|
152
|
-
|
|
153
|
-
await
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
190
|
+
buildLog = await repairGroupAccess(state, root, command.runId);
|
|
191
|
+
const group = await workspaceGroup(root);
|
|
192
|
+
const scratch = record.scratchDir ?? await mkdtemp(join(tmpdir(), 'playcraft-batch-'));
|
|
193
|
+
if (!record.scratchDir) {
|
|
194
|
+
try {
|
|
195
|
+
await openScratchToDevelopment(scratch, group);
|
|
196
|
+
record = await writeMember(state, { ...record, scratchDir: scratch });
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Scratch remains recorded until the run proves all writers stopped, including on cancellation.
|
|
204
|
+
const project = await mkdtemp(join(scratch, 'delivery-'));
|
|
205
|
+
const audioInputs = await isolateWorkspaceForBuild(root, project);
|
|
206
|
+
// The controller owns the copied files. Cocos updates metadata throughout the copy as the development user.
|
|
207
|
+
await openDirectoryToDevelopment(project, group);
|
|
208
|
+
if (await readStopMarker(state, command.runId))
|
|
209
|
+
return memberStatus(await markStopped(state, command, record));
|
|
210
|
+
const saveProcess = async (process) => {
|
|
211
|
+
const others = record.processes.filter(item => item.role !== 'build' || (process.launcher
|
|
212
|
+
? item.launcher?.processId !== process.launcher.processId
|
|
213
|
+
: item.pid !== process.pid));
|
|
214
|
+
record = await writeMember(state, { ...record, processes: [...others, process] });
|
|
215
|
+
};
|
|
216
|
+
const build = buildCommand();
|
|
217
|
+
const built = await runProcessGroup(project, build.command, 'build', saveProcess, {
|
|
218
|
+
state, processId: `batch-build-${command.runId}-${randomUUID()}`,
|
|
160
219
|
});
|
|
220
|
+
await saveProcess(built.process);
|
|
221
|
+
buildLog = [buildLog, built.output].filter(Boolean).join('\n');
|
|
161
222
|
if (await readStopMarker(state, command.runId))
|
|
162
223
|
return memberStatus(await markStopped(state, command, record));
|
|
163
|
-
const buildLog = [repairOutput, built.output].filter(Boolean).join('\n');
|
|
164
224
|
if (built.code !== 0) {
|
|
165
|
-
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
166
225
|
const tail = built.output.trim().split('\n').slice(-3).join(' | ').slice(0, 400);
|
|
167
226
|
if (tail)
|
|
168
227
|
console.warn(`[workspace-runtime] batch build failed run=${command.runId} code=${built.code}: ${tail}`);
|
|
169
228
|
throw new Error('batch_build_failed');
|
|
170
229
|
}
|
|
171
230
|
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
172
|
-
await readBuiltHtml(root);
|
|
173
231
|
const writersStopped = await prove(record);
|
|
174
232
|
if (!writersStopped)
|
|
175
233
|
return memberStatus(await writeMember(state, {
|
|
@@ -177,9 +235,20 @@ export async function deliverBatchMember(state, body) {
|
|
|
177
235
|
state: 'stopping',
|
|
178
236
|
writersStopped: false,
|
|
179
237
|
holding: true,
|
|
238
|
+
error: 'batch_build_failed',
|
|
180
239
|
}));
|
|
240
|
+
const html = await readBuiltHtml(project, build.outputPath);
|
|
241
|
+
await verifyAudioBuildInputs(root, audioInputs);
|
|
181
242
|
if (await readStopMarker(state, command.runId))
|
|
182
243
|
return memberStatus(await markStopped(state, command, record));
|
|
244
|
+
// Keep the delivery entry point and register the same build as the current workspace preview.
|
|
245
|
+
await withShareRepair(state, root, () => writeSharedFile(root, join(root, DELIVERY_OUTPUT_PATH), html));
|
|
246
|
+
const source = {
|
|
247
|
+
sessionId: record.sessionId,
|
|
248
|
+
operationId: `batch-delivery:${command.runId}:${state.config.generation}`,
|
|
249
|
+
};
|
|
250
|
+
await withShareRepair(state, root, () => finalizeWorkspacePlayable(root, markAudioBuildInputs(html, audioInputs), 'batch delivery', { source }));
|
|
251
|
+
await refreshWorkspaceArtifacts(state, command.workspaceId, undefined, command.directoryName);
|
|
183
252
|
record = await writeMember(state, {
|
|
184
253
|
...record,
|
|
185
254
|
state: 'completed',
|
|
@@ -191,6 +260,8 @@ export async function deliverBatchMember(state, body) {
|
|
|
191
260
|
return memberStatus(record);
|
|
192
261
|
}
|
|
193
262
|
catch (error) {
|
|
263
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
264
|
+
await writeBuildLog(state, command.runId, `${buildLog}\n[build] ${detail}`).catch(() => undefined);
|
|
194
265
|
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
195
266
|
? error.message
|
|
196
267
|
: 'batch_deliver_failed';
|
|
@@ -222,6 +293,7 @@ async function markStopped(state, command, existing) {
|
|
|
222
293
|
stopRequested: true,
|
|
223
294
|
processes: existing?.processes ?? [],
|
|
224
295
|
sessionId: existing?.sessionId,
|
|
296
|
+
scratchDir: existing?.scratchDir,
|
|
225
297
|
operationId: existing?.operationId,
|
|
226
298
|
headCommit: existing?.headCommit,
|
|
227
299
|
evidence: existing?.evidence,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
3
5
|
import { join } from 'node:path';
|
|
4
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
7
|
import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -7,11 +9,14 @@ import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
|
|
|
7
9
|
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
8
10
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
9
11
|
import { snapshotWorkspaceTree } from '../workspaces/git.js';
|
|
12
|
+
import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
13
|
+
import { workspaceGroup } from '../workspaces/shared-write.js';
|
|
10
14
|
import { requireBatchContentChanges } from './content-changes.js';
|
|
11
15
|
import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
|
|
12
16
|
import { processHasExited, stopProcessGroup } from './processes.js';
|
|
13
17
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
|
|
14
18
|
import { stopMemberWriters } from './writer-proof.js';
|
|
19
|
+
const LOCAL_CHROMIUM = '/opt/playcraft/renderer-arm64/chromium';
|
|
15
20
|
function hashCommand(value) {
|
|
16
21
|
return hashOperationPayload(value);
|
|
17
22
|
}
|
|
@@ -68,6 +73,25 @@ async function proveStopped(record) {
|
|
|
68
73
|
}
|
|
69
74
|
return stopped;
|
|
70
75
|
}
|
|
76
|
+
async function createMemberScratch(root) {
|
|
77
|
+
// Chromium creates Unix sockets below TMPDIR, so keep the base path short.
|
|
78
|
+
const scratch = await realpath(await mkdtemp(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'pc-')));
|
|
79
|
+
try {
|
|
80
|
+
const group = await workspaceGroup(root);
|
|
81
|
+
const config = join(scratch, 'config');
|
|
82
|
+
const cache = join(scratch, 'cache');
|
|
83
|
+
await mkdir(config);
|
|
84
|
+
await mkdir(cache);
|
|
85
|
+
await openScratchToDevelopment(config, group);
|
|
86
|
+
await openScratchToDevelopment(cache, group);
|
|
87
|
+
await openScratchToDevelopment(scratch, group);
|
|
88
|
+
return scratch;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
71
95
|
export async function generateBatchMember(state, body) {
|
|
72
96
|
if (!body || typeof body !== 'object' || !('occupancyId' in body) || !body.occupancyId)
|
|
73
97
|
throw new Error('occupancy_required');
|
|
@@ -94,11 +118,6 @@ export async function generateBatchMember(state, body) {
|
|
|
94
118
|
return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
|
|
95
119
|
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
96
120
|
const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
|
|
97
|
-
const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
|
|
98
|
-
// private
|
|
99
|
-
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
100
|
-
const policy = memberExecutionPolicy(root, tempDir);
|
|
101
|
-
await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
|
|
102
121
|
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
103
122
|
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}),
|
|
104
123
|
...(command.remixReferences?.length ? { context: { files: [], skills: [], remixReferences: command.remixReferences.map(reference => ({
|
|
@@ -110,16 +129,29 @@ export async function generateBatchMember(state, body) {
|
|
|
110
129
|
throw new Error('batch_command_conflict');
|
|
111
130
|
const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
|
|
112
131
|
const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
|
|
113
|
-
|
|
132
|
+
const scratchDir = existing?.scratchDir ?? await createMemberScratch(root);
|
|
133
|
+
const initial = {
|
|
114
134
|
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
115
135
|
directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
|
|
116
136
|
writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
|
|
117
137
|
operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
|
|
118
138
|
snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
|
|
119
139
|
evidence: existing?.evidence,
|
|
120
|
-
sourceCommit, beforeTree,
|
|
140
|
+
sourceCommit, beforeTree, scratchDir,
|
|
121
141
|
updatedAt: new Date().toISOString(),
|
|
122
|
-
}
|
|
142
|
+
};
|
|
143
|
+
let record;
|
|
144
|
+
try {
|
|
145
|
+
const localChromiumAvailable = await access(LOCAL_CHROMIUM, constants.X_OK).then(() => true, () => false);
|
|
146
|
+
const policy = memberExecutionPolicy(root, scratchDir, localChromiumAvailable ? LOCAL_CHROMIUM : undefined);
|
|
147
|
+
await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
|
|
148
|
+
record = await writeMember(state, initial);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (!existing?.scratchDir)
|
|
152
|
+
await rm(scratchDir, { recursive: true, force: true }).catch(() => undefined);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
123
155
|
if (prior)
|
|
124
156
|
return memberStatus(await finishGenerate(state, record, prior, root));
|
|
125
157
|
if (!state.native?.ready()) {
|
|
@@ -293,6 +325,6 @@ async function stoppedRecord(state, command, existing) {
|
|
|
293
325
|
stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
|
|
294
326
|
headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
|
|
295
327
|
branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|
|
296
|
-
sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
|
|
328
|
+
sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree, scratchDir: existing?.scratchDir,
|
|
297
329
|
});
|
|
298
330
|
}
|
|
@@ -3,13 +3,15 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const memory = new WeakMap();
|
|
5
5
|
/** Unattended member execution: workspace-write to the member directory and one controlled temp dir. */
|
|
6
|
-
export function memberExecutionPolicy(cwd,
|
|
6
|
+
export function memberExecutionPolicy(cwd, scratchDir, chromiumPath) {
|
|
7
7
|
return {
|
|
8
8
|
cwd,
|
|
9
|
+
scratchDir,
|
|
10
|
+
chromiumPath,
|
|
9
11
|
approvalPolicy: 'never',
|
|
10
12
|
sandboxPolicy: {
|
|
11
13
|
type: 'workspaceWrite',
|
|
12
|
-
writableRoots: [cwd,
|
|
14
|
+
writableRoots: [cwd, scratchDir],
|
|
13
15
|
networkAccess: true,
|
|
14
16
|
excludeTmpdirEnvVar: true,
|
|
15
17
|
excludeSlashTmp: true,
|
|
@@ -126,7 +126,7 @@ export async function runProcessGroup(cwd, command, role, onStart, options) {
|
|
|
126
126
|
tracked.stopped = true;
|
|
127
127
|
}
|
|
128
128
|
catch (error) {
|
|
129
|
-
collect(`\n[
|
|
129
|
+
collect(`\n[${role}] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
130
130
|
// Keep the directory occupied until the launcher confirms stop.
|
|
131
131
|
}
|
|
132
132
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { batchSourceSnapshotRefSchema, normalizeLegacyBatchSourceSnapshot, sandboxBatchMemberStatusSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { releaseBatchDirectory } from '../server/directory-access.js';
|
|
@@ -68,6 +68,9 @@ export async function writeMember(state, record) {
|
|
|
68
68
|
const next = { ...record, updatedAt: new Date().toISOString() };
|
|
69
69
|
await writeAtomic(memberFile(state, record.runId), JSON.stringify(next));
|
|
70
70
|
releaseBatchDirectory(state, next);
|
|
71
|
+
if (next.scratchDir && next.writersStopped && !next.holding) {
|
|
72
|
+
await rm(next.scratchDir, { recursive: true, force: true }).catch(() => undefined);
|
|
73
|
+
}
|
|
71
74
|
return next;
|
|
72
75
|
}
|
|
73
76
|
export async function listMembers(state) {
|
|
@@ -5,6 +5,8 @@ import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
|
5
5
|
import { gzip, gunzip } from 'node:zlib';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { WORKSPACE_REMIX_REFERENCES_MANIFEST, starterTemplateTreeHashInput, remixReferenceContentHashInput, workspaceStarterTemplateSnapshotSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
|
+
import { buildLocalProjectSkillsSnapshot, canonicalProjectSkillsJson, declarationDigest, inspectProjectSkills, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, readProjectSkillSource, readProjectSkillsLockFile, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
|
|
9
|
+
import { readLockedProjectSkillSnapshot } from '../../project-skills/discovery.js';
|
|
8
10
|
import { excludeFromGit } from '../workspaces/dependencies.js';
|
|
9
11
|
import { batchReferenceEnvExamples, batchReferenceMetadata, } from '../workspaces/remix-references.js';
|
|
10
12
|
import { gitRead, gitWrite } from './git.js';
|
|
@@ -200,8 +202,11 @@ async function inspectSource(root, files) {
|
|
|
200
202
|
ignoredDigest: createHash('sha256').update(JSON.stringify(ignored.sort())).digest('hex'),
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
function templateSkillRoot(path) {
|
|
206
|
+
return /^(\.agents\/skills\/[^/]+)\//.exec(path)?.[1];
|
|
207
|
+
}
|
|
208
|
+
/** Merge starter files; only callers that lock repository skills should include them. */
|
|
209
|
+
export function mergeStarterTemplate(files, template, { includeRepositorySkills = false } = {}) {
|
|
205
210
|
if ('error' in template)
|
|
206
211
|
throw new Error(template.error);
|
|
207
212
|
const result = workspaceStarterTemplateSnapshotSchema.safeParse(template.snapshot);
|
|
@@ -216,6 +221,22 @@ export function mergeStarterTemplate(files, template) {
|
|
|
216
221
|
throw new Error('starter_template_invalid');
|
|
217
222
|
const sourcePaths = new Set([...files.keys()].map((path) => path.toLowerCase()));
|
|
218
223
|
const added = [];
|
|
224
|
+
if (includeRepositorySkills) {
|
|
225
|
+
const templatePaths = new Set(snapshot.files.map(file => file.path));
|
|
226
|
+
const skillRoots = new Set(snapshot.files.flatMap(file => {
|
|
227
|
+
const root = templateSkillRoot(file.path);
|
|
228
|
+
return root ? [root] : [];
|
|
229
|
+
}));
|
|
230
|
+
for (const root of skillRoots) {
|
|
231
|
+
if (!templatePaths.has(`${root}/SKILL.md`))
|
|
232
|
+
throw new Error('starter_template_invalid');
|
|
233
|
+
}
|
|
234
|
+
for (const path of files.keys()) {
|
|
235
|
+
const root = templateSkillRoot(path);
|
|
236
|
+
if (root && skillRoots.has(root) && !templatePaths.has(path))
|
|
237
|
+
throw new Error('starter_template_path_conflict');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
219
240
|
for (const file of snapshot.files) {
|
|
220
241
|
const { path } = file;
|
|
221
242
|
const mode = file.mode;
|
|
@@ -231,13 +252,20 @@ export function mergeStarterTemplate(files, template) {
|
|
|
231
252
|
.startsWith('version https://git-lfs.github.com/spec/v1'))
|
|
232
253
|
throw new Error('starter_template_invalid');
|
|
233
254
|
const parts = path.split('/');
|
|
255
|
+
const skillRoot = includeRepositorySkills ? templateSkillRoot(path) : undefined;
|
|
256
|
+
if (skillRoot && !included(path, new Set()))
|
|
257
|
+
throw new Error('starter_template_invalid');
|
|
234
258
|
if (path === 'AGENTS.md' ||
|
|
235
259
|
parts[0] === '.claude' ||
|
|
236
|
-
parts[0] === '.agents' ||
|
|
260
|
+
(parts[0] === '.agents' && !skillRoot) ||
|
|
237
261
|
parts[0] === '.playcraft' ||
|
|
238
262
|
parts[0] === 'references' ||
|
|
239
263
|
!included(path, new Set()))
|
|
240
264
|
continue;
|
|
265
|
+
if (skillRoot && files.get(path)?.body.equals(body) && files.get(path)?.mode === (mode === '100755' ? 0o755 : 0o644)) {
|
|
266
|
+
added.push(path);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
241
269
|
if (path === '.gitignore' && files.has(path)) {
|
|
242
270
|
files.set(path, {
|
|
243
271
|
body: Buffer.concat([files.get(path).body, Buffer.from('\n'), body]),
|
|
@@ -257,6 +285,68 @@ export function mergeStarterTemplate(files, template) {
|
|
|
257
285
|
throw new Error('starter_template_invalid');
|
|
258
286
|
return added;
|
|
259
287
|
}
|
|
288
|
+
async function lockStarterSkills(staging, roots) {
|
|
289
|
+
try {
|
|
290
|
+
const previous = (await readProjectSkillsLockFile(staging)) === null
|
|
291
|
+
? undefined
|
|
292
|
+
: await readLockedProjectSkillSnapshot(staging);
|
|
293
|
+
const entries = [];
|
|
294
|
+
for (const root of roots) {
|
|
295
|
+
const existing = previous?.declaration.entries.find(entry => entry.source.kind === 'repository' && entry.source.root === root);
|
|
296
|
+
if (existing) {
|
|
297
|
+
const source = await readProjectSkillSource(staging, existing);
|
|
298
|
+
const locked = previous?.lock.entries.find(item => item.entryKey === existing.entryKey);
|
|
299
|
+
if (locked?.sourceTreeHash !== source.sourceTreeHash)
|
|
300
|
+
throw new Error('starter_template_path_conflict');
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const key = createHash('sha256').update(root).digest('hex').slice(0, 24);
|
|
304
|
+
entries.push({
|
|
305
|
+
entryKey: `repo-${key}`,
|
|
306
|
+
source: { kind: 'repository', root, skillRef: `local-template/repo-${key}` },
|
|
307
|
+
enabled: true,
|
|
308
|
+
surface: 'sandbox-agent',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (!entries.length)
|
|
312
|
+
return;
|
|
313
|
+
const addition = previous
|
|
314
|
+
? buildLocalProjectSkillsSnapshot({
|
|
315
|
+
declaration: { schemaVersion: 1, entries },
|
|
316
|
+
sources: await Promise.all(entries.map(entry => readProjectSkillSource(staging, entry))),
|
|
317
|
+
discovered: [],
|
|
318
|
+
issues: [],
|
|
319
|
+
}, undefined, { lockVersion: previous.lock.lockVersion })
|
|
320
|
+
: buildLocalProjectSkillsSnapshot(await inspectProjectSkills(staging));
|
|
321
|
+
const { declaration } = addition;
|
|
322
|
+
if (previous) {
|
|
323
|
+
declaration.entries.unshift(...previous.declaration.entries);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
for (const entry of declaration.entries) {
|
|
327
|
+
if (entry.source.kind === 'repository' && roots.includes(entry.source.root))
|
|
328
|
+
entry.enabled = true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const { lock } = validateProjectSkillsSnapshot(declaration, {
|
|
332
|
+
...addition.lock,
|
|
333
|
+
declarationHash: declarationDigest(declaration),
|
|
334
|
+
entries: [...(previous?.lock.entries ?? []), ...addition.lock.entries],
|
|
335
|
+
});
|
|
336
|
+
await mkdir(join(staging, '.playcraft'), { recursive: true });
|
|
337
|
+
for (const [path, value] of [
|
|
338
|
+
[PROJECT_SKILLS_DECLARATION_PATH, declaration],
|
|
339
|
+
[PROJECT_SKILLS_LOCK_PATH, lock],
|
|
340
|
+
]) {
|
|
341
|
+
await writeFile(join(staging, path), `${canonicalProjectSkillsJson(value)}\n`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error instanceof Error && error.message === 'starter_template_path_conflict')
|
|
346
|
+
throw error;
|
|
347
|
+
throw new Error('starter_template_invalid', { cause: error });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
260
350
|
/** Independent root commit. The source branch, index and config are never written. */
|
|
261
351
|
export async function captureIndependentSource(source, destination, starterTemplate, identity) {
|
|
262
352
|
const root = await realpath(source);
|
|
@@ -315,7 +405,9 @@ export async function captureIndependentSource(source, destination, starterTempl
|
|
|
315
405
|
}
|
|
316
406
|
}
|
|
317
407
|
}
|
|
318
|
-
const templatePaths = starterTemplate && inspection?.empty
|
|
408
|
+
const templatePaths = starterTemplate && inspection?.empty
|
|
409
|
+
? mergeStarterTemplate(files, starterTemplate, { includeRepositorySkills: true })
|
|
410
|
+
: [];
|
|
319
411
|
const staging = join(dirname(destination), `.batch-source-${randomUUID()}`);
|
|
320
412
|
try {
|
|
321
413
|
await mkdir(staging, { recursive: true });
|
|
@@ -327,6 +419,14 @@ export async function captureIndependentSource(source, destination, starterTempl
|
|
|
327
419
|
await mkdir(dirname(full), { recursive: true });
|
|
328
420
|
await writeFile(full, file.body, { mode: file.mode, flag: 'wx' });
|
|
329
421
|
}
|
|
422
|
+
const skillRoots = templatePaths.flatMap(path => {
|
|
423
|
+
const root = templateSkillRoot(path);
|
|
424
|
+
return root && path === `${root}/SKILL.md` ? [root] : [];
|
|
425
|
+
});
|
|
426
|
+
if (skillRoots.length) {
|
|
427
|
+
await lockStarterSkills(staging, skillRoots);
|
|
428
|
+
templatePaths.push(PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH);
|
|
429
|
+
}
|
|
330
430
|
if (headBefore !== (await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '')))
|
|
331
431
|
throw new Error('batch_source_changed');
|
|
332
432
|
const after = await filterUnstableFiles(root, await readSourceFiles(root));
|
|
@@ -42,6 +42,8 @@ const ENGINEERING_BATCH_INSTRUCTIONS = [
|
|
|
42
42
|
const MEMBER_EXECUTION_INSTRUCTIONS = [
|
|
43
43
|
'You are implementing one assigned variant in an independent Remix directory which the platform has already created.',
|
|
44
44
|
'Apply the assigned changes directly to the current working directory. The originating source is a separate directory; source freezing, variant planning and directory creation have already been handled by the platform.',
|
|
45
|
+
'For browser validation, use the executable in PLAYCRAFT_CHROMIUM_PATH when set, and otherwise use the platform browser.',
|
|
46
|
+
'Complete the implementation and applicable checks in this turn. Leave the final playable build and preview registration to the platform after the turn ends. Do not run playcraft build or publish delivery in this turn. Report your changes and checks; the platform reports the delivery build result separately.',
|
|
45
47
|
].join('\n');
|
|
46
48
|
function codexReasoningEffort(effort) {
|
|
47
49
|
return effort === 'max' ? 'xhigh' : effort;
|
|
@@ -334,7 +336,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
334
336
|
if (options.config.workspaceMode === 'multi')
|
|
335
337
|
throw new NativePreparationError('workspace_skills_unavailable');
|
|
336
338
|
const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
|
|
337
|
-
const selected = threadExecutionParams(execution);
|
|
339
|
+
const selected = threadExecutionParams(execution, asRecord(asRecord(modelConfig.config)['shell_environment_policy.set']));
|
|
338
340
|
return { params: { ...modelConfig, ...selected, config: withModelShell({ ...modelConfig.config, ...selected.config }, modelConfigId),
|
|
339
341
|
...((unattended || options.workspaceUi) ? { developerInstructions: unattended ? MEMBER_EXECUTION_INSTRUCTIONS : WORKSPACE_UI_INSTRUCTIONS } : {}) },
|
|
340
342
|
execution, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined, mcpHash: mcp?.hash };
|
|
@@ -362,7 +364,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
362
364
|
].join('\n');
|
|
363
365
|
const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
|
|
364
366
|
const execution = resolveSessionExecution(cwd, permissionMode, sessionId ? options.trustedExecution?.(sessionId) : undefined, options.fixedExecution);
|
|
365
|
-
const selected = threadExecutionParams(execution);
|
|
367
|
+
const selected = threadExecutionParams(execution, asRecord(asRecord(config)['shell_environment_policy.set']));
|
|
366
368
|
return { params: { ...modelConfig, ...selected, config: withModelShell({ ...config, ...selected.config }, modelConfigId), developerInstructions },
|
|
367
369
|
execution, modelConfigId, activate, skillsHash: frozen.lockHash, discoveryHash, mcpHash: mcp?.hash };
|
|
368
370
|
}
|