@dsh-plus/lifeboat 0.1.2 → 0.1.3
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/lib/index.d.ts +1 -1
- package/lib/index.js +161 -160
- package/package.json +1 -1
- package/src/config.ts +15 -6
- package/src/fallback-llm.ts +52 -20
- package/src/index.ts +20 -7
- package/src/notify.ts +12 -6
- package/src/quarantine.ts +8 -2
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
2
|
-
import z from "@deepseek-ai/schemastery";
|
|
3
2
|
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
5
|
import { parseDocument } from "yaml";
|
|
6
6
|
|
|
@@ -43,164 +43,6 @@ const JournalSchema = z.object({
|
|
|
43
43
|
llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
//#endregion
|
|
47
|
-
//#region src/notify.ts
|
|
48
|
-
/**
|
|
49
|
-
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
50
|
-
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
51
|
-
*/
|
|
52
|
-
function installAlerter(ctx, journal) {
|
|
53
|
-
const logger = ctx.logger("lifeboat");
|
|
54
|
-
let mailer;
|
|
55
|
-
ctx.inject(["notifyEmail"], (mailCtx) => {
|
|
56
|
-
mailer = mailCtx.notifyEmail;
|
|
57
|
-
return () => {
|
|
58
|
-
mailer = void 0;
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
return (subject, text) => {
|
|
62
|
-
journal("alert", `${subject} | ${text}`);
|
|
63
|
-
const active = mailer;
|
|
64
|
-
if (!active?.sendNotice) {
|
|
65
|
-
logger.warn(`${subject} —— ${text}`);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
active.sendNotice({
|
|
69
|
-
subject,
|
|
70
|
-
text
|
|
71
|
-
}).then((result) => {
|
|
72
|
-
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
|
|
73
|
-
}, (error) => {
|
|
74
|
-
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
-
});
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
//#endregion
|
|
80
|
-
//#region src/patch-file.ts
|
|
81
|
-
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
82
|
-
function hasDisable(entries, targetId) {
|
|
83
|
-
for (const entry of entries) {
|
|
84
|
-
if (entry.id === targetId && entry.disabled === true) return true;
|
|
85
|
-
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true;
|
|
86
|
-
}
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
91
|
-
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
92
|
-
*/
|
|
93
|
-
async function appendDisable(patchFile, targetId) {
|
|
94
|
-
const doc = parseDocument(await readFile(patchFile, "utf-8"));
|
|
95
|
-
const root = doc.toJS();
|
|
96
|
-
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
|
|
97
|
-
if (hasDisable(root, targetId)) return false;
|
|
98
|
-
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
99
|
-
doc.add({
|
|
100
|
-
id: targetId,
|
|
101
|
-
disabled: true
|
|
102
|
-
});
|
|
103
|
-
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
104
|
-
await writeFile(tmp, doc.toString(), "utf-8");
|
|
105
|
-
await rename(tmp, patchFile);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
//#endregion
|
|
110
|
-
//#region src/quarantine.ts
|
|
111
|
-
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
112
|
-
const FIBER_FAILED = 3;
|
|
113
|
-
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
114
|
-
const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
115
|
-
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
116
|
-
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
117
|
-
function isGuardedPlugin(name$1) {
|
|
118
|
-
return typeof name$1 === "string" && GUARD_RE.test(name$1) && !EXCLUDED.has(name$1);
|
|
119
|
-
}
|
|
120
|
-
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
121
|
-
function createQuarantine(ctx, deps) {
|
|
122
|
-
const logger = ctx.logger("lifeboat");
|
|
123
|
-
const lastAlertAt = /* @__PURE__ */ new Map();
|
|
124
|
-
const inFlight = /* @__PURE__ */ new Set();
|
|
125
|
-
async function quarantine(name$1, origin) {
|
|
126
|
-
if (!isGuardedPlugin(name$1)) return;
|
|
127
|
-
if (inFlight.has(name$1)) return;
|
|
128
|
-
inFlight.add(name$1);
|
|
129
|
-
try {
|
|
130
|
-
const written = await appendDisable(deps.patchFile, name$1);
|
|
131
|
-
deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
if (now - (lastAlertAt.get(name$1) ?? 0) >= deps.alertCooldownMs) {
|
|
134
|
-
lastAlertAt.set(name$1, now);
|
|
135
|
-
deps.alert(`[DSH] 插件 ${name$1} 加载失败,已自动隔离`, `检测到插件 ${name$1} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。`);
|
|
136
|
-
}
|
|
137
|
-
} catch (error) {
|
|
138
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
-
logger.warn(`隔离 ${name$1} 失败: ${message}`);
|
|
140
|
-
deps.journal("quarantine-error", `${name$1}: ${message}`);
|
|
141
|
-
deps.alert(`[DSH] 插件 ${name$1} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
|
|
142
|
-
} finally {
|
|
143
|
-
inFlight.delete(name$1);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return { quarantine };
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* host 侧监听:root context 上兄弟 fiber 的 internal/status 转移。
|
|
150
|
-
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
151
|
-
*/
|
|
152
|
-
function installHostWatch(ctx, q) {
|
|
153
|
-
ctx.root.on("internal/status", (fiber) => {
|
|
154
|
-
if (fiber.state !== FIBER_FAILED) return;
|
|
155
|
-
if (!isGuardedPlugin(fiber.name)) return;
|
|
156
|
-
q.quarantine(fiber.name, "host");
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
//#endregion
|
|
161
|
-
//#region src/quarantine-api.ts
|
|
162
|
-
const ROUTE = "/dsh-plus/lifeboat/quarantine";
|
|
163
|
-
function sendJson(res, status, body) {
|
|
164
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
165
|
-
res.end(JSON.stringify(body));
|
|
166
|
-
}
|
|
167
|
-
function readBody(req) {
|
|
168
|
-
return new Promise((resolve, reject) => {
|
|
169
|
-
const chunks = [];
|
|
170
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
171
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
172
|
-
req.on("error", reject);
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
-
function registerQuarantineApi(ctx, q) {
|
|
177
|
-
const logger = ctx.logger("lifeboat");
|
|
178
|
-
ctx.webServer.register({
|
|
179
|
-
kind: "prefix",
|
|
180
|
-
path: ROUTE,
|
|
181
|
-
handler: async (req, res) => {
|
|
182
|
-
try {
|
|
183
|
-
if (req.method !== "POST") {
|
|
184
|
-
sendJson(res, 405, { error: "POST only" });
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const body = JSON.parse(await readBody(req));
|
|
188
|
-
if (typeof body.name !== "string") {
|
|
189
|
-
sendJson(res, 400, { error: "missing name" });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
q.quarantine(body.name, "client");
|
|
193
|
-
sendJson(res, 200, { ok: true });
|
|
194
|
-
} catch (error) {
|
|
195
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
-
logger.warn(`quarantine api failed: ${message}`);
|
|
197
|
-
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
198
|
-
else res.end();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
46
|
//#endregion
|
|
205
47
|
//#region src/fallback-llm.ts
|
|
206
48
|
const NS_PI_AI = settingsNamespace("llm-pi-ai");
|
|
@@ -356,7 +198,8 @@ function installLlmFallback(ctx, deps) {
|
|
|
356
198
|
await activate(def.provider, def.model);
|
|
357
199
|
}
|
|
358
200
|
const run = () => {
|
|
359
|
-
inFlight
|
|
201
|
+
if (inFlight !== void 0) return;
|
|
202
|
+
inFlight = evaluate().catch((error) => {
|
|
360
203
|
const message = error instanceof Error ? error.message : String(error);
|
|
361
204
|
logger.warn(`llm fallback 评估失败: ${message}`);
|
|
362
205
|
deps.journal("llm-fallback-error", message);
|
|
@@ -378,6 +221,164 @@ function installLlmFallback(ctx, deps) {
|
|
|
378
221
|
ctx.root.on("llm/adapters-updated", run);
|
|
379
222
|
}
|
|
380
223
|
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/notify.ts
|
|
226
|
+
/**
|
|
227
|
+
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
228
|
+
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
229
|
+
*/
|
|
230
|
+
function installAlerter(ctx, journal) {
|
|
231
|
+
const logger = ctx.logger("lifeboat");
|
|
232
|
+
let mailer;
|
|
233
|
+
ctx.inject(["notifyEmail"], (mailCtx) => {
|
|
234
|
+
mailer = mailCtx.notifyEmail;
|
|
235
|
+
return () => {
|
|
236
|
+
mailer = void 0;
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
return (subject, text) => {
|
|
240
|
+
journal("alert", `${subject} | ${text}`);
|
|
241
|
+
const active = mailer;
|
|
242
|
+
if (!active?.sendNotice) {
|
|
243
|
+
logger.warn(`${subject} —— ${text}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
active.sendNotice({
|
|
247
|
+
subject,
|
|
248
|
+
text
|
|
249
|
+
}).then((result) => {
|
|
250
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
|
|
251
|
+
}, (error) => {
|
|
252
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/patch-file.ts
|
|
259
|
+
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
260
|
+
function hasDisable(entries, targetId) {
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (entry.id === targetId && entry.disabled === true) return true;
|
|
263
|
+
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
269
|
+
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
270
|
+
*/
|
|
271
|
+
async function appendDisable(patchFile, targetId) {
|
|
272
|
+
const doc = parseDocument(await readFile(patchFile, "utf-8"));
|
|
273
|
+
const root = doc.toJS();
|
|
274
|
+
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
|
|
275
|
+
if (hasDisable(root, targetId)) return false;
|
|
276
|
+
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
277
|
+
doc.add({
|
|
278
|
+
id: targetId,
|
|
279
|
+
disabled: true
|
|
280
|
+
});
|
|
281
|
+
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
282
|
+
await writeFile(tmp, doc.toString(), "utf-8");
|
|
283
|
+
await rename(tmp, patchFile);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/quarantine.ts
|
|
289
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
290
|
+
const FIBER_FAILED = 3;
|
|
291
|
+
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
292
|
+
const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
293
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
294
|
+
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
295
|
+
function isGuardedPlugin(name$1) {
|
|
296
|
+
return typeof name$1 === "string" && GUARD_RE.test(name$1) && !EXCLUDED.has(name$1);
|
|
297
|
+
}
|
|
298
|
+
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
299
|
+
function createQuarantine(ctx, deps) {
|
|
300
|
+
const logger = ctx.logger("lifeboat");
|
|
301
|
+
const lastAlertAt = /* @__PURE__ */ new Map();
|
|
302
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
303
|
+
async function quarantine(name$1, origin) {
|
|
304
|
+
if (!isGuardedPlugin(name$1)) return;
|
|
305
|
+
if (inFlight.has(name$1)) return;
|
|
306
|
+
inFlight.add(name$1);
|
|
307
|
+
try {
|
|
308
|
+
const written = await appendDisable(deps.patchFile, name$1);
|
|
309
|
+
deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
if (now - (lastAlertAt.get(name$1) ?? 0) >= deps.alertCooldownMs) {
|
|
312
|
+
lastAlertAt.set(name$1, now);
|
|
313
|
+
deps.alert(`[DSH] 插件 ${name$1} 加载失败,已自动隔离`, `检测到插件 ${name$1} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。`);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
317
|
+
logger.warn(`隔离 ${name$1} 失败: ${message}`);
|
|
318
|
+
deps.journal("quarantine-error", `${name$1}: ${message}`);
|
|
319
|
+
deps.alert(`[DSH] 插件 ${name$1} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
|
|
320
|
+
} finally {
|
|
321
|
+
inFlight.delete(name$1);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { quarantine };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* host 侧监听:root context 上兄弟 fiber 的 internal/status 转移。
|
|
328
|
+
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
329
|
+
*/
|
|
330
|
+
function installHostWatch(ctx, q) {
|
|
331
|
+
ctx.root.on("internal/status", (fiber) => {
|
|
332
|
+
if (fiber.state !== FIBER_FAILED) return;
|
|
333
|
+
if (!isGuardedPlugin(fiber.name)) return;
|
|
334
|
+
q.quarantine(fiber.name, "host");
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/quarantine-api.ts
|
|
340
|
+
const ROUTE = "/dsh-plus/lifeboat/quarantine";
|
|
341
|
+
function sendJson(res, status, body) {
|
|
342
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
343
|
+
res.end(JSON.stringify(body));
|
|
344
|
+
}
|
|
345
|
+
function readBody(req) {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
const chunks = [];
|
|
348
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
349
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
350
|
+
req.on("error", reject);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
354
|
+
function registerQuarantineApi(ctx, q) {
|
|
355
|
+
const logger = ctx.logger("lifeboat");
|
|
356
|
+
ctx.webServer.register({
|
|
357
|
+
kind: "prefix",
|
|
358
|
+
path: ROUTE,
|
|
359
|
+
handler: async (req, res) => {
|
|
360
|
+
try {
|
|
361
|
+
if (req.method !== "POST") {
|
|
362
|
+
sendJson(res, 405, { error: "POST only" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const body = JSON.parse(await readBody(req));
|
|
366
|
+
if (typeof body.name !== "string") {
|
|
367
|
+
sendJson(res, 400, { error: "missing name" });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
q.quarantine(body.name, "client");
|
|
371
|
+
sendJson(res, 200, { ok: true });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
+
logger.warn(`quarantine api failed: ${message}`);
|
|
375
|
+
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
376
|
+
else res.end();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
381
382
|
//#endregion
|
|
382
383
|
//#region src/index.ts
|
|
383
384
|
const name = "dsh-plus-lifeboat";
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
|
|
7
7
|
* @module lifeboat/config
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
12
|
|
|
12
13
|
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
13
14
|
|
|
@@ -17,11 +18,16 @@ export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
|
|
|
17
18
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
18
19
|
export const Config = z.object({
|
|
19
20
|
enabled: z.boolean().description('故障隔离总开关').default(true),
|
|
20
|
-
patchFile: z
|
|
21
|
-
.
|
|
21
|
+
patchFile: z
|
|
22
|
+
.string()
|
|
23
|
+
.description(
|
|
24
|
+
'隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml',
|
|
25
|
+
)
|
|
22
26
|
.default(''),
|
|
23
|
-
llmFallback: z
|
|
24
|
-
.
|
|
27
|
+
llmFallback: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.description('llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型')
|
|
30
|
+
.default(true),
|
|
25
31
|
alertCooldownMs: z.natural().description('同一插件重复告警的最小间隔毫秒数').default(300000),
|
|
26
32
|
})
|
|
27
33
|
|
|
@@ -47,7 +53,10 @@ const FallbackState = z.object({
|
|
|
47
53
|
/** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
|
|
48
54
|
export const JournalSchema = z.object({
|
|
49
55
|
journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
|
|
50
|
-
llmFallback: z
|
|
56
|
+
llmFallback: z
|
|
57
|
+
.union([FallbackState, z.const(null)])
|
|
58
|
+
.description('LLM 应急翻译状态;null 表示未处于降级')
|
|
59
|
+
.default(null),
|
|
51
60
|
})
|
|
52
61
|
|
|
53
62
|
export type JournalDoc = Schemastery.TypeT<typeof JournalSchema>
|
package/src/fallback-llm.ts
CHANGED
|
@@ -15,7 +15,11 @@ import { readFile } from 'node:fs/promises'
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
SettingsConflictError,
|
|
20
|
+
type SettingsProvider,
|
|
21
|
+
settingsNamespace,
|
|
22
|
+
} from '@deepseek-ai/dsh-settings'
|
|
19
23
|
import { parseDocument } from 'yaml'
|
|
20
24
|
|
|
21
25
|
import type { FallbackStateT } from './config.ts'
|
|
@@ -100,7 +104,8 @@ export function inferApi(routeKey: string, provider: RawProvider): string {
|
|
|
100
104
|
if (routeHint !== undefined) return routeHint
|
|
101
105
|
const firstExtends = provider.models?.find((m) => typeof m.extends === 'string')?.extends
|
|
102
106
|
const source = firstExtends?.split('/')[0]
|
|
103
|
-
if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined)
|
|
107
|
+
if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined)
|
|
108
|
+
return EXTENDS_PROTOCOL[source]
|
|
104
109
|
return 'openai-completions'
|
|
105
110
|
}
|
|
106
111
|
|
|
@@ -113,9 +118,11 @@ export function translateProvider(routeKey: string, raw: RawProvider): Translate
|
|
|
113
118
|
if (raw.apiKeyEnv !== undefined) out.apiKeyEnv = raw.apiKeyEnv
|
|
114
119
|
if (raw.baseURL !== undefined) out.baseURL = raw.baseURL
|
|
115
120
|
if (raw.headers !== undefined) out.headers = raw.headers
|
|
116
|
-
if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning))
|
|
121
|
+
if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning))
|
|
122
|
+
out.reasoning = raw.reasoning
|
|
117
123
|
if (raw.thinkingBudgets !== undefined) out.thinkingBudgets = raw.thinkingBudgets
|
|
118
|
-
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
|
|
124
|
+
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
|
|
125
|
+
out.defaultInput = raw.defaultInput
|
|
119
126
|
if (raw.compat !== undefined) {
|
|
120
127
|
const compat: Record<string, unknown> = {}
|
|
121
128
|
for (const key of COMPAT_WHITELIST) {
|
|
@@ -137,7 +144,9 @@ export function translateProvider(routeKey: string, raw: RawProvider): Translate
|
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/** 整表翻译:键加 -fb 后缀(纯函数)。 */
|
|
140
|
-
export function translateProviders(
|
|
147
|
+
export function translateProviders(
|
|
148
|
+
providers: Record<string, RawProvider>,
|
|
149
|
+
): Record<string, TranslatedProvider> {
|
|
141
150
|
const out: Record<string, TranslatedProvider> = {}
|
|
142
151
|
for (const [key, raw] of Object.entries(providers)) {
|
|
143
152
|
out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw)
|
|
@@ -146,7 +155,10 @@ export function translateProviders(providers: Record<string, RawProvider>): Reco
|
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
/** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
|
|
149
|
-
export async function readRawSection(
|
|
158
|
+
export async function readRawSection(
|
|
159
|
+
settingsFile: string,
|
|
160
|
+
ns: string,
|
|
161
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
150
162
|
const text = await readFile(settingsFile, 'utf-8')
|
|
151
163
|
const doc = parseDocument(text).toJS() as Record<string, unknown>
|
|
152
164
|
const section = doc[ns]
|
|
@@ -156,7 +168,14 @@ export async function readRawSection(settingsFile: string, ns: string): Promise<
|
|
|
156
168
|
|
|
157
169
|
interface SettingsWrite {
|
|
158
170
|
update(ns: ReturnType<typeof settingsNamespace>, patch: object): Promise<void>
|
|
159
|
-
mutate(
|
|
171
|
+
mutate(
|
|
172
|
+
ns: ReturnType<typeof settingsNamespace>,
|
|
173
|
+
ops: readonly {
|
|
174
|
+
op: 'set' | 'unset'
|
|
175
|
+
path: readonly string[]
|
|
176
|
+
value?: unknown
|
|
177
|
+
}[],
|
|
178
|
+
): Promise<void>
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
/** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。
|
|
@@ -168,7 +187,9 @@ async function writeWithRetry(write: () => Promise<void>): Promise<void> {
|
|
|
168
187
|
} catch (error) {
|
|
169
188
|
const conflict =
|
|
170
189
|
error instanceof SettingsConflictError ||
|
|
171
|
-
(typeof error === 'object' &&
|
|
190
|
+
(typeof error === 'object' &&
|
|
191
|
+
error !== null &&
|
|
192
|
+
(error as { code?: unknown }).code === 'SETTINGS_CONFLICT')
|
|
172
193
|
if (!conflict) throw error
|
|
173
194
|
await write()
|
|
174
195
|
}
|
|
@@ -190,8 +211,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
190
211
|
const settingsFile = dshHomePath('settings.yaml')
|
|
191
212
|
let inFlight: Promise<void> | undefined
|
|
192
213
|
|
|
193
|
-
const listProviderIds = (): Set<string> =>
|
|
194
|
-
new Set(ctx.llm.listProviders().map((p) => p.id))
|
|
214
|
+
const listProviderIds = (): Set<string> => new Set(ctx.llm.listProviders().map((p) => p.id))
|
|
195
215
|
|
|
196
216
|
async function activate(provider: string, model: string): Promise<void> {
|
|
197
217
|
const section = await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => undefined)
|
|
@@ -215,7 +235,10 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
215
235
|
providers: Object.keys(translated),
|
|
216
236
|
at: new Date().toISOString(),
|
|
217
237
|
})
|
|
218
|
-
deps.journal(
|
|
238
|
+
deps.journal(
|
|
239
|
+
'llm-fallback',
|
|
240
|
+
`默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`,
|
|
241
|
+
)
|
|
219
242
|
deps.alert(
|
|
220
243
|
'[DSH] LLM 配置已应急翻译',
|
|
221
244
|
`默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`,
|
|
@@ -223,13 +246,22 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
223
246
|
}
|
|
224
247
|
|
|
225
248
|
async function revert(state: FallbackStateT): Promise<void> {
|
|
226
|
-
const ops = state.providers.map((key) => ({
|
|
249
|
+
const ops = state.providers.map((key) => ({
|
|
250
|
+
op: 'unset' as const,
|
|
251
|
+
path: ['providers', key],
|
|
252
|
+
}))
|
|
227
253
|
await writeWithRetry(() => writes.mutate(NS_PI_AI, ops))
|
|
228
254
|
await writeWithRetry(() =>
|
|
229
|
-
writes.update(NS_AGENT_DEFAULT, {
|
|
255
|
+
writes.update(NS_AGENT_DEFAULT, {
|
|
256
|
+
provider: state.originalProvider,
|
|
257
|
+
model: state.originalModel,
|
|
258
|
+
}),
|
|
230
259
|
)
|
|
231
260
|
await deps.writeState(null)
|
|
232
|
-
deps.journal(
|
|
261
|
+
deps.journal(
|
|
262
|
+
'llm-fallback-revert',
|
|
263
|
+
`默认模型还原为 ${state.originalProvider}/${state.originalModel}`,
|
|
264
|
+
)
|
|
233
265
|
deps.alert(
|
|
234
266
|
'[DSH] LLM 应急翻译已还原',
|
|
235
267
|
`provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`,
|
|
@@ -243,14 +275,17 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
243
275
|
if (ids.has(state.originalProvider)) await revert(state)
|
|
244
276
|
return
|
|
245
277
|
}
|
|
246
|
-
const def = ctx.settings.get(NS_AGENT_DEFAULT) as
|
|
278
|
+
const def = ctx.settings.get(NS_AGENT_DEFAULT) as
|
|
279
|
+
| { provider?: string; model?: string }
|
|
280
|
+
| undefined
|
|
247
281
|
if (def?.provider === undefined || def.model === undefined) return
|
|
248
282
|
if (ids.has(def.provider)) return
|
|
249
283
|
await activate(def.provider, def.model)
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
const run = (): void => {
|
|
253
|
-
inFlight
|
|
287
|
+
if (inFlight !== undefined) return
|
|
288
|
+
inFlight = evaluate()
|
|
254
289
|
.catch((error: unknown) => {
|
|
255
290
|
const message = error instanceof Error ? error.message : String(error)
|
|
256
291
|
logger.warn(`llm fallback 评估失败: ${message}`)
|
|
@@ -264,10 +299,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
264
299
|
// 首次评估延迟到启动收尾:lifeboat 首位加载时兄弟插件的 adapter 尚未注册,
|
|
265
300
|
// 立刻评估会把"还没加载"误判为"缺席"。事件订阅覆盖后续变化。
|
|
266
301
|
const bootTimer = setTimeout(run, 10_000)
|
|
267
|
-
ctx.effect(
|
|
268
|
-
() => () => clearTimeout(bootTimer),
|
|
269
|
-
'lifeboat: fallback boot timer',
|
|
270
|
-
)
|
|
302
|
+
ctx.effect(() => () => clearTimeout(bootTimer), 'lifeboat: fallback boot timer')
|
|
271
303
|
ctx.on('ready' as never, run)
|
|
272
304
|
// 只响应与本判定相关的命名空间变化:journal 写自身命名空间,不过滤会自触发循环。
|
|
273
305
|
const WATCHED_NS = new Set(['agent-default-model', 'llm-pi-ai', RAW_NS_LLM_PI])
|
package/src/index.ts
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
Config,
|
|
20
|
+
type FallbackStateT,
|
|
21
|
+
type JournalDoc,
|
|
22
|
+
JournalSchema,
|
|
23
|
+
type LifeboatConfig,
|
|
24
|
+
SETTINGS_NS,
|
|
25
|
+
} from './config.ts'
|
|
26
|
+
import { installLlmFallback } from './fallback-llm.ts'
|
|
19
27
|
import { installAlerter } from './notify.ts'
|
|
20
28
|
import { createQuarantine, installHostWatch } from './quarantine.ts'
|
|
21
29
|
import { registerQuarantineApi } from './quarantine-api.ts'
|
|
22
|
-
import { installLlmFallback } from './fallback-llm.ts'
|
|
23
30
|
|
|
24
31
|
export const name = 'dsh-plus-lifeboat'
|
|
25
32
|
|
|
@@ -42,11 +49,16 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
|
42
49
|
doc = scope.get()
|
|
43
50
|
persist = (patch) => scope.update(patch)
|
|
44
51
|
} catch (error) {
|
|
45
|
-
logger.warn(
|
|
52
|
+
logger.warn(
|
|
53
|
+
`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`,
|
|
54
|
+
)
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const journal = (kind: string, detail: string): void => {
|
|
49
|
-
doc = {
|
|
58
|
+
doc = {
|
|
59
|
+
...doc,
|
|
60
|
+
journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP),
|
|
61
|
+
}
|
|
50
62
|
void persist?.({ journal: doc.journal }).catch((error: unknown) => {
|
|
51
63
|
logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
52
64
|
})
|
|
@@ -55,9 +67,10 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
|
55
67
|
const alert = installAlerter(ctx, journal)
|
|
56
68
|
|
|
57
69
|
if (config.enabled) {
|
|
58
|
-
const patchFile =
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
const patchFile =
|
|
71
|
+
config.patchFile.length > 0
|
|
72
|
+
? config.patchFile
|
|
73
|
+
: dshHomePath('profiles', 'web', 'cordis.patch.yml')
|
|
61
74
|
const quarantine = createQuarantine(ctx, {
|
|
62
75
|
patchFile,
|
|
63
76
|
alertCooldownMs: config.alertCooldownMs,
|
package/src/notify.ts
CHANGED
|
@@ -16,7 +16,10 @@ export type Alerter = (subject: string, text: string) => void
|
|
|
16
16
|
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
17
17
|
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
18
18
|
*/
|
|
19
|
-
export function installAlerter(
|
|
19
|
+
export function installAlerter(
|
|
20
|
+
ctx: Context,
|
|
21
|
+
journal: (kind: string, detail: string) => void,
|
|
22
|
+
): Alerter {
|
|
20
23
|
const logger: Logger = ctx.logger('lifeboat')
|
|
21
24
|
let mailer: NotifyEmailLike | undefined
|
|
22
25
|
ctx.inject(['notifyEmail' as never], (mailCtx) => {
|
|
@@ -32,10 +35,13 @@ export function installAlerter(ctx: Context, journal: (kind: string, detail: str
|
|
|
32
35
|
logger.warn(`${subject} —— ${text}`)
|
|
33
36
|
return
|
|
34
37
|
}
|
|
35
|
-
void active.sendNotice({ subject, text }).then(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
void active.sendNotice({ subject, text }).then(
|
|
39
|
+
(result) => {
|
|
40
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`)
|
|
41
|
+
},
|
|
42
|
+
(error: unknown) => {
|
|
43
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`)
|
|
44
|
+
},
|
|
45
|
+
)
|
|
40
46
|
}
|
|
41
47
|
}
|
package/src/quarantine.ts
CHANGED
|
@@ -46,7 +46,10 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
|
|
|
46
46
|
inFlight.add(name)
|
|
47
47
|
try {
|
|
48
48
|
const written = await appendDisable(deps.patchFile, name)
|
|
49
|
-
deps.journal(
|
|
49
|
+
deps.journal(
|
|
50
|
+
'quarantine',
|
|
51
|
+
`${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})`,
|
|
52
|
+
)
|
|
50
53
|
const now = Date.now()
|
|
51
54
|
const last = lastAlertAt.get(name) ?? 0
|
|
52
55
|
if (now - last >= deps.alertCooldownMs) {
|
|
@@ -62,7 +65,10 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
|
|
|
62
65
|
const message = error instanceof Error ? error.message : String(error)
|
|
63
66
|
logger.warn(`隔离 ${name} 失败: ${message}`)
|
|
64
67
|
deps.journal('quarantine-error', `${name}: ${message}`)
|
|
65
|
-
deps.alert(
|
|
68
|
+
deps.alert(
|
|
69
|
+
`[DSH] 插件 ${name} 加载失败,自动隔离未果`,
|
|
70
|
+
`隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`,
|
|
71
|
+
)
|
|
66
72
|
} finally {
|
|
67
73
|
inFlight.delete(name)
|
|
68
74
|
}
|