@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.
@@ -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
- const ENTRY = '/.playcraft/video-replication/';
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
- export async function ensureVideoEvidenceGitExcluded(directory) {
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() === ENTRY))
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() === ENTRY)) {
73
- await file.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}${ENTRY}\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 { lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
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 { runDeveloperCommand } from '../workspaces/developer-command.js';
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
- /** Base build writes dist/index.html. Channel packaging is done by platform export. */
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
- return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
15
- ?? 'umask 002 && CI=1 playcraft build --base --output dist .');
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 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.
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
- /** A build command that writes nothing must not inherit an earlier run's index.html as success. */
59
- async function clearBuiltHtml(root) {
60
- await rm(join(root, 'dist', 'index.html'), { force: true }).catch(() => undefined);
61
- }
62
- async function readBuiltHtml(root) {
63
- const htmlPath = join(root, 'dist', 'index.html');
64
- const info = await lstat(htmlPath).catch(() => undefined);
65
- if (!info?.isFile() || info.isSymbolicLink())
66
- throw new Error('batch_build_failed');
67
- const handle = await open(htmlPath, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => {
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?.state === 'completed' &&
101
- existing.phase === 'deliver' &&
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
- const repairOutput = await repairGroupAccess(state, root, command.runId);
153
- await clearBuiltHtml(root);
154
- const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
155
- record = await writeMember(state, { ...record, processes: [...record.processes, process] });
156
- });
157
- record = await writeMember(state, {
158
- ...record,
159
- processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
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 { mkdir } from 'node:fs/promises';
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
- let record = await writeMember(state, {
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, tempDir) {
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, tempDir],
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[install] ${error instanceof Error ? error.message : String(error)}\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
- /** Merge only the trusted engine files. Workspace instructions, skills, references and configuration remain authoritative. */
204
- export function mergeStarterTemplate(files, template) {
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 ? mergeStarterTemplate(files, starterTemplate) : [];
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
  }