@playcraft/cli 0.1.21 → 0.1.23

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.
Files changed (35) hide show
  1. package/dist/commands/tools-batch.js +59 -0
  2. package/dist/commands/tools.js +2 -0
  3. package/dist/workspace-runtime/codex/app-server.js +23 -9
  4. package/dist/workspace-runtime/codex/model-broker.js +5 -5
  5. package/dist/workspace-runtime/previews/publish.js +7 -4
  6. package/dist/workspace-runtime/production/contracts.js +17 -0
  7. package/dist/workspace-runtime/production/controller.js +207 -0
  8. package/dist/workspace-runtime/production/dependencies.js +60 -0
  9. package/dist/workspace-runtime/production/native.js +70 -0
  10. package/dist/workspace-runtime/production/processes.js +86 -0
  11. package/dist/workspace-runtime/production/save-command.js +20 -0
  12. package/dist/workspace-runtime/production/stages.js +175 -0
  13. package/dist/workspace-runtime/production/storage.js +32 -0
  14. package/dist/workspace-runtime/serve.js +3 -6
  15. package/dist/workspace-runtime/server/desktop-host.js +1 -1
  16. package/dist/workspace-runtime/server/desktop-preview.js +5 -5
  17. package/dist/workspace-runtime/server/dispatch.js +37 -24
  18. package/dist/workspace-runtime/server/http.js +111 -32
  19. package/dist/workspace-runtime/server/terminal-host.js +6 -1
  20. package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
  21. package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
  22. package/dist/workspace-runtime/workspaces/directories.js +106 -0
  23. package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
  24. package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
  25. package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
  26. package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
  27. package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
  28. package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
  29. package/dist/workspace-runtime/workspaces/prepare.js +20 -5
  30. package/dist/workspace-runtime/workspaces/registry.js +63 -12
  31. package/dist/workspace-runtime/workspaces/revisions.js +5 -5
  32. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
  33. package/package.json +3 -3
  34. package/project-templates/batch-locks/phaser.yaml +922 -0
  35. package/project-templates/batch-locks/threejs.yaml +978 -0
@@ -1,7 +1,7 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
- import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
2
+ import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { applyRuntimeEndpointPatch } from './endpoints.js';
7
7
  import { canExecuteKind, executeOperation } from './dispatch.js';
@@ -16,9 +16,14 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
16
16
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
17
17
  import { join } from 'node:path';
18
18
  import { terminalHost } from './terminal-host.js';
19
+ import { productionCapability, productionDirectoryBusy, executeSandboxProduction, sandboxProductionReceipt } from '../production/controller.js';
20
+ import { cleanupWorkspaceDirectories, syncWorkspaceDirectories } from '../workspaces/directory-maintenance.js';
21
+ import { registerWorkspaceDirectory, restoreWorkspaceDirectory } from '../workspaces/directories.js';
22
+ import { readWorkspaceDirectoryStats } from '../workspaces/directory-stats.js';
19
23
  import { probeRuntimeConnectivity } from './connectivity.js';
20
24
  export async function handleRuntimeRequest(state, request, response) {
21
25
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
26
+ const directoryName = url.searchParams.get('directoryName') ?? 'basic';
22
27
  const access = authorizeRuntime(state, request);
23
28
  if (!access) {
24
29
  writeError(response, runtimeError('unauthenticated', 'control_token_required'));
@@ -29,6 +34,21 @@ export async function handleRuntimeRequest(state, request, response) {
29
34
  return;
30
35
  }
31
36
  try {
37
+ if (request.method === 'GET' && url.pathname === '/runtime/production/capability') {
38
+ writeJson(response, 200, await productionCapability(state));
39
+ return;
40
+ }
41
+ if (request.method === 'POST' && ['/runtime/production/stages', '/runtime/production/stop', '/runtime/production/reconcile'].includes(url.pathname)) {
42
+ writeJson(response, 200, await executeSandboxProduction(state, await readJson(request)));
43
+ return;
44
+ }
45
+ if (request.method === 'GET' && url.pathname.startsWith('/runtime/production/stages/')) {
46
+ writeJson(response, 200, await sandboxProductionReceipt(state, decodeURIComponent(url.pathname.slice('/runtime/production/stages/'.length)), {
47
+ workspaceId: url.searchParams.get('workspaceId') ?? '', directoryName, taskId: url.searchParams.get('taskId') ?? '',
48
+ attemptId: url.searchParams.get('attemptId') ?? '', owner: url.searchParams.get('owner') ?? '', fence: Number(url.searchParams.get('fence')),
49
+ }));
50
+ return;
51
+ }
32
52
  if (request.method === 'GET' && url.pathname === '/runtime/health') {
33
53
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
34
54
  return;
@@ -42,7 +62,7 @@ export async function handleRuntimeRequest(state, request, response) {
42
62
  return;
43
63
  }
44
64
  const workspaceId = parsed.data;
45
- await workspaceRoot(state, workspaceId);
65
+ await sessionWorkspaceRoot(state, workspaceId, directoryName);
46
66
  writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
47
67
  return;
48
68
  }
@@ -82,6 +102,34 @@ export async function handleRuntimeRequest(state, request, response) {
82
102
  writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
83
103
  return;
84
104
  }
105
+ if (request.method === 'POST' && url.pathname === '/runtime/workspaces/directories/register') {
106
+ if (writeDestroyed(state, response))
107
+ return;
108
+ const body = asRecord(await readJson(request));
109
+ if (body.generation !== state.config.generation || body.runtimeEpoch !== state.runtimeEpoch || !Number.isSafeInteger(body.leaseFence)) {
110
+ writeJson(response, 409, { error: 'invalid_workspace_registration' });
111
+ return;
112
+ }
113
+ writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
114
+ return;
115
+ }
116
+ const maintenance = /^\/runtime\/workspaces\/([a-f0-9-]{36})\/directories\/(cleanup|sync|stats)$/.exec(url.pathname);
117
+ if ((request.method === 'GET' || request.method === 'POST') && maintenance?.[2] === 'stats') {
118
+ if (writeDestroyed(state, response))
119
+ return;
120
+ writeJson(response, 200, await readWorkspaceDirectoryStats(state, maintenance[1]));
121
+ return;
122
+ }
123
+ if (request.method === 'POST' && maintenance) {
124
+ if (writeDestroyed(state, response))
125
+ return;
126
+ const body = asRecord(await readJson(request));
127
+ const workspaceId = maintenance[1];
128
+ writeJson(response, 200, maintenance[2] === 'cleanup'
129
+ ? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames)
130
+ : await syncWorkspaceDirectories(state, workspaceId, body.commitHash, body.targetDirectoryNames));
131
+ return;
132
+ }
85
133
  if (request.method === 'POST' && url.pathname === '/runtime/endpoints') {
86
134
  try {
87
135
  writeJson(response, 200, await applyRuntimeEndpointPatch(state, await readJson(request)));
@@ -114,7 +162,11 @@ export async function handleRuntimeRequest(state, request, response) {
114
162
  if (writeDestroyed(state, response))
115
163
  return;
116
164
  const workspaceId = url.searchParams.get('workspaceId') ?? '';
117
- const root = await workspaceRoot(state, workspaceId);
165
+ if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
166
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
167
+ return;
168
+ }
169
+ const root = await workspaceRoot(state, workspaceId, directoryName);
118
170
  try {
119
171
  writeJson(response, 200, await uploadWorkspaceAttachment(root, await readAttachmentUpload(request)));
120
172
  }
@@ -127,8 +179,8 @@ export async function handleRuntimeRequest(state, request, response) {
127
179
  if (writeDestroyed(state, response))
128
180
  return;
129
181
  const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
130
- const root = await workspaceRoot(state, workspaceId);
131
- const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId)));
182
+ const root = await workspaceRoot(state, workspaceId, directoryName);
183
+ const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId, directoryName)));
132
184
  console.log(`[workspace-files] GET /runtime/files workspaceId=${workspaceId ?? '-'} root=${root} files=${listed.files.length}`);
133
185
  writeJson(response, 200, listed);
134
186
  return;
@@ -138,7 +190,7 @@ export async function handleRuntimeRequest(state, request, response) {
138
190
  return;
139
191
  const relativePath = url.searchParams.get('path') ?? '';
140
192
  try {
141
- writeJson(response, 200, await readObservedFile(state, url.searchParams.get('workspaceId') ?? undefined, relativePath));
193
+ writeJson(response, 200, await readObservedFile(state, url.searchParams.get('workspaceId') ?? undefined, relativePath, directoryName));
142
194
  }
143
195
  catch (error) {
144
196
  if (error instanceof InvalidWorkspacePathError) {
@@ -154,11 +206,11 @@ export async function handleRuntimeRequest(state, request, response) {
154
206
  return;
155
207
  }
156
208
  if (request.method === 'GET' && url.pathname === '/runtime/git/status') {
157
- await writeGitRead(state, response, async () => readWorkspaceGitStatus(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
209
+ await writeGitRead(state, response, async () => readWorkspaceGitStatus(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
158
210
  return;
159
211
  }
160
212
  if (request.method === 'GET' && url.pathname === '/runtime/git/diff') {
161
- await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
213
+ await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
162
214
  return;
163
215
  }
164
216
  if (request.method === 'GET' && url.pathname === '/runtime/git/file') {
@@ -167,15 +219,15 @@ export async function handleRuntimeRequest(state, request, response) {
167
219
  writeJson(response, 400, { error: 'invalid_git_ref' });
168
220
  return;
169
221
  }
170
- await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
222
+ await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName), url.searchParams.get('path') ?? '', ref.data));
171
223
  return;
172
224
  }
173
225
  if (request.method === 'GET' && url.pathname === '/runtime/dependencies') {
174
226
  if (writeDestroyed(state, response))
175
227
  return;
176
228
  const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
177
- await workspaceRoot(state, workspaceId);
178
- writeJson(response, 200, readDependencies(state, workspaceId ?? 'workspace'));
229
+ await workspaceRoot(state, workspaceId, directoryName);
230
+ writeJson(response, 200, readDependencies(state, revisionScope(state, workspaceId, directoryName) ?? 'workspace'));
179
231
  return;
180
232
  }
181
233
  if (request.method === 'GET' && url.pathname === '/runtime/skills') {
@@ -183,7 +235,7 @@ export async function handleRuntimeRequest(state, request, response) {
183
235
  return;
184
236
  const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
185
237
  try {
186
- const view = await state.journal.exclusive(`workspace:${revisionScope(state, workspaceId) ?? 'single'}`, async () => readWorkspaceSkills(await workspaceRoot(state, workspaceId), join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, workspaceId ?? 'workspace', entry)));
238
+ const view = await state.journal.exclusive(`workspace:${revisionScope(state, workspaceId, directoryName) ?? 'single'}`, async () => readWorkspaceSkills(await workspaceRoot(state, workspaceId, directoryName), join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, workspaceId ?? 'workspace', entry, directoryName)));
187
239
  writeJson(response, 200, view);
188
240
  }
189
241
  catch (error) {
@@ -194,20 +246,20 @@ export async function handleRuntimeRequest(state, request, response) {
194
246
  return;
195
247
  }
196
248
  if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
197
- await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
249
+ await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
198
250
  return;
199
251
  }
200
252
  if (request.method === 'GET' && url.pathname === '/runtime/parameters') {
201
253
  if (writeDestroyed(state, response))
202
254
  return;
203
- writeJson(response, 200, await readObservedParameters(state, url.searchParams.get('workspaceId') ?? undefined));
255
+ writeJson(response, 200, await readObservedParameters(state, url.searchParams.get('workspaceId') ?? undefined, directoryName));
204
256
  return;
205
257
  }
206
258
  if (request.method === 'GET' && url.pathname === '/runtime/terminals') {
207
259
  if (writeDestroyed(state, response))
208
260
  return;
209
261
  writeJson(response, 200, {
210
- terminals: state.journal.listTerminals(url.searchParams.get('workspaceId') ?? undefined),
262
+ terminals: state.journal.listTerminals(url.searchParams.get('workspaceId') ?? undefined).filter(terminal => (terminal.directoryName ?? 'basic') === directoryName),
211
263
  });
212
264
  return;
213
265
  }
@@ -358,8 +410,19 @@ async function acceptOperation(state, request, response) {
358
410
  writeJson(response, 400, { error: 'invalid_operation', reason: 'sessionId_required' });
359
411
  return;
360
412
  }
413
+ if (parsed.data.directoryName === 'root' && !parsed.data.kind.startsWith('session.') && parsed.data.kind !== 'runtime.ensure') {
414
+ writeJson(response, 400, { error: 'invalid_workspace_directory' });
415
+ return;
416
+ }
417
+ if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
418
+ await restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName);
361
419
  if (state.config.workspaceMode === 'multi')
362
- await workspaceRoot(state, parsed.data.workspaceId);
420
+ await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
421
+ const mutation = isWorkspaceMutation(parsed.data.kind);
422
+ if (mutation && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
423
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
424
+ return;
425
+ }
363
426
  const payloadHash = hashOperationPayload(parsed.data.payload);
364
427
  const scopeKey = operationScopeKey({
365
428
  environment: state.config.environment,
@@ -367,6 +430,7 @@ async function acceptOperation(state, request, response) {
367
430
  kind: parsed.data.kind,
368
431
  clientRequestId: parsed.data.clientRequestId,
369
432
  workspaceId: parsed.data.workspaceId,
433
+ directoryName: parsed.data.directoryName,
370
434
  sessionId: parsed.data.sessionId,
371
435
  });
372
436
  const accept = () => state.journal.exclusive(scopeKey, async () => {
@@ -428,7 +492,8 @@ async function acceptOperation(state, request, response) {
428
492
  const session = state.journal.getSession(parsed.data.sessionId);
429
493
  // A replaced sandbox has no binding yet; the recovering turn creates it.
430
494
  const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
431
- if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId)) {
495
+ if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
496
+ || (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
432
497
  writeJson(response, 409, { error: 'session_workspace_mismatch' });
433
498
  return;
434
499
  }
@@ -445,6 +510,7 @@ async function acceptOperation(state, request, response) {
445
510
  status: executable ? 'queued' : 'failed',
446
511
  payloadHash,
447
512
  workspaceId: parsed.data.workspaceId,
513
+ directoryName: parsed.data.directoryName,
448
514
  sessionId: parsed.data.sessionId,
449
515
  expectedRevision: parsed.data.expectedRevision,
450
516
  generation: state.config.generation,
@@ -479,19 +545,32 @@ async function acceptOperation(state, request, response) {
479
545
  }
480
546
  writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
481
547
  });
482
- if (parsed.data.kind === 'session.create')
483
- await state.journal.exclusive(`session:${parsed.data.sessionId}`, accept);
484
- else if (parsed.data.kind === 'terminal.create') {
485
- const terminalId = typeof asRecord(parsed.data.payload).terminalId === 'string'
486
- ? String(asRecord(parsed.data.payload).terminalId).trim()
487
- : '';
488
- if (terminalId)
489
- await state.journal.exclusive(`terminal:${terminalId}`, accept);
548
+ const acceptBound = async () => {
549
+ if (mutation && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
550
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
551
+ return;
552
+ }
553
+ if (state.config.workspaceMode === 'multi')
554
+ await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
555
+ if (parsed.data.kind === 'session.create')
556
+ await state.journal.exclusive(`session:${parsed.data.sessionId}`, accept);
557
+ else if (parsed.data.kind === 'terminal.create') {
558
+ const terminalId = typeof asRecord(parsed.data.payload).terminalId === 'string'
559
+ ? String(asRecord(parsed.data.payload).terminalId).trim()
560
+ : '';
561
+ if (terminalId)
562
+ await state.journal.exclusive(`terminal:${terminalId}`, accept);
563
+ else
564
+ await accept();
565
+ }
490
566
  else
491
567
  await accept();
492
- }
568
+ };
569
+ // Production admission and maintenance use this same lock before checking durable occupancy.
570
+ if (mutation && state.config.workspaceMode === 'multi')
571
+ await state.journal.exclusive(`directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
493
572
  else
494
- await accept();
573
+ await acceptBound();
495
574
  }
496
575
  async function writeGitRead(state, response, read) {
497
576
  if (writeDestroyed(state, response))
@@ -638,13 +717,13 @@ async function handleSessionHistory(state, response, workspaceId, sessionId) {
638
717
  }
639
718
  writeJson(response, 200, body.data);
640
719
  }
641
- async function readObservedParameters(state, workspaceId) {
642
- const root = await workspaceRoot(state, workspaceId);
643
- const scope = revisionScope(state, workspaceId);
720
+ async function readObservedParameters(state, workspaceId, directoryName = 'basic') {
721
+ const root = await workspaceRoot(state, workspaceId, directoryName);
722
+ const scope = revisionScope(state, workspaceId, directoryName);
644
723
  const first = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
645
724
  for (const path of [first.schemaPath, first.dataPath]) {
646
725
  if (path)
647
- await withObservedFile(state, workspaceId, path, async () => undefined);
726
+ await withObservedFile(state, workspaceId, path, async () => undefined, directoryName);
648
727
  }
649
728
  return readWorkspaceParameters(root, state.journal.fileRevisions(scope));
650
729
  }
@@ -1,3 +1,4 @@
1
+ import { productionDirectoryBusy } from '../production/controller.js';
1
2
  import { createRequire } from 'node:module';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { lstat } from 'node:fs/promises';
@@ -59,6 +60,10 @@ export class TerminalHost {
59
60
  rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
60
61
  return;
61
62
  }
63
+ if (await productionDirectoryBusy(this.state, terminal.workspaceId, terminal.directoryName)) {
64
+ rejectUpgrade(socket, 409, { error: 'batch_workspace_readonly' });
65
+ return;
66
+ }
62
67
  if (terminal.status === 'destroyed') {
63
68
  rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
64
69
  return;
@@ -258,7 +263,7 @@ function exitFrame(session) {
258
263
  };
259
264
  }
260
265
  async function terminalDirectory(state, terminal) {
261
- const root = await workspaceRoot(state, terminal.workspaceId);
266
+ const root = await workspaceRoot(state, terminal.workspaceId, terminal.directoryName);
262
267
  if (!terminal.cwd)
263
268
  return root;
264
269
  const resolved = await resolveWorkspaceFile(root, terminal.cwd);
@@ -0,0 +1,142 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { batchCommitSourceVersion } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
9
+ const execute = promisify(execFile);
10
+ const ID = /^[A-Za-z0-9_-]{1,128}$/;
11
+ const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
12
+ const PRIVATE_NAMES = new Set([
13
+ '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store', '.config',
14
+ '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json',
15
+ 'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json',
16
+ ]);
17
+ const DEPENDENCY_LOCKS = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
18
+ const SKILLS_DECLARATION = '.playcraft/skills.json';
19
+ const SKILLS_LOCK = '.playcraft/skills.lock.json';
20
+ const sha256 = (body) => createHash('sha256').update(body).digest('hex');
21
+ async function git(root, args, encoding = 'utf8') {
22
+ const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
23
+ timeout: 90_000, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer',
24
+ env: { PATH: process.env.PATH, HOME: root, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
25
+ });
26
+ return encoding === 'utf8' ? Buffer.from(stdout.toString('utf8').trim()) : stdout;
27
+ }
28
+ function assertSource(source) {
29
+ if (!ID.test(source.workspaceId) || !isAbsolute(source.root))
30
+ throw new Error('batch_snapshot_scope_invalid');
31
+ }
32
+ function engineeringPath(path) {
33
+ return !path.split('/').some((name) => PRIVATE_NAMES.has(name) || /^\.env(?:$|\.)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name));
34
+ }
35
+ function lockedArtifacts(declaration, lock) {
36
+ const snapshot = validateProjectSkillsSnapshot(JSON.parse(declaration.toString('utf8')), JSON.parse(lock.toString('utf8')));
37
+ return snapshot.lock.entries.map((entry) => `.playcraft/skill-artifacts/${entry.revision.artifactHash}.tar.gz`);
38
+ }
39
+ function inside(root, path) {
40
+ const rel = relative(root, path);
41
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
42
+ }
43
+ /** Locked skill archives are kept out of Git; each one is named by the hash of its exact bytes. */
44
+ async function readArtifact(root, path) {
45
+ const full = join(root, ...path.split('/'));
46
+ if (!inside(root, await realpath(dirname(full)).catch(() => '')))
47
+ throw new Error('batch_snapshot_skill_artifact_missing');
48
+ const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => undefined);
49
+ if (!handle)
50
+ throw new Error('batch_snapshot_skill_artifact_missing');
51
+ try {
52
+ const stat = await handle.stat();
53
+ if (!stat.isFile() || stat.nlink !== 1)
54
+ throw new Error('batch_snapshot_skill_artifact_missing');
55
+ const body = await handle.readFile();
56
+ if (sha256(body) !== path.slice(path.lastIndexOf('/') + 1, -'.tar.gz'.length))
57
+ throw new Error('batch_snapshot_skill_artifact_missing');
58
+ return body;
59
+ }
60
+ finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+ async function treeChecksum(root, commitHash) {
65
+ const listing = await git(root, ['ls-tree', '-r', '--full-tree', '-z', commitHash], 'buffer');
66
+ const paths = listing.toString('utf8').split('\0').filter(Boolean).map((line) => line.slice(line.indexOf('\t') + 1));
67
+ return { checksum: sha256(listing), paths };
68
+ }
69
+ /**
70
+ * Freezes a commit of `basic` as batch input. The commit is already saved by Git, so later edits to
71
+ * `basic` never reach the batch; nothing is copied to separate storage. A commit is accepted only
72
+ * when it carries the dependency lock and skill lock its build needs and no credential files.
73
+ */
74
+ export async function freezeBatchCommit(input) {
75
+ assertSource(input.source);
76
+ if (!COMMIT.test(input.commitHash) || !ID.test(input.snapshotId))
77
+ throw new Error('batch_snapshot_commit_required');
78
+ const root = await realpath(input.source.root);
79
+ const resolved = (await git(root, ['rev-parse', '--verify', '--quiet', `${input.commitHash}^{commit}`]).catch(() => Buffer.from(''))).toString();
80
+ if (resolved !== input.commitHash)
81
+ throw new Error('batch_snapshot_commit_required');
82
+ const { checksum, paths } = await treeChecksum(root, input.commitHash);
83
+ if (paths.some((path) => !engineeringPath(path)))
84
+ throw new Error('batch_snapshot_path_invalid');
85
+ const tracked = new Set(paths);
86
+ if (!tracked.has('package.json') || !DEPENDENCY_LOCKS.some((path) => tracked.has(path)))
87
+ throw new Error('batch_snapshot_dependency_lock_required');
88
+ if (!tracked.has(SKILLS_DECLARATION) || !tracked.has(SKILLS_LOCK))
89
+ throw new Error('batch_snapshot_skills_lock_required');
90
+ const declaration = await git(root, ['show', `${input.commitHash}:${SKILLS_DECLARATION}`], 'buffer');
91
+ const lock = await git(root, ['show', `${input.commitHash}:${SKILLS_LOCK}`], 'buffer');
92
+ for (const path of lockedArtifacts(declaration, lock))
93
+ if (!tracked.has(path))
94
+ await readArtifact(root, path);
95
+ const committedAt = (await git(root, ['show', '-s', '--format=%cI', input.commitHash])).toString();
96
+ return {
97
+ snapshotId: input.snapshotId, sourceWorkspaceId: input.source.workspaceId, commitHash: input.commitHash, checksum,
98
+ sourceVersion: batchCommitSourceVersion(input.commitHash), skillLockDigest: sha256(lock),
99
+ frozenAt: new Date(committedAt).toISOString(),
100
+ };
101
+ }
102
+ /**
103
+ * Checks the frozen commit out into a new private directory and adds the locked skill archives. A
104
+ * pre-existing destination is never reused, and the checkout must still match the frozen checksum.
105
+ */
106
+ export async function checkoutBatchCommit(input) {
107
+ assertSource(input.source);
108
+ if (!isAbsolute(input.destination) || !COMMIT.test(input.frozen.commitHash))
109
+ throw new Error('batch_snapshot_scope_invalid');
110
+ if (input.frozen.sourceWorkspaceId !== input.source.workspaceId)
111
+ throw new Error('batch_snapshot_identity_mismatch');
112
+ if (await lstat(input.destination).catch(() => undefined))
113
+ throw new Error('batch_snapshot_destination_exists');
114
+ const root = await realpath(input.source.root);
115
+ const { checksum } = await treeChecksum(root, input.frozen.commitHash);
116
+ if (checksum !== input.frozen.checksum)
117
+ throw new Error('batch_snapshot_identity_mismatch');
118
+ await mkdir(dirname(input.destination), { recursive: true });
119
+ try {
120
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', root, input.destination], {
121
+ timeout: 90_000, maxBuffer: 1024 * 1024,
122
+ env: { PATH: process.env.PATH, HOME: dirname(input.destination), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
123
+ });
124
+ await git(input.destination, ['checkout', '--detach', input.frozen.commitHash]);
125
+ const lock = await git(input.destination, ['show', `${input.frozen.commitHash}:${SKILLS_LOCK}`], 'buffer');
126
+ if (sha256(lock) !== input.frozen.skillLockDigest)
127
+ throw new Error('batch_snapshot_identity_mismatch');
128
+ const declaration = await git(input.destination, ['show', `${input.frozen.commitHash}:${SKILLS_DECLARATION}`], 'buffer');
129
+ for (const path of lockedArtifacts(declaration, lock)) {
130
+ const target = join(input.destination, ...path.split('/'));
131
+ if (await lstat(target).catch(() => undefined))
132
+ continue;
133
+ const body = await readArtifact(root, path);
134
+ await mkdir(dirname(target), { recursive: true });
135
+ await writeFile(target, body, { flag: 'wx', mode: 0o600 });
136
+ }
137
+ }
138
+ catch (cause) {
139
+ await rm(input.destination, { recursive: true, force: true });
140
+ throw cause;
141
+ }
142
+ }
@@ -11,11 +11,11 @@ const LOG_TAIL_MAX_CHARS = 8_000;
11
11
  const WRITE_PREFIX = 'dependencies-';
12
12
  const SHARE_WAIT_MS = 60_000;
13
13
  /**
14
- * Projects are managed with pnpm. It follows pnpm-lock.yaml when the lockfile matches, never
14
+ * Projects are managed with pnpm. Frozen installation preserves pnpm-lock.yaml, never
15
15
  * prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
16
16
  * sandbox and the install runs as the development user.
17
17
  */
18
- const INSTALL_COMMAND = 'pnpm install --reporter=append-only --config.confirm-modules-purge=false';
18
+ const INSTALL_COMMAND = 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --reporter=append-only --config.confirm-modules-purge=false';
19
19
  /** Installs whenever there is a package.json; the hash says whether a finished install is current. */
20
20
  export async function planInstall(root) {
21
21
  const manifest = await readOptional(join(root, 'package.json'));
@@ -159,7 +159,9 @@ async function installOnce(state, workspaceId, root) {
159
159
  */
160
160
  async function runInstall(state, workspaceId, root) {
161
161
  // Tests replace the install; a real registry is never reached from them.
162
- const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND;
162
+ const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
163
+ const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
164
+ const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND.replace('/var/cache/playcraft/pnpm-store', quotedStore);
163
165
  const logDir = join(state.config.stateDir, 'dependency-logs');
164
166
  await mkdir(logDir, { recursive: true, mode: 0o700 });
165
167
  const logPath = join(logDir, `${workspaceId}.log`);
@@ -0,0 +1,106 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { lstat, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { workspaceDirectoryRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { workspaceContainerRoot, workspaceRoot, WorkspaceContextError } from './registry.js';
8
+ import { openDirectoryToDevelopment } from './directory-access.js';
9
+ import { prepareWorkspaceCheckout } from './prepare.js';
10
+ import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
11
+ import { revisionScope } from './registry.js';
12
+ import { ensureWorkspaceSkills } from './skills.js';
13
+ const execute = promisify(execFile);
14
+ /** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
15
+ export async function registerWorkspaceDirectory(state, input, leaseFence) {
16
+ const directory = workspaceDirectoryRegistrationSchema.parse(input);
17
+ if (directory.directoryName === 'basic')
18
+ throw new WorkspaceContextError('workspace_directory_binding_conflict');
19
+ return state.journal.exclusive(`directory:${directory.workspaceId}:${directory.directoryName}`, async () => {
20
+ if (state.journal.destroyed())
21
+ throw new WorkspaceContextError('runtime_destroyed');
22
+ if (leaseFence < state.acceptedLeaseFence)
23
+ throw new WorkspaceContextError('stale_lease');
24
+ const binding = state.journal.getWorkspace(directory.workspaceId);
25
+ if (!binding || !binding.repositoryUrl)
26
+ throw new WorkspaceContextError('workspace_not_registered');
27
+ const existing = binding.directories?.find(item => item.directoryName === directory.directoryName);
28
+ if (existing && (existing.remixId !== directory.remixId || existing.branchName !== directory.branchName || (existing.sourceCommit !== undefined && existing.sourceCommit !== directory.sourceCommit)))
29
+ throw new WorkspaceContextError('workspace_directory_binding_conflict');
30
+ const parent = await workspaceContainerRoot(state, directory.workspaceId);
31
+ const root = join(parent, directory.directoryName);
32
+ const git = async (cwd, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
33
+ timeout: 90_000, maxBuffer: 1024 * 1024,
34
+ env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
35
+ })).stdout.trim();
36
+ const stat = await lstat(root).catch(() => undefined);
37
+ if (stat) {
38
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await git(root, 'symbolic-ref', '--short', 'HEAD') !== directory.branchName || await git(root, 'config', '--get', 'remote.origin.url') !== binding.repositoryUrl)
39
+ throw new WorkspaceContextError('workspace_checkout_mismatch');
40
+ }
41
+ else {
42
+ if (existing && existing.status !== 'pending')
43
+ throw new WorkspaceContextError('workspace_recovery_required');
44
+ if (!directory.sourceCommit)
45
+ throw new WorkspaceContextError('workspace_directory_source_commit_required');
46
+ const source = await workspaceRoot(state, directory.workspaceId, 'basic');
47
+ await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
48
+ const staging = join(parent, `.directory-${randomUUID()}`);
49
+ try {
50
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
51
+ await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
52
+ await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
53
+ await git(staging, 'config', 'user.name', 'PlayCraft');
54
+ await git(staging, 'config', 'user.email', 'playcraft@localhost');
55
+ await ensureWorkspaceSkills(staging, join(state.config.stateDir, 'skill-artifacts'));
56
+ if (leaseFence < state.acceptedLeaseFence)
57
+ throw new WorkspaceContextError('stale_lease');
58
+ await rename(staging, root);
59
+ }
60
+ finally {
61
+ await rm(staging, { recursive: true, force: true });
62
+ }
63
+ }
64
+ await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
65
+ state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
66
+ await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
67
+ await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
68
+ if (state.gitCredentials)
69
+ await state.gitCredentials.configure(binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
70
+ return directory;
71
+ });
72
+ }
73
+ /** A reclaimed checkout recovers its existing remote branch; no new variant or moving basic head is used. */
74
+ export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic') {
75
+ if (directoryName === 'root')
76
+ return;
77
+ await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
78
+ const binding = state.journal.getWorkspace(workspaceId);
79
+ const directory = binding?.directories?.find(item => item.directoryName === directoryName);
80
+ if (!binding || directory?.status !== 'reclaimed')
81
+ return;
82
+ if (binding.targetKind === 'standalone' || !state.gitCredentials)
83
+ throw new WorkspaceContextError('workspace_recovery_required');
84
+ const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
85
+ const { pendingFork: _pendingFork, directories: _directories, ...base } = binding;
86
+ const target = directoryName === 'basic' ? base : { ...base, targetKind: 'remix', remixId: directory.remixId, branchName: directory.branchName };
87
+ const root = join(state.config.workspaceRoot, workspaceId, directoryName);
88
+ if (!await lstat(root).catch(() => undefined))
89
+ await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
90
+ await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
91
+ await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
92
+ await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
93
+ try {
94
+ await state.gitCredentials.configure(binding, preparation, directoryName);
95
+ const scope = revisionScope(state, workspaceId, directoryName);
96
+ await scheduleDependencyInstall(state, scope, root);
97
+ if (!['ready', 'none'].includes(readDependencies(state, scope).status))
98
+ throw new WorkspaceContextError('workspace_directory_restore_dependencies_failed');
99
+ }
100
+ catch (error) {
101
+ const current = state.journal.getWorkspace(workspaceId);
102
+ await state.journal.putWorkspace({ ...current, directories: current.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'reclaimed' } : item) });
103
+ throw error;
104
+ }
105
+ });
106
+ }
@@ -0,0 +1,20 @@
1
+ import { chmod, chown, lstat, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
4
+ export async function openDirectoryToDevelopment(root, groupId) {
5
+ const visit = async (path) => {
6
+ const stat = await lstat(path);
7
+ if (stat.isSymbolicLink())
8
+ return;
9
+ if (stat.gid !== groupId)
10
+ await chown(path, stat.uid, groupId);
11
+ if (stat.isDirectory()) {
12
+ await chmod(path, (stat.mode & 0o777) | 0o2770);
13
+ for (const entry of await readdir(path))
14
+ await visit(join(path, entry));
15
+ }
16
+ else if (stat.isFile())
17
+ await chmod(path, (stat.mode & 0o777) | 0o660);
18
+ };
19
+ await visit(root);
20
+ }