@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 CHANGED
@@ -1,5 +1,5 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import "@deepseek-ai/dsh-settings";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
 
5
5
  //#region src/config.d.ts
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 ??= evaluate().catch((error) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/config.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
7
7
  * @module lifeboat/config
8
8
  */
9
- import z from '@deepseek-ai/schemastery'
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.string()
21
- .description('隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml')
21
+ patchFile: z
22
+ .string()
23
+ .description(
24
+ '隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml',
25
+ )
22
26
  .default(''),
23
- llmFallback: z.boolean()
24
- .description('llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型').default(true),
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.union([FallbackState, z.const(null)]).description('LLM 应急翻译状态;null 表示未处于降级').default(null),
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>
@@ -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 { SettingsConflictError, settingsNamespace, type SettingsProvider } from '@deepseek-ai/dsh-settings'
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) return EXTENDS_PROTOCOL[source]
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)) out.reasoning = 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) out.defaultInput = raw.defaultInput
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(providers: Record<string, RawProvider>): Record<string, TranslatedProvider> {
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(settingsFile: string, ns: string): Promise<Record<string, unknown> | undefined> {
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(ns: ReturnType<typeof settingsNamespace>, ops: readonly { op: 'set' | 'unset'; path: readonly string[]; value?: unknown }[]): Promise<void>
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' && error !== null && (error as { code?: unknown }).code === 'SETTINGS_CONFLICT')
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('llm-fallback', `默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`)
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) => ({ op: 'unset' as const, path: ['providers', 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, { provider: state.originalProvider, model: state.originalModel }),
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('llm-fallback-revert', `默认模型还原为 ${state.originalProvider}/${state.originalModel}`)
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 { provider?: string; model?: string } | undefined
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 ??= evaluate()
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 { Config, JournalSchema, SETTINGS_NS, type FallbackStateT, type JournalDoc, type LifeboatConfig } from './config.ts'
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(`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`)
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 = { ...doc, journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP) }
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 = config.patchFile.length > 0
59
- ? config.patchFile
60
- : dshHomePath('profiles', 'web', 'cordis.patch.yml')
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(ctx: Context, journal: (kind: string, detail: string) => void): Alerter {
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((result) => {
36
- if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`)
37
- }, (error: unknown) => {
38
- logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`)
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('quarantine', `${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})`)
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(`[DSH] 插件 ${name} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`)
68
+ deps.alert(
69
+ `[DSH] 插件 ${name} 加载失败,自动隔离未果`,
70
+ `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`,
71
+ )
66
72
  } finally {
67
73
  inFlight.delete(name)
68
74
  }