@playcraft/cli 0.1.10 → 0.1.12
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.
- package/dist/utils/agent-api-client.js +26 -11
- package/dist/workspace-runtime/codex/app-server.js +250 -50
- package/dist/workspace-runtime/codex/transcripts.js +94 -0
- package/dist/workspace-runtime/previews/publish.js +18 -0
- package/dist/workspace-runtime/serve.js +12 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -9
- package/dist/workspace-runtime/server/http.js +35 -0
- package/dist/workspace-runtime/workspaces/attachments.js +109 -0
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +79 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +114 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +32 -0
- package/dist/workspace-runtime/workspaces/file-snapshot.js +163 -22
- package/dist/workspace-runtime/workspaces/files.js +9 -8
- package/dist/workspace-runtime/workspaces/git.js +93 -1
- package/dist/workspace-runtime/workspaces/prepare.js +12 -2
- package/dist/workspace-runtime/workspaces/registry.js +12 -0
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +47 -0
- package/dist/workspace-runtime/workspaces/skills.js +179 -0
- package/package.json +3 -3
- package/project-templates/empty/AGENTS.md +23 -0
- package/project-templates/empty/gitignore +31 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { loadGlobalConfig } from '../config.js';
|
|
5
5
|
import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
@@ -18,8 +18,8 @@ function isPatToken(token) {
|
|
|
18
18
|
* or from the CLI after `playcraft login`.
|
|
19
19
|
*
|
|
20
20
|
* 凭证解析优先级:
|
|
21
|
-
* API URL : PLAYCRAFT_API_URL → .playcraft.json → ~/.playcraft/config.json → DEFAULT_PLAYCRAFT_CLOUD_BASE
|
|
22
|
-
* Token : PLAYCRAFT_SANDBOX_TOKEN → .playcraft.json
|
|
21
|
+
* API URL : PLAYCRAFT_API_URL → sandbox global .playcraft.json → project .playcraft.json → ~/.playcraft/config.json → DEFAULT_PLAYCRAFT_CLOUD_BASE
|
|
22
|
+
* Token : PLAYCRAFT_SANDBOX_TOKEN → sandbox global .playcraft.json → project .playcraft.json → ~/.playcraft/config.json token (PAT)
|
|
23
23
|
*
|
|
24
24
|
* 鉴权 header 自动选择:
|
|
25
25
|
* - 沙箱 JWT (非 PAT) → X-Sandbox-Token: <token>(后端 @SandboxToken 路由的沙箱分支)
|
|
@@ -28,7 +28,7 @@ function isPatToken(token) {
|
|
|
28
28
|
export class AgentApiClient {
|
|
29
29
|
apiUrl;
|
|
30
30
|
token;
|
|
31
|
-
|
|
31
|
+
credentialFile;
|
|
32
32
|
playcraftEnv;
|
|
33
33
|
constructor() {
|
|
34
34
|
const localConfig = AgentApiClient.loadLocalConfig();
|
|
@@ -39,7 +39,7 @@ export class AgentApiClient {
|
|
|
39
39
|
globalConfig.backendUrl ||
|
|
40
40
|
globalConfig.url ||
|
|
41
41
|
DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
|
|
42
|
-
// Token:
|
|
42
|
+
// Token: 环境变量 → 沙箱全局配置 → 项目配置 → 用户登录配置
|
|
43
43
|
const token = process.env.PLAYCRAFT_SANDBOX_TOKEN ||
|
|
44
44
|
localConfig.token ||
|
|
45
45
|
globalConfig.token ||
|
|
@@ -50,14 +50,22 @@ export class AgentApiClient {
|
|
|
50
50
|
}
|
|
51
51
|
this.apiUrl = apiUrl;
|
|
52
52
|
this.token = token;
|
|
53
|
-
this.
|
|
53
|
+
this.credentialFile = localConfig.credentialFile;
|
|
54
54
|
this.playcraftEnv = (process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
|
|
55
55
|
}
|
|
56
56
|
/** 根据 token 类型构造鉴权 headers */
|
|
57
57
|
authHeaders() {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
let token = this.token;
|
|
59
|
+
// Polling commands can outlive a credential: read the atomically refreshed file per request.
|
|
60
|
+
if (this.credentialFile && !process.env.PLAYCRAFT_SANDBOX_TOKEN) {
|
|
61
|
+
try {
|
|
62
|
+
token = JSON.parse(readFileSync(this.credentialFile, 'utf8')).token || token;
|
|
63
|
+
}
|
|
64
|
+
catch { /* Keep the last credential if a local file is temporarily unavailable. */ }
|
|
65
|
+
}
|
|
66
|
+
const headers = isPatToken(token)
|
|
67
|
+
? { Authorization: `Bearer ${token}` }
|
|
68
|
+
: { 'X-Sandbox-Token': token };
|
|
61
69
|
if (this.playcraftEnv) {
|
|
62
70
|
headers['playcraft-env'] = this.playcraftEnv;
|
|
63
71
|
}
|
|
@@ -196,15 +204,22 @@ export class AgentApiClient {
|
|
|
196
204
|
* 与全局 login 配置(~/.playcraft/config.json)不同,前者专用于沙箱 token。
|
|
197
205
|
*/
|
|
198
206
|
static loadLocalConfig() {
|
|
207
|
+
const ancestors = [];
|
|
208
|
+
for (let directory = process.cwd();; directory = dirname(directory)) {
|
|
209
|
+
ancestors.push(join(directory, '.playcraft.json'));
|
|
210
|
+
if (dirname(directory) === directory)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
199
213
|
const searchPaths = [
|
|
200
|
-
|
|
214
|
+
process.env.PLAYCRAFT_SANDBOX_CONFIG_PATH || '/workspaces/.playcraft.json',
|
|
215
|
+
...ancestors,
|
|
201
216
|
'/project/.playcraft.json',
|
|
202
217
|
join(homedir(), '.playcraft.json'),
|
|
203
218
|
];
|
|
204
219
|
for (const p of searchPaths) {
|
|
205
220
|
if (existsSync(p)) {
|
|
206
221
|
try {
|
|
207
|
-
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
222
|
+
return { ...JSON.parse(readFileSync(p, 'utf-8')), credentialFile: p };
|
|
208
223
|
}
|
|
209
224
|
catch {
|
|
210
225
|
continue;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { startLauncherProcess } from './launcher-client.js';
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
@@ -7,6 +7,9 @@ import { writePlaycraftCodexConfig } from './config-toml.js';
|
|
|
7
7
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
8
8
|
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
9
|
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
10
|
+
import { exportTranscript } from './transcripts.js';
|
|
11
|
+
import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
|
|
12
|
+
import { freezeWorkspaceSkills } from '../workspaces/skills.js';
|
|
10
13
|
function asRecord(value) {
|
|
11
14
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
12
15
|
? value
|
|
@@ -61,6 +64,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
61
64
|
let readyFlag = false;
|
|
62
65
|
let closed = false;
|
|
63
66
|
let startPromise;
|
|
67
|
+
let skillsRestartFailed = false;
|
|
68
|
+
let preparationQueue = Promise.resolve();
|
|
64
69
|
let live;
|
|
65
70
|
const exitListeners = new Set();
|
|
66
71
|
const collected = new Set();
|
|
@@ -68,7 +73,35 @@ export function createCodexAppServerAdapter(options) {
|
|
|
68
73
|
for (const listener of collected)
|
|
69
74
|
listener(event);
|
|
70
75
|
}
|
|
76
|
+
async function withPreparation(action) {
|
|
77
|
+
const previous = preparationQueue;
|
|
78
|
+
let release;
|
|
79
|
+
preparationQueue = new Promise(resolve => { release = resolve; });
|
|
80
|
+
await previous;
|
|
81
|
+
try {
|
|
82
|
+
return await action();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function transcriptHome() {
|
|
89
|
+
if (!launcherSocket)
|
|
90
|
+
return { homeDir };
|
|
91
|
+
return {
|
|
92
|
+
homeDir,
|
|
93
|
+
launcher: {
|
|
94
|
+
socketPath: launcherSocket,
|
|
95
|
+
runtimeEpoch: executionEpoch,
|
|
96
|
+
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
71
100
|
async function ensureStarted() {
|
|
101
|
+
if (closed)
|
|
102
|
+
throw new Error('codex_not_started');
|
|
103
|
+
if (skillsRestartFailed)
|
|
104
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
72
105
|
if (readyFlag && rpc)
|
|
73
106
|
return;
|
|
74
107
|
startPromise ??= start();
|
|
@@ -85,9 +118,100 @@ export function createCodexAppServerAdapter(options) {
|
|
|
85
118
|
? reason : 'model_credential_unavailable');
|
|
86
119
|
}
|
|
87
120
|
}
|
|
121
|
+
async function lockedThreadConfig(cwd) {
|
|
122
|
+
const modelConfig = await threadConfig(cwd);
|
|
123
|
+
// Single-workspace legacy runners retain their prior behaviour until explicitly adopted.
|
|
124
|
+
if (await readProjectSkillsLockFile(cwd) === null) {
|
|
125
|
+
if (options.config.workspaceMode === 'multi')
|
|
126
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
127
|
+
return { params: modelConfig, skillsHash: undefined, discoveryHash: undefined };
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const frozen = await freezeWorkspaceSkills(cwd, join(options.config.stateDir, 'skill-artifacts'), options.loadSkillArtifact ? entry => options.loadSkillArtifact(cwd, entry) : undefined);
|
|
131
|
+
if (!rpc)
|
|
132
|
+
throw new Error('codex_not_started');
|
|
133
|
+
const found = await rpc.request('skills/list', { cwds: [cwd], forceReload: true }, 30_000);
|
|
134
|
+
const listing = found.data.find(item => item.cwd === cwd);
|
|
135
|
+
if (!listing || listing.errors.length)
|
|
136
|
+
throw new Error('workspace_skills_unavailable');
|
|
137
|
+
// Native scanning is used only to disable paths. Availability comes exclusively from the lock.
|
|
138
|
+
const config = { ...('config' in modelConfig ? modelConfig.config : {}),
|
|
139
|
+
'skills.include_instructions': false,
|
|
140
|
+
'skills.config': listing.skills.map(skill => ({ path: skill.path, enabled: false })) };
|
|
141
|
+
const developerInstructions = [
|
|
142
|
+
`Available workspace skills are fixed by lock ${frozen.declarationHash}.`,
|
|
143
|
+
'Use only the following locked skills. Read its SKILL.md when a skill applies, and resolve supporting files relative to that file. Disabled and unlisted skill directories are not available.',
|
|
144
|
+
...frozen.skills.map(skill => JSON.stringify(skill)),
|
|
145
|
+
...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
|
|
146
|
+
].join('\n');
|
|
147
|
+
const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
|
|
148
|
+
return { params: { ...modelConfig, config, developerInstructions }, skillsHash: frozen.lockHash, discoveryHash };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function restartForSkills(threadId) {
|
|
155
|
+
if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
|
|
156
|
+
throw new NativePreparationError('skills_reload_pending');
|
|
157
|
+
const previous = live;
|
|
158
|
+
if (previous)
|
|
159
|
+
previous.superseded = true;
|
|
160
|
+
readyFlag = false;
|
|
161
|
+
await previous?.rpc.close();
|
|
162
|
+
await previous?.child.stop();
|
|
163
|
+
if (closed)
|
|
164
|
+
throw new Error('codex_not_started');
|
|
165
|
+
rpc = undefined;
|
|
166
|
+
startPromise = undefined;
|
|
167
|
+
for (const thread of threads.values()) {
|
|
168
|
+
thread.loaded = false;
|
|
169
|
+
thread.needsRecreate = !thread.hasNativeHistory;
|
|
170
|
+
}
|
|
171
|
+
// A controlled idle restart retains the runtime epoch and all durable thread identities.
|
|
172
|
+
try {
|
|
173
|
+
startPromise = start();
|
|
174
|
+
await startPromise;
|
|
175
|
+
if (closed)
|
|
176
|
+
throw new Error('codex_not_started');
|
|
177
|
+
if (!live)
|
|
178
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
179
|
+
await options.onSkillProcessRestart?.({ pid: live.pid, startedAt: live.startedAt });
|
|
180
|
+
if (closed)
|
|
181
|
+
throw new Error('codex_not_started');
|
|
182
|
+
// Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
|
|
183
|
+
const thread = threads.get(threadId);
|
|
184
|
+
if (thread?.hasNativeHistory && thread.cwd)
|
|
185
|
+
await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
readyFlag = false;
|
|
189
|
+
skillsRestartFailed = true;
|
|
190
|
+
if (live) {
|
|
191
|
+
live.superseded = true;
|
|
192
|
+
await live.rpc.close();
|
|
193
|
+
await live.child.stop();
|
|
194
|
+
}
|
|
195
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function resumePreparedThread(threadId, thread, prepared) {
|
|
199
|
+
const resumed = await rpc.request('thread/resume', {
|
|
200
|
+
threadId, cwd: thread.cwd, ...prepared.params,
|
|
201
|
+
}, 30_000);
|
|
202
|
+
if (resumed.thread?.id !== threadId)
|
|
203
|
+
throw new NativePreparationError('workspace_skills_unavailable');
|
|
204
|
+
thread.loaded = true;
|
|
205
|
+
thread.skillsHash = prepared.skillsHash;
|
|
206
|
+
thread.discoveryHash = prepared.discoveryHash;
|
|
207
|
+
}
|
|
88
208
|
async function start() {
|
|
209
|
+
if (closed)
|
|
210
|
+
throw new Error('codex_not_started');
|
|
89
211
|
await options.modelBroker?.start();
|
|
90
212
|
const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
|
|
213
|
+
if (closed)
|
|
214
|
+
throw new Error('codex_not_started');
|
|
91
215
|
const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
|
|
92
216
|
const { command, prefix } = spawnCommand(bin);
|
|
93
217
|
const args = [...prefix, ...(options.args ?? ['app-server'])];
|
|
@@ -118,6 +242,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
118
242
|
}
|
|
119
243
|
else {
|
|
120
244
|
await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
|
|
245
|
+
if (closed)
|
|
246
|
+
throw new Error('codex_not_started');
|
|
121
247
|
const local = spawn(command, args, {
|
|
122
248
|
cwd: options.config.workspaceRoot,
|
|
123
249
|
env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
|
|
@@ -134,11 +260,25 @@ export function createCodexAppServerAdapter(options) {
|
|
|
134
260
|
},
|
|
135
261
|
};
|
|
136
262
|
}
|
|
263
|
+
if (closed) {
|
|
264
|
+
await child.stop();
|
|
265
|
+
throw new Error('codex_not_started');
|
|
266
|
+
}
|
|
137
267
|
child.stderr?.on('data', (chunk) => {
|
|
138
268
|
process.stderr.write(chunk);
|
|
139
269
|
});
|
|
140
270
|
rpc = createJsonRpcStdioClient(child);
|
|
141
271
|
rpc.onNotification((method, params) => {
|
|
272
|
+
if (method === 'turn/completed') {
|
|
273
|
+
const record = asRecord(params), turnId = asRecord(record.turn).id;
|
|
274
|
+
const thread = typeof record.threadId === 'string' ? threads.get(record.threadId)
|
|
275
|
+
: [...threads.values()].find(item => item.lastTurnId === turnId);
|
|
276
|
+
if (thread) {
|
|
277
|
+
thread.turnActive = false;
|
|
278
|
+
if (typeof turnId === 'string')
|
|
279
|
+
thread.lastCompletedTurnId = turnId;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
142
282
|
emitCollected(collectedEvent('notification', method, params));
|
|
143
283
|
});
|
|
144
284
|
rpc.onServerRequest((id, method, params) => {
|
|
@@ -169,7 +309,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
169
309
|
readyFlag = false;
|
|
170
310
|
// Retain the old handle until its entire execution group is confirmed stopped.
|
|
171
311
|
rpc = undefined;
|
|
172
|
-
|
|
312
|
+
if (!current.superseded)
|
|
313
|
+
emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
|
|
173
314
|
}
|
|
174
315
|
if (!current.superseded && !closed) {
|
|
175
316
|
for (const listener of exitListeners)
|
|
@@ -184,6 +325,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
184
325
|
version: options.config.cliVersion,
|
|
185
326
|
},
|
|
186
327
|
});
|
|
328
|
+
if (closed)
|
|
329
|
+
throw new Error('codex_not_started');
|
|
187
330
|
rpc.notify('initialized', {});
|
|
188
331
|
readyFlag = true;
|
|
189
332
|
}
|
|
@@ -224,6 +367,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
224
367
|
if (previous)
|
|
225
368
|
await previous.child.stop();
|
|
226
369
|
executionEpoch += 1;
|
|
370
|
+
skillsRestartFailed = false;
|
|
227
371
|
if (closed)
|
|
228
372
|
throw new Error('codex_not_started');
|
|
229
373
|
try {
|
|
@@ -240,21 +384,38 @@ export function createCodexAppServerAdapter(options) {
|
|
|
240
384
|
return { pid: next.pid, startedAt: next.startedAt };
|
|
241
385
|
},
|
|
242
386
|
async canContinueThread(input) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
|
|
248
|
-
...await threadConfig(input.cwd ?? '') }, 30_000);
|
|
249
|
-
const resumedId = result.thread?.id ?? result.threadId;
|
|
250
|
-
if (resumedId !== input.threadId)
|
|
387
|
+
return withPreparation(async () => {
|
|
388
|
+
await ensureStarted();
|
|
389
|
+
if (!rpc)
|
|
251
390
|
return false;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
391
|
+
try {
|
|
392
|
+
const prepared = await lockedThreadConfig(input.cwd ?? '');
|
|
393
|
+
const existing = threads.get(input.threadId);
|
|
394
|
+
if (existing && existing.sessionId !== input.sessionId)
|
|
395
|
+
return false;
|
|
396
|
+
if (existing?.needsRecreate)
|
|
397
|
+
return false;
|
|
398
|
+
if (existing?.loaded) {
|
|
399
|
+
if (existing.skillsHash !== prepared.skillsHash || existing.discoveryHash !== prepared.discoveryHash) {
|
|
400
|
+
if (!existing.hasNativeHistory)
|
|
401
|
+
return false;
|
|
402
|
+
await restartForSkills(input.threadId);
|
|
403
|
+
}
|
|
404
|
+
return threads.get(input.threadId)?.sessionId === input.sessionId;
|
|
405
|
+
}
|
|
406
|
+
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
|
|
407
|
+
...prepared.params }, 30_000);
|
|
408
|
+
const resumedId = result.thread?.id ?? result.threadId;
|
|
409
|
+
if (resumedId !== input.threadId)
|
|
410
|
+
return false;
|
|
411
|
+
threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
412
|
+
discoveryHash: prepared.discoveryHash, hasNativeHistory: true, loaded: true });
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
258
419
|
},
|
|
259
420
|
async readThread(input) {
|
|
260
421
|
try {
|
|
@@ -272,6 +433,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
272
433
|
return undefined;
|
|
273
434
|
}
|
|
274
435
|
},
|
|
436
|
+
exportThread(input) {
|
|
437
|
+
return exportTranscript(transcriptHome(), input.threadId);
|
|
438
|
+
},
|
|
275
439
|
async captureNativeCheckpoint(input) {
|
|
276
440
|
const thread = threads.get(input.threadId);
|
|
277
441
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -284,44 +448,80 @@ export function createCodexAppServerAdapter(options) {
|
|
|
284
448
|
await ensureStarted();
|
|
285
449
|
},
|
|
286
450
|
async startThread(input) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
451
|
+
return withPreparation(async () => {
|
|
452
|
+
await ensureStarted();
|
|
453
|
+
if (!rpc)
|
|
454
|
+
throw new Error('codex_not_started');
|
|
455
|
+
const prepared = await lockedThreadConfig(input.cwd);
|
|
456
|
+
const result = await rpc.request('thread/start', {
|
|
457
|
+
cwd: input.cwd,
|
|
458
|
+
model,
|
|
459
|
+
...prepared.params,
|
|
460
|
+
});
|
|
461
|
+
const threadId = result.thread?.id;
|
|
462
|
+
if (!threadId)
|
|
463
|
+
throw new Error('native_receipt_lost');
|
|
464
|
+
threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
465
|
+
discoveryHash: prepared.discoveryHash, hasNativeHistory: false, loaded: true });
|
|
466
|
+
return { threadId };
|
|
294
467
|
});
|
|
295
|
-
const threadId = result.thread?.id;
|
|
296
|
-
if (!threadId)
|
|
297
|
-
throw new Error('native_receipt_lost');
|
|
298
|
-
threads.set(threadId, { sessionId: input.sessionId });
|
|
299
|
-
return { threadId };
|
|
300
468
|
},
|
|
301
469
|
async startTurn(input) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
await input.persistReservation({ nativeRequestId: String(requestId) });
|
|
310
|
-
if (input.simulateLostReceipt)
|
|
311
|
-
return { outcome: 'uncertain', threadId: input.threadId };
|
|
312
|
-
try {
|
|
313
|
-
const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [{ type: 'text', text: input.text }] }, turnTimeoutMs);
|
|
314
|
-
const turnId = started.turn?.id;
|
|
315
|
-
if (!turnId)
|
|
316
|
-
return { outcome: 'uncertain', threadId: input.threadId };
|
|
470
|
+
return withPreparation(async () => {
|
|
471
|
+
await ensureStarted();
|
|
472
|
+
if (!rpc)
|
|
473
|
+
throw new Error('codex_not_started');
|
|
474
|
+
if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
|
|
475
|
+
return { outcome: 'failed', error: 'unknown_thread' };
|
|
476
|
+
}
|
|
317
477
|
const thread = threads.get(input.threadId);
|
|
318
|
-
if (thread)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
478
|
+
if (thread.needsRecreate)
|
|
479
|
+
return { outcome: 'failed', error: 'workspace_skills_changed' };
|
|
480
|
+
if (thread.skillsHash && thread.cwd) {
|
|
481
|
+
try {
|
|
482
|
+
const prepared = await lockedThreadConfig(thread.cwd);
|
|
483
|
+
// Codex does not persist an empty thread rollout until its first turn.
|
|
484
|
+
if (!thread.hasNativeHistory) {
|
|
485
|
+
if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)
|
|
486
|
+
return { outcome: 'failed', error: 'workspace_skills_changed' };
|
|
487
|
+
}
|
|
488
|
+
else if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash) {
|
|
489
|
+
await restartForSkills(input.threadId);
|
|
490
|
+
}
|
|
491
|
+
if (!thread.loaded)
|
|
492
|
+
await resumePreparedThread(input.threadId, thread, prepared);
|
|
493
|
+
thread.skillsHash = prepared.skillsHash;
|
|
494
|
+
thread.discoveryHash = prepared.discoveryHash;
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
return { outcome: 'failed', error: error instanceof NativePreparationError ? error.message : 'workspace_skills_unavailable' };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const requestId = rpc.reserveRequest();
|
|
501
|
+
await input.persistReservation({ nativeRequestId: String(requestId) });
|
|
502
|
+
if (input.simulateLostReceipt)
|
|
503
|
+
return { outcome: 'uncertain', threadId: input.threadId };
|
|
504
|
+
try {
|
|
505
|
+
thread.turnActive = true;
|
|
506
|
+
const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
|
|
507
|
+
{ type: 'text', text: input.text },
|
|
508
|
+
...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
|
|
509
|
+
] }, turnTimeoutMs);
|
|
510
|
+
const turnId = started.turn?.id;
|
|
511
|
+
if (!turnId)
|
|
512
|
+
return { outcome: 'uncertain', threadId: input.threadId };
|
|
513
|
+
const acceptedThread = threads.get(input.threadId);
|
|
514
|
+
if (acceptedThread) {
|
|
515
|
+
acceptedThread.lastTurnId = turnId;
|
|
516
|
+
acceptedThread.hasNativeHistory = true;
|
|
517
|
+
acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
|
|
518
|
+
}
|
|
519
|
+
return { outcome: 'accepted', threadId: input.threadId, turnId };
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return { outcome: 'uncertain', threadId: input.threadId };
|
|
523
|
+
}
|
|
524
|
+
});
|
|
325
525
|
},
|
|
326
526
|
async interrupt(input) {
|
|
327
527
|
await ensureStarted();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { startLauncherProcess } from './launcher-client.js';
|
|
5
|
+
export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
7
|
+
const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
|
|
8
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
9
|
+
/**
|
|
10
|
+
* Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
|
|
11
|
+
* trailing partial line is dropped; what is returned always parses line by line.
|
|
12
|
+
*/
|
|
13
|
+
export async function exportTranscript(home, threadId) {
|
|
14
|
+
if (!THREAD_ID.test(threadId))
|
|
15
|
+
return undefined;
|
|
16
|
+
const found = home.launcher
|
|
17
|
+
? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
|
|
18
|
+
: await exportDirect(home.homeDir, threadId);
|
|
19
|
+
if (!found || !transcriptPathFor(found.path, threadId))
|
|
20
|
+
return undefined;
|
|
21
|
+
const end = found.body.lastIndexOf(0x0a);
|
|
22
|
+
if (end < 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
return { path: found.path, body: found.body.subarray(0, end + 1) };
|
|
25
|
+
}
|
|
26
|
+
function transcriptPathFor(path, threadId) {
|
|
27
|
+
return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
async function exportDirect(homeDir, threadId) {
|
|
30
|
+
const suffix = `-${threadId}.jsonl`;
|
|
31
|
+
for (const year of await names(join(homeDir, 'sessions'))) {
|
|
32
|
+
for (const month of await names(join(homeDir, 'sessions', year))) {
|
|
33
|
+
for (const day of await names(join(homeDir, 'sessions', year, month))) {
|
|
34
|
+
const directory = join('sessions', year, month, day);
|
|
35
|
+
const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
|
|
36
|
+
if (!match)
|
|
37
|
+
continue;
|
|
38
|
+
const path = `${directory}/${match}`;
|
|
39
|
+
const absolute = join(homeDir, path);
|
|
40
|
+
if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { path, body: await readFile(absolute) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
async function exportAsDeveloper(homeDir, launcher, threadId) {
|
|
49
|
+
const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
|
|
50
|
+
const outcome = await runAsDeveloper(launcher, [
|
|
51
|
+
`home=${quote(homeDir)}`,
|
|
52
|
+
`f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
|
|
53
|
+
'[ -n "$f" ] || exit 3',
|
|
54
|
+
'printf \'%s\\n\' "${f#"$home"/}"',
|
|
55
|
+
'base64 -w 0 "$f"',
|
|
56
|
+
].join('; '));
|
|
57
|
+
if (outcome.code !== 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const text = outcome.stdout.toString('utf8');
|
|
60
|
+
const newline = text.indexOf('\n');
|
|
61
|
+
if (newline < 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
|
|
64
|
+
}
|
|
65
|
+
async function runAsDeveloper(launcher, command) {
|
|
66
|
+
const child = await startLauncherProcess({
|
|
67
|
+
socketPath: launcher.socketPath,
|
|
68
|
+
processId: `transcript-${randomUUID()}`,
|
|
69
|
+
runtimeEpoch: launcher.runtimeEpoch,
|
|
70
|
+
argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
|
|
71
|
+
cwd: launcher.cwd,
|
|
72
|
+
env: {},
|
|
73
|
+
});
|
|
74
|
+
child.stdin?.end();
|
|
75
|
+
const chunks = [];
|
|
76
|
+
child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
77
|
+
const code = await new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
void child.stop().catch(() => undefined);
|
|
80
|
+
resolve(null);
|
|
81
|
+
}, COMMAND_TIMEOUT_MS);
|
|
82
|
+
child.onExit((value) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(value);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return { code, stdout: Buffer.concat(chunks) };
|
|
88
|
+
}
|
|
89
|
+
async function names(directory) {
|
|
90
|
+
return readdir(directory).then((entries) => entries.sort(), () => []);
|
|
91
|
+
}
|
|
92
|
+
function quote(value) {
|
|
93
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
94
|
+
}
|
|
@@ -33,6 +33,12 @@ export async function previewWorkspaceRoot(state, operation) {
|
|
|
33
33
|
if (!operation.workspaceId || !/^[A-Za-z0-9_-]+$/.test(operation.operationId)) {
|
|
34
34
|
throw new PreviewFailure('invalid_preview_publish');
|
|
35
35
|
}
|
|
36
|
+
if (operation.sessionId !== undefined) {
|
|
37
|
+
const session = state.journal.getSession(operation.sessionId);
|
|
38
|
+
if (!session || (state.config.workspaceMode === 'multi' && session.workspaceId !== operation.workspaceId)) {
|
|
39
|
+
throw new PreviewFailure('session_workspace_mismatch');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
36
42
|
return workspaceRoot(state, operation.workspaceId);
|
|
37
43
|
}
|
|
38
44
|
/** Tracks a background publish so tests and shutdown can wait for it. */
|
|
@@ -55,6 +61,9 @@ export async function recoverInterruptedPreviews(journal) {
|
|
|
55
61
|
}
|
|
56
62
|
export async function runPreviewPublish(state, operation, root) {
|
|
57
63
|
const workspaceId = operation.workspaceId;
|
|
64
|
+
const scope = operation.sessionId === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
|
|
58
67
|
const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
|
|
59
68
|
const output = { log: '' };
|
|
60
69
|
try {
|
|
@@ -73,6 +82,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
73
82
|
const source = await sourceRevision(root);
|
|
74
83
|
const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
|
|
75
84
|
workspaceId,
|
|
85
|
+
...scope,
|
|
76
86
|
operationId: operation.operationId,
|
|
77
87
|
contentDigest,
|
|
78
88
|
contentMd5,
|
|
@@ -81,6 +91,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
81
91
|
dirty: source.dirty,
|
|
82
92
|
cliVersion: state.config.cliVersion,
|
|
83
93
|
}));
|
|
94
|
+
if (reserved.sessionId !== operation.sessionId)
|
|
95
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
84
96
|
if (!reserved.alreadyUploaded) {
|
|
85
97
|
const uploaded = await fetch(reserved.uploadUrl, {
|
|
86
98
|
method: 'PUT',
|
|
@@ -99,11 +111,16 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
99
111
|
}
|
|
100
112
|
const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
|
|
101
113
|
workspaceId,
|
|
114
|
+
...scope,
|
|
102
115
|
operationId: operation.operationId,
|
|
103
116
|
artifactId: reserved.artifactId,
|
|
104
117
|
expectedPublishRevision: reserved.expectedPublishRevision,
|
|
105
118
|
logTail: tail(output.log),
|
|
106
119
|
}));
|
|
120
|
+
if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
|
|
121
|
+
published.artifactId !== reserved.artifactId) {
|
|
122
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
123
|
+
}
|
|
107
124
|
if (published.status === 'conflict')
|
|
108
125
|
throw new PreviewFailure('publish_conflict');
|
|
109
126
|
return published;
|
|
@@ -113,6 +130,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
113
130
|
if (code !== 'publish_conflict') {
|
|
114
131
|
await postControl(state, '/runtime/internal/preview-failures', {
|
|
115
132
|
workspaceId,
|
|
133
|
+
...scope,
|
|
116
134
|
operationId: operation.operationId,
|
|
117
135
|
error: code,
|
|
118
136
|
logTail: tail(output.log || (error instanceof Error ? error.message : '')),
|