@inobit/pi-instabox 0.0.0-stage → 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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1172 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +473 -0
- package/src/tools.ts +362 -0
package/src/template.ts
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — **per-box** 模板缓存 + 版本比对 + 拉取编排(方案 §4.6 V3、§4.7 D7、§5.1)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* - 缓存按 box 建索引(`{ version: 1, boxes: { [box]: CachedTemplate } }`),落在
|
|
6
|
+
* `<agentDir>/pi-instabox/templates.json`(`paths.templateCacheFile`,见 `config.ts`)。
|
|
7
|
+
* 按箱建索引的理由(方案 C-b):`template.version` 在本机是日期串且三台箱各不相同,
|
|
8
|
+
* 全局单条缓存会被“版本永远不同 → 反复重拉”拖成每次都拉。
|
|
9
|
+
* - 版本比对的 `serverInfoVersion` **只能**取自远端 `initialize` 的 `serverInfo.version`
|
|
10
|
+
* (即 `instabox-computer-use` 版本),不是 `ls` 的 `template.version`。`templateId` /
|
|
11
|
+
* `templateVersion` 只作为诊断字段透传保留,本模块不采集。
|
|
12
|
+
* - 同一次握手的 `instructions`(远端对整组工具的一句话描述)随工具一起缓存(
|
|
13
|
+
* `serverInstructions`):发现提示由它生成,不该在装配层另写一份文案。
|
|
14
|
+
* - **绝不唤醒 box**:本模块不 import `cli.ts`,也不调用任何 `wake` / `start` / `resume`;
|
|
15
|
+
* 只通过注入的 `clientFactory` 拿连接、`initialize()` + `listTools()`。`instabox ssh` 到
|
|
16
|
+
* 暂停箱会直接失败,失败即返回 `ok: false` 并保留旧缓存——唤醒链归 `pool.ts`。
|
|
17
|
+
* - **唤醒策略不在本模块**:`ensure(box, { allowWake })` 只把 `allowWake` 原样交给
|
|
18
|
+
* `clientFactory(box, { allowWake })`(没传就不传第二参,由工厂按自身默认策略处理)。
|
|
19
|
+
* `allowWake: false` 时工厂不唤醒,握手失败仍返回 `{ ok: false, error, stale? }`(现状不变)。
|
|
20
|
+
* - **每次 `ensure()` 先握手一次**(`initialize` → `serverInfo.version`),用这次握手的版本
|
|
21
|
+
* 与缓存比对(方案 §5.4):版本相同且未过期 → 直接返回缓存(不 `listTools`);版本不同 /
|
|
22
|
+
* 已过期 / 无缓存 → `listTools` 并写缓存。版本校验不依赖调用方传参,因此远端
|
|
23
|
+
* `instabox-computer-use` 升版后下一次 `ensure()` 就会刷新 schema,而不是等 TTL 到期。
|
|
24
|
+
* 这笔握手成本是设计的一部分(`session_start` 一次),不要用“跳过握手”去优化。
|
|
25
|
+
* - 复用而非独占连接:客户端由 `pool.ts` 持有,本模块**不调用 `close()`**。
|
|
26
|
+
* - 失败面:拉取失败只返回 `{ ok: false, error, stale? }`(`stale` 为旧缓存,供调用方降级),
|
|
27
|
+
* 写盘失败只 `warn`,都不 throw 到会话层。
|
|
28
|
+
* - 注入面:`fs`(读/写文本)、`clock`(epoch 毫秒)、`clientFactory` 全部可替;默认实现的
|
|
29
|
+
* `fs` 用 `node:fs`,原子写(同目录临时文件 + `rename`)只在默认实现里。
|
|
30
|
+
* - 埋点(经 `logEvent`,未配置 logger 时 no-op):`template.hit`(版本与 TTL 双通过的命中,
|
|
31
|
+
* debug)、`template.refetch`(重拉成功,带原因与耗时)、`template.fail`(握手/拉取失败或取消,
|
|
32
|
+
* warn,`stale` 说明磁盘上还有没有旧缓存可降级)。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import type { InstaboxPaths } from "./config.ts";
|
|
38
|
+
import { logEvent } from "./log.ts";
|
|
39
|
+
import type { InitializeResult, RemoteClient, RemoteTool } from "./remote.ts";
|
|
40
|
+
|
|
41
|
+
/** 缓存文件结构版本(结构不兼容时整份丢弃并重建)。 */
|
|
42
|
+
export const TEMPLATE_CACHE_VERSION = 1;
|
|
43
|
+
|
|
44
|
+
/** 默认 TTL:168 小时(7 天,方案 §5.1)。 */
|
|
45
|
+
export const DEFAULT_TEMPLATE_TTL_MS = 168 * 60 * 60 * 1_000;
|
|
46
|
+
|
|
47
|
+
/** 单箱缓存条目。 */
|
|
48
|
+
export interface CachedTemplate {
|
|
49
|
+
/** 远端 `initialize` 的 `serverInfo.version`(远端未报时为空串,此时只靠 TTL 刷新) */
|
|
50
|
+
serverInfoVersion: string;
|
|
51
|
+
/** 诊断字段(来自 `ls`;本模块不采集远端值,只保留入参或旧缓存里的值) */
|
|
52
|
+
templateId?: string;
|
|
53
|
+
/** 诊断字段(来自 `ls`;本模块不采集远端值,只保留入参或旧缓存里的值) */
|
|
54
|
+
templateVersion?: string;
|
|
55
|
+
/**
|
|
56
|
+
* 远端 `initialize` 的 `instructions`(裁掉两侧空白后的全文);远端没报时为 `undefined`
|
|
57
|
+
* (旧缓存没有该字段也照常可用)。发现提示取它的首行,见 `tools.buildDiscoveryHint`。
|
|
58
|
+
*/
|
|
59
|
+
serverInstructions?: string;
|
|
60
|
+
/** 拉取时间(ISO 8601,用于 TTL 判定) */
|
|
61
|
+
fetchedAt: string;
|
|
62
|
+
/** 远端 `tools/list` 原样结果(schema 未转 TypeBox) */
|
|
63
|
+
tools: RemoteTool[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 缓存文件内容:per-box 索引。 */
|
|
67
|
+
export type TemplateCache = {
|
|
68
|
+
version: typeof TEMPLATE_CACHE_VERSION;
|
|
69
|
+
boxes: Record<string, CachedTemplate>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** 读文本(默认 {@link nodeReadText};抛错视为该文件不存在)。 */
|
|
73
|
+
export type ReadTextFn = (file: string) => string;
|
|
74
|
+
|
|
75
|
+
/** 写文本(默认 {@link nodeWriteText},同目录临时文件 + `rename`;失败可抛,由调用方降级)。 */
|
|
76
|
+
export type WriteTextFn = (file: string, text: string) => void;
|
|
77
|
+
|
|
78
|
+
/** 注入的文件系统:本模块只用到这两个动作。 */
|
|
79
|
+
export interface TemplateFileSystem {
|
|
80
|
+
readText: ReadTextFn;
|
|
81
|
+
writeText: WriteTextFn;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 注入的时钟:返回 epoch 毫秒(默认 `Date.now`)。 */
|
|
85
|
+
export type ClockFn = () => number;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 传给 {@link TemplateClientFactory} 的选项:本模块**只透传**,不决定唤醒策略。
|
|
89
|
+
*
|
|
90
|
+
* `allowWake` 的语义由工厂(装配处是 `pool.get(box, { allowWake })`)定义:
|
|
91
|
+
* `false` = 不唤醒,箱不在 `running` 就失败;`true` 或省略 = 工厂按自身默认策略处理。
|
|
92
|
+
*/
|
|
93
|
+
export interface TemplateClientOptions {
|
|
94
|
+
/** 是否允许工厂在取连接时唤醒箱(`undefined` = 工厂自己定) */
|
|
95
|
+
allowWake?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** per-box 客户端工厂(装配处传 `pool.get(box, { allowWake })`)。 */
|
|
99
|
+
export type TemplateClientFactory = (box: string, options?: TemplateClientOptions) => RemoteClient;
|
|
100
|
+
|
|
101
|
+
/** 警告出口(写盘失败等非致命问题;默认丢弃)。 */
|
|
102
|
+
export type WarnFn = (message: string) => void;
|
|
103
|
+
|
|
104
|
+
export interface TemplateStoreOptions {
|
|
105
|
+
/** `resolvePaths()` 的返回值;本模块只消费 `templateCacheFile` */
|
|
106
|
+
paths: Pick<InstaboxPaths, "templateCacheFile">;
|
|
107
|
+
/** 按 box 取一个远端客户端(`allowWake` 由 `ensure()` 透传,工厂决定唤醒策略) */
|
|
108
|
+
clientFactory: TemplateClientFactory;
|
|
109
|
+
/** 注入的文件系统,默认 {@link nodeTemplateFileSystem} */
|
|
110
|
+
fs?: TemplateFileSystem;
|
|
111
|
+
/** 注入的时钟,默认 `Date.now` */
|
|
112
|
+
clock?: ClockFn;
|
|
113
|
+
/** 覆盖 {@link DEFAULT_TEMPLATE_TTL_MS} */
|
|
114
|
+
ttlMs?: number;
|
|
115
|
+
/** 诊断出口(写盘失败、坏缓存等只在这里报) */
|
|
116
|
+
warn?: WarnFn;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface TemplateEnsureOptions {
|
|
120
|
+
/**
|
|
121
|
+
* 显式声明的远端版本,**优先于本次握手得到的 `serverInfo.version`**。
|
|
122
|
+
* 一般不传:`ensure()` 自己会握手取版本。仅在调用方已经确定版本时用于覆盖判定。
|
|
123
|
+
*/
|
|
124
|
+
serverVersion?: string;
|
|
125
|
+
/** 诊断字段(来自 `ls` 的 `template.id`):给定时覆盖旧缓存值,缺省继承 */
|
|
126
|
+
templateId?: string;
|
|
127
|
+
/** 诊断字段(来自 `ls` 的 `template.version`):给定时覆盖旧缓存值,缺省继承 */
|
|
128
|
+
templateVersion?: string;
|
|
129
|
+
/**
|
|
130
|
+
* 透传给 `clientFactory` 的唤醒许可:`true` / `false` 原样传出,
|
|
131
|
+
* **省略时不传第二参**(让工厂按自身默认策略处理)。本模块自己不唤醒任何箱。
|
|
132
|
+
*/
|
|
133
|
+
allowWake?: boolean;
|
|
134
|
+
/** 取消信号(透传给 `tools/list`) */
|
|
135
|
+
signal?: AbortSignal;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `ensure` 的成功分支:`refetched: false` 表示这次是缓存命中。 */
|
|
139
|
+
export interface TemplateEnsureSuccess {
|
|
140
|
+
ok: true;
|
|
141
|
+
template: CachedTemplate;
|
|
142
|
+
refetched: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `ensure` 的失败分支:`error` 可直接进 debug 日志或工具结果。 */
|
|
146
|
+
export interface TemplateEnsureFailure {
|
|
147
|
+
ok: false;
|
|
148
|
+
error: string;
|
|
149
|
+
/** 该箱的旧缓存(存在时给出):调用方可用过期 schema 降级,`ok: false` 语义不变 */
|
|
150
|
+
stale?: CachedTemplate;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type TemplateEnsureResult = TemplateEnsureSuccess | TemplateEnsureFailure;
|
|
154
|
+
|
|
155
|
+
/** per-box 模板缓存读写 + 拉取编排。 */
|
|
156
|
+
export interface TemplateStore {
|
|
157
|
+
/** 内存中的该箱缓存(首次调用时从磁盘惰性加载);未知箱返回 `undefined` */
|
|
158
|
+
get(box: string): CachedTemplate | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* 先握手一次拿远端版本(`serverInfo.version`),再决定:
|
|
161
|
+
* 版本相同且未过期 → 返回缓存(`refetched: false`,不 `listTools`);
|
|
162
|
+
* 版本不同 / 已过期 / 无缓存 → `listTools` 并写缓存(`refetched: true`)。
|
|
163
|
+
* `options.allowWake` 只透传给 `clientFactory`(本模块不决定唤醒策略)。
|
|
164
|
+
* 任何失败都返回 `ok: false`(有旧缓存时一并给出 `stale`),不动内存与磁盘。
|
|
165
|
+
*/
|
|
166
|
+
ensure(box: string, options?: TemplateEnsureOptions): Promise<TemplateEnsureResult>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 空的 boxes 索引:**空原型**。键来自磁盘 / 远端(任意字符串),`__proto__`、`constructor`
|
|
171
|
+
* 这类键不该读到 `Object.prototype` 上的成员,也不该写穿到原型。
|
|
172
|
+
*/
|
|
173
|
+
function emptyBoxes(): Record<string, CachedTemplate> {
|
|
174
|
+
return Object.create(null);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 空缓存(每次新建,避免调用方改到共享对象)。 */
|
|
178
|
+
export function createEmptyTemplateCache(): TemplateCache {
|
|
179
|
+
return { version: TEMPLATE_CACHE_VERSION, boxes: emptyBoxes() };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 缓存是否超过 TTL。
|
|
184
|
+
*
|
|
185
|
+
* - `now` 是 epoch 毫秒;恰好等于 `ttlMs` 边界即视为过期(“最多用这么久”)。
|
|
186
|
+
* - `fetchedAt` 无法解析时判过期(宁可重拉一次)。
|
|
187
|
+
* - `fetchedAt` 在未来(`now - fetchedAt < 0`)判未过期:时钟回拨不至于每次都重拉。
|
|
188
|
+
*/
|
|
189
|
+
export function isStale(entry: CachedTemplate, now: number, ttlMs: number = DEFAULT_TEMPLATE_TTL_MS): boolean {
|
|
190
|
+
const fetchedAt = Date.parse(entry.fetchedAt);
|
|
191
|
+
if (Number.isNaN(fetchedAt)) return true;
|
|
192
|
+
return now - fetchedAt >= ttlMs;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 缓存条目是否缺 / 版本是否已变(无条目即为需要拉取)。 */
|
|
196
|
+
export function needsRefetch(entry: CachedTemplate | undefined, serverInfoVersion: string): boolean {
|
|
197
|
+
if (entry === undefined) return true;
|
|
198
|
+
return entry.serverInfoVersion !== serverInfoVersion;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 宽松对象判定(磁盘内容一律先过这里再取字段)。 */
|
|
202
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
203
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `unknown` → message 文本(错误文本里不出现 `[object Object]`)。 */
|
|
207
|
+
function messageOf(error: unknown): string {
|
|
208
|
+
return error instanceof Error ? error.message : String(error);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 远端缺 `inputSchema` 时的空骨架(新建,避免共享引用)。 */
|
|
212
|
+
function emptyInputSchema(): Record<string, unknown> {
|
|
213
|
+
return { type: "object", properties: {} };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 磁盘上的工具数组 → {@link RemoteTool}[]:坏条目静默跳过,整项非数组则 `undefined`(丢弃该箱)。 */
|
|
217
|
+
function parseTools(raw: unknown): RemoteTool[] | undefined {
|
|
218
|
+
if (!Array.isArray(raw)) return undefined;
|
|
219
|
+
const tools: RemoteTool[] = [];
|
|
220
|
+
for (const entry of raw) {
|
|
221
|
+
if (!isRecord(entry)) continue;
|
|
222
|
+
const name = entry["name"];
|
|
223
|
+
if (typeof name !== "string" || name === "") continue;
|
|
224
|
+
const description = entry["description"];
|
|
225
|
+
const inputSchema = entry["inputSchema"];
|
|
226
|
+
tools.push({
|
|
227
|
+
name,
|
|
228
|
+
description: typeof description === "string" ? description : undefined,
|
|
229
|
+
inputSchema: isRecord(inputSchema) ? inputSchema : emptyInputSchema(),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return tools;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 远端 `instructions` 归一:非字符串 / 空白 → `undefined`。
|
|
237
|
+
* 空串不进缓存,免得发现提示里出现空描述(回退到命名空间描述更诚实)。
|
|
238
|
+
*/
|
|
239
|
+
function normalizeInstructions(value: unknown): string | undefined {
|
|
240
|
+
if (typeof value !== "string") return undefined;
|
|
241
|
+
const trimmed = value.trim();
|
|
242
|
+
return trimmed === "" ? undefined : trimmed;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 单个缓存条目:字段不全就整条丢弃(不拿半条缓存去注册工具)。 */
|
|
246
|
+
function parseCachedTemplate(raw: unknown): CachedTemplate | undefined {
|
|
247
|
+
if (!isRecord(raw)) return undefined;
|
|
248
|
+
const serverInfoVersion = raw["serverInfoVersion"];
|
|
249
|
+
if (typeof serverInfoVersion !== "string") return undefined;
|
|
250
|
+
const fetchedAt = raw["fetchedAt"];
|
|
251
|
+
if (typeof fetchedAt !== "string" || Number.isNaN(Date.parse(fetchedAt))) return undefined;
|
|
252
|
+
const tools = parseTools(raw["tools"]);
|
|
253
|
+
if (tools === undefined) return undefined;
|
|
254
|
+
const entry: CachedTemplate = { serverInfoVersion, fetchedAt, tools };
|
|
255
|
+
const templateId = raw["templateId"];
|
|
256
|
+
if (typeof templateId === "string") entry.templateId = templateId;
|
|
257
|
+
const templateVersion = raw["templateVersion"];
|
|
258
|
+
if (typeof templateVersion === "string") entry.templateVersion = templateVersion;
|
|
259
|
+
// 旧缓存没有这个字段:缺了就用命名空间描述兜底,不算坏条目
|
|
260
|
+
const serverInstructions = normalizeInstructions(raw["serverInstructions"]);
|
|
261
|
+
if (serverInstructions !== undefined) entry.serverInstructions = serverInstructions;
|
|
262
|
+
return entry;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 读缓存:任何异常(文件不存在 / 非法 JSON / 结构不兼容)都退化成空缓存,**不 throw**。
|
|
267
|
+
* 单条条目坏掉只丢那一箱,其余箱继续可用。
|
|
268
|
+
*/
|
|
269
|
+
export function readCache(readText: ReadTextFn, file: string): TemplateCache {
|
|
270
|
+
let text: string;
|
|
271
|
+
try {
|
|
272
|
+
text = readText(file);
|
|
273
|
+
} catch {
|
|
274
|
+
return createEmptyTemplateCache();
|
|
275
|
+
}
|
|
276
|
+
let raw: unknown;
|
|
277
|
+
try {
|
|
278
|
+
raw = JSON.parse(text);
|
|
279
|
+
} catch {
|
|
280
|
+
return createEmptyTemplateCache();
|
|
281
|
+
}
|
|
282
|
+
if (!isRecord(raw)) return createEmptyTemplateCache();
|
|
283
|
+
if (raw["version"] !== TEMPLATE_CACHE_VERSION) return createEmptyTemplateCache();
|
|
284
|
+
const boxesRaw = raw["boxes"];
|
|
285
|
+
if (!isRecord(boxesRaw)) return createEmptyTemplateCache();
|
|
286
|
+
const boxes = emptyBoxes();
|
|
287
|
+
for (const [box, value] of Object.entries(boxesRaw)) {
|
|
288
|
+
if (box === "") continue;
|
|
289
|
+
const entry = parseCachedTemplate(value);
|
|
290
|
+
if (entry !== undefined) boxes[box] = entry;
|
|
291
|
+
}
|
|
292
|
+
return { version: TEMPLATE_CACHE_VERSION, boxes };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 写缓存:失败只 `warn`,**不 throw**(缓存是加速层,写不进去不影响本次会话)。
|
|
297
|
+
* 返回是否写成功(调用方只用于诊断,不据此改变行为)。
|
|
298
|
+
*/
|
|
299
|
+
export function writeCache(
|
|
300
|
+
writeText: WriteTextFn,
|
|
301
|
+
file: string,
|
|
302
|
+
cache: TemplateCache,
|
|
303
|
+
warn?: WarnFn,
|
|
304
|
+
): boolean {
|
|
305
|
+
try {
|
|
306
|
+
writeText(file, `${JSON.stringify(cache, null, 2)}\n`);
|
|
307
|
+
return true;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
warn?.(`template cache write failed (${file}): ${messageOf(error)}`);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 默认读:`node:fs` 同步读文本(ENOENT 等异常由 {@link readCache} 兜住)。 */
|
|
315
|
+
export const nodeReadText: ReadTextFn = (file) => fs.readFileSync(file, "utf8");
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 默认写:`mkdir -p` + 同目录临时文件 + `rename`(原子替换,读者永远看不到半份 JSON)。
|
|
319
|
+
* 失败时尽力删掉临时文件再抛,由 {@link writeCache} 降级成 `warn`。
|
|
320
|
+
*/
|
|
321
|
+
export const nodeWriteText: WriteTextFn = (file, text) => {
|
|
322
|
+
const dir = path.dirname(file);
|
|
323
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
324
|
+
const temp = path.join(dir, `${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
325
|
+
try {
|
|
326
|
+
fs.writeFileSync(temp, text, "utf8");
|
|
327
|
+
fs.renameSync(temp, file);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
try {
|
|
330
|
+
fs.rmSync(temp, { force: true });
|
|
331
|
+
} catch {
|
|
332
|
+
// 清理是尽力而为:真正的失败原因下面照原样抛出
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** 默认文件系统(`node:fs`);单测一律注入内存实现。 */
|
|
339
|
+
export const nodeTemplateFileSystem: TemplateFileSystem = { readText: nodeReadText, writeText: nodeWriteText };
|
|
340
|
+
|
|
341
|
+
/** 远端 `initialize` 结果 → 缓存版本键(缺 `serverInfo.version` 时记空串:此时只靠 TTL 刷新)。 */
|
|
342
|
+
function serverVersionOf(result: InitializeResult): string {
|
|
343
|
+
const version = result.serverInfo?.version;
|
|
344
|
+
return typeof version === "string" ? version : "";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 失败结果(统一处):有旧缓存就一并返回,无旧缓存时不带 `stale` 键。 */
|
|
348
|
+
function failure(error: string, stale: CachedTemplate | undefined): TemplateEnsureFailure {
|
|
349
|
+
return stale === undefined ? { ok: false, error } : { ok: false, error, stale };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 拉取失败时的错误文本(带 box,便于 debug 日志与现状对照)。 */
|
|
353
|
+
function fetchFailure(box: string, error: unknown, stale: CachedTemplate | undefined): TemplateEnsureFailure {
|
|
354
|
+
const message = `failed to fetch remote template for ${box}: ${messageOf(error)}`;
|
|
355
|
+
logEvent("template.fail", { box, error: messageOf(error), stale: stale !== undefined }, "warn");
|
|
356
|
+
return failure(message, stale);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 取消判定(包成函数:跨 `await` 后仍能重新读到 `aborted`,不被前一次检查收窄成 `false`)。 */
|
|
360
|
+
function isAborted(signal: AbortSignal | undefined): boolean {
|
|
361
|
+
return signal?.aborted === true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** `template.refetch.reason`:为何走了重拉(无缓存 / TTL 过期 / 握手版本变了)。 */
|
|
365
|
+
type RefetchReason = "no-cache" | "ttl-expired" | "version-changed";
|
|
366
|
+
|
|
367
|
+
/** 取消时的失败结果。 */
|
|
368
|
+
function abortFailure(box: string, stale: CachedTemplate | undefined): TemplateEnsureFailure {
|
|
369
|
+
const message = `template refetch for ${box} aborted by caller`;
|
|
370
|
+
logEvent("template.fail", { box, error: message, stale: stale !== undefined }, "warn");
|
|
371
|
+
return failure(message, stale);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 实现:内存缓存 + 磁盘单点(`paths.templateCacheFile`)。
|
|
376
|
+
*
|
|
377
|
+
* 磁盘只在首次访问时读一次(惰性),之后以内存为准;每次成功拉取都整体重写一次
|
|
378
|
+
* (文件只有几 KB,整体重写免掉合并逻辑与半写风险)。
|
|
379
|
+
*/
|
|
380
|
+
class FileTemplateStore implements TemplateStore {
|
|
381
|
+
private readonly clientFactory: TemplateClientFactory;
|
|
382
|
+
private readonly fs: TemplateFileSystem;
|
|
383
|
+
private readonly clock: ClockFn;
|
|
384
|
+
private readonly ttlMs: number;
|
|
385
|
+
private readonly warn: WarnFn | undefined;
|
|
386
|
+
private readonly cacheFile: string;
|
|
387
|
+
private cache: TemplateCache | undefined;
|
|
388
|
+
|
|
389
|
+
constructor(options: TemplateStoreOptions) {
|
|
390
|
+
this.clientFactory = options.clientFactory;
|
|
391
|
+
this.fs = options.fs ?? nodeTemplateFileSystem;
|
|
392
|
+
this.clock = options.clock ?? Date.now;
|
|
393
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TEMPLATE_TTL_MS;
|
|
394
|
+
this.warn = options.warn;
|
|
395
|
+
this.cacheFile = options.paths.templateCacheFile;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
get(box: string): CachedTemplate | undefined {
|
|
399
|
+
const { boxes } = this.load();
|
|
400
|
+
// hasOwn:`constructor` / `toString` 等原型成员不该被当成命中
|
|
401
|
+
return Object.hasOwn(boxes, box) ? boxes[box] : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async ensure(box: string, options: TemplateEnsureOptions = {}): Promise<TemplateEnsureResult> {
|
|
405
|
+
const cached = this.get(box);
|
|
406
|
+
if (isAborted(options.signal)) return abortFailure(box, cached);
|
|
407
|
+
// 每次 ensure 都握手一次:版本校验的唯一可靠来源是远端 `serverInfo.version`
|
|
408
|
+
let client: RemoteClient;
|
|
409
|
+
let handshakeVersion: string;
|
|
410
|
+
let handshakeInstructions: string | undefined;
|
|
411
|
+
try {
|
|
412
|
+
// `allowWake` 只透传:没传第二参就交给工厂的默认策略
|
|
413
|
+
const allowWake = options.allowWake;
|
|
414
|
+
client = this.clientFactory(box, allowWake === undefined ? undefined : { allowWake });
|
|
415
|
+
const initialized = await client.initialize();
|
|
416
|
+
if (isAborted(options.signal)) return abortFailure(box, cached);
|
|
417
|
+
handshakeVersion = serverVersionOf(initialized);
|
|
418
|
+
handshakeInstructions = normalizeInstructions(initialized["instructions"]);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
// 握手失败保留旧缓存(不写盘、不改内存),让调用方还能用上次的 schema 降级
|
|
421
|
+
this.warn?.(`template ensure failed for ${box}: ${messageOf(error)}`);
|
|
422
|
+
return fetchFailure(box, error, cached);
|
|
423
|
+
}
|
|
424
|
+
// 显式传入的版本优先于握手值(调用方已确定版本时以它为准)
|
|
425
|
+
const knownVersion = options.serverVersion ?? handshakeVersion;
|
|
426
|
+
const now = this.clock();
|
|
427
|
+
const ttlExpired = cached !== undefined && isStale(cached, now, this.ttlMs);
|
|
428
|
+
if (cached !== undefined && !ttlExpired && !needsRefetch(cached, knownVersion)) {
|
|
429
|
+
// `stale` 在这里恒为 false(命中要求 TTL 与版本双通过);沿用旧缓存的降级命中记在
|
|
430
|
+
// `template.fail` 的 `stale` 上。
|
|
431
|
+
logEvent("template.hit", { box, serverInfoVersion: cached.serverInfoVersion, stale: ttlExpired }, "debug");
|
|
432
|
+
return { ok: true, template: cached, refetched: false };
|
|
433
|
+
}
|
|
434
|
+
const reason: RefetchReason =
|
|
435
|
+
cached === undefined ? "no-cache" : ttlExpired ? "ttl-expired" : "version-changed";
|
|
436
|
+
const startedAt = this.clock();
|
|
437
|
+
let tools: RemoteTool[];
|
|
438
|
+
try {
|
|
439
|
+
tools = await client.listTools(options.signal);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.warn?.(`template ensure failed for ${box}: ${messageOf(error)}`);
|
|
442
|
+
return fetchFailure(box, error, cached);
|
|
443
|
+
}
|
|
444
|
+
logEvent("template.refetch", { box, reason, ms: this.clock() - startedAt, tools: tools.length });
|
|
445
|
+
const entry: CachedTemplate = {
|
|
446
|
+
// 缓存版本键只记握手得到的真实版本(显式 `serverVersion` 只参与上面的判定)
|
|
447
|
+
serverInfoVersion: handshakeVersion,
|
|
448
|
+
templateId: options.templateId ?? cached?.templateId,
|
|
449
|
+
templateVersion: options.templateVersion ?? cached?.templateVersion,
|
|
450
|
+
fetchedAt: new Date(this.clock()).toISOString(),
|
|
451
|
+
tools,
|
|
452
|
+
};
|
|
453
|
+
// 不继承旧缓存的 instructions:这次握手没报就说明远端现在没有这句话
|
|
454
|
+
if (handshakeInstructions !== undefined) entry.serverInstructions = handshakeInstructions;
|
|
455
|
+
const cache = this.load();
|
|
456
|
+
cache.boxes[box] = entry;
|
|
457
|
+
writeCache(this.fs.writeText, this.cacheFile, cache, this.warn);
|
|
458
|
+
return { ok: true, template: entry, refetched: true };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 惰性加载:磁盘坏 / 缺失都退化成空缓存,不 warn、不 throw(缓存只是加速层)。 */
|
|
462
|
+
private load(): TemplateCache {
|
|
463
|
+
if (this.cache === undefined) {
|
|
464
|
+
this.cache = readCache(this.fs.readText, this.cacheFile);
|
|
465
|
+
}
|
|
466
|
+
return this.cache;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 建一个 per-box 模板缓存(单会话一实例,持有内存副本并写 `paths.templateCacheFile`)。 */
|
|
471
|
+
export function createTemplateStore(options: TemplateStoreOptions): TemplateStore {
|
|
472
|
+
return new FileTemplateStore(options);
|
|
473
|
+
}
|