@playcraft/cli 0.0.59 → 0.1.1
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/agent/local-backend.js +1 -1
- 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 +135 -169
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +118 -37
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +35 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +108 -48
- package/dist/workspace-runtime/server/http.js +260 -12
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- 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 +7 -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
|
|
|
@@ -2,7 +2,7 @@ import express from 'express';
|
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
|
-
import { eq, inArray } from '
|
|
5
|
+
import { eq, inArray } from '@playcraft/common/database/operators';
|
|
6
6
|
import pc from 'picocolors';
|
|
7
7
|
import { createSqliteAdapter } from '@playcraft/common/database';
|
|
8
8
|
import { AssetModel, ProjectModel, SceneModel } from '@playcraft/common/models';
|
|
@@ -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
|
+
}
|