@playcraft/cli 0.1.21 → 0.1.22

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 +16 -5
  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 +34 -24
  18. package/dist/workspace-runtime/server/http.js +110 -31
  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
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { batchCreateSchema } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { AgentApiClient } from '../utils/agent-api-client.js';
4
+ import { handleError } from './tools-utils.js';
5
+ const messages = {
6
+ en: {
7
+ create: 'Create a batch draft; confirm the whole batch in the browser',
8
+ input: 'Path to the batch draft JSON (JavaScript Object Notation, 数据交换格式) file',
9
+ status: 'Read a batch draft and its generation status',
10
+ id: 'Batch identifier',
11
+ created: 'Batch draft created',
12
+ help: '\nDraft shape: { clientRequestId, title, commonGoal, sourceWorkspaceId, engine, variants: [{ variantId, name, changes, preserves }] }\nUse the current workspace identifier as sourceWorkspaceId and a stable clientRequestId for retries. Maximum 10 variants. Confirm the batch in the browser.\n',
13
+ confirmation: 'Review the baseline and differences, then approve the whole batch in the browser.',
14
+ },
15
+ zh: {
16
+ create: '创建批次草案;整批确认须在浏览器完成',
17
+ input: '批次草案 JSON (JavaScript Object Notation, 数据交换格式) 文件路径',
18
+ status: '查询批次草案及生成状态',
19
+ id: '批次标识',
20
+ created: '批次草案已创建',
21
+ help: '\n草案格式:{ clientRequestId, title, commonGoal, sourceWorkspaceId, engine, variants: [{ variantId, name, changes, preserves }] }\nsourceWorkspaceId 填当前 workspace 标识,重试使用稳定的 clientRequestId。最多 10 个变体,整批在浏览器确认。\n',
22
+ confirmation: '请在浏览器查看基准和差异清单,然后确认整批。',
23
+ },
24
+ };
25
+ const language = () => /^zh/i.test(process.env.PLAYCRAFT_LANG ?? process.env.LANG ?? '') ? messages.zh : messages.en;
26
+ export async function createBatchDraft(path, client = new AgentApiClient()) {
27
+ const draft = batchCreateSchema.parse(JSON.parse(await readFile(path, 'utf8')));
28
+ return client.post('/create-batch', draft);
29
+ }
30
+ export function registerBatchCommands(tools) {
31
+ const t = language();
32
+ tools.command('create-batch')
33
+ .description(t.create)
34
+ .requiredOption('--input <path>', t.input)
35
+ .option('--json', 'JSON (JavaScript Object Notation, 数据交换格式)')
36
+ .addHelpText('after', t.help)
37
+ .action(async (opts) => {
38
+ try {
39
+ const result = await createBatchDraft(opts.input);
40
+ console.log(opts.json ? JSON.stringify(result, null, 2) : `${t.created}: ${result.batchId}\n${t.confirmation}`);
41
+ }
42
+ catch (error) {
43
+ handleError(error);
44
+ }
45
+ });
46
+ tools.command('get-batch')
47
+ .description(t.status)
48
+ .requiredOption('--batch-id <id>', t.id)
49
+ .action(async (opts) => {
50
+ try {
51
+ const client = new AgentApiClient();
52
+ const result = await client.get('/get-batch', { batchId: opts.batchId });
53
+ console.log(JSON.stringify(result, null, 2));
54
+ }
55
+ catch (error) {
56
+ handleError(error);
57
+ }
58
+ });
59
+ }
@@ -1,4 +1,5 @@
1
1
  import { registerGenerationCommands } from './tools-generation.js';
2
+ import { registerBatchCommands } from './tools-batch.js';
2
3
  import { registerProjectCommands } from './tools-project.js';
3
4
  import { register3DCommands } from './tools-3d.js';
4
5
  import { registerDetectCommand } from './tools-detect.js';
@@ -9,6 +10,7 @@ export function registerToolsCommands(program) {
9
10
  .description('后端 /api/agent/tools;运行 playcraft login 后即可直接使用,也支持 .playcraft.json 或 PLAYCRAFT_SANDBOX_TOKEN 环境变量');
10
11
  registerGenerationCommands(tools);
11
12
  registerProjectCommands(tools);
13
+ registerBatchCommands(tools);
12
14
  register3DCommands(tools);
13
15
  registerDetectCommand(tools);
14
16
  registerVideoReviewCommand(tools);
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { startLauncherProcess } from './launcher-client.js';
4
4
  import { chmod, mkdtemp, rm } from 'node:fs/promises';
5
- import { join } from 'node:path';
5
+ import { relative, join } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { portableCodexConfig, writePortableCodexCatalog } from './config-toml.js';
8
8
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
@@ -87,7 +87,7 @@ export function createCodexAppServerAdapter(options) {
87
87
  async function threadConfig(cwd, modelConfigId, sessionId) {
88
88
  try {
89
89
  return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId)
90
- ?? { modelConfigId: undefined, activate: () => { } };
90
+ ?? { ...options.fixedThreadConfig, modelConfigId: undefined, activate: () => { } };
91
91
  }
92
92
  catch (error) {
93
93
  const reason = error instanceof Error ? error.message : '';
@@ -100,7 +100,9 @@ export function createCodexAppServerAdapter(options) {
100
100
  const { modelConfigId, activate, ...modelConfig } = await threadConfig(cwd, selectedConfigId, sessionId);
101
101
  // Single-workspace legacy runners retain their prior behaviour until explicitly adopted.
102
102
  if (await readProjectSkillsLockFile(cwd) === null) {
103
- if (options.config.workspaceMode === 'multi')
103
+ const scope = relative(options.config.workspaceRoot, cwd);
104
+ const orchestration = /^[0-9a-f-]{36}$/i.test(scope);
105
+ if (options.config.workspaceMode === 'multi' && !orchestration)
104
106
  throw new NativePreparationError('workspace_skills_unavailable');
105
107
  return { params: modelConfig, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined };
106
108
  }
@@ -234,14 +236,23 @@ export function createCodexAppServerAdapter(options) {
234
236
  cwd: options.config.workspaceRoot,
235
237
  env: { ...process.env, ...options.env, CODEX_HOME: homeDir, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
236
238
  stdio: ['pipe', 'pipe', 'pipe'],
239
+ detached: options.processGroup === true,
237
240
  });
238
241
  await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
239
242
  child = {
240
243
  pid: local.pid, stdin: local.stdin, stdout: local.stdout, stderr: local.stderr,
241
244
  onExit(listener) { local.once('exit', listener); },
242
245
  async stop() {
243
- if (local.exitCode === null && local.signalCode === null)
244
- local.kill('SIGTERM');
246
+ if (local.exitCode === null && local.signalCode === null) {
247
+ if (options.processGroup && local.pid) {
248
+ try {
249
+ process.kill(-local.pid, 'SIGTERM');
250
+ }
251
+ catch { /* already stopped */ }
252
+ }
253
+ else
254
+ local.kill('SIGTERM');
255
+ }
245
256
  await waitForChildExit(local);
246
257
  },
247
258
  };
@@ -2,9 +2,9 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { Readable } from 'node:stream';
4
4
  import { pipeline } from 'node:stream/promises';
5
- import { basename } from 'node:path';
5
+ import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
- import { workspaceRoot } from '../workspaces/registry.js';
7
+ import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
9
  const credentialSchema = z.object({
10
10
  token: z.string().min(1), expiresAt: z.string().datetime(),
@@ -49,9 +49,9 @@ export class WorkspaceModelBroker {
49
49
  return settings;
50
50
  }
51
51
  async prepareThreadConfig(cwd, modelConfigId, sessionId) {
52
- const workspaceId = basename(cwd);
53
- if (!this.server || await workspaceRoot(this.state, workspaceId) !== cwd)
52
+ if (!this.server)
54
53
  throw new Error('workspace_not_registered');
54
+ const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
55
55
  if (modelConfigId !== undefined && !z.string().uuid().safeParse(modelConfigId).success)
56
56
  throw new Error('model_config_denied');
57
57
  const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
@@ -217,7 +217,7 @@ export class WorkspaceModelBroker {
217
217
  }
218
218
  }
219
219
  cancelSession(cwd, sessionId) {
220
- const routeKey = `${basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
220
+ const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
221
221
  const route = this.routes.get(routeKey);
222
222
  if (route)
223
223
  for (const request of this.sessionRequests.get(route) ?? [])
@@ -7,7 +7,7 @@ import { join } from 'node:path';
7
7
  import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
8
8
  import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
9
9
  import { runDeveloperCommand } from '../workspaces/developer-command.js';
10
- import { workspaceRoot } from '../workspaces/registry.js';
10
+ import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
11
11
  import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
12
12
  const BUILD_TIMEOUT_MS = 10 * 60_000;
13
13
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
@@ -36,11 +36,11 @@ export async function previewWorkspaceRoot(state, operation) {
36
36
  }
37
37
  if (operation.sessionId !== undefined) {
38
38
  const session = state.journal.getSession(operation.sessionId);
39
- if (!session || (state.config.workspaceMode === 'multi' && session.workspaceId !== operation.workspaceId)) {
39
+ if (!session || (state.config.workspaceMode === 'multi' && (session.workspaceId !== operation.workspaceId || (session.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic')))) {
40
40
  throw new PreviewFailure('session_workspace_mismatch');
41
41
  }
42
42
  }
43
- return workspaceRoot(state, operation.workspaceId);
43
+ return workspaceRoot(state, operation.workspaceId, operation.directoryName);
44
44
  }
45
45
  /** Tracks a background publish so tests and shutdown can wait for it. */
46
46
  export function trackPreviewPublish(state, task) {
@@ -68,7 +68,7 @@ export async function runPreviewPublish(state, operation, root) {
68
68
  const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
69
69
  const output = { log: '' };
70
70
  try {
71
- await ensureDependencies(state, workspaceId, root, output);
71
+ await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
72
72
  await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
73
73
  await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
74
74
  await buildPreview(state, root, outputDir, operation.operationId, output);
@@ -83,6 +83,7 @@ export async function runPreviewPublish(state, operation, root) {
83
83
  const source = await sourceRevision(root);
84
84
  const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
85
85
  workspaceId,
86
+ directoryName: operation.directoryName ?? 'basic',
86
87
  ...scope,
87
88
  operationId: operation.operationId,
88
89
  contentDigest,
@@ -112,6 +113,7 @@ export async function runPreviewPublish(state, operation, root) {
112
113
  }
113
114
  const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
114
115
  workspaceId,
116
+ directoryName: operation.directoryName ?? 'basic',
115
117
  ...scope,
116
118
  operationId: operation.operationId,
117
119
  artifactId: reserved.artifactId,
@@ -131,6 +133,7 @@ export async function runPreviewPublish(state, operation, root) {
131
133
  if (code !== 'publish_conflict') {
132
134
  await postControl(state, '/runtime/internal/preview-failures', {
133
135
  workspaceId,
136
+ directoryName: operation.directoryName ?? 'basic',
134
137
  ...scope,
135
138
  operationId: operation.operationId,
136
139
  error: code,
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ export const productionStageNames = ['capture-source', 'prepare', 'generate', 'freeze', 'save', 'build', 'preview'];
3
+ export const productionExecutionRequestSchema = z.object({
4
+ action: z.enum(['stage', 'stop', 'reconcile']),
5
+ task: z.object({ taskId: z.string().uuid(), attemptId: z.string().uuid(), batchId: z.string().uuid(), workspaceId: z.string().uuid(),
6
+ environment: z.string().min(1), userId: z.string().min(1), payload: z.record(z.unknown()), }).passthrough(),
7
+ lease: z.object({ taskId: z.string().uuid(), attemptId: z.string().uuid(), owner: z.string().min(1), fence: z.number().int().positive() }).strict(),
8
+ directoryName: z.string().regex(/^(?:basic|remix-[a-z0-9][a-z0-9-]{3,63})$/).default('basic'), generation: z.number().int().positive(), runtimeEpoch: z.number().int().nonnegative(),
9
+ callbackUrl: z.string().url(), runnerToken: z.string().min(1), payload: z.record(z.unknown()),
10
+ stage: z.enum(productionStageNames).optional(),
11
+ stages: z.array(z.object({ stage: z.enum(productionStageNames), requestId: z.string(), status: z.string(),
12
+ result: z.record(z.unknown()).optional(), payload: z.record(z.unknown()).optional() }).passthrough()).optional(),
13
+ source: z.object({ workspaceId: z.string().uuid(), generation: z.number().int().positive(), runtimeId: z.string().min(1) }).optional(),
14
+ model: z.object({ endpoint: z.string().url(), token: z.string().min(1), model: z.string().min(1) }).optional(),
15
+ git: z.object({ repositoryUrl: z.string().min(1), branchName: z.string().min(1), username: z.string().optional(), password: z.string().optional() }).optional(),
16
+ repairSnapshot: z.unknown().optional(), repairSource: z.unknown().optional(), repairCommitHash: z.string().optional(),
17
+ }).strict();
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { hashOperationPayload } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { productionExecutionRequestSchema } from './contracts.js';
8
+ import { executeProductionStage } from './stages.js';
9
+ import { groupExited, stopProductionGroup } from './processes.js';
10
+ import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
11
+ import { settleDependencyInstalls } from '../workspaces/dependencies.js';
12
+ import { verifyProductionLease } from './storage.js';
13
+ const execute = promisify(execFile);
14
+ const active = new WeakMap();
15
+ const receiptsRoot = (state) => join(state.config.stateDir, 'production-receipts');
16
+ const attemptPath = (state, id) => join(receiptsRoot(state), `${id}.attempt.json`);
17
+ const receiptPath = (state, id, stage) => join(receiptsRoot(state), `${id}-${stage}.json`);
18
+ async function read(path) {
19
+ try {
20
+ return JSON.parse(await readFile(path, 'utf8'));
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT')
24
+ return undefined;
25
+ throw error;
26
+ }
27
+ }
28
+ async function write(state, path, value) {
29
+ await mkdir(receiptsRoot(state), { recursive: true, mode: 0o700 });
30
+ const temporary = `${path}.${randomUUID()}.tmp`;
31
+ await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
32
+ await rename(temporary, path);
33
+ }
34
+ export async function productionCapability(state) {
35
+ if (state.config.workspaceMode !== 'multi' || state.journal.destroyed())
36
+ throw new Error('production_executor_unavailable');
37
+ const command = process.env.PLAYCRAFT_PRODUCTION_CODEX_COMMAND ?? process.env.PLAYCRAFT_CODEX_COMMAND ?? 'codex';
38
+ const binary = command.endsWith('.mjs') || command.endsWith('.js') ? process.execPath : command;
39
+ await Promise.all([execute('git', ['--version']), execute('pnpm', ['--version']),
40
+ execute(binary, binary === process.execPath ? [command, '--version'] : ['--version'])]);
41
+ return { available: true, protocol: 1 };
42
+ }
43
+ export async function productionDirectoryBusy(state, workspaceId, directoryName = 'basic') {
44
+ if (!workspaceId)
45
+ return false;
46
+ const files = await readdir(receiptsRoot(state)).catch(error => {
47
+ if (error.code === 'ENOENT')
48
+ return [];
49
+ throw error;
50
+ });
51
+ for (const file of files.filter(value => value.endsWith('.attempt.json'))) {
52
+ const attempt = await read(join(receiptsRoot(state), file));
53
+ if (attempt && !attempt.stopped && attempt.workspaceId === workspaceId && attempt.directoryName === directoryName)
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ export async function executeSandboxProduction(state, raw) {
59
+ const input = productionExecutionRequestSchema.parse(raw);
60
+ if (input.task.environment !== state.config.environment || input.task.userId !== state.config.userId
61
+ || input.generation !== state.config.generation || input.runtimeEpoch !== state.runtimeEpoch
62
+ || input.lease.taskId !== input.task.taskId || input.lease.attemptId !== input.task.attemptId)
63
+ throw new Error('production_scope_mismatch');
64
+ return state.journal.exclusive(`directory:${input.task.workspaceId}:${input.directoryName}`, () => state.journal.exclusive(`production:${input.task.attemptId}`, async () => {
65
+ const path = attemptPath(state, input.task.attemptId);
66
+ let attempt = await read(path);
67
+ if (attempt && (attempt.taskId !== input.task.taskId || attempt.workspaceId !== input.task.workspaceId
68
+ || attempt.directoryName !== input.directoryName || attempt.fence > input.lease.fence
69
+ || (attempt.fence === input.lease.fence && attempt.owner !== input.lease.owner)))
70
+ throw new Error('production_scope_mismatch');
71
+ if (input.action !== 'stage')
72
+ return stopOrReconcile(state, input, attempt);
73
+ if (!input.stage)
74
+ throw new Error('invalid_production_stage');
75
+ if (attempt?.stopped || attempt?.cancelling)
76
+ return { status: 'failed', result: { error: 'production_execution_stopped' } };
77
+ const file = receiptPath(state, input.task.attemptId, input.stage);
78
+ const old = await read(file);
79
+ const payloadHash = hashOperationPayload(input.payload);
80
+ if (old) {
81
+ if (old.payloadHash !== payloadHash)
82
+ throw new Error('production_payload_conflict');
83
+ // Existing intent only reports evidence. It never authorizes a second native turn.
84
+ return { status: old.status, result: old.result };
85
+ }
86
+ if (active.get(state)?.has(input.task.attemptId))
87
+ throw new Error('production_stage_still_running');
88
+ await verifyProductionLease(input);
89
+ if (!attempt) {
90
+ if (input.stage === 'capture-source' || input.stage === 'prepare')
91
+ await settleDependencyInstalls(state);
92
+ if (state.journal.getWorkspace(input.task.workspaceId)?.directories?.some(directory => directory.directoryName === input.directoryName && directory.status === 'reclaimed'))
93
+ throw new Error('production_directory_reclaimed');
94
+ if (await workspaceDirectoryOccupied(state, input.task.workspaceId, input.directoryName))
95
+ throw new Error('production_directory_occupied');
96
+ attempt = { taskId: input.task.taskId, attemptId: input.task.attemptId, workspaceId: input.task.workspaceId,
97
+ directoryName: input.directoryName, owner: input.lease.owner, fence: input.lease.fence, stopped: false };
98
+ }
99
+ attempt.owner = input.lease.owner;
100
+ attempt.fence = input.lease.fence;
101
+ await write(state, path, attempt);
102
+ const receipt = { status: 'unknown', result: { error: 'production_stage_running' },
103
+ taskId: input.task.taskId, attemptId: input.task.attemptId, requestId: `${input.task.attemptId}:${input.stage}`,
104
+ stage: input.stage, workspaceId: input.task.workspaceId, directoryName: input.directoryName,
105
+ fence: input.lease.fence, payloadHash, startedAt: new Date().toISOString(), processes: [] };
106
+ await write(state, file, receipt);
107
+ const running = active.get(state) ?? new Map();
108
+ active.set(state, running);
109
+ const entry = { task: Promise.resolve(receipt), stops: [] };
110
+ entry.task = runStage(state, input, receipt, entry).finally(() => running.delete(input.task.attemptId));
111
+ running.set(input.task.attemptId, entry);
112
+ // Returning from the lock lets stop and reconciliation reach the in-flight native group.
113
+ return { status: 'unknown', result: { error: 'production_stage_running' } };
114
+ })).then(async (result) => {
115
+ const current = active.get(state)?.get(input.task.attemptId);
116
+ return input.action === 'stage' && current ? current.task : result;
117
+ });
118
+ }
119
+ async function runStage(state, input, receipt, running) {
120
+ const file = receiptPath(state, input.task.attemptId, input.stage);
121
+ let writes = Promise.resolve();
122
+ const recordProcess = async (process) => {
123
+ const at = receipt.processes.findIndex(value => value.pid === process.pid);
124
+ if (at === -1)
125
+ receipt.processes.push(process);
126
+ else
127
+ receipt.processes[at] = process;
128
+ writes = writes.then(() => write(state, file, receipt));
129
+ await writes;
130
+ const attempt = await read(attemptPath(state, input.task.attemptId));
131
+ if (attempt?.cancelling && !process.stopped) {
132
+ process.stopped = await stopProductionGroup(process);
133
+ throw new Error('production_execution_stopped');
134
+ }
135
+ };
136
+ try {
137
+ receipt.result = await executeProductionStage(state, input, recordProcess, stop => running.stops.push(stop));
138
+ await verifyProductionLease(input);
139
+ receipt.status = 'completed';
140
+ }
141
+ catch (error) {
142
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'production_stage_failed';
143
+ receipt.status = code.includes('unknown') || code.includes('unavailable') ? 'unknown' : 'failed';
144
+ receipt.result = { error: code };
145
+ }
146
+ if (receipt.processes.some(process => !process.stopped && !groupExited(process.pid))) {
147
+ receipt.status = 'unknown';
148
+ receipt.result = { error: 'production_stop_unconfirmed' };
149
+ }
150
+ receipt.finishedAt = new Date().toISOString();
151
+ await writes;
152
+ await write(state, file, receipt);
153
+ return { status: receipt.status, result: receipt.result };
154
+ }
155
+ async function attemptReceipts(state, attemptId) {
156
+ const files = await readdir(receiptsRoot(state)).catch(() => []);
157
+ const receipts = [];
158
+ for (const file of files.filter(value => value.startsWith(`${attemptId}-`) && value.endsWith('.json'))) {
159
+ const receipt = await read(join(receiptsRoot(state), file));
160
+ if (receipt)
161
+ receipts.push(receipt);
162
+ }
163
+ return receipts;
164
+ }
165
+ async function stopOrReconcile(state, input, old) {
166
+ const attempt = old ?? { taskId: input.task.taskId, attemptId: input.task.attemptId, workspaceId: input.task.workspaceId,
167
+ directoryName: input.directoryName, owner: input.lease.owner, fence: input.lease.fence, stopped: false };
168
+ attempt.owner = input.lease.owner;
169
+ attempt.fence = input.lease.fence;
170
+ attempt.cancelling = true;
171
+ await write(state, attemptPath(state, attempt.attemptId), attempt);
172
+ const running = active.get(state)?.get(attempt.attemptId);
173
+ await Promise.allSettled(running?.stops.map(stop => stop()) ?? []);
174
+ const receipts = await attemptReceipts(state, attempt.attemptId);
175
+ let groupsStopped = true;
176
+ for (const receipt of receipts) {
177
+ for (const process of receipt.processes) {
178
+ process.stopped = await stopProductionGroup(process);
179
+ if (!process.stopped)
180
+ groupsStopped = false;
181
+ }
182
+ await write(state, receiptPath(state, receipt.attemptId, receipt.stage), receipt);
183
+ }
184
+ if (running)
185
+ await Promise.race([running.task.catch(() => undefined), new Promise(resolve => setTimeout(resolve, 5000))]);
186
+ const dispatchStopped = !active.get(state)?.has(attempt.attemptId);
187
+ attempt.stopped = groupsStopped && dispatchStopped;
188
+ await write(state, attemptPath(state, attempt.attemptId), attempt);
189
+ const durable = await attemptReceipts(state, attempt.attemptId);
190
+ return { status: attempt.stopped ? 'completed' : 'unknown', result: {
191
+ executionStopped: attempt.stopped, managedWritesStopped: attempt.stopped,
192
+ receipts: Object.fromEntries(durable.map(receipt => [receipt.stage, { status: receipt.status, result: receipt.result }])),
193
+ } };
194
+ }
195
+ export async function sandboxProductionReceipt(state, operationId, identity) {
196
+ if (!/^[a-f0-9-]{36}:(capture-source|prepare|generate|freeze|save|build|preview)$/.test(operationId))
197
+ throw new Error('production_receipt_invalid');
198
+ const [attemptId, stage] = operationId.split(':');
199
+ const receipt = await read(receiptPath(state, attemptId, stage));
200
+ if (!receipt || receipt.taskId !== identity.taskId || receipt.attemptId !== identity.attemptId || receipt.workspaceId !== identity.workspaceId
201
+ || receipt.directoryName !== identity.directoryName || receipt.fence > identity.fence)
202
+ throw new Error('production_receipt_scope_mismatch');
203
+ const attempt = await read(attemptPath(state, identity.attemptId));
204
+ if (!attempt || attempt.owner !== identity.owner || attempt.fence !== identity.fence)
205
+ throw new Error('production_receipt_scope_mismatch');
206
+ return { status: receipt.status, result: receipt.result };
207
+ }
@@ -0,0 +1,60 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ const LOCKS = [
5
+ ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'],
6
+ ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'],
7
+ ];
8
+ const hash = (body) => createHash('sha256').update(body).digest('hex');
9
+ async function optional(path) {
10
+ try {
11
+ return await readFile(path);
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT')
15
+ return undefined;
16
+ throw error;
17
+ }
18
+ }
19
+ /** Resolve one locked toolchain. A conflicting lock or floating declaration never silently selects npm. */
20
+ export async function resolveProductionDependencies(root) {
21
+ const packageBody = await readFile(join(root, 'package.json'));
22
+ const pkg = JSON.parse(packageBody.toString());
23
+ const bodies = new Map();
24
+ const managers = new Set();
25
+ for (const [name, manager] of LOCKS) {
26
+ const body = await optional(join(root, name));
27
+ if (body) {
28
+ bodies.set(name, body);
29
+ managers.add(manager);
30
+ }
31
+ }
32
+ if (!managers.size)
33
+ throw new Error('production_dependency_lock_required');
34
+ if (managers.size !== 1)
35
+ throw new Error('production_dependency_locks_conflict');
36
+ const manager = [...managers][0];
37
+ if (manager !== 'pnpm')
38
+ throw new Error('production_pnpm_required_convert_first');
39
+ const declared = typeof pkg.packageManager === 'string'
40
+ ? /^(npm|pnpm|yarn|bun)@(\d+\.\d+\.\d+)(?:\+sha(?:224|512)\.[a-fA-F0-9]+)?$/.exec(pkg.packageManager) : undefined;
41
+ if (pkg.packageManager !== undefined && !declared)
42
+ throw new Error('production_package_manager_declaration_invalid');
43
+ if (declared && declared[1] !== manager)
44
+ throw new Error('production_package_manager_lock_mismatch');
45
+ const modernYarn = false;
46
+ const pnpmLock = bodies.get('pnpm-lock.yaml')?.toString();
47
+ const oldPnpm = /^lockfileVersion:\s*['\"]?6(?:\.|['\"]?\s*$)/m.test(pnpmLock ?? '');
48
+ const version = declared?.[2] ?? (oldPnpm ? '8.15.9' : '9.15.9');
49
+ return { manager, version, reference: declared ? String(pkg.packageManager) : `${manager}@${version}`, modernYarn,
50
+ locks: Object.fromEntries([...bodies].map(([name, body]) => [name, hash(body)])), packageDigest: hash(packageBody) };
51
+ }
52
+ export async function verifyProductionDependencyLocks(root, policy) {
53
+ for (const [name] of LOCKS) {
54
+ const body = await optional(join(root, name));
55
+ if ((body ? hash(body) : undefined) !== policy.locks[name])
56
+ throw new Error('production_dependency_lock_changed');
57
+ }
58
+ if (hash(await readFile(join(root, 'package.json'))) !== policy.packageDigest)
59
+ throw new Error('production_dependency_manifest_changed');
60
+ }
@@ -0,0 +1,70 @@
1
+ import { join } from 'node:path';
2
+ import { createCodexAppServerAdapter } from '../codex/app-server.js';
3
+ import { verifyProductionLease } from './storage.js';
4
+ import { processBirth, stopProductionGroup } from './processes.js';
5
+ export async function runProductionNative(state, input, root, record, track) {
6
+ if (!input.model)
7
+ throw new Error('production_model_configuration_required');
8
+ const groups = [];
9
+ const adapter = createCodexAppServerAdapter({ config: { ...state.config, workspaceMode: 'single', workspaceRoot: root },
10
+ command: process.env.PLAYCRAFT_PRODUCTION_CODEX_COMMAND,
11
+ homeDir: process.env.PLAYCRAFT_LAUNCHER_SOCKET
12
+ ? `/var/lib/playcraft/codex/production-${input.task.attemptId}` : join(state.config.stateDir, 'production-native', input.task.attemptId),
13
+ model: input.model.model, processGroup: true,
14
+ fixedThreadConfig: { model: input.model.model, modelProvider: 'playcraft_production', config: {
15
+ approval_policy: 'never', sandbox_mode: 'danger-full-access',
16
+ 'model_providers.playcraft_production': { name: 'PlayCraft Production', base_url: input.model.endpoint,
17
+ wire_api: 'responses', requires_openai_auth: false, http_headers: { Authorization: `Bearer ${input.model.token}` } },
18
+ } },
19
+ onSkillProcessRestart: async (process) => {
20
+ const group = { pid: process.pid, birth: await processBirth(process.pid), stopped: false };
21
+ groups.push(group);
22
+ await record(group);
23
+ },
24
+ });
25
+ track(() => adapter.close());
26
+ const sessionId = `production-${input.task.attemptId}`;
27
+ let completion;
28
+ const done = new Promise(resolve => { completion = resolve; });
29
+ const unsubscribe = adapter.onCollected?.(event => {
30
+ if (event.kind === 'process_exit')
31
+ completion('unknown');
32
+ if (event.method === 'turn/completed') {
33
+ const value = event.params;
34
+ completion(value.turn?.status === 'completed' ? 'completed' : 'failed');
35
+ }
36
+ });
37
+ try {
38
+ await adapter.start();
39
+ const process = adapter.executionProcess?.();
40
+ if (!process)
41
+ throw new Error('production_process_not_started');
42
+ const group = { pid: process.pid, birth: await processBirth(process.pid), stopped: false };
43
+ groups.push(group);
44
+ await record(group);
45
+ const thread = await adapter.startThread({ sessionId, cwd: root });
46
+ const turn = await adapter.startTurn({ sessionId, threadId: thread.threadId, text: String(input.payload.prompt ?? ''),
47
+ clientRequestId: `${input.task.attemptId}:generate`, persistReservation: async () => undefined });
48
+ if (turn.outcome !== 'accepted')
49
+ throw new Error('production_native_start_unknown');
50
+ const timeout = setTimeout(() => completion('unknown'), 30 * 60_000);
51
+ let checking = false;
52
+ const leaseCheck = setInterval(() => {
53
+ if (checking)
54
+ return;
55
+ checking = true;
56
+ void verifyProductionLease(input).catch(async () => { completion('unknown'); await adapter.close?.().catch(() => undefined); }).finally(() => { checking = false; });
57
+ }, 5000);
58
+ const status = await done;
59
+ clearInterval(leaseCheck);
60
+ clearTimeout(timeout);
61
+ if (status !== 'completed')
62
+ throw new Error(status === 'failed' ? 'production_native_failed' : 'production_native_unknown');
63
+ }
64
+ finally {
65
+ unsubscribe?.();
66
+ await adapter.close?.().catch(() => undefined);
67
+ for (const group of groups)
68
+ await record({ ...group, stopped: await stopProductionGroup(group) });
69
+ }
70
+ }