@playcraft/cli 0.1.23 → 0.1.25

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 (65) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/playable.js +53 -0
  3. package/dist/commands/tools-3d.js +11 -0
  4. package/dist/commands/tools-batch.js +42 -14
  5. package/dist/commands/tools-generation.js +6 -0
  6. package/dist/commands/video.js +111 -0
  7. package/dist/index.js +29 -3
  8. package/dist/project-skills/builtin-import.js +225 -0
  9. package/dist/project-skills/commands.js +6 -1
  10. package/dist/project-skills/local-store.js +73 -1
  11. package/dist/project-skills/messages.js +8 -0
  12. package/dist/recording/messages.js +40 -0
  13. package/dist/recording/project-digest.js +85 -0
  14. package/dist/recording/recorder.js +293 -0
  15. package/dist/recording/scenario.js +50 -0
  16. package/dist/utils/agent-api-client.js +39 -3
  17. package/dist/utils/video-evidence-git.js +81 -0
  18. package/dist/video-replication/client.js +222 -0
  19. package/dist/video-replication/comparison-evidence.js +149 -0
  20. package/dist/video-replication/comparison-files.js +57 -0
  21. package/dist/video-replication/comparison-log.js +72 -0
  22. package/dist/video-replication/comparison-messages.js +104 -0
  23. package/dist/video-replication/comparison-output.js +63 -0
  24. package/dist/video-replication/comparison-preflight.js +66 -0
  25. package/dist/video-replication/comparison.js +216 -0
  26. package/dist/video-replication/evidence.js +165 -0
  27. package/dist/video-replication/media.js +111 -0
  28. package/dist/video-replication/messages.js +89 -0
  29. package/dist/workspace-runtime/codex/app-server.js +65 -12
  30. package/dist/workspace-runtime/codex/config-toml.js +3 -2
  31. package/dist/workspace-runtime/persistence/native-subagents.js +162 -0
  32. package/dist/workspace-runtime/persistence/sqlite-client.js +17 -1
  33. package/dist/workspace-runtime/persistence/sqlite-worker.js +116 -74
  34. package/dist/workspace-runtime/production/native.js +21 -1
  35. package/dist/workspace-runtime/production/save-command.js +5 -1
  36. package/dist/workspace-runtime/production/source-copy.js +91 -0
  37. package/dist/workspace-runtime/production/stages.js +48 -21
  38. package/dist/workspace-runtime/serve.js +3 -0
  39. package/dist/workspace-runtime/server/dispatch.js +34 -6
  40. package/dist/workspace-runtime/server/http.js +60 -3
  41. package/dist/workspace-runtime/server/terminal-host.js +2 -2
  42. package/dist/workspace-runtime/workspaces/artifact-events.js +55 -0
  43. package/dist/workspace-runtime/workspaces/artifact-files.js +195 -0
  44. package/dist/workspace-runtime/workspaces/artifact-receipts.js +89 -0
  45. package/dist/workspace-runtime/workspaces/artifacts.js +260 -0
  46. package/dist/workspace-runtime/workspaces/attachments.js +70 -4
  47. package/dist/workspace-runtime/workspaces/batch-commit.js +1 -1
  48. package/dist/workspace-runtime/workspaces/cli-context.js +23 -0
  49. package/dist/workspace-runtime/workspaces/directories.js +10 -3
  50. package/dist/workspace-runtime/workspaces/directory-access.js +9 -0
  51. package/dist/workspace-runtime/workspaces/disk-snapshot.js +31 -0
  52. package/dist/workspace-runtime/workspaces/empty-template.js +12 -4
  53. package/dist/workspace-runtime/workspaces/files.js +8 -1
  54. package/dist/workspace-runtime/workspaces/prepare.js +2 -1
  55. package/dist/workspace-runtime/workspaces/registry.js +13 -5
  56. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +45 -0
  57. package/dist/workspace-runtime/workspaces/skills.js +28 -18
  58. package/dist/workspace-runtime/workspaces/video-attachments.js +49 -0
  59. package/package.json +7 -4
  60. package/project-template/.claude/skills/playcraft-replica-validation/SKILL.md +25 -0
  61. package/project-template/.claude/skills/playcraft-replica-validation/references/contracts.md +79 -0
  62. package/project-template/.claude/skills/playcraft-video-understanding/SKILL.md +29 -0
  63. package/project-template/.claude/skills/playcraft-video-understanding/references/contracts.md +37 -0
  64. package/project-templates/empty/AGENTS.md +2 -0
  65. package/project-templates/empty/gitignore +2 -0
@@ -7,11 +7,12 @@
7
7
  * reports 3.51.2 and must not be used for this file.
8
8
  * Do not read Codex JSONL from this worker.
9
9
  */
10
- import { randomBytes, randomUUID } from 'node:crypto';
10
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
11
11
  import { chmodSync, mkdirSync } from 'node:fs';
12
12
  import { dirname } from 'node:path';
13
13
  import { parentPort } from 'node:worker_threads';
14
14
  import Database from 'better-sqlite3-pinned';
15
+ import { clearPendingNativeFollowups, learnNativeSubagents, nativeEventOwner, nativeSubagentThreads } from './native-subagents.js';
15
16
  import { hashOperationPayload, operationRecordSchema, pendingSourceEventSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
16
17
  const SCHEMA_VERSION = 1;
17
18
  const MIN_SQLITE = [3, 51, 3];
@@ -154,6 +155,16 @@ function openDatabase(payload) {
154
155
  else {
155
156
  quarantineReservedTurns(database);
156
157
  }
158
+ database.exec(`
159
+ CREATE INDEX IF NOT EXISTS native_inbox_pending_thread
160
+ ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
161
+ CREATE INDEX IF NOT EXISTS records_native_session_thread
162
+ ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
163
+ CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
164
+ ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
165
+ json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
166
+ WHERE converted = 0 AND method = 'thread/started';
167
+ `);
157
168
  return {
158
169
  ...dump(database),
159
170
  imported,
@@ -391,34 +402,13 @@ function ingest(database, body) {
391
402
  const ingress = nextIngress(database);
392
403
  const kind = String(body.kind);
393
404
  const method = optionalString(body.method);
394
- const session = threadId ? sessionByThread(database, threadId) : undefined;
395
405
  const hintedTurn = turnId || nativeIds(body.params).turnId;
396
- const operation = hintedTurn ? operationByTurn(database, hintedTurn) : undefined;
397
- const linked = canConvert(kind, threadId, hintedTurn, session, operation);
398
406
  database
399
407
  .prepare(`INSERT INTO native_inbox (
400
408
  ingress_sequence, runtime_epoch, kind, method, thread_id, turn_id, item_id, payload_json, converted, received_at
401
409
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
402
410
  .run(ingress, Number(body.runtimeEpoch) || 0, kind, method ?? null, threadId ?? null, hintedTurn ?? null, itemId ?? null, JSON.stringify(body.params ?? {}), String(body.occurredAt));
403
- if (!linked)
404
- return { committedEvents: [] };
405
- const earlier = operation
406
- ? releaseInbox(database, threadId ?? '', hintedTurn ?? '', { scopeKey: operation.scopeKey, operation: operation.operation }, ingress)
407
- : [];
408
- const event = convertInboxRow(database, ingress, {
409
- runtimeId: String(body.runtimeId),
410
- generation: Number(body.generation) || 0,
411
- runtimeEpoch: Number(body.runtimeEpoch) || 0,
412
- kind,
413
- method,
414
- threadId,
415
- turnId: hintedTurn,
416
- itemId,
417
- occurredAt: String(body.occurredAt),
418
- session,
419
- operation,
420
- });
421
- return { committedEvents: event ? [...earlier, event] : earlier };
411
+ return { committedEvents: releaseInbox(database, { ingress }, { runtimeId: String(body.runtimeId), generation: Number(body.generation) || 0 }) };
422
412
  }
423
413
  function bindTurn(database, body) {
424
414
  const operation = operationRecordSchema.parse(body.operation);
@@ -437,41 +427,81 @@ function bindTurn(database, body) {
437
427
  }
438
428
  const threadId = String(body.threadId);
439
429
  const turnId = String(body.turnId);
440
- const released = releaseInbox(database, threadId, turnId, { scopeKey: String(body.scopeKey), operation });
430
+ const released = releaseInbox(database, { rootThreadId: threadId, turnId });
441
431
  applyRecordedTurn(database, threadId, turnId);
442
432
  return { committedEvents: released };
443
433
  }
444
- function releaseInbox(database, threadId, turnId, bound, beforeIngress) {
445
- const rows = database
446
- .prepare(`SELECT ingress_sequence FROM native_inbox
447
- WHERE converted = 0
448
- AND (? IS NULL OR ingress_sequence < ?)
449
- AND (turn_id = ? OR (thread_id = ? AND turn_id IS NULL))
450
- ORDER BY ingress_sequence`)
451
- .all(beforeIngress ?? null, beforeIngress ?? null, turnId, threadId);
434
+ function releaseInbox(database, selection, identity) {
452
435
  const events = [];
453
- for (const row of rows) {
454
- const stored = database.prepare('SELECT * FROM native_inbox WHERE ingress_sequence = ?').get(row.ingress_sequence);
455
- if (stored.turn_id && stored.turn_id !== turnId)
436
+ const rows = [];
437
+ const queued = new Set();
438
+ const enqueue = (pending) => {
439
+ for (const row of pending) {
440
+ const ingress = Number(row.ingress_sequence);
441
+ if (!queued.has(ingress)) {
442
+ queued.add(ingress);
443
+ rows.push(row);
444
+ }
445
+ }
446
+ };
447
+ const enqueueChildren = (parentId) => enqueue(database.prepare(`
448
+ SELECT * FROM native_inbox WHERE converted = 0 AND method = 'thread/started'
449
+ AND COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
450
+ json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')) = ?
451
+ ORDER BY ingress_sequence
452
+ `).all(parentId));
453
+ const enqueueThread = (threadId) => {
454
+ enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? ORDER BY ingress_sequence').all(threadId));
455
+ enqueueChildren(threadId);
456
+ };
457
+ if ('ingress' in selection) {
458
+ const current = database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND ingress_sequence = ?').get(selection.ingress);
459
+ if (current) {
460
+ const threadId = optionalString(current.thread_id), turnId = optionalString(current.turn_id);
461
+ if (nativeEventOwner(database, threadId, turnId)) {
462
+ // A receipt can bind between notifications. Drain that thread's earlier events first.
463
+ enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? AND (turn_id = ? OR turn_id IS NULL) AND ingress_sequence <= ? ORDER BY ingress_sequence')
464
+ .all(threadId, turnId ?? null, selection.ingress));
465
+ }
466
+ else
467
+ enqueue([current]);
468
+ }
469
+ }
470
+ else {
471
+ enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? AND (turn_id = ? OR turn_id IS NULL) ORDER BY ingress_sequence')
472
+ .all(selection.rootThreadId, selection.turnId));
473
+ enqueueChildren(selection.rootThreadId);
474
+ for (const threadId of nativeSubagentThreads(database, selection.rootThreadId))
475
+ enqueueThread(threadId);
476
+ }
477
+ for (const stored of rows) {
478
+ const threadId = optionalString(stored.thread_id);
479
+ const turnId = optionalString(stored.turn_id);
480
+ const method = optionalString(stored.method);
481
+ const params = JSON.parse(String(stored.payload_json || '{}'));
482
+ if (stored.kind === 'process_exit')
483
+ for (const child of clearPendingNativeFollowups(database))
484
+ enqueueThread(child);
485
+ for (const child of learnNativeSubagents(database, method ?? '', params, threadId, turnId))
486
+ enqueueThread(child);
487
+ const owner = nativeEventOwner(database, threadId, turnId);
488
+ if (stored.kind !== 'process_exit' && !owner) {
489
+ queued.delete(Number(stored.ingress_sequence));
456
490
  continue;
457
- const session = sessionByThread(database, threadId);
458
- const operation = operationByTurn(database, turnId) ?? bound;
459
- const event = convertInboxRow(database, row.ingress_sequence, {
460
- runtimeId: metaString(database, 'runtime_id') || 'runtime',
461
- generation: metaNumber(database, 'generation'),
462
- runtimeEpoch: Number(stored.runtime_epoch) || metaNumber(database, 'runtime_epoch'),
463
- kind: String(stored.kind),
464
- method: optionalString(stored.method),
465
- threadId,
466
- turnId: optionalString(stored.turn_id) || turnId,
467
- itemId: optionalString(stored.item_id),
468
- occurredAt: String(stored.received_at),
469
- session,
470
- operation,
471
- params: JSON.parse(String(stored.payload_json || '{}')),
491
+ }
492
+ const event = convertInboxRow(database, Number(stored.ingress_sequence), {
493
+ runtimeId: identity?.runtimeId ?? (metaString(database, 'runtime_id') || 'runtime'),
494
+ generation: identity?.generation ?? metaNumber(database, 'generation'),
495
+ runtimeEpoch: Number(stored.runtime_epoch),
496
+ kind: String(stored.kind), method, threadId, turnId,
497
+ itemId: optionalString(stored.item_id), occurredAt: String(stored.received_at),
498
+ session: owner?.session, operation: owner?.operation, subAgent: owner?.subAgent, params,
472
499
  });
473
500
  if (event)
474
501
  events.push(event);
502
+ if (!owner?.subAgent && threadId && terminalTurn(method ?? '', asRecord(params)))
503
+ for (const child of nativeSubagentThreads(database, threadId))
504
+ enqueueThread(child);
475
505
  }
476
506
  return events;
477
507
  }
@@ -480,7 +510,12 @@ function convertInboxRow(database, ingress, input) {
480
510
  if (!row || row.converted)
481
511
  return undefined;
482
512
  const params = input.params ?? JSON.parse(row.payload_json || '{}');
483
- const payload = linkedPayload(params, input.session);
513
+ const payload = linkedPayload(params, input.session, input.subAgent);
514
+ const nativeApprovalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
515
+ const childApproval = input.subAgent && nativeApprovalId && input.method?.includes('requestApproval');
516
+ if (input.subAgent && nativeApprovalId && (childApproval || input.method === 'approval.resolved')) {
517
+ payload.approvalId = `subagent_${createHash('sha256').update(JSON.stringify([input.subAgent.threadId, nativeApprovalId])).digest('hex')}`;
518
+ }
484
519
  const type = input.kind === 'process_exit' ? 'process.exited' : input.method || input.kind;
485
520
  const allocated = allocateEvent(database, {
486
521
  runtimeId: input.runtimeId || metaString(database, 'runtime_id'),
@@ -504,20 +539,34 @@ function convertInboxRow(database, ingress, input) {
504
539
  SET converted = 1, source_sequence = ?, payload_json = '{}'
505
540
  WHERE ingress_sequence = ?`)
506
541
  .run(event.source.sourceSequence, ingress);
507
- applyNativeToState(database, { ...input, operation: resolveOperation(database, input, payload) }, payload);
542
+ if (childApproval && input.operation?.operation.sessionId) {
543
+ upsertRecord(database, 'approvals', `${input.operation.operation.sessionId}:${String(payload.approvalId)}`, {
544
+ approvalId: payload.approvalId,
545
+ sessionId: input.operation.operation.sessionId,
546
+ runtimeEpoch: input.runtimeEpoch,
547
+ nativeRequestId: `approval:${nativeApprovalId}`,
548
+ nativeApprovalId,
549
+ nativeThreadId: input.subAgent.threadId,
550
+ status: 'pending',
551
+ });
552
+ }
553
+ if (!input.subAgent)
554
+ applyNativeToState(database, { ...input, operation: resolveOperation(database, input, payload) }, payload);
508
555
  return event;
509
556
  }
510
557
  function applyRecordedTurn(database, threadId, turnId) {
511
558
  const rows = database
512
559
  .prepare(`SELECT type, payload_json, thread_id, session_id FROM source_events
513
- WHERE turn_id = ? OR payload_json LIKE ?
560
+ WHERE (thread_id = ? OR thread_id IS NULL) AND (turn_id = ? OR payload_json LIKE ?)
514
561
  ORDER BY source_sequence`)
515
- .all(turnId, `%"${turnId}"%`);
562
+ .all(threadId, turnId, `%"${turnId}"%`);
516
563
  for (const row of rows) {
517
564
  if (!row.type.includes('requestApproval') && !row.type.includes('requestUserInput') && !row.type.includes('completed') && !row.type.includes('interrupted') && row.type !== 'turn.failed' && row.type !== 'approval.resolved') {
518
565
  continue;
519
566
  }
520
567
  const payload = asRecord(JSON.parse(row.payload_json));
568
+ if (payload.subAgent)
569
+ continue;
521
570
  const session = sessionByThread(database, threadId) ?? (row.session_id ? readSession(database, row.session_id) : undefined);
522
571
  applyNativeToState(database, {
523
572
  method: row.type,
@@ -525,14 +574,14 @@ function applyRecordedTurn(database, threadId, turnId) {
525
574
  threadId: optionalString(row.thread_id) || threadId,
526
575
  turnId,
527
576
  session,
528
- operation: operationByTurn(database, turnId),
577
+ operation: operationByTurn(database, turnId, threadId),
529
578
  }, payload);
530
579
  }
531
580
  }
532
581
  function resolveOperation(database, input, payload) {
533
582
  const turnId = input.turnId || nativeIds(payload).turnId;
534
583
  if (turnId)
535
- return operationByTurn(database, turnId) ?? input.operation;
584
+ return input.operation ?? operationByTurn(database, turnId, input.threadId ?? nativeIds(payload).threadId);
536
585
  return input.operation;
537
586
  }
538
587
  function applyNativeToState(database, input, payload) {
@@ -563,7 +612,7 @@ function applyNativeToState(database, input, payload) {
563
612
  if (!input.operation)
564
613
  return;
565
614
  let operation = input.operation.operation;
566
- const approvalId = optionalString(payload.itemId) || optionalString(payload.approvalId) || input.itemId;
615
+ const approvalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
567
616
  if ((method.includes('requestApproval') || method.includes('requestUserInput')) && approvalId && operation.sessionId) {
568
617
  upsertRecord(database, 'approvals', `${operation.sessionId}:${approvalId}`, {
569
618
  approvalId,
@@ -851,10 +900,10 @@ function sessionByThread(database, threadId) {
851
900
  return undefined;
852
901
  return matches[0];
853
902
  }
854
- function operationByTurn(database, turnId) {
903
+ function operationByTurn(database, turnId, threadId) {
855
904
  const row = database
856
- .prepare('SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?')
857
- .get(turnId);
905
+ .prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''}`)
906
+ .get(...(threadId ? [turnId, threadId] : [turnId]));
858
907
  if (!row)
859
908
  return undefined;
860
909
  const parsed = operationRecordSchema.safeParse(JSON.parse(row.operation_json));
@@ -862,24 +911,17 @@ function operationByTurn(database, turnId) {
862
911
  return undefined;
863
912
  return { scopeKey: row.scope_key, operation: parsed.data };
864
913
  }
865
- function canConvert(kind, threadId, turnId, session, operation) {
866
- if (kind === 'process_exit')
867
- return true;
868
- if (turnId && !operation)
869
- return false;
870
- if (turnId && operation)
871
- return true;
872
- if (!session || !threadId)
873
- return false;
874
- return true;
875
- }
876
- function linkedPayload(params, session) {
877
- const payload = asRecord(params);
914
+ function linkedPayload(params, session, subAgent) {
915
+ const payload = { ...asRecord(params) };
878
916
  delete payload.workspaceId;
917
+ delete payload.sessionId;
918
+ delete payload.subAgent;
879
919
  if (typeof session?.workspaceId === 'string')
880
920
  payload.workspaceId = session.workspaceId;
881
921
  if (typeof session?.sessionId === 'string')
882
922
  payload.sessionId = session.sessionId;
923
+ if (subAgent)
924
+ payload.subAgent = subAgent;
883
925
  return payload;
884
926
  }
885
927
  function nativeIds(params) {
@@ -889,7 +931,7 @@ function nativeIds(params) {
889
931
  return {
890
932
  threadId: optionalString(record.threadId) || optionalString(thread.id),
891
933
  turnId: optionalString(record.turnId) || optionalString(turn.id),
892
- itemId: optionalString(record.itemId) || optionalString(record.approvalId),
934
+ itemId: optionalString(record.itemId) || optionalString(record.approvalId) || optionalString(asRecord(record.item).id),
893
935
  };
894
936
  }
895
937
  function terminalTurn(method, payload) {
@@ -1,18 +1,38 @@
1
1
  import { join } from 'node:path';
2
+ import { lstat, mkdir } from 'node:fs/promises';
2
3
  import { createCodexAppServerAdapter } from '../codex/app-server.js';
3
4
  import { verifyProductionLease } from './storage.js';
4
5
  import { processBirth, stopProductionGroup } from './processes.js';
6
+ import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
5
7
  export async function runProductionNative(state, input, root, record, track) {
6
8
  if (!input.model)
7
9
  throw new Error('production_model_configuration_required');
10
+ const temporaryRoot = join(root, '.playcraft-temp');
11
+ const existing = await lstat(temporaryRoot).catch(error => {
12
+ if (error.code === 'ENOENT')
13
+ return undefined;
14
+ throw error;
15
+ });
16
+ if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
17
+ throw new Error('production_source_path_invalid');
18
+ await mkdir(temporaryRoot, { recursive: true });
19
+ await openDirectoryToDevelopment(temporaryRoot, (await lstat(root)).gid);
8
20
  const groups = [];
9
21
  const adapter = createCodexAppServerAdapter({ config: { ...state.config, workspaceMode: 'single', workspaceRoot: root },
10
22
  command: process.env.PLAYCRAFT_PRODUCTION_CODEX_COMMAND,
11
23
  homeDir: process.env.PLAYCRAFT_LAUNCHER_SOCKET
12
24
  ? `/var/lib/playcraft/codex/production-${input.task.attemptId}` : join(state.config.stateDir, 'production-native', input.task.attemptId),
13
25
  model: input.model.model, processGroup: true,
26
+ fixedExecution: { cwd: root, approvalPolicy: 'never', sandboxPolicy: { type: 'workspaceWrite', writableRoots: [root],
27
+ networkAccess: true, excludeTmpdirEnvVar: true, excludeSlashTmp: true } },
14
28
  fixedThreadConfig: { model: input.model.model, modelProvider: 'playcraft_production', config: {
15
- approval_policy: 'never', sandbox_mode: 'danger-full-access',
29
+ approval_policy: 'never', sandbox_mode: 'workspace-write',
30
+ 'sandbox_workspace_write.writable_roots': [root],
31
+ 'sandbox_workspace_write.network_access': true,
32
+ 'sandbox_workspace_write.exclude_tmpdir_env_var': true,
33
+ 'sandbox_workspace_write.exclude_slash_tmp': true,
34
+ 'shell_environment_policy.set': { PLAYCRAFT_TOOL_REQUEST_DIR: join(temporaryRoot, 'tool-requests'), PLAYCRAFT_SKIP_UPDATE_CHECK: '1',
35
+ TMPDIR: temporaryRoot, npm_config_cache: join(temporaryRoot, 'npm-cache'), npm_config_store_dir: join(temporaryRoot, 'pnpm-store') },
16
36
  'model_providers.playcraft_production': { name: 'PlayCraft Production', base_url: input.model.endpoint,
17
37
  wire_api: 'responses', requires_openai_auth: false, http_headers: { Authorization: `Bearer ${input.model.token}` } },
18
38
  } },
@@ -14,7 +14,11 @@ if [ -n "$remote" ]; then
14
14
  git -c core.hooksPath=/dev/null -c core.fsmonitor=false reset --soft FETCH_HEAD
15
15
  fi
16
16
  git -c core.hooksPath=/dev/null -c core.fsmonitor=false add -A .
17
+ if [ -n "$(git ls-files -- .playcraft.json playcraft.config.json ':(glob)**/.playcraft.json' ':(glob)**/playcraft.config.json')" ]; then
18
+ printf 'production_git_private_path\\n' >&2
19
+ exit 1
20
+ fi
17
21
  git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save generated engineering"
18
- git -c protocol.version=0 -c core.hooksPath=/dev/null push "$PRODUCTION_REPOSITORY" HEAD:"refs/heads/$PRODUCTION_BRANCH"
22
+ git -c protocol.version=0 -c core.hooksPath=/dev/null -c remote.origin.url="$PRODUCTION_REPOSITORY" -c remote.origin.pushurl="$PRODUCTION_REPOSITORY" push --set-upstream origin HEAD:"refs/heads/$PRODUCTION_BRANCH"
19
23
  git rev-parse HEAD`;
20
24
  }
@@ -0,0 +1,91 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, join, relative } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { excludeFromGit } from '../workspaces/dependencies.js';
8
+ const execute = promisify(execFile);
9
+ const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
10
+ '.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
11
+ 'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
12
+ const engineering = (path) => !path.split('/').some(name => privateNames.has(name)
13
+ || /^\.env(?:$|\.)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name)) && !path.startsWith('.playcraft/skills/');
14
+ const inside = (root, path) => { const rel = relative(root, path); return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel); };
15
+ const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=',
16
+ '-c', 'core.fsmonitor=false', '-C', root, ...args], { timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
17
+ env: { PATH: process.env.PATH, HOME: dirname(root), GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } })).stdout;
18
+ async function sourceFiles(root) {
19
+ const paths = new Set((await git(root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z')).split('\0').filter(Boolean));
20
+ const artifacts = await readdir(join(root, '.playcraft/skill-artifacts')).catch(error => {
21
+ if (error.code === 'ENOENT')
22
+ return [];
23
+ throw error;
24
+ });
25
+ for (const name of artifacts)
26
+ if (/^[a-f0-9]{64}\.tar\.gz$/.test(name))
27
+ paths.add(`.playcraft/skill-artifacts/${name}`);
28
+ const files = new Map();
29
+ for (const path of [...paths].sort()) {
30
+ if (!engineering(path))
31
+ continue;
32
+ const full = join(root, path), stat = await lstat(full).catch(error => {
33
+ if (error.code === 'ENOENT')
34
+ return undefined;
35
+ throw error;
36
+ });
37
+ if (!stat)
38
+ continue;
39
+ if (!stat.isFile() || stat.isSymbolicLink() || !inside(root, await realpath(full)))
40
+ throw new Error('production_source_path_invalid');
41
+ const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
42
+ try {
43
+ files.set(path, { body: await handle.readFile(), mode: stat.mode & 0o777 });
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ }
49
+ return files;
50
+ }
51
+ function digest(files) {
52
+ const hash = createHash('sha256');
53
+ for (const [path, file] of files)
54
+ hash.update(path).update('\0').update(String(file.mode)).update('\0').update(file.body).update('\0');
55
+ return hash.digest('hex');
56
+ }
57
+ /** Capture the selected working tree into a private branch; the source index, head and configuration are read only. */
58
+ export async function copyProductionSource(input) {
59
+ const source = await realpath(input.source);
60
+ if (await lstat(input.destination).catch(() => undefined))
61
+ throw new Error('production_source_destination_exists');
62
+ const sourceHead = await git(source, 'rev-parse', '--verify', 'HEAD').catch(() => '');
63
+ const files = await sourceFiles(source), sourceDigest = digest(files);
64
+ const staging = join(dirname(input.destination), `.production-source-${randomUUID()}`);
65
+ try {
66
+ await mkdir(staging);
67
+ // An independent root commit also excludes credentials that existed only in source history.
68
+ await git(staging, '-c', 'init.templateDir=', 'init', '--initial-branch', input.branchName);
69
+ for (const [path, file] of files) {
70
+ await mkdir(dirname(join(staging, path)), { recursive: true });
71
+ await writeFile(join(staging, path), file.body, { mode: file.mode, flag: 'wx' });
72
+ }
73
+ if (sourceHead !== await git(source, 'rev-parse', '--verify', 'HEAD').catch(() => '') || sourceDigest !== digest(await sourceFiles(source)))
74
+ throw new Error('production_source_changed');
75
+ await git(staging, 'remote', 'add', 'origin', input.repositoryUrl);
76
+ await git(staging, 'config', 'user.name', 'PlayCraft');
77
+ await git(staging, 'config', 'user.email', 'playcraft@localhost');
78
+ await excludeFromGit(staging);
79
+ await excludeFromGit(staging, '/.env*');
80
+ for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skill-artifacts/', '/.playcraft/skills/', '/.playcraft-temp/'])
81
+ await excludeFromGit(staging, entry);
82
+ await git(staging, 'add', '-A', '.');
83
+ await git(staging, 'commit', '--allow-empty', '-m', 'Capture batch source in independent remix');
84
+ const commitHash = (await git(staging, 'rev-parse', 'HEAD')).trim();
85
+ await rename(staging, input.destination);
86
+ return { commitHash, sourceDigest };
87
+ }
88
+ finally {
89
+ await rm(staging, { recursive: true, force: true });
90
+ }
91
+ }
@@ -5,9 +5,9 @@ import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { batchBaselineSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { checkoutBatchCommit, freezeBatchCommit } from '../workspaces/batch-commit.js';
8
- import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
8
+ import { grantWorkspaceTraversal, openDirectoryToDevelopment } from '../workspaces/directory-access.js';
9
9
  import { registerWorkspaceDirectory } from '../workspaces/directories.js';
10
- import { workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
10
+ import { workspaceContainerRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
11
11
  import { excludeFromGit, settleDependencyInstalls } from '../workspaces/dependencies.js';
12
12
  import { resolveProductionDependencies, verifyProductionDependencyLocks } from './dependencies.js';
13
13
  import { productionSaveCommand } from './save-command.js';
@@ -15,6 +15,9 @@ import { productionOutputStore, verifyProductionLease } from './storage.js';
15
15
  import { runProductionCommand } from './processes.js';
16
16
  import { runProductionNative } from './native.js';
17
17
  import { classifyPreviewOutput, findExternalLocalReferences } from '../previews/single-file.js';
18
+ import { copyProductionSource } from './source-copy.js';
19
+ import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
20
+ import { ensureWorkspaceSkills } from '../workspaces/skills.js';
18
21
  const execute = promisify(execFile);
19
22
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
20
23
  const hash = (body) => createHash('sha256').update(body).digest('hex');
@@ -25,6 +28,10 @@ export async function executeProductionStage(state, input, processRecord, track)
25
28
  const stage = input.stage;
26
29
  const workspaceId = input.task.workspaceId;
27
30
  const resultId = String(input.payload.resultVersionId ?? input.task.payload.resultVersionId ?? input.task.attemptId);
31
+ const sourceDirectoryName = String(input.payload.sourceDirectoryName ?? input.task.payload.sourceDirectoryName ?? 'basic');
32
+ if (input.task.payload.sourceDirectoryName !== undefined && input.payload.sourceDirectoryName !== undefined
33
+ && input.task.payload.sourceDirectoryName !== input.payload.sourceDirectoryName)
34
+ throw new Error('production_source_directory_mismatch');
28
35
  const previous = (name) => input.stages?.find(value => value.stage === name && value.status === 'completed')?.result;
29
36
  const run = async (cwd, command, env, trusted = false) => {
30
37
  const outcome = await runProductionCommand(state, { processId: `production-${input.task.attemptId}-${stage}`, cwd, command, env, trusted,
@@ -38,7 +45,34 @@ export async function executeProductionStage(state, input, processRecord, track)
38
45
  const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
39
46
  timeout: 20_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' },
40
47
  })).stdout.trim();
41
- if (stage === 'prepare' && input.directoryName !== 'basic') {
48
+ if (stage === 'capture-source') {
49
+ if (input.directoryName === 'basic' || input.directoryName === sourceDirectoryName)
50
+ throw new Error('production_source_directory_mismatch');
51
+ const authority = record(input.payload.workspace);
52
+ if (authority.directoryName !== input.directoryName || authority.workspaceId !== workspaceId
53
+ || typeof authority.remixId !== 'string' || typeof authority.branchName !== 'string' || typeof authority.repositoryUrl !== 'string')
54
+ throw new Error('production_source_directory_mismatch');
55
+ const { remixId, branchName, repositoryUrl } = authority;
56
+ await settleDependencyInstalls(state);
57
+ return state.journal.exclusive(`directory:${workspaceId}:${sourceDirectoryName}`, async () => {
58
+ if (await workspaceDirectoryOccupied(state, workspaceId, sourceDirectoryName)
59
+ || workspaceSessions(state, workspaceId, sourceDirectoryName).some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
60
+ throw new Error('production_source_busy');
61
+ const source = await workspaceRoot(state, workspaceId, sourceDirectoryName);
62
+ const container = await workspaceContainerRoot(state, workspaceId);
63
+ const destination = join(container, input.directoryName);
64
+ const developmentGroup = (await lstat(state.config.workspaceRoot)).gid;
65
+ const snapshot = await copyProductionSource({ source, destination, branchName, repositoryUrl });
66
+ await ensureWorkspaceSkills(destination, join(state.config.stateDir, 'skill-artifacts'));
67
+ await openDirectoryToDevelopment(destination, developmentGroup);
68
+ await grantWorkspaceTraversal(container, developmentGroup);
69
+ const binding = state.journal.getWorkspace(workspaceId);
70
+ await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(value => value.directoryName !== input.directoryName),
71
+ { workspaceId, directoryName: input.directoryName, remixId, branchName, sourceCommit: snapshot.commitHash, status: 'ready' }] });
72
+ return { sourceSnapshot: snapshot, commitHash: snapshot.commitHash };
73
+ });
74
+ }
75
+ if (stage === 'prepare' && input.directoryName !== 'basic' && (input.payload.baseline ?? input.task.payload.baseline)) {
42
76
  const baseline = record(input.payload.baseline ?? input.task.payload.baseline);
43
77
  const authority = record(input.payload.workspace);
44
78
  const sourceCommit = baseline.commitHash;
@@ -46,28 +80,21 @@ export async function executeProductionStage(state, input, processRecord, track)
46
80
  throw new Error('production_approved_commit_required');
47
81
  const directory = { workspaceId, directoryName: input.directoryName,
48
82
  remixId: String(authority.remixId), branchName: String(authority.branchName), sourceCommit };
49
- await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence);
83
+ await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence, sourceDirectoryName);
50
84
  }
51
85
  const root = await workspaceRoot(state, workspaceId, input.directoryName);
52
- if (stage === 'capture-source') {
53
- if (input.directoryName !== 'basic')
54
- throw new Error('production_source_directory_mismatch');
55
- if (workspaceSessions(state, workspaceId, 'basic').some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
56
- throw new Error('production_source_busy');
57
- await settleDependencyInstalls(state);
58
- await resolveProductionDependencies(root);
59
- await excludeFromGit(root);
60
- await excludeFromGit(root, '/.env*');
61
- await git(root, 'config', 'user.name', 'PlayCraft');
62
- await git(root, 'config', 'user.email', 'playcraft@localhost');
63
- await run(root, 'git -c core.hooksPath=/dev/null add -A . && (git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Preserve basic before common repair")', undefined, true);
64
- const commitHash = await git(root, 'rev-parse', 'HEAD');
65
- const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-capture-source` });
66
- return { sourceSnapshot: snapshot, commitHash };
67
- }
68
86
  if (stage === 'prepare') {
69
87
  await settleDependencyInstalls(state);
70
- const policy = await resolveProductionDependencies(root);
88
+ if (!(await lstat(join(root, 'package.json')).catch(() => undefined)))
89
+ return { prepared: true, directoryName: input.directoryName, emptySource: true };
90
+ const policy = await resolveProductionDependencies(root).catch(error => {
91
+ if (!(input.payload.baseline ?? input.task.payload.baseline) && error instanceof Error
92
+ && error.message === 'production_dependency_lock_required')
93
+ return undefined;
94
+ throw error;
95
+ });
96
+ if (!policy)
97
+ return { prepared: true, directoryName: input.directoryName, dependencyRepairRequired: true };
71
98
  await run(root, process.env.PLAYCRAFT_PRODUCTION_INSTALL_COMMAND ?? 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
72
99
  await verifyProductionDependencyLocks(root, policy);
73
100
  return { prepared: true, directoryName: input.directoryName };
@@ -19,6 +19,7 @@ import { recoverInterruptedPreviews } from './previews/publish.js';
19
19
  import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
20
20
  import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
21
21
  import { workspaceContextFromRoot } from './workspaces/registry.js';
22
+ import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
22
23
  export async function startWorkspaceRuntime(config, options = {}) {
23
24
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
24
25
  const negotiated = negotiateProtocol(config);
@@ -63,6 +64,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
63
64
  gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
64
65
  await gitCredentials.start();
65
66
  }
67
+ const stopArtifactObserver = observeWorkspaceArtifacts(state);
66
68
  attachNativeCollector(state);
67
69
  native?.onUnexpectedExit?.((event) => {
68
70
  void handleExecutionGroupCrash(state, event.pid);
@@ -134,6 +136,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
134
136
  /* adapter shutdown is best-effort */
135
137
  }
136
138
  await serverClosed;
139
+ await stopArtifactObserver();
137
140
  await journal?.close();
138
141
  lock.release();
139
142
  }