@playcraft/cli 0.1.0 → 0.1.2
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/README.md +14 -2
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +109 -51
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +32 -2
- package/dist/workspace-runtime/codex/model-broker.js +183 -0
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/serve.js +23 -7
- package/dist/workspace-runtime/server/dispatch.js +15 -4
- package/dist/workspace-runtime/server/http.js +67 -4
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -192,13 +192,25 @@ echo '{"token":"<sandbox-jwt>","apiUrl":"https://..."}' > .playcraft.json
|
|
|
192
192
|
|
|
193
193
|
业务工具写请求要求服务支持持久操作协议。命令行先保存请求编号、路径和输入摘要到 `~/.playcraft/tool-requests/`,再发送请求;记录不包含凭证或请求正文。可用绝对路径环境变量 `PLAYCRAFT_TOOL_REQUEST_DIR` 指定可持久保留的目录。
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
原样重跑同一条命令(路径与参数都相同)时,命令行按上一次的结果决定是否沿用原请求编号:
|
|
196
|
+
|
|
197
|
+
| 上一次的结果 | 原样重跑时 |
|
|
198
|
+
| -------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
199
|
+
| 成功(2xx) | 新操作 |
|
|
200
|
+
| 请求被拒绝(4xx,下列 409 除外):请求没有被执行 | 新操作 |
|
|
201
|
+
| 服务端错误(5xx)、超时或连接中断:结果未知 | 沿用原编号,不会另起一次操作;服务端按下文的恢复方式处理 |
|
|
202
|
+
| 409 `business_operation_in_progress`:原操作仍在执行 | 沿用原编号,稍后重跑取得结果 |
|
|
203
|
+
| 409 `business_operation_requires_reconciliation`:结果未知,需要核对 | 沿用原编号,不会再执行 |
|
|
204
|
+
|
|
205
|
+
沿用原编号时,服务端按原操作的状态处理:已完成则返回原结果;仍在执行则返回 409 `business_operation_in_progress`;没有拿到结果就结束时,按该工具声明的恢复方式决定,可以安全重做的工具(生图、构建、发布到 COS 等)用原编号再执行一次,其余返回 409 `business_operation_requires_reconciliation`。原调用的执行租约有效期间(执行中每 20 秒续期,60 秒未续即过期),同一编号不会被再次执行;续期因服务或数据库故障中断超过 60 秒时,重做可能与仍在运行的原调用重叠。
|
|
206
|
+
|
|
207
|
+
需要核对的操作,只有确认它没有生效后,才可设置 `PLAYCRAFT_TOOL_NEW_REQUEST=1` 强制发起新操作;不要删除记录来绕过。也可以显式指定编号,只查询原操作的确定结果(不会重新执行;声明了恢复方式的工具若上次报错,返回那次的错误)。只查询时即使是可以安全重做的工具,没有结果也返回需要核对;去掉 `PLAYCRAFT_TOOL_REQUEST_ID` 原样重跑即可续做:
|
|
196
208
|
|
|
197
209
|
```bash
|
|
198
210
|
PLAYCRAFT_TOOL_REQUEST_ID=<错误中显示的请求编号> playcraft tools build-project --project-id <原项目编号>
|
|
199
211
|
```
|
|
200
212
|
|
|
201
|
-
|
|
213
|
+
能力检查失败时命令会明确说明尚未发送操作。服务不兼容时命令会在发送业务请求前停止;沙箱命令行与 Business 须排空旧调用后同步升级。
|
|
202
214
|
|
|
203
215
|
### 构建相关
|
|
204
216
|
|
|
@@ -72,7 +72,13 @@ export class AgentApiClient {
|
|
|
72
72
|
const serializedBody = JSON.stringify(body);
|
|
73
73
|
if (serializedBody === undefined)
|
|
74
74
|
throw new Error('Tool request body is required');
|
|
75
|
-
const
|
|
75
|
+
const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
|
|
76
|
+
const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
|
|
77
|
+
newRequest: options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1',
|
|
78
|
+
});
|
|
79
|
+
const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
|
|
80
|
+
`continue this request: it returns the result if the request finished, or finishes it if ` +
|
|
81
|
+
`the tool can be safely redone; it never starts a second operation. Record: ${operation.file}.`;
|
|
76
82
|
try {
|
|
77
83
|
const capabilityResponse = await fetch(this.toolsRequestUrl(AGENT_TOOL_OPERATION_CAPABILITIES_PATH), {
|
|
78
84
|
headers: this.authHeaders(),
|
|
@@ -81,12 +87,12 @@ export class AgentApiClient {
|
|
|
81
87
|
});
|
|
82
88
|
if (!capabilityResponse.ok) {
|
|
83
89
|
await capabilityResponse.body?.cancel();
|
|
84
|
-
throw new
|
|
90
|
+
throw new ToolRequestError(`Business tool protocol unavailable (${capabilityResponse.status}); no operation was sent`);
|
|
85
91
|
}
|
|
86
92
|
const capabilities = (await capabilityResponse.json());
|
|
87
93
|
if (capabilities.protocol !== AGENT_TOOL_OPERATION_PROTOCOL ||
|
|
88
94
|
capabilities.settledOnly !== true)
|
|
89
|
-
throw new
|
|
95
|
+
throw new ToolRequestError('Business tool protocol is incompatible; no operation was sent');
|
|
90
96
|
const res = await fetch(url, {
|
|
91
97
|
method: 'POST',
|
|
92
98
|
redirect: 'error',
|
|
@@ -103,10 +109,31 @@ export class AgentApiClient {
|
|
|
103
109
|
if (res.headers.get(AGENT_TOOL_OPERATION_HEADERS.protocol) !== AGENT_TOOL_OPERATION_PROTOCOL ||
|
|
104
110
|
res.headers.get(AGENT_TOOL_OPERATION_HEADERS.requestId) !== operation.requestId) {
|
|
105
111
|
await res.body?.cancel();
|
|
112
|
+
// Possibly an instance that ignores identities: a rerun may only read the receipt.
|
|
113
|
+
operation.requireReceipt();
|
|
106
114
|
throw new Error('Business did not acknowledge a durable receipt; outcome remains unconfirmed');
|
|
107
115
|
}
|
|
108
116
|
if (!res.ok) {
|
|
109
117
|
const text = await res.text().catch(() => res.statusText);
|
|
118
|
+
const code = errorCode(text);
|
|
119
|
+
if (res.status === 409 && code === 'business_operation_in_progress')
|
|
120
|
+
throw new ToolRequestError(`API 409: ${text}\nTool request ${operation.requestId} is still running. Rerun the ` +
|
|
121
|
+
'same command later to get its result.');
|
|
122
|
+
if (res.status === 409 && code === 'business_operation_requires_reconciliation')
|
|
123
|
+
throw new ToolRequestError(recoverId !== undefined
|
|
124
|
+
? // A receipt query never runs the request, even for a tool that can be safely redone.
|
|
125
|
+
`API 409: ${text}\nTool request ${operation.requestId} has no settled result yet. ` +
|
|
126
|
+
'Rerun the same command without PLAYCRAFT_TOOL_REQUEST_ID: a tool that can be ' +
|
|
127
|
+
'safely redone continues this request; any other tool then needs reconciliation.'
|
|
128
|
+
: `API 409: ${text}\nTool request ${operation.requestId} needs reconciliation: its ` +
|
|
129
|
+
'outcome is unknown, and rerunning the same command will not run it again. Only ' +
|
|
130
|
+
'after confirming it had no effect, rerun with PLAYCRAFT_TOOL_NEW_REQUEST=1 to ' +
|
|
131
|
+
'start a new operation.');
|
|
132
|
+
if (res.status >= 400 && res.status < 500) {
|
|
133
|
+
// The request itself was refused and not carried out: a rerun is a new operation.
|
|
134
|
+
operation.reject();
|
|
135
|
+
throw new ToolRequestError(`API ${res.status}: ${text}`);
|
|
136
|
+
}
|
|
110
137
|
throw new Error(`API ${res.status}: ${text}`);
|
|
111
138
|
}
|
|
112
139
|
const result = (await res.json());
|
|
@@ -114,10 +141,14 @@ export class AgentApiClient {
|
|
|
114
141
|
return result;
|
|
115
142
|
}
|
|
116
143
|
catch (error) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
if (error instanceof ToolRequestError)
|
|
145
|
+
throw error;
|
|
146
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${operation.settledOnly
|
|
147
|
+
? `Tool request ${operation.requestId}; record: ${operation.file}. Its settled result is not available yet.`
|
|
148
|
+
: unknownOutcome}`);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
operation.release();
|
|
121
152
|
}
|
|
122
153
|
}
|
|
123
154
|
async get(path, params) {
|
|
@@ -160,3 +191,15 @@ export class AgentApiClient {
|
|
|
160
191
|
return { apiUrl: '', token: '' };
|
|
161
192
|
}
|
|
162
193
|
}
|
|
194
|
+
/** A failure whose meaning is settled; no recovery hint applies. */
|
|
195
|
+
class ToolRequestError extends Error {
|
|
196
|
+
}
|
|
197
|
+
function errorCode(text) {
|
|
198
|
+
try {
|
|
199
|
+
const message = JSON.parse(text).message;
|
|
200
|
+
return typeof message === 'string' ? message : undefined;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -1,31 +1,61 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
import { isAgentToolRequestId } from '@playcraft/common/agent-tools';
|
|
6
|
+
/** Operations this process is sending now; never continued by another call of this process. */
|
|
7
|
+
const inFlight = new Set();
|
|
8
|
+
/** Linux start time of a process, so a reused pid is not taken for the process that held it. */
|
|
9
|
+
function startOf(pid) {
|
|
10
|
+
try {
|
|
11
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
12
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function holderAlive(holder) {
|
|
19
|
+
if (!holder || holder.pid === process.pid)
|
|
20
|
+
return false;
|
|
21
|
+
try {
|
|
22
|
+
process.kill(holder.pid, 0);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== 'EPERM')
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return holder.start === null || startOf(holder.pid) === holder.start;
|
|
29
|
+
}
|
|
6
30
|
/** Persist an identity before sending. Store neither credentials nor request/response bodies. */
|
|
7
|
-
export function beginToolOperation(path, serializedBody, recoverId) {
|
|
8
|
-
const identifier = recoverId ?? randomUUID();
|
|
9
|
-
if (!isAgentToolRequestId(identifier))
|
|
10
|
-
throw new Error('Invalid PLAYCRAFT_TOOL_REQUEST_ID');
|
|
11
|
-
const requestId = identifier.toLowerCase();
|
|
31
|
+
export function beginToolOperation(path, serializedBody, recoverId, options = {}) {
|
|
12
32
|
const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
13
33
|
if (!isAbsolute(directory))
|
|
14
34
|
throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
|
|
15
35
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
16
|
-
const file = join(directory, `${requestId}.json`);
|
|
17
36
|
const inputHash = createHash('sha256').update(serializedBody).digest('hex');
|
|
37
|
+
// Rerunning the same command while its outcome is unknown continues that operation: the server
|
|
38
|
+
// answers the same identity with its receipt instead of running it again.
|
|
39
|
+
const unconfirmed = recoverId === undefined && !options.newRequest
|
|
40
|
+
? findUnconfirmed(directory, path, inputHash)
|
|
41
|
+
: undefined;
|
|
42
|
+
const identifier = recoverId ?? unconfirmed?.requestId ?? randomUUID();
|
|
43
|
+
if (!isAgentToolRequestId(identifier))
|
|
44
|
+
throw new Error('Invalid PLAYCRAFT_TOOL_REQUEST_ID');
|
|
45
|
+
const requestId = identifier.toLowerCase();
|
|
46
|
+
const file = join(directory, `${requestId}.json`);
|
|
18
47
|
const now = new Date().toISOString();
|
|
48
|
+
const holder = { pid: process.pid, start: startOf(process.pid) };
|
|
19
49
|
const entry = {
|
|
20
50
|
version: 1,
|
|
21
51
|
requestId,
|
|
22
52
|
path,
|
|
23
53
|
inputHash,
|
|
24
54
|
state: 'unconfirmed',
|
|
55
|
+
holder,
|
|
25
56
|
createdAt: now,
|
|
26
57
|
updatedAt: now,
|
|
27
58
|
};
|
|
28
|
-
let settledOnly = recoverId !== undefined;
|
|
29
59
|
try {
|
|
30
60
|
writeFileSync(file, JSON.stringify(entry), { flag: 'wx', mode: 0o600 });
|
|
31
61
|
}
|
|
@@ -38,17 +68,53 @@ export function beginToolOperation(path, serializedBody, recoverId) {
|
|
|
38
68
|
previous.path !== path ||
|
|
39
69
|
previous.inputHash !== inputHash)
|
|
40
70
|
throw new Error(`Tool request ${requestId} conflicts with its durable local record`);
|
|
41
|
-
settledOnly = true;
|
|
42
71
|
}
|
|
72
|
+
const update = (change) => {
|
|
73
|
+
const current = JSON.parse(readFileSync(file, 'utf8'));
|
|
74
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
writeFileSync(temporary, JSON.stringify({ ...current, ...change, updatedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
76
|
+
renameSync(temporary, file);
|
|
77
|
+
};
|
|
78
|
+
// Only the sender holds an operation; a concurrent run of the same command gets its own.
|
|
79
|
+
inFlight.add(requestId);
|
|
80
|
+
if (unconfirmed || recoverId !== undefined)
|
|
81
|
+
update({ holder });
|
|
43
82
|
return {
|
|
44
83
|
requestId,
|
|
45
|
-
|
|
84
|
+
/** An explicit recovery, or a server that did not acknowledge the identity, reads only. */
|
|
85
|
+
settledOnly: recoverId !== undefined || unconfirmed?.receiptOnly === true,
|
|
46
86
|
file,
|
|
47
|
-
complete() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
complete: () => update({ state: 'completed' }),
|
|
88
|
+
/** A definite refusal: the request was not carried out, so a rerun is a new operation. */
|
|
89
|
+
reject: () => update({ state: 'rejected' }),
|
|
90
|
+
/** The outcome stays unknown and a rerun may only read the receipt. */
|
|
91
|
+
requireReceipt: () => update({ receiptOnly: true }),
|
|
92
|
+
/** This call is done with the operation; a later run of the command may continue it. */
|
|
93
|
+
release: () => inFlight.delete(requestId),
|
|
53
94
|
};
|
|
54
95
|
}
|
|
96
|
+
function findUnconfirmed(directory, path, inputHash) {
|
|
97
|
+
let latest;
|
|
98
|
+
for (const name of readdirSync(directory)) {
|
|
99
|
+
if (!name.endsWith('.json'))
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
const entry = JSON.parse(readFileSync(join(directory, name), 'utf8'));
|
|
103
|
+
if (entry.version === 1 &&
|
|
104
|
+
entry.state === 'unconfirmed' &&
|
|
105
|
+
entry.path === path &&
|
|
106
|
+
entry.inputHash === inputHash &&
|
|
107
|
+
isAgentToolRequestId(entry.requestId) &&
|
|
108
|
+
(!latest || entry.updatedAt > latest.updatedAt) &&
|
|
109
|
+
// Still being sent, here or by another live process: that run is not interrupted.
|
|
110
|
+
// Checked last, so only this command's candidates cost a process probe.
|
|
111
|
+
!inFlight.has(entry.requestId) &&
|
|
112
|
+
!holderAlive(entry.holder))
|
|
113
|
+
latest = entry;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A torn or foreign file is not an operation of this command.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return latest;
|
|
120
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { startLauncherProcess } from './launcher-client.js';
|
|
2
4
|
import { readFile } from 'node:fs/promises';
|
|
3
5
|
import { join } from 'node:path';
|
|
4
6
|
import { writePlaycraftCodexConfig } from './config-toml.js';
|
|
5
7
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
6
|
-
import {
|
|
8
|
+
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
+
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
7
10
|
function asRecord(value) {
|
|
8
11
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
9
12
|
? value
|
|
@@ -45,7 +48,10 @@ async function readToken(options) {
|
|
|
45
48
|
throw new Error('model_proxy_token_missing');
|
|
46
49
|
}
|
|
47
50
|
export function createCodexAppServerAdapter(options) {
|
|
48
|
-
const
|
|
51
|
+
const launcherSocket = options.launcherSocket || process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
52
|
+
const homeDir = options.homeDir || process.env.PLAYCRAFT_CODEX_HOME ||
|
|
53
|
+
(launcherSocket ? '/var/lib/playcraft/codex' : join(options.config.stateDir, 'codex-home'));
|
|
54
|
+
let executionEpoch = options.config.runtimeEpoch;
|
|
49
55
|
const model = options.model || process.env.PLAYCRAFT_CODEX_MODEL || 'gpt-4o';
|
|
50
56
|
const turnTimeoutMs = options.turnTimeoutMs ?? Number(process.env.PLAYCRAFT_CODEX_TURN_TIMEOUT_MS || 120_000);
|
|
51
57
|
const threads = new Map();
|
|
@@ -68,27 +74,66 @@ export function createCodexAppServerAdapter(options) {
|
|
|
68
74
|
startPromise ??= start();
|
|
69
75
|
await startPromise;
|
|
70
76
|
}
|
|
77
|
+
async function threadConfig(cwd) {
|
|
78
|
+
try {
|
|
79
|
+
return await options.modelBroker?.threadConfig(cwd) ?? {};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const reason = error instanceof Error ? error.message : '';
|
|
83
|
+
throw new NativePreparationError(['model_config_required', 'model_callback_insecure',
|
|
84
|
+
'model_credential_scope_mismatch', 'workspace_not_registered'].includes(reason)
|
|
85
|
+
? reason : 'model_credential_unavailable');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
71
88
|
async function start() {
|
|
72
|
-
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
modelProxyUrl: options.config.modelProxyUrl,
|
|
76
|
-
model,
|
|
77
|
-
});
|
|
78
|
-
const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || 'codex';
|
|
89
|
+
await options.modelBroker?.start();
|
|
90
|
+
const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
|
|
91
|
+
const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
|
|
79
92
|
const { command, prefix } = spawnCommand(bin);
|
|
80
93
|
const args = [...prefix, ...(options.args ?? ['app-server'])];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
if (launcherSocket) {
|
|
95
|
+
// Configuration travels as non-secret overrides: control cannot write dev's home.
|
|
96
|
+
for (const [key, value] of Object.entries({
|
|
97
|
+
model,
|
|
98
|
+
model_provider: 'playcraft',
|
|
99
|
+
approval_policy: 'never',
|
|
100
|
+
sandbox_mode: 'danger-full-access',
|
|
101
|
+
'model_providers.playcraft.name': 'PlayCraft Model Proxy',
|
|
102
|
+
'model_providers.playcraft.base_url': options.config.modelProxyUrl.replace(/\/+$/, ''),
|
|
103
|
+
'model_providers.playcraft.env_key': 'OPENAI_API_KEY',
|
|
104
|
+
'model_providers.playcraft.wire_api': 'responses',
|
|
105
|
+
'model_providers.playcraft.requires_openai_auth': false,
|
|
106
|
+
}))
|
|
107
|
+
args.push('-c', `${key}=${JSON.stringify(value)}`);
|
|
108
|
+
child = await startLauncherProcess({
|
|
109
|
+
socketPath: launcherSocket,
|
|
110
|
+
processId: `codex-${randomUUID()}`,
|
|
111
|
+
runtimeEpoch: executionEpoch,
|
|
112
|
+
argv: [command, ...args],
|
|
113
|
+
// The launcher admits the dev home, not the shared /workspaces root.
|
|
114
|
+
// Each thread supplies its registered workspace separately.
|
|
115
|
+
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
116
|
+
env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
|
|
121
|
+
const local = spawn(command, args, {
|
|
122
|
+
cwd: options.config.workspaceRoot,
|
|
123
|
+
env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
|
|
127
|
+
child = {
|
|
128
|
+
pid: local.pid, stdin: local.stdin, stdout: local.stdout, stderr: local.stderr,
|
|
129
|
+
onExit(listener) { local.once('exit', listener); },
|
|
130
|
+
async stop() {
|
|
131
|
+
if (local.exitCode === null && local.signalCode === null)
|
|
132
|
+
local.kill('SIGTERM');
|
|
133
|
+
await waitForChildExit(local);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
92
137
|
child.stderr?.on('data', (chunk) => {
|
|
93
138
|
process.stderr.write(chunk);
|
|
94
139
|
});
|
|
@@ -118,12 +163,12 @@ export function createCodexAppServerAdapter(options) {
|
|
|
118
163
|
superseded: false,
|
|
119
164
|
};
|
|
120
165
|
live = current;
|
|
121
|
-
child.
|
|
166
|
+
child.onExit(() => {
|
|
167
|
+
void current.rpc.close();
|
|
122
168
|
if (live === current) {
|
|
123
169
|
readyFlag = false;
|
|
124
|
-
|
|
170
|
+
// Retain the old handle until its entire execution group is confirmed stopped.
|
|
125
171
|
rpc = undefined;
|
|
126
|
-
child = undefined;
|
|
127
172
|
emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
|
|
128
173
|
}
|
|
129
174
|
if (!current.superseded && !closed) {
|
|
@@ -131,15 +176,23 @@ export function createCodexAppServerAdapter(options) {
|
|
|
131
176
|
listener({ pid: current.pid });
|
|
132
177
|
}
|
|
133
178
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
179
|
+
try {
|
|
180
|
+
await rpc.request('initialize', {
|
|
181
|
+
clientInfo: {
|
|
182
|
+
name: 'playcraft_workspace_runtime',
|
|
183
|
+
title: 'PlayCraft Workspace Runtime',
|
|
184
|
+
version: options.config.cliVersion,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
rpc.notify('initialized', {});
|
|
188
|
+
readyFlag = true;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
current.superseded = true;
|
|
192
|
+
await current.rpc.close();
|
|
193
|
+
await current.child.stop();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
143
196
|
}
|
|
144
197
|
return {
|
|
145
198
|
name: 'codex-app-server',
|
|
@@ -167,12 +220,10 @@ export function createCodexAppServerAdapter(options) {
|
|
|
167
220
|
startPromise = undefined;
|
|
168
221
|
threads.clear();
|
|
169
222
|
approvals.clear();
|
|
170
|
-
if (previous && previous.child.exitCode === null && previous.child.signalCode === null) {
|
|
171
|
-
previous.child.kill('SIGTERM');
|
|
172
|
-
}
|
|
173
223
|
await previous?.rpc.close().catch(() => undefined);
|
|
174
224
|
if (previous)
|
|
175
|
-
await
|
|
225
|
+
await previous.child.stop();
|
|
226
|
+
executionEpoch += 1;
|
|
176
227
|
if (closed)
|
|
177
228
|
throw new Error('codex_not_started');
|
|
178
229
|
try {
|
|
@@ -193,7 +244,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
193
244
|
if (!rpc)
|
|
194
245
|
return false;
|
|
195
246
|
try {
|
|
196
|
-
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd
|
|
247
|
+
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
|
|
248
|
+
...await threadConfig(input.cwd ?? '') }, 30_000);
|
|
197
249
|
const resumedId = result.thread?.id ?? result.threadId;
|
|
198
250
|
if (resumedId !== input.threadId)
|
|
199
251
|
return false;
|
|
@@ -204,6 +256,22 @@ export function createCodexAppServerAdapter(options) {
|
|
|
204
256
|
return false;
|
|
205
257
|
}
|
|
206
258
|
},
|
|
259
|
+
async readThread(input) {
|
|
260
|
+
try {
|
|
261
|
+
await ensureStarted();
|
|
262
|
+
if (!rpc)
|
|
263
|
+
return undefined;
|
|
264
|
+
const result = await rpc.request('thread/read', {
|
|
265
|
+
threadId: input.threadId,
|
|
266
|
+
includeTurns: true,
|
|
267
|
+
}, 30_000);
|
|
268
|
+
const projected = projectNativeThread(input.threadId, result.thread ?? result);
|
|
269
|
+
return projected;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
207
275
|
async captureNativeCheckpoint(input) {
|
|
208
276
|
const thread = threads.get(input.threadId);
|
|
209
277
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -222,6 +290,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
222
290
|
const result = await rpc.request('thread/start', {
|
|
223
291
|
cwd: input.cwd,
|
|
224
292
|
model,
|
|
293
|
+
...await threadConfig(input.cwd),
|
|
225
294
|
});
|
|
226
295
|
const threadId = result.thread?.id;
|
|
227
296
|
if (!threadId)
|
|
@@ -299,22 +368,11 @@ export function createCodexAppServerAdapter(options) {
|
|
|
299
368
|
const current = child;
|
|
300
369
|
await rpc?.close().catch(() => undefined);
|
|
301
370
|
rpc = undefined;
|
|
302
|
-
if (current
|
|
303
|
-
current.
|
|
304
|
-
await new Promise((resolve) => {
|
|
305
|
-
const timer = setTimeout(() => {
|
|
306
|
-
if (current.exitCode === null)
|
|
307
|
-
current.kill('SIGKILL');
|
|
308
|
-
resolve();
|
|
309
|
-
}, 3000);
|
|
310
|
-
current.once('exit', () => {
|
|
311
|
-
clearTimeout(timer);
|
|
312
|
-
resolve();
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
}
|
|
371
|
+
if (current)
|
|
372
|
+
await current.stop();
|
|
316
373
|
child = undefined;
|
|
317
374
|
live = undefined;
|
|
375
|
+
await options.modelBroker?.close();
|
|
318
376
|
},
|
|
319
377
|
};
|
|
320
378
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
3
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
function request(socketPath, message) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const socket = createConnection(socketPath);
|
|
8
|
+
let buffer = '';
|
|
9
|
+
socket.setEncoding('utf8');
|
|
10
|
+
const timer = setTimeout(() => socket.destroy(new Error('launcher_request_timeout')), 5000);
|
|
11
|
+
socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`));
|
|
12
|
+
socket.on('data', (chunk) => {
|
|
13
|
+
buffer += chunk;
|
|
14
|
+
if (buffer.length > 64 * 1024)
|
|
15
|
+
return socket.destroy(new Error('launcher_reply_too_large'));
|
|
16
|
+
if (!buffer.includes('\n'))
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
const reply = JSON.parse(buffer.split('\n')[0]);
|
|
20
|
+
if (!reply.ok)
|
|
21
|
+
throw new Error(reply.error || 'launcher_request_failed');
|
|
22
|
+
resolve(reply);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
reject(error);
|
|
26
|
+
}
|
|
27
|
+
socket.destroy();
|
|
28
|
+
});
|
|
29
|
+
socket.on('error', reject);
|
|
30
|
+
socket.on('close', () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
reject(new Error('launcher_connection_closed'));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function startLauncherProcess(input) {
|
|
37
|
+
const socket = createConnection(input.socketPath);
|
|
38
|
+
const stdout = new PassThrough();
|
|
39
|
+
const stderr = new PassThrough();
|
|
40
|
+
const events = new EventEmitter();
|
|
41
|
+
let exited = false;
|
|
42
|
+
let buffer = '';
|
|
43
|
+
let identity;
|
|
44
|
+
let stopPromise;
|
|
45
|
+
const stdin = new Writable({
|
|
46
|
+
write(chunk, _encoding, callback) {
|
|
47
|
+
socket.write(`${JSON.stringify({ action: 'stdin', data: chunk.toString('base64') })}\n`, callback);
|
|
48
|
+
},
|
|
49
|
+
final(callback) {
|
|
50
|
+
socket.write(`${JSON.stringify({ action: 'stdin-end' })}\n`, callback);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
// Transport loss is reported through onExit, never an unhandled stream error.
|
|
54
|
+
stdin.on('error', () => socket.destroy());
|
|
55
|
+
function exit() {
|
|
56
|
+
if (exited)
|
|
57
|
+
return;
|
|
58
|
+
exited = true;
|
|
59
|
+
events.emit('exit');
|
|
60
|
+
}
|
|
61
|
+
socket.setEncoding('utf8');
|
|
62
|
+
const pid = await new Promise((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => socket.destroy(new Error('launcher_start_timeout')), 5000);
|
|
64
|
+
let started = false;
|
|
65
|
+
socket.on('connect', () => {
|
|
66
|
+
const { socketPath: _socketPath, ...payload } = input;
|
|
67
|
+
socket.write(`${JSON.stringify({ ...payload, action: 'start-stdio' })}\n`);
|
|
68
|
+
});
|
|
69
|
+
socket.on('data', (chunk) => {
|
|
70
|
+
buffer += chunk;
|
|
71
|
+
if (buffer.length > 8 * 1024 * 1024)
|
|
72
|
+
return socket.destroy(new Error('launcher_reply_too_large'));
|
|
73
|
+
let newline;
|
|
74
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
75
|
+
const line = buffer.slice(0, newline);
|
|
76
|
+
buffer = buffer.slice(newline + 1);
|
|
77
|
+
try {
|
|
78
|
+
const frame = JSON.parse(line);
|
|
79
|
+
if (!started) {
|
|
80
|
+
if (!frame.ok || !frame.pid || !frame.instanceId)
|
|
81
|
+
throw new Error(frame.error || 'launcher_start_failed');
|
|
82
|
+
identity = {
|
|
83
|
+
processId: input.processId,
|
|
84
|
+
runtimeEpoch: input.runtimeEpoch,
|
|
85
|
+
instanceId: frame.instanceId,
|
|
86
|
+
};
|
|
87
|
+
started = true;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve(frame.pid);
|
|
90
|
+
}
|
|
91
|
+
else if (frame.channel === 'stdout' || frame.channel === 'stderr') {
|
|
92
|
+
if (typeof frame.data !== 'string')
|
|
93
|
+
throw new Error('launcher_invalid_frame');
|
|
94
|
+
const stream = frame.channel === 'stdout' ? stdout : stderr;
|
|
95
|
+
if (!stream.write(Buffer.from(frame.data, 'base64'))) {
|
|
96
|
+
socket.pause();
|
|
97
|
+
stream.once('drain', () => socket.resume());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (frame.event === 'exit')
|
|
101
|
+
exit();
|
|
102
|
+
else
|
|
103
|
+
throw new Error('launcher_invalid_frame');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
socket.destroy(error instanceof Error ? error : new Error('launcher_invalid_frame'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
socket.on('error', (error) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(error);
|
|
114
|
+
});
|
|
115
|
+
socket.on('close', () => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
reject(new Error('launcher_connection_closed'));
|
|
118
|
+
stdout.end();
|
|
119
|
+
stderr.end();
|
|
120
|
+
stdin.destroy();
|
|
121
|
+
exit();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
pid,
|
|
126
|
+
stdin,
|
|
127
|
+
stdout,
|
|
128
|
+
stderr,
|
|
129
|
+
onExit(listener) {
|
|
130
|
+
if (exited)
|
|
131
|
+
queueMicrotask(listener);
|
|
132
|
+
else
|
|
133
|
+
events.once('exit', listener);
|
|
134
|
+
},
|
|
135
|
+
stop() {
|
|
136
|
+
stopPromise ??= (async () => {
|
|
137
|
+
// A new launcher's empty registry cannot confirm an old group's exit.
|
|
138
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
139
|
+
await request(input.socketPath, { ...identity, action: 'stop', signal });
|
|
140
|
+
const deadline = Date.now() + 3000;
|
|
141
|
+
while (Date.now() < deadline) {
|
|
142
|
+
const status = await request(input.socketPath, { ...identity, action: 'status' });
|
|
143
|
+
if (status.running === false) {
|
|
144
|
+
socket.destroy();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await delay(50);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
throw new Error('codex_group_stop_unconfirmed');
|
|
151
|
+
})();
|
|
152
|
+
return stopPromise;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|