@playcraft/cli 0.1.47-beta → 0.1.49-beta

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 (28) hide show
  1. package/dist/commands/tools-batch.js +12 -2
  2. package/dist/workspace-runtime/batch/control.js +6 -0
  3. package/dist/workspace-runtime/batch/deliver.js +105 -87
  4. package/dist/workspace-runtime/batch/generate.js +26 -3
  5. package/dist/workspace-runtime/batch/prepare.js +169 -56
  6. package/dist/workspace-runtime/batch/source-capture.js +7 -20
  7. package/dist/workspace-runtime/batch/source-publish.js +2 -4
  8. package/dist/workspace-runtime/codex/app-server.js +29 -4
  9. package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
  10. package/dist/workspace-runtime/codex/tool-response.js +10 -0
  11. package/dist/workspace-runtime/codex/ui-tools.js +22 -5
  12. package/dist/workspace-runtime/previews/diagnostics.js +44 -0
  13. package/dist/workspace-runtime/previews/publish.js +16 -4
  14. package/dist/workspace-runtime/serve.js +60 -0
  15. package/dist/workspace-runtime/server/accept-operation.js +61 -0
  16. package/dist/workspace-runtime/server/dispatch.js +47 -2
  17. package/dist/workspace-runtime/server/http.js +163 -130
  18. package/dist/workspace-runtime/server/operation-record.js +33 -0
  19. package/dist/workspace-runtime/server/submit-operation.js +41 -0
  20. package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
  21. package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
  22. package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
  23. package/dist/workspace-runtime/workspaces/delivery.js +64 -0
  24. package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
  25. package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
  26. package/dist/workspace-runtime/workspaces/directories.js +9 -4
  27. package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
  28. package/package.json +3 -3
@@ -35,12 +35,22 @@ const messages = {
35
35
  },
36
36
  };
37
37
  const language = () => /^zh/i.test(process.env.PLAYCRAFT_LANG ?? process.env.LANG ?? '') ? messages.zh : messages.en;
38
+ const modelConfigIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
39
+ /** Model bound by the runtime to the conversation that is creating the batch. */
40
+ export function sessionModelConfigId(env = process.env) {
41
+ const value = env.PLAYCRAFT_MODEL_CONFIG_ID?.trim();
42
+ return value && modelConfigIdPattern.test(value) ? value : undefined;
43
+ }
44
+ function bindSessionModel(body) {
45
+ const modelConfigId = sessionModelConfigId();
46
+ return modelConfigId ? { ...body, modelConfigId } : body;
47
+ }
38
48
  export async function createBatchDraft(path, client = new AgentApiClient()) {
39
- const draft = batchCreateSchema.parse(JSON.parse(await readFile(path, 'utf8')));
49
+ const draft = batchCreateSchema.parse(bindSessionModel(JSON.parse(await readFile(path, 'utf8'))));
40
50
  return client.post('/create-batch', draft);
41
51
  }
42
52
  export async function createPromptBatch(input, client = new AgentApiClient()) {
43
- return client.post('/create-batch', batchPromptCreateSchema.parse(input));
53
+ return client.post('/create-batch', batchPromptCreateSchema.parse(bindSessionModel(input)));
44
54
  }
45
55
  export function registerBatchCommands(tools) {
46
56
  const t = language();
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { sandboxBatchMemberStopSchema } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { groupExited, stopProcessGroup } from './processes.js';
4
+ import { settleObservedGenerate } from './generate.js';
4
5
  import { memberStatus, readMember, serializeRun, writeMember, writeStopMarker } from './records.js';
5
6
  async function prove(record) {
6
7
  let stopped = true;
@@ -26,6 +27,11 @@ export async function readBatchMemberStatus(state, runId) {
26
27
  const record = await readMember(state, runId);
27
28
  if (!record)
28
29
  return undefined;
30
+ if (record.phase === 'generate' && record.state === 'running') {
31
+ const settled = await settleObservedGenerate(state, record);
32
+ if (settled.state !== 'running')
33
+ return memberStatus(settled);
34
+ }
29
35
  const processes = record.processes.map(process => ({ ...process, stopped: process.stopped || groupExited(process.pid) }));
30
36
  const live = processes.some(process => !process.stopped);
31
37
  let next = { ...record, processes };
@@ -1,26 +1,23 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { hashOperationPayload, sandboxBatchMemberDeliverSchema } from '@playcraft/common/cloud-workspace/runtime';
5
- import { findExternalLocalReferences } from '../previews/single-file.js';
4
+ import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
5
  import { workspaceRoot } from '../workspaces/registry.js';
7
- import { requireBatchContentChanges } from './content-changes.js';
8
- import { gitRead, gitWrite } from './git.js';
9
6
  import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
10
- import { filesystemObjectStore, memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
7
+ import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
8
+ /** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
11
9
  function buildCommand() {
12
- return process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
13
- ?? 'playcraft build --mode full --platform playcraft --format html --output dist/index.html .';
10
+ return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND ?? 'CI=1 playcraft build --mode base --output dist .');
14
11
  }
15
- async function saveExact(root) {
16
- await gitWrite(root, ['add', '-A', '.']);
17
- const privatePaths = await gitRead(root, ['ls-files', '--', '.playcraft.json', 'playcraft.config.json', ':(glob)**/.playcraft.json', ':(glob)**/playcraft.config.json']);
18
- if (privatePaths.trim())
19
- throw new Error('batch_private_path');
20
- const clean = await gitWrite(root, ['diff', '--cached', '--quiet']).then(() => true, () => false);
21
- if (!clean)
22
- await gitWrite(root, ['commit', '-m', 'Save batch member']);
23
- return (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
12
+ async function readBuiltHtml(root) {
13
+ const htmlPath = join(root, 'dist', 'index.html');
14
+ const info = await lstat(htmlPath).catch(() => undefined);
15
+ if (!info?.isFile() || info.isSymbolicLink())
16
+ throw new Error('batch_build_failed');
17
+ const handle = await open(htmlPath, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => {
18
+ throw new Error('batch_build_failed');
19
+ });
20
+ await handle.close();
24
21
  }
25
22
  async function prove(record) {
26
23
  let stopped = true;
@@ -35,8 +32,11 @@ async function prove(record) {
35
32
  }
36
33
  return stopped;
37
34
  }
38
- export async function deliverBatchMember(state, body, options) {
39
- if (!body || typeof body !== 'object' || !('occupancyId' in body) || !body.occupancyId)
35
+ export async function deliverBatchMember(state, body) {
36
+ if (!body ||
37
+ typeof body !== 'object' ||
38
+ !('occupancyId' in body) ||
39
+ !body.occupancyId)
40
40
  throw new Error('occupancy_required');
41
41
  const parsed = sandboxBatchMemberDeliverSchema.safeParse(body);
42
42
  if (!parsed.success)
@@ -47,109 +47,127 @@ export async function deliverBatchMember(state, body, options) {
47
47
  const existing = await readMember(state, command.runId);
48
48
  if (existing && command.ownerFence < existing.ownerFence)
49
49
  throw new Error('stale_owner');
50
- if (existing?.state === 'completed' && existing.phase === 'deliver' && existing.evidence && existing.commandHash?.deliver === hash && existing.ownerFence === command.ownerFence) {
50
+ if (existing?.state === 'completed' &&
51
+ existing.phase === 'deliver' &&
52
+ existing.evidence &&
53
+ existing.commandHash?.deliver === hash &&
54
+ existing.ownerFence === command.ownerFence) {
51
55
  return memberStatus(existing);
52
56
  }
53
- if (await readStopMarker(state, command.runId) && existing?.state === 'completed' && existing.evidence)
57
+ if ((await readStopMarker(state, command.runId)) &&
58
+ existing?.state === 'completed' &&
59
+ existing.evidence)
54
60
  return memberStatus(existing);
55
61
  if (await readStopMarker(state, command.runId))
56
62
  return memberStatus(await markStopped(state, command, existing));
57
- if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.deliver && existing.commandHash.deliver !== hash && existing.state === 'completed') {
63
+ if (existing &&
64
+ existing.ownerFence === command.ownerFence &&
65
+ existing.commandHash?.deliver &&
66
+ existing.commandHash.deliver !== hash &&
67
+ existing.state === 'completed') {
58
68
  throw new Error('batch_command_conflict');
59
69
  }
60
70
  const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
61
71
  let record = await writeMember(state, {
62
- runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
63
- directoryName: command.directoryName, phase: 'deliver', state: 'running', writersStopped: false, holding: true, stopRequested: false,
64
- processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId, commandHash: { ...existing?.commandHash, deliver: hash },
65
- snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, updatedAt: new Date().toISOString(),
66
- sourceCommit: existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit,
72
+ runId: command.runId,
73
+ ownerFence: command.ownerFence,
74
+ occupancyId: command.occupancyId,
75
+ workspaceId: command.workspaceId,
76
+ directoryName: command.directoryName,
77
+ phase: 'deliver',
78
+ state: 'running',
79
+ writersStopped: false,
80
+ holding: true,
81
+ stopRequested: false,
82
+ processes: existing?.processes ?? [],
83
+ sessionId: existing?.sessionId,
84
+ operationId: existing?.operationId,
85
+ commandHash: { ...existing?.commandHash, deliver: hash },
86
+ snapshotDigest: existing?.snapshotDigest,
87
+ branchName: existing?.branchName,
88
+ updatedAt: new Date().toISOString(),
89
+ sourceCommit: existing?.sourceCommit ??
90
+ state.journal
91
+ .getWorkspace(command.workspaceId)
92
+ ?.directories?.find((directory) => directory.directoryName === command.directoryName)
93
+ ?.sourceCommit,
67
94
  beforeTree: existing?.beforeTree,
68
95
  });
69
- const buildRoot = join(state.config.stateDir, 'batch-builds', `${command.runId}-${randomUUID()}`);
70
96
  try {
71
- const savedCommit = command.fromCommit ?? await saveExact(root);
72
- if (command.fromCommit)
73
- await gitRead(root, ['cat-file', '-e', `${command.fromCommit}^{commit}`]);
74
- await requireBatchContentChanges(root, record.sourceCommit ?? '', savedCommit);
75
97
  if (await readStopMarker(state, command.runId))
76
98
  return memberStatus(await markStopped(state, command, record));
77
- let pushedCommit;
78
- if (command.pushRemote) {
79
- const branch = existing?.branchName ?? (await gitRead(root, ['symbolic-ref', '--short', 'HEAD'])).trim();
80
- try {
81
- await gitWrite(root, ['push', '--porcelain', 'origin', `${savedCommit}:refs/heads/${branch}`]);
82
- }
83
- catch {
84
- throw new Error('batch_push_failed');
85
- }
86
- const remote = (await gitWrite(root, ['ls-remote', 'origin', `refs/heads/${branch}`])).slice(0, savedCommit.length);
87
- if (remote !== savedCommit)
88
- throw new Error('batch_push_failed');
89
- pushedCommit = savedCommit;
90
- }
91
- await mkdir(join(buildRoot, '..'), { recursive: true });
92
- await gitWrite(join(buildRoot, '..'), ['clone', '--local', '--no-hardlinks', '--no-checkout', '--', root, buildRoot]);
93
- await gitWrite(buildRoot, ['checkout', '--detach', savedCommit]);
94
- const built = await runProcessGroup(buildRoot, buildCommand(), 'build', async (process) => {
99
+ const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
95
100
  record = await writeMember(state, { ...record, processes: [...record.processes, process] });
96
101
  });
97
- record = await writeMember(state, { ...record, processes: record.processes.map(process => process.pid === built.process.pid ? built.process : process) });
102
+ record = await writeMember(state, {
103
+ ...record,
104
+ processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
105
+ });
98
106
  if (await readStopMarker(state, command.runId))
99
107
  return memberStatus(await markStopped(state, command, record));
100
108
  if (built.code !== 0)
101
109
  throw new Error('batch_build_failed');
102
- const htmlPath = join(buildRoot, 'dist', 'index.html');
103
- if (!(await lstat(htmlPath).catch(() => undefined))?.isFile())
104
- throw new Error('batch_build_failed');
105
- const entries = await readdir(join(buildRoot, 'dist'));
106
- if (!entries.includes('index.html'))
107
- throw new Error('batch_build_failed');
108
- const bodyBytes = await readFile(htmlPath);
109
- if (findExternalLocalReferences(bodyBytes.toString()).length)
110
- throw new Error('batch_verify_failed');
111
- const buildDigest = createHash('sha256').update(bodyBytes).digest('hex');
112
- const previewRef = `workspaces/${command.workspaceId}/batches/members/${command.runId}/${buildDigest}.html`;
113
- const store = options?.objectStore ?? filesystemObjectStore(state);
114
- await store.put(previewRef, bodyBytes);
115
- const published = await store.get(previewRef);
116
- if (createHash('sha256').update(published).digest('hex') !== buildDigest)
117
- throw new Error('batch_verify_failed');
118
- await mkdir(join(root, 'dist'), { recursive: true });
119
- await writeFile(join(root, 'dist', 'index.html'), published);
120
- if (createHash('sha256').update(await readFile(join(root, 'dist', 'index.html'))).digest('hex') !== buildDigest)
121
- throw new Error('batch_verify_failed');
110
+ await readBuiltHtml(root);
122
111
  const writersStopped = await prove(record);
123
112
  if (!writersStopped)
124
- return memberStatus(await writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true, headCommit: savedCommit }));
113
+ return memberStatus(await writeMember(state, {
114
+ ...record,
115
+ state: 'stopping',
116
+ writersStopped: false,
117
+ holding: true,
118
+ }));
125
119
  if (await readStopMarker(state, command.runId))
126
- return memberStatus(await markStopped(state, command, { ...record, headCommit: savedCommit }));
120
+ return memberStatus(await markStopped(state, command, record));
127
121
  record = await writeMember(state, {
128
- ...record, state: 'completed', writersStopped: true, holding: false, headCommit: savedCommit, error: undefined,
129
- evidence: { savedCommit, ...(pushedCommit ? { pushedCommit } : {}), buildDigest, verified: true, previewRef },
122
+ ...record,
123
+ state: 'completed',
124
+ writersStopped: true,
125
+ holding: false,
126
+ error: undefined,
127
+ evidence: { verified: true },
130
128
  });
131
129
  return memberStatus(record);
132
130
  }
133
131
  catch (error) {
134
- const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'batch_deliver_failed';
132
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
133
+ ? error.message
134
+ : 'batch_deliver_failed';
135
135
  const writersStopped = await prove(record);
136
- record = await writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, error: code });
136
+ record = await writeMember(state, {
137
+ ...record,
138
+ state: writersStopped ? 'failed' : 'stopping',
139
+ writersStopped,
140
+ holding: !writersStopped,
141
+ error: code,
142
+ });
137
143
  return memberStatus(record);
138
144
  }
139
- finally {
140
- await rm(buildRoot, { recursive: true, force: true });
141
- }
142
145
  });
143
146
  }
144
147
  async function markStopped(state, command, existing) {
145
148
  if (existing)
146
149
  await prove(existing);
147
150
  return writeMember(state, {
148
- runId: command.runId, ownerFence: command.ownerFence, occupancyId: existing?.occupancyId ?? command.occupancyId,
149
- workspaceId: command.workspaceId, directoryName: command.directoryName, phase: 'deliver', state: 'stopped', writersStopped: true,
150
- holding: false, stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
151
- headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
152
- branchName: existing?.branchName, updatedAt: new Date().toISOString(),
153
- sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
151
+ runId: command.runId,
152
+ ownerFence: command.ownerFence,
153
+ occupancyId: existing?.occupancyId ?? command.occupancyId,
154
+ workspaceId: command.workspaceId,
155
+ directoryName: command.directoryName,
156
+ phase: 'deliver',
157
+ state: 'stopped',
158
+ writersStopped: true,
159
+ holding: false,
160
+ stopRequested: true,
161
+ processes: existing?.processes ?? [],
162
+ sessionId: existing?.sessionId,
163
+ operationId: existing?.operationId,
164
+ headCommit: existing?.headCommit,
165
+ evidence: existing?.evidence,
166
+ commandHash: existing?.commandHash,
167
+ snapshotDigest: existing?.snapshotDigest,
168
+ branchName: existing?.branchName,
169
+ updatedAt: new Date().toISOString(),
170
+ sourceCommit: existing?.sourceCommit,
171
+ beforeTree: existing?.beforeTree,
154
172
  });
155
173
  }
@@ -80,7 +80,8 @@ export async function generateBatchMember(state, body) {
80
80
  await mkdir(tempDir, { recursive: true, mode: 0o700 });
81
81
  const policy = memberExecutionPolicy(root, tempDir);
82
82
  await bindMemberPolicy(state, sessionId, policy);
83
- const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? { modelConfigId: command.model } : {}) };
83
+ const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
84
+ const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
84
85
  const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
85
86
  const prior = state.journal.getOperation(turnKey.scopeKey);
86
87
  if (prior && prior.payloadHash !== turnKey.record.payloadHash)
@@ -114,9 +115,9 @@ export async function generateBatchMember(state, body) {
114
115
  });
115
116
  try {
116
117
  if (!state.journal.getSession(sessionId)) {
117
- const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto' } });
118
+ const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
118
119
  if (!state.journal.getOperation(created.scopeKey))
119
- await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto' });
120
+ await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
120
121
  }
121
122
  if (await readStopMarker(state, command.runId))
122
123
  return memberStatus(await stoppedRecord(state, command, record));
@@ -179,6 +180,11 @@ async function finishGenerate(state, record, started, root, early) {
179
180
  return stoppedRecord(state, record, record);
180
181
  return completeGeneration(state, { ...record, operationId: started.operationId }, root);
181
182
  }
183
+ if (!terminal) {
184
+ return writeMember(state, {
185
+ ...record, state: 'running', writersStopped: false, holding: true, operationId: started.operationId, error: undefined,
186
+ });
187
+ }
182
188
  const writersStopped = await proveStopped(record);
183
189
  return writeMember(state, {
184
190
  ...record, state: writersStopped ? (terminal === 'interrupted' ? 'stopped' : 'failed') : 'unknown',
@@ -186,6 +192,23 @@ async function finishGenerate(state, record, started, root, early) {
186
192
  error: writersStopped ? (terminal === 'interrupted' ? undefined : 'batch_turn_failed') : 'native_receipt_lost',
187
193
  });
188
194
  }
195
+ /** A later status read finishes a generate turn that was still running when dispatch returned. */
196
+ export async function settleObservedGenerate(state, record) {
197
+ if (record.phase !== 'generate' || record.state !== 'running' || record.stopRequested)
198
+ return record;
199
+ const session = record.sessionId ? state.journal.getSession(record.sessionId) : undefined;
200
+ if (!session || session.turnBlock === 'uncertain' || session.turnStatus === 'starting' || session.turnStatus === 'running')
201
+ return record;
202
+ const terminal = session.turnStatus === 'completed' ? 'completed'
203
+ : session.turnStatus === 'failed' ? 'failed'
204
+ : session.turnStatus === 'interrupted' ? 'interrupted'
205
+ : session.turnStatus === 'waiting_for_user' ? 'needs_user'
206
+ : undefined;
207
+ if (!terminal)
208
+ return record;
209
+ const root = await workspaceRoot(state, record.workspaceId, record.directoryName);
210
+ return finishGenerate(state, record, { operationId: record.operationId, status: 'accepted' }, root, () => terminal);
211
+ }
189
212
  async function reconcileGenerate(state, record) {
190
213
  const session = record.sessionId ? state.journal.getSession(record.sessionId) : undefined;
191
214
  if (record.state === 'unknown' && session?.turnStatus === 'completed') {