dsh-tabbit 0.2.3 → 0.3.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/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +70 -76
- package/client/client.js +390 -0
- package/cordis.patch.yml +76 -5
- package/lib/core/index.js +756 -0
- package/lib/installer/detect.js +374 -0
- package/lib/installer/download.js +247 -0
- package/lib/installer/index.js +254 -0
- package/lib/mentions/index.js +595 -0
- package/lib/permissions/index.js +136 -0
- package/lib/runtime/cli.js +229 -0
- package/lib/runtime/client.js +454 -0
- package/lib/runtime/codec.js +126 -0
- package/lib/runtime/endpoint.js +248 -0
- package/lib/runtime/errors.js +126 -0
- package/lib/runtime/instances.js +287 -0
- package/lib/runtime/net.js +143 -0
- package/lib/runtime/peer.js +132 -0
- package/lib/tool-browser/index.js +476 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +55 -16
- package/skills/tabbit/SKILL.md +66 -0
- package/skills/tabbit/references/interaction-helpers.md +150 -0
- package/skills/tabbit/references/platform-invocation.md +174 -0
- package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
- package/skills/tabbit/references/runtime-recovery.md +104 -0
- package/README.zh-CN.md +0 -114
- package/index.js +0 -352
- package/installer.js +0 -568
- package/skills/tabbit-browser/SKILL.md +0 -274
- package/skills/tabbit-browser/agents/openai.yaml +0 -4
- package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
- package/skills/tabbit-browser/references/platform-invocation.md +0 -45
- package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
- package/update-check.js +0 -177
- /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:Runtime Service 的高层客户端 `TabbitClient`
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* runtime/ 目录的"总装层":把 cli.ts(子进程调用)、codec.ts(base64 信封)、
|
|
7
|
+
* errors.ts(错误分类)、instances.ts(实例注册表)组合成一个好用的类。
|
|
8
|
+
* 负责:实例选择、请求排队/并发控制、结果解码、超大结果的分块读回、
|
|
9
|
+
* 以及两类故障(任务隔离 quarantine、任务重置 task-reset)的自动恢复。
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ 本文件(乃至整个 runtime/ 目录)完全不依赖任何 dsh 包——可以脱离 dsh
|
|
12
|
+
* 单独使用(`node` 里直接 import lib/runtime/client.js 做脚本调试)。
|
|
13
|
+
* 与 dsh 的对接全部发生在上层(core/tool-browser/web-fetch/mentions)。
|
|
14
|
+
*
|
|
15
|
+
* ─── 必备背景:Runtime Service 的「任务」(task)模型 ───────────────────
|
|
16
|
+
*
|
|
17
|
+
* Tabbit 的 Runtime Service 以「任务」为隔离单位:
|
|
18
|
+
* - 每个任务有一个名字(task name)。这个名字【同时也是浏览器里那个
|
|
19
|
+
* 标签组(tab group)唯一可见的标题】——服务没有独立的"标题"字段,
|
|
20
|
+
* 改标题只能改任务命名本身;
|
|
21
|
+
* - 一个任务 = 一个独立的浏览器上下文视角:自己开的标签页、自己的
|
|
22
|
+
* globalThis(跨调用持久,可以存变量)、自己的 artifacts 目录;
|
|
23
|
+
* - 任务【只能看到自己打开的或被显式 claim(认领)的标签页】,无法枚举
|
|
24
|
+
* 用户的其它标签页——这是浏览器侧的安全边界;
|
|
25
|
+
* - 整机最多 8 个并发任务;单次求值最长 120 秒;
|
|
26
|
+
* - 本客户端用到的 CLI 动词:`nodejs`(求值,创建时可带 --claim-tab)、
|
|
27
|
+
* `finish`(结束任务,keep 语义两代有别,见 finishTask 注释)、
|
|
28
|
+
* `receipt`(查回执)、`checkpoint`(检查点)、`resource`(分块读资源)、
|
|
29
|
+
* `tasks`(列任务)、`claim`(对已存在的任务追加认领标签页,见
|
|
30
|
+
* claimTabs()——真机 `--help` 确认过是独立顶层子命令,不是只存在于
|
|
31
|
+
* persistent 帧协议里)。新代 CLI(1.11.16+)另有 tabs/resume/
|
|
32
|
+
* screenshot/inspect/paste 和 persistent 持久模式(JSON 帧协议)——
|
|
33
|
+
* 本客户端尚未使用,见浏览器共享 skill(~/.agents/skills/tabbit)与
|
|
34
|
+
* TabbitDance 源码。
|
|
35
|
+
*
|
|
36
|
+
* ─── 一次 evaluate 的完整旅程 ─────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* evaluate()
|
|
39
|
+
* └─ withTaskLock() 同名任务串行化(服务端 worker 本来就一次只做一件事)
|
|
40
|
+
* └─ 重试循环(最多 3 轮)
|
|
41
|
+
* └─ evaluateOnce()
|
|
42
|
+
* ├─ buildEvaluationSource() 给代码穿上 base64 信封(codec.ts)
|
|
43
|
+
* ├─ invoke('nodejs', ...) 起 CLI 子进程提交(cli.ts;全局限流 4 并发)
|
|
44
|
+
* ├─ waitForTerminalReceipt() 回执没到终态就轮询 receipt
|
|
45
|
+
* └─ decodeReceiptResult() 内联结果直接解信封;溢出结果先
|
|
46
|
+
* resource 分块读回再解信封
|
|
47
|
+
*/
|
|
48
|
+
import { randomUUID } from 'node:crypto';
|
|
49
|
+
import { runCli } from './cli.js';
|
|
50
|
+
import { buildEvaluationSource, decodeEnvelope } from './codec.js';
|
|
51
|
+
import { CLI_ERROR_CODES, TabbitCliError, isUnknownTaskError } from './errors.js';
|
|
52
|
+
import { defaultLauncherPath, listInstances, resolveInstanceId } from './instances.js';
|
|
53
|
+
/* 求值超时的天花板:服务端硬上限 120 秒,请求再大也压到这里。 */
|
|
54
|
+
const EVAL_TIMEOUT_CEILING_MS = 120_000;
|
|
55
|
+
/* CLI 子进程的墙钟超时。CLI 自己会等结果最长 125 秒,浏览器冷启动还要 ~20 秒,
|
|
56
|
+
* 所以子进程超时必须比求值超时富余一大截,否则会在正常等待时误杀。 */
|
|
57
|
+
const SUBPROCESS_TIMEOUT_MS = 145_000;
|
|
58
|
+
/* 控制类命令(finish/receipt/resource/tasks)的超时——也要容纳浏览器自动拉起的 ~20 秒。 */
|
|
59
|
+
const CONTROL_TIMEOUT_MS = 40_000;
|
|
60
|
+
/* 溢出资源默认最多读回 4 MB。 */
|
|
61
|
+
const DEFAULT_MAX_RESULT_BYTES = 4_000_000;
|
|
62
|
+
/* 同一客户端同时在跑的 CLI 子进程上限(对整台机器的 Runtime Service 友好些)。 */
|
|
63
|
+
const MAX_CONCURRENT_CALLS = 4;
|
|
64
|
+
export class TabbitClient {
|
|
65
|
+
options;
|
|
66
|
+
/* 每个任务名一条 Promise 链,实现按任务串行(见 withTaskLock)。 */
|
|
67
|
+
taskQueues = new Map();
|
|
68
|
+
/* 当前在飞的 CLI 子进程数(配合 MAX_CONCURRENT_CALLS 限流)。 */
|
|
69
|
+
inFlight = 0;
|
|
70
|
+
/* 排队等空位的唤醒回调(先来先走)。 */
|
|
71
|
+
waiters = [];
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
}
|
|
75
|
+
listInstances() {
|
|
76
|
+
return listInstances();
|
|
77
|
+
}
|
|
78
|
+
launcher() {
|
|
79
|
+
return this.options.launcherPath ?? defaultLauncherPath();
|
|
80
|
+
}
|
|
81
|
+
/* 每次调用时实时解析实例(配置的 id 校验一遍;没配就自动选)。可能抛引导错误。 */
|
|
82
|
+
instance() {
|
|
83
|
+
const instances = listInstances();
|
|
84
|
+
// Windows:实例注册表目录在 Windows 上的位置未经真机确认(本地解析很可能
|
|
85
|
+
// 读到空列表),此时不要在客户端这层拦死——把选择(连同可能配置的 id)
|
|
86
|
+
// 原样委托给原生 tabbit-cli.exe,它自有实例选择与可解码的报错
|
|
87
|
+
// (exit 69 → cli.ts 归类为 instance-selection)。
|
|
88
|
+
if (process.platform === 'win32' && instances.length === 0)
|
|
89
|
+
return this.options.instanceId;
|
|
90
|
+
return resolveInstanceId(this.options.instanceId, instances);
|
|
91
|
+
}
|
|
92
|
+
/*
|
|
93
|
+
* 当前实际会解析到的实例 id(不抛错版本;解析不出就 undefined)。
|
|
94
|
+
* 用途:上层(core 的任务登记)在每次求值成功后记下"这个任务真正跑在了
|
|
95
|
+
* 哪个实例上",会话结束清理时对症下药——因为"当前解析结果"是会漂移的
|
|
96
|
+
* (用户换个 Tabbit 窗口看 dsh,观看实例就变了)。
|
|
97
|
+
*/
|
|
98
|
+
resolvedInstanceId() {
|
|
99
|
+
try {
|
|
100
|
+
return this.instance();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
log(message) {
|
|
107
|
+
this.options.logger?.(message);
|
|
108
|
+
}
|
|
109
|
+
/*
|
|
110
|
+
* 并发限流的"占坑":满员就把自己的唤醒函数排进 waiters 等着。
|
|
111
|
+
* 返回一个"释放函数",用 released 标志保证幂等(重复调用只生效一次);
|
|
112
|
+
* 释放时顺手唤醒队首的等待者。这是不依赖任何库的手写信号量(semaphore)。
|
|
113
|
+
*/
|
|
114
|
+
async acquireSlot() {
|
|
115
|
+
if (this.inFlight >= MAX_CONCURRENT_CALLS) {
|
|
116
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
117
|
+
}
|
|
118
|
+
this.inFlight += 1;
|
|
119
|
+
let released = false;
|
|
120
|
+
return () => {
|
|
121
|
+
if (released)
|
|
122
|
+
return;
|
|
123
|
+
released = true;
|
|
124
|
+
this.inFlight -= 1;
|
|
125
|
+
this.waiters.shift()?.();
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/*
|
|
129
|
+
* 按任务串行化:同一个任务名下的求值排成一条 Promise 链,前一个完成
|
|
130
|
+
* (无论成败——所以 then 的两个参数都是 fn)才轮到下一个。
|
|
131
|
+
* 为什么:服务端的任务 worker 本来就一次只处理一个请求,客户端排好队
|
|
132
|
+
* 能避免请求在服务端队列里堆积、超时语义也更清晰。
|
|
133
|
+
* 不同任务之间互不阻塞(只受全局 4 并发限流约束)。
|
|
134
|
+
*/
|
|
135
|
+
async withTaskLock(task, fn) {
|
|
136
|
+
const previous = this.taskQueues.get(task) ?? Promise.resolve();
|
|
137
|
+
const run = previous.then(fn, fn);
|
|
138
|
+
// 存进 map 的是"吞掉结果和错误"的版本,防止链上残留 rejected Promise
|
|
139
|
+
// 触发 unhandled rejection 警告。
|
|
140
|
+
this.taskQueues.set(task, run.then(() => undefined, () => undefined));
|
|
141
|
+
return await run;
|
|
142
|
+
}
|
|
143
|
+
/* 所有 CLI 调用的统一入口:先占并发坑,再执行,finally 保证释放。 */
|
|
144
|
+
async invoke(argv, stdin, timeoutMs, signal) {
|
|
145
|
+
const release = await this.acquireSlot();
|
|
146
|
+
try {
|
|
147
|
+
return await runCli(argv, stdin, {
|
|
148
|
+
launcherPath: this.launcher(),
|
|
149
|
+
instanceId: this.instance(),
|
|
150
|
+
timeoutMs,
|
|
151
|
+
...(signal ? { signal } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/*
|
|
159
|
+
* 求值主入口:带自动恢复的重试循环(最多 3 次尝试,attempt 0/1/2)。
|
|
160
|
+
* 三种可自动恢复的失败,各自的处理:
|
|
161
|
+
*
|
|
162
|
+
* - quarantined(任务被隔离):一次带副作用的求值被中断后,服务端把任务
|
|
163
|
+
* 锁起来拒绝新请求,要求先 checkpoint 确认状态。我们自动补一次
|
|
164
|
+
* checkpoint 然后重试,并在 notes 里向模型说明(它可能需要核实上次
|
|
165
|
+
* 操作到底成没成功)。
|
|
166
|
+
*
|
|
167
|
+
* - task-reset(任务重置):worker 丢了/浏览器重启了,任务里的页面和
|
|
168
|
+
* globalThis 全没了。直接重试会在【全新的空任务】里执行——所以必须
|
|
169
|
+
* 把 taskWasReset 标记出来告诉模型"你之前存的状态没了",否则它会
|
|
170
|
+
* 对着空任务困惑。
|
|
171
|
+
*
|
|
172
|
+
* - browser-unavailable(浏览器暂不可达):可能正在启动,等 3 秒重试
|
|
173
|
+
* 一次(只在第一次尝试时这么做,避免反复空等)。
|
|
174
|
+
*
|
|
175
|
+
* 其余错误不重试,原样抛出(由上层决定措辞)。
|
|
176
|
+
*/
|
|
177
|
+
async evaluate(request) {
|
|
178
|
+
return await this.withTaskLock(request.task, async () => {
|
|
179
|
+
const notes = [];
|
|
180
|
+
let taskWasReset = false;
|
|
181
|
+
for (let attempt = 0;; attempt += 1) {
|
|
182
|
+
try {
|
|
183
|
+
return await this.evaluateOnce(request, notes, taskWasReset);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (!(error instanceof TabbitCliError) || attempt >= 2)
|
|
187
|
+
throw error;
|
|
188
|
+
if (error.kind === 'quarantined') {
|
|
189
|
+
this.log(`task ${request.task} quarantined; running checkpoint`);
|
|
190
|
+
await this.checkpoint(request.task).catch(() => undefined);
|
|
191
|
+
notes.push('Task was quarantined after an interrupted run; a checkpoint was taken and the call was retried.');
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (error.kind === 'task-reset') {
|
|
195
|
+
taskWasReset = true;
|
|
196
|
+
notes.push('The browser task was reset (worker lost or browser restarted). Pages and globalThis state from earlier calls are gone; the call was retried in a fresh task.');
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (error.kind === 'browser-unavailable' && attempt === 0) {
|
|
200
|
+
notes.push('Tabbit Browser runtime was unavailable; retried once after 3s.');
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/*
|
|
210
|
+
* 单次求值(不含重试)。流程:
|
|
211
|
+
* 1. 生成唯一 requestId(幂等追踪用)并拼 `nodejs` 命令的参数;
|
|
212
|
+
* 2. 用信封包装代码,经 invoke 提交(代码走 stdin);
|
|
213
|
+
* 3. 校验 claim_tabs 语义:claim 只在任务【创建】时生效——若服务端说
|
|
214
|
+
* reused(任务早已存在),claim 实际被无视了,与其静默让调用者误以为
|
|
215
|
+
* 认领成功,不如报错让它换个新任务名;
|
|
216
|
+
* 4. 回执可能还没到终态(queued/running),轮询等它;
|
|
217
|
+
* 5. 按回执状态返回成功(解码结果)或失败(提取错误信息)。
|
|
218
|
+
*/
|
|
219
|
+
async evaluateOnce(request, notes, taskWasReset) {
|
|
220
|
+
const requestId = `dsh-${randomUUID()}`;
|
|
221
|
+
const argv = ['nodejs', '--task', request.task, '--request-id', requestId];
|
|
222
|
+
if (request.readOnly)
|
|
223
|
+
argv.push('--read-only');
|
|
224
|
+
if (request.foreground)
|
|
225
|
+
argv.push('--foreground');
|
|
226
|
+
// 超时钳位到 [1000, 120000] 区间(服务端上限 120 秒)。
|
|
227
|
+
const timeoutMs = Math.min(Math.max(request.timeoutMs ?? EVAL_TIMEOUT_CEILING_MS, 1000), EVAL_TIMEOUT_CEILING_MS);
|
|
228
|
+
argv.push('--timeout-ms', String(timeoutMs));
|
|
229
|
+
for (const tab of request.claimTabs ?? [])
|
|
230
|
+
argv.push('--claim-tab', String(tab));
|
|
231
|
+
const source = buildEvaluationSource(request.code);
|
|
232
|
+
const response = (await this.invoke(argv, source, SUBPROCESS_TIMEOUT_MS, request.signal));
|
|
233
|
+
const task = response.task;
|
|
234
|
+
if ((request.claimTabs?.length ?? 0) > 0 && task.reused) {
|
|
235
|
+
throw new TabbitCliError({
|
|
236
|
+
kind: 'tab-claim',
|
|
237
|
+
code: 'CLAIM_REQUIRES_NEW_TASK',
|
|
238
|
+
message: `Task "${request.task}" already exists, and tab claims are only honored when a task is created. Use a new task name to claim tabs.`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// 兼容两代 `nodejs` 输出(真机对照实测):
|
|
242
|
+
// - 旧版(≤ Tabbit 1.10 / Cr150):{ task, receipt }——receipt 里带
|
|
243
|
+
// requestId/status/result(result 有 type 判别);
|
|
244
|
+
// - 新版(Tabbit 1.11+/Cr151):顶层就是一张【扁平终态回执】
|
|
245
|
+
// { status, result, task, transition }——result 无 type 字段(inline
|
|
246
|
+
// 有 value、溢出有 resourceId+byteLength),失败时错误在 result.error。
|
|
247
|
+
// 注意 `receipt` 子命令两代都返回旧版包裹形(回执存储没变),所以只需
|
|
248
|
+
// 在这里归一化,轮询路径照旧。
|
|
249
|
+
let receipt = response.receipt !== undefined ? response.receipt : normalizeFlatReceipt(response, requestId);
|
|
250
|
+
receipt = await this.waitForTerminalReceipt(request.task, receipt);
|
|
251
|
+
if (receipt.status === 'succeeded') {
|
|
252
|
+
const result = await this.decodeReceiptResult(request.task, receipt, request.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES, notes);
|
|
253
|
+
return { status: 'succeeded', result, task, taskWasReset, notes };
|
|
254
|
+
}
|
|
255
|
+
const errorMessage = extractReceiptError(receipt);
|
|
256
|
+
return { status: 'failed', errorMessage, task, taskWasReset, notes };
|
|
257
|
+
}
|
|
258
|
+
/*
|
|
259
|
+
* 回执轮询:正常情况下 `nodejs` 命令会阻塞到求值结束才返回终态回执,但
|
|
260
|
+
* 边缘情况(CLI 等待窗口耗尽等)下可能拿到 queued/running。此时每 2 秒
|
|
261
|
+
* 用 `receipt` 命令查一次,最多 20 次(40 秒);到头了就把非终态回执
|
|
262
|
+
* 原样返回,让上层把它当失败处理。
|
|
263
|
+
*/
|
|
264
|
+
async waitForTerminalReceipt(task, receipt) {
|
|
265
|
+
let current = receipt;
|
|
266
|
+
for (let poll = 0; poll < 20 && (current.status === 'queued' || current.status === 'running'); poll += 1) {
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
268
|
+
current = (await this.invoke(['receipt', '--task', task, '--request', current.requestId], '', CONTROL_TIMEOUT_MS));
|
|
269
|
+
}
|
|
270
|
+
return current;
|
|
271
|
+
}
|
|
272
|
+
/*
|
|
273
|
+
* 从成功回执里取出并解码返回值:
|
|
274
|
+
* - 无 result 字段:视为 null;
|
|
275
|
+
* - inline:值就在回执里,直接解信封;
|
|
276
|
+
* - resource(结果 >8KiB 溢出成了资源文件):分块读回文本——
|
|
277
|
+
* · 读完整了:先 JSON.parse(资源里存的是"包含信封字符串的 JSON",
|
|
278
|
+
* 即形如 `"b64:xxxx"` 的带引号文本)再解信封;parse 不动就把文本
|
|
279
|
+
* 当 rawText 交上去;
|
|
280
|
+
* · 被字节上限截停:文本只是前缀(形如 `"b64:AAAA...` 掐头去尾都
|
|
281
|
+
* 不完整),剥掉开头引号后按"不完整"模式解信封(codec 的宽松
|
|
282
|
+
* 解码能救回前缀部分),并在 notes 里提醒模型返回值太大了。
|
|
283
|
+
*/
|
|
284
|
+
async decodeReceiptResult(task, receipt, maxResultBytes, notes) {
|
|
285
|
+
const result = receipt.result;
|
|
286
|
+
if (!result)
|
|
287
|
+
return { value: null, truncated: false };
|
|
288
|
+
if (result.type === 'inline')
|
|
289
|
+
return decodeEnvelope(result.value);
|
|
290
|
+
const { text, complete } = await this.readResourceText(task, result.resourceId, maxResultBytes);
|
|
291
|
+
if (!complete) {
|
|
292
|
+
notes.push(`Result was ${result.byteLength} bytes; only the first ${maxResultBytes} bytes were read back. Return smaller values (or write files via artifactPath) for complete results.`);
|
|
293
|
+
}
|
|
294
|
+
if (complete) {
|
|
295
|
+
try {
|
|
296
|
+
return decodeEnvelope(JSON.parse(text));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { rawText: text, truncated: true };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// 不完整的资源:JSON 文本是形如 `"b64:AAAA...` 的前缀,剥掉引号解信封。
|
|
303
|
+
const stripped = text.startsWith('"') ? text.slice(1) : text;
|
|
304
|
+
return decodeEnvelope(stripped, false);
|
|
305
|
+
}
|
|
306
|
+
/*
|
|
307
|
+
* 用 `resource` 命令分块读回一个溢出资源。
|
|
308
|
+
* 协议:每次带 --offset 请求,返回 {data: 本块文本, nextOffset, eof};
|
|
309
|
+
* eof=true 表示读完。达到 maxBytes 上限就带着 complete:false 提前收手。
|
|
310
|
+
* 中间那个防御分支:万一服务端返回畸形块(没有 nextOffset 或空 data),
|
|
311
|
+
* 宁可当"没读完"返回也绝不无限自旋。
|
|
312
|
+
*/
|
|
313
|
+
async readResourceText(task, resourceId, maxBytes) {
|
|
314
|
+
let offset = 0;
|
|
315
|
+
let text = '';
|
|
316
|
+
for (;;) {
|
|
317
|
+
const chunk = (await this.invoke(['resource', '--task', task, '--resource', resourceId, '--offset', String(offset)], '', CONTROL_TIMEOUT_MS));
|
|
318
|
+
text += chunk.data;
|
|
319
|
+
if (chunk.eof)
|
|
320
|
+
return { text, complete: true };
|
|
321
|
+
if (typeof chunk.nextOffset !== 'number' || chunk.data.length === 0) {
|
|
322
|
+
// 防御:畸形块回复,绝不自旋。
|
|
323
|
+
return { text, complete: false };
|
|
324
|
+
}
|
|
325
|
+
offset = chunk.nextOffset;
|
|
326
|
+
if (offset >= maxBytes)
|
|
327
|
+
return { text, complete: false };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/*
|
|
331
|
+
* 结束一个任务(`finish` 命令)。
|
|
332
|
+
*
|
|
333
|
+
* ── keep 语义的两代差异与本实现的策略(2026-08-28 按新语义适配)──────
|
|
334
|
+
* 新代 Runtime(本机稳定/Dev 的 1.11.16+ CLI 源码实证):
|
|
335
|
+
* `finish` 缺省【保留】标签页与可恢复组(keep:true),`--discard` 才
|
|
336
|
+
* 关闭;`--keep --discard` 同传报错。
|
|
337
|
+
* 旧代(≤1.11.13):缺省关闭,`--keep` 保留;解析器风格是"扫描已知
|
|
338
|
+
* flag、忽略未知"(新旧同款代码风格),未知的 `--discard` 会被忽略。
|
|
339
|
+
* 因此【恒显式】即可两代通吃、无需探测版本:
|
|
340
|
+
* keep=true → `--keep` (新代:保留✓;旧代:保留✓)
|
|
341
|
+
* keep=false → `--discard`(新代:关闭✓;旧代:忽略未知 flag → 裸
|
|
342
|
+
* finish → 旧默认关闭✓)
|
|
343
|
+
*
|
|
344
|
+
* 三类错误吞掉不抛(视为"清理已达成"),但每次吞掉都【记一行日志】——
|
|
345
|
+
* 吞错本身是对的,可完全无痕就没法排障(真机复现过:finish 打到漂移后的
|
|
346
|
+
* 错误实例,命中 Unknown task name 被吞,调用方误以为成功,真正持有任务
|
|
347
|
+
* 的实例上标签组一直挂着):
|
|
348
|
+
* - Unknown task name:任务在【本实例】上不存在——可能确实早没了,也可能
|
|
349
|
+
* 它活在另一个实例上(实例漂移场景),所以日志里特意带上实例 id 供对账;
|
|
350
|
+
* - browser-unavailable:浏览器都没在跑,任务自然也没了(不能为了 finish
|
|
351
|
+
* 把浏览器拉起来);
|
|
352
|
+
* - task-reset:服务重启过,旧任务已作废。
|
|
353
|
+
* 其余错误照常抛出(调用方大多也会 catch 住做尽力而为清理)。
|
|
354
|
+
*/
|
|
355
|
+
async finishTask(task, options = {}) {
|
|
356
|
+
const argv = ['finish', '--task', task, options.keep ? '--keep' : '--discard'];
|
|
357
|
+
try {
|
|
358
|
+
await this.invoke(argv, '', CONTROL_TIMEOUT_MS);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
const instance = this.resolvedInstanceId() ?? 'unresolved';
|
|
362
|
+
if (isUnknownTaskError(error)) {
|
|
363
|
+
this.log(`finish --task "${task}" ignored: unknown task on instance ${instance} (already gone there; if its tab group is still visible, the task may live on a DIFFERENT instance)`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (error instanceof TabbitCliError && (error.kind === 'browser-unavailable' || error.kind === 'task-reset')) {
|
|
367
|
+
this.log(`finish --task "${task}" skipped on instance ${instance}: ${error.kind} (${error.message})`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/* 打检查点(`checkpoint` 命令)——解除任务隔离状态的钥匙(见 evaluate 的恢复逻辑)。 */
|
|
374
|
+
async checkpoint(task) {
|
|
375
|
+
return await this.invoke(['checkpoint', '--task', task], '', CONTROL_TIMEOUT_MS);
|
|
376
|
+
}
|
|
377
|
+
/*
|
|
378
|
+
* 把一个或多个可用(available)标签页追加认领进一个【已经存在】的任务
|
|
379
|
+
* (`claim` 命令)。与 evaluate() 的 claimTabs 只在任务创建那一刻生效不同,
|
|
380
|
+
* 这是独立的一次性 CLI 子命令——真机 `tabbit-cli --help` 的 usage 行确认过
|
|
381
|
+
* 它是顶层动词(`claim --task <name> --tab <id>...`),不是只存在于 persistent
|
|
382
|
+
* 帧协议里;真机验证过对一个此前已创建好的任务追加 claim 可行。
|
|
383
|
+
* 批量原子:文档明确"duplicate, stale, busy, unsupported, or cross-window
|
|
384
|
+
* inputs do not partially claim"——要么全部认领成功,要么整批失败并抛错,
|
|
385
|
+
* 调用方不需要处理"部分成功"的中间态。
|
|
386
|
+
*/
|
|
387
|
+
async claimTabs(task, tabIds) {
|
|
388
|
+
const argv = ['claim', '--task', task];
|
|
389
|
+
for (const tab of tabIds)
|
|
390
|
+
argv.push('--tab', String(tab));
|
|
391
|
+
const response = (await this.invoke(argv, '', CONTROL_TIMEOUT_MS));
|
|
392
|
+
return {
|
|
393
|
+
...(typeof response.groupId === 'string' ? { groupId: response.groupId } : {}),
|
|
394
|
+
...(typeof response.ownedPageCount === 'number' ? { ownedPageCount: response.ownedPageCount } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/* 列出当前实例上的所有任务(`tasks` 命令)。返回值形状异常时兜底为空数组。 */
|
|
398
|
+
async listTasks() {
|
|
399
|
+
const value = await this.invoke(['tasks'], '', CONTROL_TIMEOUT_MS);
|
|
400
|
+
return Array.isArray(value) ? value : [];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/*
|
|
404
|
+
* 把新版(Cr151 世代)`nodejs` 命令的扁平终态输出归一成旧版 Receipt 形状
|
|
405
|
+
* (形状差异与实测样本见 evaluateOnce 里的注释)。要点:
|
|
406
|
+
* - result 无 type 判别:有 resourceId 视为溢出资源,否则带 value 键视为
|
|
407
|
+
* inline(值本身可以是 undefined/null,所以用 'value' in result 判断);
|
|
408
|
+
* - 失败时错误在 result.error(字符串);requestId 可能在顶层或 result 里,
|
|
409
|
+
* 都没有就用我们自己生成的那个(轮询/日志用,语义不受影响)。
|
|
410
|
+
*/
|
|
411
|
+
function normalizeFlatReceipt(raw, fallbackRequestId) {
|
|
412
|
+
const result = raw.result;
|
|
413
|
+
const normalizedResult = result === undefined
|
|
414
|
+
? undefined
|
|
415
|
+
: result.resourceId !== undefined
|
|
416
|
+
? { type: 'resource', resourceId: String(result.resourceId), byteLength: Number(result.byteLength ?? 0) }
|
|
417
|
+
: 'value' in result
|
|
418
|
+
? { type: 'inline', value: result.value }
|
|
419
|
+
: undefined;
|
|
420
|
+
const requestId = typeof raw.requestId === 'string' ? raw.requestId : typeof result?.requestId === 'string' ? result.requestId : fallbackRequestId;
|
|
421
|
+
const error = raw.error ?? result?.error;
|
|
422
|
+
return {
|
|
423
|
+
requestId,
|
|
424
|
+
status: raw.status,
|
|
425
|
+
...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
|
|
426
|
+
...(error !== undefined ? { error } : {}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/*
|
|
430
|
+
* 从失败回执里尽力抠出一条人话错误信息:
|
|
431
|
+
* error 字段可能是字符串、带 message 的对象、或任意奇形怪状——逐层降级;
|
|
432
|
+
* interrupted 状态(求值没跑完服务就重启了)给专门文案。
|
|
433
|
+
*/
|
|
434
|
+
function extractReceiptError(receipt) {
|
|
435
|
+
const error = receipt.error;
|
|
436
|
+
if (typeof error === 'string')
|
|
437
|
+
return error;
|
|
438
|
+
if (error && typeof error === 'object') {
|
|
439
|
+
const message = error.message;
|
|
440
|
+
if (typeof message === 'string')
|
|
441
|
+
return message;
|
|
442
|
+
try {
|
|
443
|
+
return JSON.stringify(error);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
/* 序列化不了就落到最后的兜底文案 */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (receipt.status === 'interrupted')
|
|
450
|
+
return 'Evaluation was interrupted (runtime restarted before it settled).';
|
|
451
|
+
return `Evaluation ${receipt.status} without an error message.`;
|
|
452
|
+
}
|
|
453
|
+
// 把底层的错误类型和实例类型一并转出去,上层只需要 import 本文件。
|
|
454
|
+
export { CLI_ERROR_CODES, TabbitCliError };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:求值结果的 base64「信封」编解码(codec = coder/decoder)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要这个文件?——为了绕开 Tabbit CLI 的一个真实 bug:
|
|
7
|
+
*
|
|
8
|
+
* 1. 我们提交给浏览器执行的代码,其返回值由 Runtime Service 写进一张「回执」
|
|
9
|
+
* (receipt)。回执小于 8 KiB 时直接内联返回;【大于 8 KiB 时会被"溢出"
|
|
10
|
+
* (spill)到一个资源文件】,之后要用 `resource` 子命令按固定
|
|
11
|
+
* 8192 字节一块地分块读回。
|
|
12
|
+
*
|
|
13
|
+
* 2. 问题在于:CLI 读回资源时是【逐块按 UTF-8 解码】的。而 UTF-8 里一个中文
|
|
14
|
+
* 字符占 3 字节——如果这 3 个字节恰好横跨两个 8192 字节块的边界,两边各自
|
|
15
|
+
* 解码时都会把这半个字符变成乱码(U+FFFD)。也就是说:只要返回值里有中文
|
|
16
|
+
* (或任何非 ASCII 字符)且体积超过 8 KiB,读回的内容【必然损坏】。
|
|
17
|
+
* 这是实测踩到的坑,不是理论推演。
|
|
18
|
+
*
|
|
19
|
+
* 解决方案(本文件实现的"信封"协议):
|
|
20
|
+
* - 发送端(浏览器里):把模型代码的返回值先 JSON.stringify,再整体转成
|
|
21
|
+
* base64。base64 只含 ASCII 字符(每字符 1 字节),怎么切块都不会损坏。
|
|
22
|
+
* 最后拼上前缀 "b64:"(或截断时 "b64trunc:")作为标记。
|
|
23
|
+
* - 接收端(本文件 decodeEnvelope):识别前缀、base64 解码、JSON.parse,
|
|
24
|
+
* 还原出原始值。
|
|
25
|
+
*
|
|
26
|
+
* 这层信封对模型完全透明:模型写的代码正常 return,工具正常拿到值。
|
|
27
|
+
*/
|
|
28
|
+
/* 完整结果的信封前缀。 */
|
|
29
|
+
export const ENVELOPE_PREFIX = 'b64:';
|
|
30
|
+
/* 浏览器侧就已截断(超过下面的字符上限)的信封前缀。 */
|
|
31
|
+
export const ENVELOPE_TRUNCATED_PREFIX = 'b64trunc:';
|
|
32
|
+
/*
|
|
33
|
+
* 返回值 JSON 文本的字符数上限(150 万字符),超过就在浏览器侧先截断。
|
|
34
|
+
* 目的:防止模型不小心 return 一个巨型对象,把分块读回的时间拖到不可接受。
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_RESULT_JSON_CHARS = 1_500_000;
|
|
37
|
+
/*
|
|
38
|
+
* 把模型写的「async 函数体」包装成实际提交给 Runtime Service 的完整源码。
|
|
39
|
+
*
|
|
40
|
+
* 背景知识:Runtime Service 的持久求值器(persistent evaluator)收到 stdin
|
|
41
|
+
* 里的代码后,会自己再套一层 `(async () => { <stdin内容> })()` 执行。所以
|
|
42
|
+
* 我们这里生成的字符串本身就是一个"函数体"——最外层的 `return` 就是最终
|
|
43
|
+
* 返回给服务端的值。
|
|
44
|
+
*
|
|
45
|
+
* 包装结构(生成的代码在【浏览器进程里】执行,不在本 Node 进程):
|
|
46
|
+
* 1. __dshEncode:UTF-8 → base64 的编码函数。优先用 Node 的 Buffer
|
|
47
|
+
* (Tabbit 的求值环境里有);万一没有则退回浏览器经典的
|
|
48
|
+
* btoa+encodeURIComponent 组合技。
|
|
49
|
+
* 2. 把模型代码再包一层嵌套的 async IIFE(立即执行函数)并 await——
|
|
50
|
+
* 这样模型代码里自己写的 `return` 只会结束这层 IIFE、把值交给
|
|
51
|
+
* __dshValue,不会干扰我们外层的信封逻辑。
|
|
52
|
+
* 3. JSON.stringify 该值;undefined 归一成 null;序列化抛异常(比如值里
|
|
53
|
+
* 有循环引用)时改为返回 {__dshSerializationError: 错误信息}。
|
|
54
|
+
* 4. 超长先截断(打 b64trunc: 前缀),否则打 b64: 前缀,整体 base64 后
|
|
55
|
+
* return——这就是走网线(其实是走 CLI stdout/资源文件)的最终形态。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ 注意:下面模板字符串里的内容是要原样发给浏览器执行的代码,
|
|
58
|
+
* 请勿在字符串内部添加任何注释或改动(会改变实际下发的代码)。
|
|
59
|
+
*/
|
|
60
|
+
export function buildEvaluationSource(body) {
|
|
61
|
+
return `const __dshEncode = (text) => {
|
|
62
|
+
if (typeof Buffer !== 'undefined') return Buffer.from(text, 'utf8').toString('base64');
|
|
63
|
+
return btoa(unescape(encodeURIComponent(text)));
|
|
64
|
+
};
|
|
65
|
+
const __dshValue = await (async () => {
|
|
66
|
+
${body}
|
|
67
|
+
})();
|
|
68
|
+
let __dshJson;
|
|
69
|
+
try {
|
|
70
|
+
__dshJson = JSON.stringify(__dshValue === undefined ? null : __dshValue);
|
|
71
|
+
if (typeof __dshJson !== 'string') __dshJson = 'null';
|
|
72
|
+
} catch (error) {
|
|
73
|
+
__dshJson = JSON.stringify({ __dshSerializationError: String((error && error.message) || error) });
|
|
74
|
+
}
|
|
75
|
+
if (__dshJson.length > ${MAX_RESULT_JSON_CHARS}) {
|
|
76
|
+
return '${ENVELOPE_TRUNCATED_PREFIX}' + __dshEncode(__dshJson.slice(0, ${MAX_RESULT_JSON_CHARS}));
|
|
77
|
+
}
|
|
78
|
+
return '${ENVELOPE_PREFIX}' + __dshEncode(__dshJson);`;
|
|
79
|
+
}
|
|
80
|
+
/*
|
|
81
|
+
* 宽松的 base64 解码:
|
|
82
|
+
* 1. 先剔除所有非 base64 字符(分块读回时可能混入引号、换行等杂质);
|
|
83
|
+
* 2. 把长度裁到 4 的整数倍(base64 每 4 字符编码 3 字节,尾部不完整的
|
|
84
|
+
* "量子"直接丢弃)——这样即使数据被截断在任意位置也能解出前缀部分。
|
|
85
|
+
*/
|
|
86
|
+
function decodeBase64Lenient(body) {
|
|
87
|
+
const clean = body.replace(/[^A-Za-z0-9+/=]/gu, '');
|
|
88
|
+
const usable = clean.slice(0, clean.length - (clean.length % 4));
|
|
89
|
+
return Buffer.from(usable, 'base64').toString('utf8');
|
|
90
|
+
}
|
|
91
|
+
/*
|
|
92
|
+
* 解码一个从求值返回的"线上值"(wire value)。
|
|
93
|
+
*
|
|
94
|
+
* @param wireValue CLI 返回的原始值。可能是:带信封前缀的字符串(正常路径)、
|
|
95
|
+
* 不带前缀的任意值(比如老版本/别的任务写的,直接原样透传)。
|
|
96
|
+
* @param complete 资源分块读回是否读到了 EOF。为 false 表示读取被字节上限
|
|
97
|
+
* 截停了——此时 base64 可能断在任意位置,解出来的 JSON 文本
|
|
98
|
+
* 只是个前缀,不再尝试 JSON.parse,直接作为 rawText 返回。
|
|
99
|
+
*/
|
|
100
|
+
export function decodeEnvelope(wireValue, complete = true) {
|
|
101
|
+
// 不是字符串就不可能带信封,原样返回(数字、布尔、对象等)。
|
|
102
|
+
if (typeof wireValue !== 'string') {
|
|
103
|
+
return { value: wireValue, truncated: false };
|
|
104
|
+
}
|
|
105
|
+
if (wireValue.startsWith(ENVELOPE_PREFIX)) {
|
|
106
|
+
const text = decodeBase64Lenient(wireValue.slice(ENVELOPE_PREFIX.length));
|
|
107
|
+
if (complete) {
|
|
108
|
+
try {
|
|
109
|
+
return { value: JSON.parse(text), truncated: false };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// 理论上完整信封必可解析;解析不了说明数据异常,把文本原样交上去。
|
|
113
|
+
return { rawText: text, truncated: true };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 读回不完整:JSON 文本只是前缀,parse 注定失败,直接给 rawText。
|
|
117
|
+
return { rawText: text, truncated: true };
|
|
118
|
+
}
|
|
119
|
+
if (wireValue.startsWith(ENVELOPE_TRUNCATED_PREFIX)) {
|
|
120
|
+
// 浏览器侧就截断过了,永远只能拿到文本片段。
|
|
121
|
+
const text = decodeBase64Lenient(wireValue.slice(ENVELOPE_TRUNCATED_PREFIX.length));
|
|
122
|
+
return { rawText: text, truncated: true };
|
|
123
|
+
}
|
|
124
|
+
// 无信封前缀的普通字符串:原样返回。
|
|
125
|
+
return { value: wireValue, truncated: false };
|
|
126
|
+
}
|