@inobit/pi-instabox 0.0.0-stage → 0.1.0

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/src/boxes.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @inobit/pi-instabox — `instabox ls --json` 解析与时序缓存(零第三方依赖)。
3
+ *
4
+ * 契约(方案 §5.1、§7.2):
5
+ * - 只保留本包用到的字段:`name`(匹配与展示)、`state`(是否 running)、`desiredState`
6
+ * (唤醒分流)、`template.id` / `template.version`(诊断)。`ls` 的实际输出是顶层
7
+ * `name` / `state` / `desiredState` + 嵌套 `template.{id,version,digest}`(本机实测),
8
+ * 其余字段(`spec` / `storage` / `runtime` / …)一律丢弃。
9
+ * - 解析容错:非数组输入、非对象元素、缺 `name` 的元素都跳过,**绝不 throw**;
10
+ * 标量字段先判类型再转字符串(对象/数组/`null` 视为缺失),不把 `[object Object]`
11
+ * 之类的东西塞进 box 名。
12
+ * - 缓存:成功结果按 {@link BOXES_TTL_MS} 记时;失败**不缓存**(下次调用重试);
13
+ * 并发调用共享同一次 in-flight `ls`(同一 Promise),因此不会重复 spawn CLI。
14
+ * - 时间为注入面({@link ClockFn}),单测用假时钟验证命中/过期边界,不 sleep。
15
+ */
16
+
17
+ import { type ExecFn, listBoxesRaw } from "./cli.ts";
18
+
19
+ /** 会话内 `ls` 缓存有效期(毫秒):覆盖单轮内的多次解析,又不至于让状态长期陈旧。 */
20
+ export const BOXES_TTL_MS = 5_000;
21
+
22
+ /** `ls --json` 里本包保留的字段;缺失的字符串字段归一为 `""`(未知态)。 */
23
+ export interface BoxInfo {
24
+ /** box 名(`ls` 的顶层 `name`,也是解析链与工具参数的匹配键) */
25
+ name: string;
26
+ /** 当前状态(本机观察到 `running` / `suspended`;未知态按「不可用」处理) */
27
+ state: string;
28
+ /** 期望状态(`suspended` 表示人工暂停 → 唤醒链走 `start`) */
29
+ desiredState: string;
30
+ /** 模板 id(`template.id`,仅诊断) */
31
+ templateId: string;
32
+ /** 模板版本(`template.version`,日期串,仅诊断;**不是** MCP `serverInfo.version`) */
33
+ templateVersion: string;
34
+ }
35
+
36
+ /**
37
+ * `instabox ls --json` 的 `JSON.parse` 结果 → {@link BoxInfo}[]。
38
+ * 非数组 → `[]`;单项缺 `name`(或名字为空)→ 跳过;其余字段缺失 → `""`。
39
+ */
40
+ export function parseLsJson(raw: unknown): BoxInfo[] {
41
+ if (!Array.isArray(raw)) return [];
42
+ const boxes: BoxInfo[] = [];
43
+ for (const item of raw) {
44
+ const box = parseBox(item);
45
+ if (box) boxes.push(box);
46
+ }
47
+ return boxes;
48
+ }
49
+
50
+ /** 单元素解析:`undefined` 表示该条不是可用 box,调用方跳过。 */
51
+ function parseBox(item: unknown): BoxInfo | undefined {
52
+ if (!isRecord(item)) return undefined;
53
+ const name = text(item["name"])?.trim() ?? "";
54
+ if (!name) return undefined;
55
+ const template = item["template"];
56
+ return {
57
+ name,
58
+ state: text(item["state"])?.trim() ?? "",
59
+ desiredState: text(item["desiredState"])?.trim() ?? "",
60
+ templateId: isRecord(template) ? (text(template["id"]) ?? "") : "",
61
+ templateVersion: isRecord(template) ? (text(template["version"]) ?? "") : "",
62
+ };
63
+ }
64
+
65
+ /** 注入的时钟:返回 epoch 毫秒(默认 `Date.now`,单测换成可控计数器)。与 `template.ts` 的 `ClockFn` 同形。 */
66
+ export type ClockFn = () => number;
67
+
68
+ /** `list()` 选项:`force` 跳过 TTL 直接重拉(并发时仍复用 in-flight,不会多 spawn)。 */
69
+ export interface ListBoxesOptions {
70
+ force?: boolean;
71
+ }
72
+
73
+ /** `list()` 结果:失败时 `boxes` 为空且带 `error`,**不 throw**、不回放过期缓存。 */
74
+ export interface ListBoxesResult {
75
+ /** 本次是否拿到 `ls` 的输出(缓存命中也算成功) */
76
+ ok: boolean;
77
+ /** box 列表(`ok: false` 时为 `[]`) */
78
+ boxes: BoxInfo[];
79
+ /** 失败原因(`ok: false` 时必有) */
80
+ error?: string;
81
+ }
82
+
83
+ /** 会话级 `ls` 缓存句柄:单例挂在 `index.ts` 的装配里。 */
84
+ export interface BoxLister {
85
+ /** 取 box 列表:TTL 内命中缓存;并发调用共享同一次 `ls` */
86
+ list(opts?: ListBoxesOptions): Promise<ListBoxesResult>;
87
+ /** 丢弃缓存(例如唤醒后要重读状态时),不影响已在飞的请求 */
88
+ invalidate(): void;
89
+ }
90
+
91
+ /** 构造 {@link BoxLister} 的注入面。 */
92
+ export interface BoxListerOptions {
93
+ /** 注入的 CLI 执行函数(见 `cli.ts` 的 `ExecFn`) */
94
+ exec: ExecFn;
95
+ /** 时钟(默认 `Date.now`) */
96
+ clock?: ClockFn;
97
+ /** 覆盖 {@link BOXES_TTL_MS}(非负数才生效;`0` = 每次都重拉) */
98
+ ttlMs?: number;
99
+ }
100
+
101
+ /** 默认真实时钟:缓存记时用,与单测注入面无关。 */
102
+ const defaultClock: ClockFn = () => Date.now();
103
+
104
+ /**
105
+ * 创建会话级 `ls` 缓存:
106
+ * - TTL 从**成功**结果写入缓存的那一刻算起,`elapsed < ttl` 命中(等于 TTL 即过期)。
107
+ * - 失败的 `ls` 不写缓存,下一次 `list()` 立即重试。
108
+ * - 已在飞的请求一律复用(`force` 也不并发 spawn)。
109
+ */
110
+ export function createBoxLister(options: BoxListerOptions): BoxLister {
111
+ const { exec } = options;
112
+ const clock = options.clock ?? defaultClock;
113
+ const ttlMs = resolveTtl(options.ttlMs);
114
+ let cached: { at: number; boxes: BoxInfo[] } | undefined;
115
+ let inflight: Promise<ListBoxesResult> | undefined;
116
+
117
+ async function fetchBoxes(): Promise<ListBoxesResult> {
118
+ const raw = await listBoxesRaw(exec);
119
+ if (!raw.ok) return { ok: false, boxes: [], error: raw.error };
120
+ const boxes = parseLsJson(raw.json);
121
+ cached = { at: clock(), boxes };
122
+ return { ok: true, boxes };
123
+ }
124
+
125
+ return {
126
+ list(opts: ListBoxesOptions = {}): Promise<ListBoxesResult> {
127
+ if (inflight) return inflight;
128
+ if (opts.force !== true && cached !== undefined && clock() - cached.at < ttlMs) {
129
+ return Promise.resolve({ ok: true, boxes: cached.boxes });
130
+ }
131
+ const pending = fetchBoxes().finally(() => {
132
+ inflight = undefined;
133
+ });
134
+ inflight = pending;
135
+ return pending;
136
+ },
137
+ invalidate(): void {
138
+ cached = undefined;
139
+ },
140
+ };
141
+ }
142
+
143
+ /** TTL 归一:只接受有限非负数,其余回落到 {@link BOXES_TTL_MS}。 */
144
+ function resolveTtl(ttlMs: number | undefined): number {
145
+ return typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : BOXES_TTL_MS;
146
+ }
147
+
148
+ /** 宽松对象判定(数组与 `null` 都不算对象)。 */
149
+ function isRecord(value: unknown): value is Record<string, unknown> {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+
153
+ /** 标量转字符串:只认 `string` / `number` / `boolean`,其余(对象、数组、`null`)视为缺失。 */
154
+ function text(raw: unknown): string | undefined {
155
+ if (typeof raw === "string") return raw;
156
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
157
+ return undefined;
158
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * @inobit/pi-instabox — `instabox` CLI 薄封装(注入 `ExecFn`,零第三方依赖)。
3
+ *
4
+ * 契约(方案 §4.7 D7、§4.4 D4、§5.1):
5
+ * - 只覆盖**一次性命令**:`version` / `ls --json` / `wake` / `start` / `resume` / `exec`。
6
+ * 遥控长连接(远端 MCP)必须走 `remote.ts` 的 spawn 客户端——`pi.exec` 没有双工流。
7
+ * - 超时不自建计时器:毫秒预算**透传**给注入的 `ExecFn` 的 `timeout` 选项(宿主 `pi.exec`
8
+ * 内部实现超时并回 `killed: true`),因此整套逻辑可离线单测。探测/列表/`exec` 的预算在
9
+ * 本文件顶部单点维护;`wake`/`start`/`resume` 的生命周期预算单点取自 `config.ts` 的
10
+ * `DEFAULT_CONFIG.timeouts`(`wakeMs` / `startMs`,方案 §4.4),本文件不另存数字。
11
+ * 调用方一律可用 `opts.timeout` 覆盖——准备阶段(`pool.ts`)传的就是**钳制后的**值
12
+ * (单步上限与剩余预算取小),日志里的 `timeoutMs` 记的就是实际生效的那个数。
13
+ * - 一参数一元素:所有子命令都用 argv 数组传给 `ExecFn`(不拼 shell 字符串),
14
+ * `exec` 的远端预算用 `--timeout <秒>` 显式传,上限 600 秒。
15
+ * - 失败归一:任何非 0 退出 / `killed` / 抛异常都转成 `{ ok: false, error }`,**绝不 throw**
16
+ * 到会话层;stderr 保留尾部最多 {@link STDERR_TAIL_LIMIT} 字符便于排障。`probeVersion` 与
17
+ * `listBoxesRaw` 的失败额外给出 {@link CliFailureReason},让调用方把「超时」与「没装 CLI」分开处理。
18
+ * - 退出码 0 不算成功凭据:`wake`/`start`/`resume` 还要求 stderr 里没有 CLI 自报错误
19
+ * (实测 `wake` 对人工暂停的箱退出码 0 + `instabox: error machine_not_running`)。
20
+ * - 结果类型是可辨识联合(`ok: true` 与 `ok: false` 两个分支,失败分支必带 `error`),
21
+ * 调用方靠 `if (!result.ok)` 收窄,不写 `error?: string` 之类的可选字段。
22
+ */
23
+
24
+ import { DEFAULT_CONFIG } from "./config.ts";
25
+ import { logEvent } from "./log.ts";
26
+
27
+ /**
28
+ * 加载期 `version` 探测预算:不得拖慢会话启动(方案 R8)。
29
+ * 取 3 秒而不是更短:本机 `instabox version` 实测 803–1178ms(pnpm shim → node 冷启动),
30
+ * 而 pi 加载期并发初始化会再叠加启动开销;1200ms 实测 5 次里 2 次被误杀,导致扩展
31
+ * 整个自禁用(约 30–40% 冷启动误判)。正常情况约 1 秒即返回,只有真正变慢时才吃满预算。
32
+ */
33
+ export const PROBE_VERSION_TIMEOUT_MS = 3_000;
34
+
35
+ /**
36
+ * `ls --json` 预算。取 10 秒而不是更短:`instabox ls` 先付 CLI 冷启动成本(pnpm shim → node,
37
+ * 与 `version` 同一量级:实测 803–1178ms),再走远端 API 拉箱列表;真实加载里连续 6 次有 2 次
38
+ * 被 3000ms 杀掉(用户看到「桌面工具尚未注册(cannot list boxes: instabox ls was killed after
39
+ * 3000ms)」)。`ls` 只在后台/唤醒链路径跑(不阻塞工具调用),10 秒不会拖住会话;
40
+ * `session_start` 后台拉取的总体时效由调用方自己的预算把关。
41
+ */
42
+ export const LIST_BOXES_TIMEOUT_MS = 10_000;
43
+
44
+ /** `instabox exec --timeout` 的单位是秒,CLI 侧上限 600 秒。 */
45
+ export const EXEC_TIMEOUT_MAX_SECONDS = 600;
46
+
47
+ /** `exec` 远端命令默认预算(对齐 D7 的 `tools/call` 120 秒)。 */
48
+ export const EXEC_REMOTE_TIMEOUT_MS = 120_000;
49
+
50
+ /** 本地等待相对远端预算的余量:让 CLI 先超时,本地只做兜底。 */
51
+ export const EXEC_LOCAL_GRACE_MS = 5_000;
52
+
53
+ /** 失败原因里保留的 stderr 尾部字符数。 */
54
+ export const STDERR_TAIL_LIMIT = 500;
55
+
56
+ /** CLI 可执行文件名(argv[0],不经 shell)。 */
57
+ const INSTABOX_BIN = "instabox";
58
+
59
+ /** 与宿主 `pi.exec` 兼容的选项子集(宿主 `ExecOptions` 的超集方向可赋值)。 */
60
+ export interface ExecOptionsLike {
61
+ /** 超时毫秒数,由 `ExecFn` 实现方执行 */
62
+ timeout?: number;
63
+ /** 取消信号 */
64
+ signal?: AbortSignal;
65
+ /** 工作目录 */
66
+ cwd?: string;
67
+ /** 输出字节上限 */
68
+ maxBuffer?: number;
69
+ }
70
+
71
+ /**
72
+ * 与宿主 `pi.exec` 兼容的返回子集:`stdout`/`stderr`/`code` 必有,
73
+ * `killed`/`truncated` 可选(宿主 `ExecResult` 两个都带,因此仍可赋值)。
74
+ */
75
+ export interface ExecResultLike {
76
+ stdout: string;
77
+ stderr: string;
78
+ code: number;
79
+ /** 被宿主终止(超时 / abort / 输出超 `maxBuffer`) */
80
+ killed?: boolean;
81
+ /** 输出超 `maxBuffer`,内容不完整 */
82
+ truncated?: boolean;
83
+ }
84
+
85
+ /**
86
+ * 注入的执行函数:签名与宿主 `pi.exec` 一致(少用字段全部可选),
87
+ * 装配处可直接传 `(command, args, opts) => pi.exec(command, args, opts)`。
88
+ */
89
+ export type ExecFn = (command: string, args: string[], opts?: ExecOptionsLike) => Promise<ExecResultLike>;
90
+
91
+ /**
92
+ * 一次性命令的失败分类——决定调用方能不能据此禁用/放弃重试:
93
+ * - `missing`:命令不可用(ENOENT / not found),可直接判定「没装 CLI」;
94
+ * - `timeout`:被宿主杀掉(`killed: true`,超时/abort)。**不等于**没装 CLI,通常值得重试一次;
95
+ * - `not-running`:生命周期命令确认箱根本没跑起来(实测 `wake` 对人工暂停的箱退出码 0,
96
+ * 只在 stderr 自报 `machine_not_running`)——只能靠 `start`/`resume` 补救,重试同一命令没用;
97
+ * 只有 `wake`/`start`/`resume` 会产出该值,探测与 `ls` 永远不返回它;
98
+ * - `error`:CLI 在,但非 0 退出 / 输出不可用 / 注入实现抛了别的异常。
99
+ */
100
+ export type CliFailureReason = "missing" | "timeout" | "not-running" | "error";
101
+
102
+ /** `probeVersion` 结果:成功必带版本串,失败必带原因文本与可分支处理的 {@link CliFailureReason}。 */
103
+ export type ProbeVersionResult =
104
+ | { ok: true; version: string }
105
+ | { ok: false; error: string; reason: CliFailureReason };
106
+
107
+ /**
108
+ * `listBoxesRaw` 结果:JSON 解析交给 `boxes.ts`,本层只保证是合法 JSON 值。
109
+ * 失败分支带 {@link CliFailureReason}:`timeout` 表示「慢」而不是「箱列表不对」,调用方可重试。
110
+ */
111
+ export type ListBoxesRawResult =
112
+ | { ok: true; json: unknown }
113
+ | { ok: false; error: string; reason: CliFailureReason };
114
+
115
+ /**
116
+ * `wake` / `start` / `resume` 的统一结果(失败**不 throw**)。
117
+ * `stderrTail` 两个分支都可能存在(成功时的进度信息不丢),最多 {@link STDERR_TAIL_LIMIT} 字符;
118
+ * 失败分支必带 {@link CliFailureReason}(其中 `not-running` 只可能来自这三个命令)。
119
+ */
120
+ export type CliOutcome =
121
+ | { ok: true; stderrTail?: string }
122
+ | { ok: false; error: string; reason: CliFailureReason; stderrTail?: string };
123
+
124
+ /** `execRemote` 结果:额外带回远端 stdout(失败时保留已产生的部分输出)。 */
125
+ export type RemoteExecOutcome =
126
+ | { ok: true; stdout: string; stderrTail?: string }
127
+ | { ok: false; error: string; stdout?: string; stderrTail?: string };
128
+
129
+ /** 一次性命令的通用可选项。 */
130
+ export interface CliRunOptions {
131
+ /** 覆盖该函数自己的超时预算(毫秒) */
132
+ timeout?: number;
133
+ /** 透传给 `ExecFn` 的取消信号 */
134
+ signal?: AbortSignal;
135
+ }
136
+
137
+ /** `execRemote` 可选项:远端秒级预算与本地毫秒级兜底分开写。 */
138
+ export interface ExecRemoteOptions {
139
+ /** 远端命令预算(毫秒),默认 {@link EXEC_REMOTE_TIMEOUT_MS};转整数秒并钳制到 [1, 600] */
140
+ timeoutMs?: number;
141
+ /** 本地等待上限(毫秒),默认「钳制后的远端秒数 × 1000 + {@link EXEC_LOCAL_GRACE_MS}」 */
142
+ execTimeoutMs?: number;
143
+ /** 透传给 `ExecFn` 的取消信号 */
144
+ signal?: AbortSignal;
145
+ }
146
+
147
+ /** 单次 `ExecFn` 调用:抛异常在这里被收成 `ok: false`。 */
148
+ type CliCall = { ok: true; result: ExecResultLike } | { ok: false; error: string };
149
+
150
+ /**
151
+ * 「命令不可用」的文本特征:宿主 `pi.exec` 把 spawn 失败收成 `code: 1` +
152
+ * `stderr: "spawn instabox ENOENT"`(`core/exec.js` 的 child-process 错误分支),
153
+ * 而注入的其它实现可能直接抛同样文本的异常——两种都要认。
154
+ *
155
+ * 取舍(两个方向的代价不对称):误判 `missing` 会让调用方**直接自禁用**整个扩展,而误判
156
+ * `error` 顶多多走一次慢探测 / 一次会话内重试。所以这里只认**真正的 spawn / 查找失败形态**,
157
+ * 不收裸的 `no such file or directory`(业务输出里很常见,例如「配置文件 … no such file or
158
+ * directory」),也不收裸的 `not found`。
159
+ *
160
+ * 认的形态:`ENOENT`(宿主 `spawn instabox ENOENT` / 注入实现抛的同文本)、`command not found`、
161
+ * `spawn <cmd> EACCES|ENOENT`、`errno -2/-13`(EACCES 也是「跑不起来」)。
162
+ */
163
+ const MISSING_BIN_RE = /\bENOENT\b|command not found|spawn .* (ENOENT|EACCES)|errno -(2|13)\b/i;
164
+
165
+ /**
166
+ * CLI 自己的错误信封:`instabox: error <code>: <message> [request …]`(本轮实测的 `wake` 输出)。
167
+ * 二进制里只有一个这样的格式化串(`instabox: error %s`),所以三个生命周期命令共用。
168
+ */
169
+ const CLI_ERROR_LINE_RE = /^instabox: error /m;
170
+
171
+ /**
172
+ * 「箱没在跑」的证据串。实测 `instabox wake <box>`(用户手动 suspend):**退出码 0**,stderr 为
173
+ * `instabox: error machine_not_running: machine is suspended; start it first [request …]`
174
+ * + `hint: start it with \`instabox start <sandbox>\``,而沙箱状态不变。
175
+ * 两项任一命中即判失败(关键词兼做信封被包装/缩进时的兜底)。
176
+ */
177
+ const NOT_RUNNING_RE = /machine_not_running|start it with/i;
178
+
179
+ /**
180
+ * `instabox exec` 的 `--timeout` 秒数:向上取整、下限 1 秒、上限 {@link EXEC_TIMEOUT_MAX_SECONDS}。
181
+ * 非有限值(NaN/Infinity)按上限处理,绝不把非法秒数交给 CLI。
182
+ */
183
+ export function remoteTimeoutSeconds(timeoutMs: number): number {
184
+ if (!Number.isFinite(timeoutMs)) return EXEC_TIMEOUT_MAX_SECONDS;
185
+ const seconds = Math.ceil(timeoutMs / 1000);
186
+ return Math.min(EXEC_TIMEOUT_MAX_SECONDS, Math.max(1, seconds));
187
+ }
188
+
189
+ /**
190
+ * 加载期探活:`instabox version`,默认 {@link PROBE_VERSION_TIMEOUT_MS}。
191
+ * CLI 不存在(ENOENT)/ 非 0 / 超时都返回 `ok: false`,**不抛**;失败按 {@link CliFailureReason} 分类。
192
+ *
193
+ * 调用方注意:**探测超时不等于没装 CLI**。`instabox version` 走 pnpm shim → node 冷启动,
194
+ * 加载期并发初始化下可能吃满预算;只有 `reason === "missing"` 才能直接判定「未找到 instabox CLI」,
195
+ * `reason === "timeout"` 时应(用同一份 `exec`)**重试一次**再决定是否禁用扩展,否则慢一次启动就
196
+ * 整个扩展自禁用。
197
+ */
198
+ export async function probeVersion(exec: ExecFn, opts: CliRunOptions = {}): Promise<ProbeVersionResult> {
199
+ const startedAt = Date.now();
200
+ const timeout = opts.timeout ?? PROBE_VERSION_TIMEOUT_MS;
201
+ const label = "instabox version";
202
+ const call = await runOnce(exec, label, ["version"], { timeout, signal: opts.signal });
203
+ if (!call.ok) {
204
+ const reason = classifyFailure(call.error, false);
205
+ logEvent("cli.probe", { ok: false, reason, ms: Date.now() - startedAt }, "warn");
206
+ return { ok: false, error: call.error, reason };
207
+ }
208
+ const { result } = call;
209
+ // 宿主对超时/abort 回 `killed: true`(`code` 可能仍是 0)→ 归为 timeout,不当作「CLI 有问题」
210
+ if (failed(result)) {
211
+ const error = describeFailure(label, result, timeout);
212
+ const reason = classifyFailure(error, result.killed === true);
213
+ logEvent("cli.probe", { ok: false, reason, ms: Date.now() - startedAt }, "warn");
214
+ return { ok: false, error, reason };
215
+ }
216
+ const version = firstLine(result.stdout);
217
+ if (!version) {
218
+ logEvent("cli.probe", { ok: false, reason: "error", ms: Date.now() - startedAt }, "warn");
219
+ return { ok: false, error: `${label} printed no version text`, reason: "error" };
220
+ }
221
+ logEvent("cli.probe", { ok: true, version, ms: Date.now() - startedAt });
222
+ return { ok: true, version };
223
+ }
224
+
225
+ /**
226
+ * `instabox ls --json`,默认 {@link LIST_BOXES_TIMEOUT_MS}。
227
+ * 只保证「命令成功 + 输出是合法 JSON」;结构与字段校验属 `boxes.ts`。
228
+ * 失败按 {@link CliFailureReason} 分类:`timeout`(被 `killed`)应重试,不是箱列表坏了。
229
+ */
230
+ export async function listBoxesRaw(exec: ExecFn, opts: CliRunOptions = {}): Promise<ListBoxesRawResult> {
231
+ const startedAt = Date.now();
232
+ const timeout = opts.timeout ?? LIST_BOXES_TIMEOUT_MS;
233
+ const label = "instabox ls";
234
+ const call = await runOnce(exec, label, ["ls", "--json"], { timeout, signal: opts.signal });
235
+ if (!call.ok) {
236
+ const reason = classifyFailure(call.error, false);
237
+ logEvent("cli.ls", { ok: false, count: 0, reason, ms: Date.now() - startedAt, timeoutMs: timeout });
238
+ return { ok: false, error: call.error, reason };
239
+ }
240
+ const { result } = call;
241
+ if (failed(result)) {
242
+ const error = describeFailure(label, result, timeout);
243
+ const reason = classifyFailure(error, result.killed === true);
244
+ logEvent("cli.ls", { ok: false, count: 0, reason, ms: Date.now() - startedAt, timeoutMs: timeout });
245
+ return { ok: false, error, reason };
246
+ }
247
+ try {
248
+ const json: unknown = JSON.parse(result.stdout);
249
+ // 顶层数组长度就是箱数;非数组(结构不对)记 0,解析容错仍由 `boxes.ts` 负责
250
+ logEvent("cli.ls", { ok: true, count: Array.isArray(json) ? json.length : 0, ms: Date.now() - startedAt, timeoutMs: timeout });
251
+ return { ok: true, json };
252
+ } catch {
253
+ logEvent("cli.ls", { ok: false, count: 0, reason: "error", ms: Date.now() - startedAt, timeoutMs: timeout });
254
+ return {
255
+ ok: false,
256
+ error: `instabox ls --json returned invalid JSON: ${tail(result.stdout)}`,
257
+ reason: "error",
258
+ };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * `instabox wake <box>`:只唤「自己暂停」的箱,默认预算 {@link DEFAULT_CONFIG.timeouts.wakeMs}
264
+ * (20 秒,是**单步上限**;整段准备阶段的上界由 `prepareMs` 的截止时间管)。
265
+ * 人工 `suspend`/`stop` 的箱必然失败 → 由 `pool.ts` 退到 `start`/`resume`。
266
+ * 注意:这条失败是**退出码 0 + stderr 自报**(见 {@link reportedFailure}),所以不能只看退出码。
267
+ */
268
+ export function wake(exec: ExecFn, box: string, opts: CliRunOptions = {}): Promise<CliOutcome> {
269
+ return runLifecycle(exec, "wake", box, DEFAULT_CONFIG.timeouts.wakeMs, opts);
270
+ }
271
+
272
+ /** `instabox start <box>`:人工 `suspend`/`stop` 后的箱(`wake` 不覆盖),默认预算 `startMs`。 */
273
+ export function start(exec: ExecFn, box: string, opts: CliRunOptions = {}): Promise<CliOutcome> {
274
+ return runLifecycle(exec, "start", box, DEFAULT_CONFIG.timeouts.startMs, opts);
275
+ }
276
+
277
+ /** `instabox resume <box>`:与 `start` 同预算(`startMs`)。 */
278
+ export function resume(exec: ExecFn, box: string, opts: CliRunOptions = {}): Promise<CliOutcome> {
279
+ return runLifecycle(exec, "resume", box, DEFAULT_CONFIG.timeouts.startMs, opts);
280
+ }
281
+
282
+ /**
283
+ * `instabox exec <box> --timeout <秒> -- <argv...>`:在目标箱里跑一条命令。
284
+ * argv 逐元素透传(不经 shell 拼接),时间和 `signal` 见 {@link ExecRemoteOptions}。
285
+ */
286
+ export async function execRemote(
287
+ exec: ExecFn,
288
+ box: string,
289
+ argv: readonly string[],
290
+ opts: ExecRemoteOptions = {},
291
+ ): Promise<RemoteExecOutcome> {
292
+ const startedAt = Date.now();
293
+ const seconds = remoteTimeoutSeconds(opts.timeoutMs ?? EXEC_REMOTE_TIMEOUT_MS);
294
+ const localTimeout = opts.execTimeoutMs ?? seconds * 1000 + EXEC_LOCAL_GRACE_MS;
295
+ const label = `instabox exec ${box}`;
296
+ const args = ["exec", box, "--timeout", String(seconds), "--", ...argv];
297
+ const call = await runOnce(exec, label, args, { timeout: localTimeout, signal: opts.signal });
298
+ if (!call.ok) {
299
+ logEvent("cli.exec", { box, timeoutSec: seconds, ok: false, ms: Date.now() - startedAt });
300
+ return { ok: false, error: call.error };
301
+ }
302
+ const stderrTail = tailOrUndefined(call.result.stderr);
303
+ if (failed(call.result)) {
304
+ logEvent("cli.exec", { box, timeoutSec: seconds, ok: false, ms: Date.now() - startedAt });
305
+ return { ok: false, error: describeFailure(label, call.result, localTimeout), stderrTail, stdout: call.result.stdout };
306
+ }
307
+ logEvent("cli.exec", { box, timeoutSec: seconds, ok: true, ms: Date.now() - startedAt });
308
+ return { ok: true, stdout: call.result.stdout, stderrTail };
309
+ }
310
+
311
+ /** `wake`/`start`/`resume` 共用实现:只有子命令与默认预算不同。 */
312
+ async function runLifecycle(
313
+ exec: ExecFn,
314
+ subcommand: "wake" | "start" | "resume",
315
+ box: string,
316
+ defaultTimeoutMs: number,
317
+ opts: CliRunOptions,
318
+ ): Promise<CliOutcome> {
319
+ const startedAt = Date.now();
320
+ const timeout = opts.timeout ?? defaultTimeoutMs;
321
+ const label = `instabox ${subcommand} ${box}`;
322
+ const call = await runOnce(exec, label, [subcommand, box], { timeout, signal: opts.signal });
323
+ if (!call.ok) {
324
+ const reason = classifyLifecycleFailure(call.error, false);
325
+ logEvent("cli.lifecycle", {
326
+ command: subcommand,
327
+ box,
328
+ ok: false,
329
+ reason,
330
+ ms: Date.now() - startedAt,
331
+ timeoutMs: timeout,
332
+ });
333
+ return { ok: false, error: call.error, reason };
334
+ }
335
+ const { result } = call;
336
+ const stderrTail = tailOrUndefined(result.stderr);
337
+ // 退出码 0 不等于成功:CLI 会用 stderr 自报错误(实测 `wake` 对人工暂停的箱)
338
+ const reported = reportedFailure(result.stderr);
339
+ if (failed(result) || reported !== undefined) {
340
+ const error =
341
+ reported === undefined ? describeFailure(label, result, timeout) : `${label} reported an error: ${tail(reported)}`;
342
+ const reason = classifyLifecycleFailure(error, result.killed === true);
343
+ logEvent("cli.lifecycle", {
344
+ command: subcommand,
345
+ box,
346
+ ok: false,
347
+ reason,
348
+ ms: Date.now() - startedAt,
349
+ timeoutMs: timeout,
350
+ });
351
+ return { ok: false, error, reason, stderrTail };
352
+ }
353
+ logEvent("cli.lifecycle", {
354
+ command: subcommand,
355
+ box,
356
+ ok: true,
357
+ ms: Date.now() - startedAt,
358
+ timeoutMs: timeout,
359
+ });
360
+ return { ok: true, stderrTail };
361
+ }
362
+
363
+ /**
364
+ * 退出码 0 时仍要判失败的形态:CLI 在 stderr 上自报错误(错误信封或「箱没在跑」证据串)。
365
+ * 成功路径的 stderr 只有进度文本(如 `waking …`),不会被误判。
366
+ * 三个生命周期命令共用同一份判定:错误信封是 CLI 全局唯一的格式化串(`instabox: error %s`),
367
+ * 不随子命令变化。
368
+ */
369
+ function reportedFailure(stderr: string): string | undefined {
370
+ const text = stderr.trim();
371
+ if (!text) return undefined;
372
+ return CLI_ERROR_LINE_RE.test(text) || NOT_RUNNING_RE.test(text) ? text : undefined;
373
+ }
374
+
375
+ /** 生命周期命令的失败分类:被杀 → timeout;CLI 报「箱没在跑」→ not-running;其余走通用三分。 */
376
+ function classifyLifecycleFailure(failureText: string, killed: boolean): CliFailureReason {
377
+ if (killed) return "timeout";
378
+ if (NOT_RUNNING_RE.test(failureText)) return "not-running";
379
+ return classifyFailure(failureText, false);
380
+ }
381
+
382
+ /** 唯一调用 `ExecFn` 的地方:异常统一收成 `ok: false`,绝不外抛。 */
383
+ async function runOnce(
384
+ exec: ExecFn,
385
+ label: string,
386
+ args: readonly string[],
387
+ opts: ExecOptionsLike,
388
+ ): Promise<CliCall> {
389
+ try {
390
+ const result = await exec(INSTABOX_BIN, [...args], opts);
391
+ return { ok: true, result };
392
+ } catch (err) {
393
+ return { ok: false, error: `${label} failed: ${errorText(err)}` };
394
+ }
395
+ }
396
+
397
+ /** 非 0 退出或被宿主杀掉(超时/abort/输出超限)都算失败。 */
398
+ function failed(result: ExecResultLike): boolean {
399
+ return result.code !== 0 || result.killed === true;
400
+ }
401
+
402
+ /**
403
+ * 失败分类(`probeVersion` 与 `listBoxesRaw` 共用):
404
+ * `killed` 一律归 `timeout`,否则看文本是否指向「命令不存在」,其余都是 `error`。
405
+ */
406
+ function classifyFailure(text: string, killed: boolean): CliFailureReason {
407
+ if (killed) return "timeout";
408
+ return MISSING_BIN_RE.test(text) ? "missing" : "error";
409
+ }
410
+
411
+ /** 失败文本:退出码或超时说明,并附 stderr 尾部。 */
412
+ function describeFailure(label: string, result: ExecResultLike, timeoutMs: number): string {
413
+ const what = result.killed === true ? `was killed after ${timeoutMs}ms` : `exited with code ${result.code}`;
414
+ const stderr = tailOrUndefined(result.stderr);
415
+ return stderr ? `${label} ${what}: ${stderr}` : `${label} ${what}`;
416
+ }
417
+
418
+ /** 保留文本尾部,最多 {@link STDERR_TAIL_LIMIT} 字符(尾部才是根因)。 */
419
+ function tail(text: string): string {
420
+ const trimmed = text.trim();
421
+ return trimmed.length <= STDERR_TAIL_LIMIT ? trimmed : trimmed.slice(trimmed.length - STDERR_TAIL_LIMIT);
422
+ }
423
+
424
+ /** 同上,但空文本回 `undefined`(调用方据此决定字段是否存在)。 */
425
+ function tailOrUndefined(text: string): string | undefined {
426
+ const trimmed = text.trim();
427
+ return trimmed ? tail(trimmed) : undefined;
428
+ }
429
+
430
+ /** 取首行(`instabox version` 只认第一行版本串)。 */
431
+ function firstLine(text: string): string | undefined {
432
+ const line = text.split("\n")[0]?.trim();
433
+ return line ? line : undefined;
434
+ }
435
+
436
+ /** 异常转文本(保持 `error` 可直接展示)。 */
437
+ function errorText(err: unknown): string {
438
+ if (err instanceof Error) return err.message || err.name;
439
+ return String(err);
440
+ }