@dsh-plus/lifeboat 0.1.3 → 0.1.4

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/client.js CHANGED
@@ -3,37 +3,35 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
-
7
-
8
- //#region src/client/client.ts
9
- const name = "dsh-plus-lifeboat";
10
- /** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
11
- const FIBER_FAILED = 3;
12
- /** 永不回报的条目:救生艇自身与聚合 bundle。 */
13
- const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
14
- const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
15
- function apply(ctx) {
16
- try {
17
- const reported = /* @__PURE__ */ new Set();
18
- ctx.root.on("internal/status", (fiber) => {
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region src/client/client.ts
8
+ const name = "dsh-plus-lifeboat";
9
+ /** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
10
+ const FIBER_FAILED = 3;
11
+ /** 永不回报的条目:救生艇自身与聚合 bundle。 */
12
+ const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
13
+ const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
14
+ function apply(ctx) {
19
15
  try {
20
- if (fiber.state !== FIBER_FAILED) return;
21
- const plugin = fiber.name;
22
- if (!GUARD_RE.test(plugin) || EXCLUDED.has(plugin) || reported.has(plugin)) return;
23
- reported.add(plugin);
24
- fetch("/dsh-plus/lifeboat/quarantine", {
25
- method: "POST",
26
- headers: { "content-type": "application/json" },
27
- body: JSON.stringify({ name: plugin })
28
- }).catch(() => {});
16
+ const reported = /* @__PURE__ */ new Set();
17
+ ctx.root.on("internal/status", (fiber) => {
18
+ try {
19
+ if (fiber.state !== FIBER_FAILED) return;
20
+ const plugin = fiber.name;
21
+ if (!GUARD_RE.test(plugin) || EXCLUDED.has(plugin) || reported.has(plugin)) return;
22
+ reported.add(plugin);
23
+ fetch("/dsh-plus/lifeboat/quarantine", {
24
+ method: "POST",
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({ name: plugin })
27
+ }).catch(() => {});
28
+ } catch {}
29
+ });
29
30
  } catch {}
30
- });
31
- } catch {}
32
- }
33
-
34
- //#endregion
35
- exports.apply = apply;
36
- exports.name = name;
31
+ }
32
+ //#endregion
33
+ exports.apply = apply;
34
+ exports.name = name;
37
35
  return module.exports;
38
36
  }
39
37
  });
package/lib/index.d.ts CHANGED
@@ -1,9 +1,6 @@
1
- import "@deepseek-ai/dsh-settings";
2
1
  import z from "@deepseek-ai/schemastery";
3
2
  import { Context } from "@deepseek-ai/cordis";
4
-
5
3
  //#region src/config.d.ts
6
-
7
4
  /** 行级配置:全部有默认值,正常部署零配置。 */
8
5
  declare const Config: z<Schemastery.ObjectS<{
9
6
  enabled: z<boolean, boolean>;
package/lib/index.js CHANGED
@@ -3,18 +3,18 @@ import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-setti
3
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
-
7
- //#region src/ns.ts
8
- /**
9
- * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
10
- * @module lifeboat/ns
11
- */
12
- const SETTINGS_NS$1 = "dsh-plus-lifeboat";
13
-
14
6
  //#endregion
15
7
  //#region src/config.ts
8
+ /**
9
+ * 配置与 journal 单一事实源。
10
+ * - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
11
+ * - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
12
+ * 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
13
+ * schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
14
+ * @module lifeboat/config
15
+ */
16
16
  /** settings 命名空间(品牌化后供 settings 服务注册/读写)。 */
17
- const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
17
+ const SETTINGS_NS = settingsNamespace("dsh-plus-lifeboat");
18
18
  /** 行级配置:全部有默认值,正常部署零配置。 */
19
19
  const Config = z.object({
20
20
  enabled: z.boolean().description("故障隔离总开关").default(true),
@@ -42,18 +42,27 @@ const JournalSchema = z.object({
42
42
  journal: z.array(JournalEntry).description("救生艇操作日志(最新在尾,封顶 50 条)").default([]),
43
43
  llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
44
44
  });
45
-
46
45
  //#endregion
47
46
  //#region src/fallback-llm.ts
47
+ /**
48
+ * LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
49
+ * 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
50
+ * 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
51
+ * 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
52
+ *
53
+ * 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
54
+ * 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
55
+ * 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
56
+ * 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
57
+ * @module lifeboat/fallback-llm
58
+ */
48
59
  const NS_PI_AI = settingsNamespace("llm-pi-ai");
49
60
  const NS_AGENT_DEFAULT = settingsNamespace("agent-default-model");
50
61
  const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
51
- /** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
52
- const FALLBACK_SUFFIX = "-fb";
53
62
  /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
54
63
  const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
55
64
  /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
56
- const REASONING_LEVELS = new Set([
65
+ const REASONING_LEVELS = /* @__PURE__ */ new Set([
57
66
  "off",
58
67
  "minimal",
59
68
  "low",
@@ -120,12 +129,13 @@ function translateProvider(routeKey, raw) {
120
129
  /** 整表翻译:键加 -fb 后缀(纯函数)。 */
121
130
  function translateProviders(providers) {
122
131
  const out = {};
123
- for (const [key, raw] of Object.entries(providers)) out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw);
132
+ for (const [key, raw] of Object.entries(providers)) out[`${key}-fb`] = translateProvider(key, raw);
124
133
  return out;
125
134
  }
126
135
  /** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
127
136
  async function readRawSection(settingsFile, ns) {
128
- const section = parseDocument(await readFile(settingsFile, "utf-8")).toJS()[ns];
137
+ const text = await readFile(settingsFile, "utf-8");
138
+ const section = parseDocument(text).toJS()[ns];
129
139
  if (typeof section !== "object" || section === null) return void 0;
130
140
  return section;
131
141
  }
@@ -154,7 +164,7 @@ function installLlmFallback(ctx, deps) {
154
164
  return;
155
165
  }
156
166
  const translated = translateProviders(providers);
157
- const fbProvider = `${provider}${FALLBACK_SUFFIX}`;
167
+ const fbProvider = `${provider}-fb`;
158
168
  await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }));
159
169
  await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
160
170
  provider: fbProvider,
@@ -210,7 +220,7 @@ function installLlmFallback(ctx, deps) {
210
220
  const bootTimer = setTimeout(run, 1e4);
211
221
  ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
212
222
  ctx.on("ready", run);
213
- const WATCHED_NS = new Set([
223
+ const WATCHED_NS = /* @__PURE__ */ new Set([
214
224
  "agent-default-model",
215
225
  "llm-pi-ai",
216
226
  RAW_NS_LLM_PI
@@ -220,7 +230,6 @@ function installLlmFallback(ctx, deps) {
220
230
  });
221
231
  ctx.root.on("llm/adapters-updated", run);
222
232
  }
223
-
224
233
  //#endregion
225
234
  //#region src/notify.ts
226
235
  /**
@@ -253,9 +262,15 @@ function installAlerter(ctx, journal) {
253
262
  });
254
263
  };
255
264
  }
256
-
257
265
  //#endregion
258
266
  //#region src/patch-file.ts
267
+ /**
268
+ * profile 用户 patch 文件(cordis.patch.yml)的安全写入。
269
+ * 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
270
+ * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
271
+ * 绝不写半个文件、绝不改动既有条目内容。
272
+ * @module lifeboat/patch-file
273
+ */
259
274
  /** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
260
275
  function hasDisable(entries, targetId) {
261
276
  for (const entry of entries) {
@@ -269,7 +284,8 @@ function hasDisable(entries, targetId) {
269
284
  * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
270
285
  */
271
286
  async function appendDisable(patchFile, targetId) {
272
- const doc = parseDocument(await readFile(patchFile, "utf-8"));
287
+ const text = await readFile(patchFile, "utf-8");
288
+ const doc = parseDocument(text);
273
289
  const root = doc.toJS();
274
290
  if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
275
291
  if (hasDisable(root, targetId)) return false;
@@ -283,42 +299,51 @@ async function appendDisable(patchFile, targetId) {
283
299
  await rename(tmp, patchFile);
284
300
  return true;
285
301
  }
286
-
287
302
  //#endregion
288
303
  //#region src/quarantine.ts
289
304
  /** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
290
305
  const FIBER_FAILED = 3;
291
306
  /** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
292
- const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
307
+ const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
293
308
  const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
294
309
  /** 名字是否属于可隔离的 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);
310
+ function isGuardedPlugin(name) {
311
+ return typeof name === "string" && GUARD_RE.test(name) && !EXCLUDED.has(name);
312
+ }
313
+ /** 告警/journal 里的失败原因摘录上限,防长堆栈淹没通知渠道。 */
314
+ const REASON_CAP = 500;
315
+ /** 提取 fiber 失败原因的短文本(无则空串);cordis 把错误挂在 fiber._error。 */
316
+ function reasonText(reason) {
317
+ if (reason === void 0 || reason === null) return "";
318
+ const text = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
319
+ return text.length > REASON_CAP ? `${text.slice(0, REASON_CAP)}…` : text;
297
320
  }
298
321
  /** 装配隔离执行器(纯逻辑,可测)。 */
299
322
  function createQuarantine(ctx, deps) {
300
323
  const logger = ctx.logger("lifeboat");
301
324
  const lastAlertAt = /* @__PURE__ */ new Map();
302
325
  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);
326
+ async function quarantine(name, origin, reason) {
327
+ if (!isGuardedPlugin(name)) return;
328
+ if (inFlight.has(name)) return;
329
+ inFlight.add(name);
330
+ const reasonBlock = reasonText(reason);
331
+ const reasonSuffix = reasonBlock === "" ? "" : `\n失败原因:\n${reasonBlock}`;
307
332
  try {
308
- const written = await appendDisable(deps.patchFile, name$1);
309
- deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
333
+ const written = await appendDisable(deps.patchFile, name);
334
+ deps.journal("quarantine", `${name}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})${reasonSuffix}`);
310
335
  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修复后删除该条覆盖即可恢复。`);
336
+ if (now - (lastAlertAt.get(name) ?? 0) >= deps.alertCooldownMs) {
337
+ lastAlertAt.set(name, now);
338
+ deps.alert(`[DSH] 插件 ${name} 加载失败,已自动隔离`, `检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。${reasonSuffix}`);
314
339
  }
315
340
  } catch (error) {
316
341
  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 层禁用该插件。`);
342
+ logger.warn(`隔离 ${name} 失败: ${message}`);
343
+ deps.journal("quarantine-error", `${name}: ${message}`);
344
+ deps.alert(`[DSH] 插件 ${name} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
320
345
  } finally {
321
- inFlight.delete(name$1);
346
+ inFlight.delete(name);
322
347
  }
323
348
  }
324
349
  return { quarantine };
@@ -331,10 +356,9 @@ function installHostWatch(ctx, q) {
331
356
  ctx.root.on("internal/status", (fiber) => {
332
357
  if (fiber.state !== FIBER_FAILED) return;
333
358
  if (!isGuardedPlugin(fiber.name)) return;
334
- q.quarantine(fiber.name, "host");
359
+ q.quarantine(fiber.name, "host", fiber._error);
335
360
  });
336
361
  }
337
-
338
362
  //#endregion
339
363
  //#region src/quarantine-api.ts
340
364
  const ROUTE = "/dsh-plus/lifeboat/quarantine";
@@ -378,13 +402,10 @@ function registerQuarantineApi(ctx, q) {
378
402
  }
379
403
  });
380
404
  }
381
-
382
405
  //#endregion
383
406
  //#region src/index.ts
384
407
  const name = "dsh-plus-lifeboat";
385
408
  const inject = ["settings", "llm"];
386
- /** journal 上限:只留最近 50 条,防长期运行膨胀。 */
387
- const JOURNAL_CAP = 50;
388
409
  function apply(ctx, config) {
389
410
  const logger = ctx.logger("lifeboat");
390
411
  let doc = {
@@ -406,7 +427,7 @@ function apply(ctx, config) {
406
427
  at: (/* @__PURE__ */ new Date()).toISOString(),
407
428
  kind,
408
429
  detail
409
- }].slice(-JOURNAL_CAP)
430
+ }].slice(-50)
410
431
  };
411
432
  persist?.({ journal: doc.journal }).catch((error) => {
412
433
  logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`);
@@ -438,6 +459,5 @@ function apply(ctx, config) {
438
459
  }
439
460
  });
440
461
  }
441
-
442
462
  //#endregion
443
- export { Config, apply, inject, name };
463
+ export { Config, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@deepseek-ai/cordis": "4.0.1",
41
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.8",
42
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
43
- "@deepseek-ai/dsh-llm": "0.1.0-rc.8",
44
- "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
41
+ "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
42
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
43
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
44
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
45
45
  "@deepseek-ai/schemastery": "3.18.1"
46
46
  },
47
47
  "publishConfig": {
package/src/quarantine.ts CHANGED
@@ -31,7 +31,17 @@ export interface QuarantineDeps {
31
31
 
32
32
  export interface Quarantine {
33
33
  /** 隔离指定插件(幂等;冷却期内重复触发只计 journal 不写文件)。 */
34
- quarantine(name: string, origin: 'host' | 'client'): Promise<void>
34
+ quarantine(name: string, origin: 'host' | 'client', reason?: unknown): Promise<void>
35
+ }
36
+
37
+ /** 告警/journal 里的失败原因摘录上限,防长堆栈淹没通知渠道。 */
38
+ const REASON_CAP = 500
39
+
40
+ /** 提取 fiber 失败原因的短文本(无则空串);cordis 把错误挂在 fiber._error。 */
41
+ function reasonText(reason: unknown): string {
42
+ if (reason === undefined || reason === null) return ''
43
+ const text = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
44
+ return text.length > REASON_CAP ? `${text.slice(0, REASON_CAP)}…` : text
35
45
  }
36
46
 
37
47
  /** 装配隔离执行器(纯逻辑,可测)。 */
@@ -40,15 +50,21 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
40
50
  const lastAlertAt = new Map<string, number>()
41
51
  const inFlight = new Set<string>()
42
52
 
43
- async function quarantine(name: string, origin: 'host' | 'client'): Promise<void> {
53
+ async function quarantine(
54
+ name: string,
55
+ origin: 'host' | 'client',
56
+ reason?: unknown,
57
+ ): Promise<void> {
44
58
  if (!isGuardedPlugin(name)) return
45
59
  if (inFlight.has(name)) return
46
60
  inFlight.add(name)
61
+ const reasonBlock = reasonText(reason)
62
+ const reasonSuffix = reasonBlock === '' ? '' : `\n失败原因:\n${reasonBlock}`
47
63
  try {
48
64
  const written = await appendDisable(deps.patchFile, name)
49
65
  deps.journal(
50
66
  'quarantine',
51
- `${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})`,
67
+ `${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})${reasonSuffix}`,
52
68
  )
53
69
  const now = Date.now()
54
70
  const last = lastAlertAt.get(name) ?? 0
@@ -58,7 +74,7 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
58
74
  `[DSH] 插件 ${name} 加载失败,已自动隔离`,
59
75
  `检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n` +
60
76
  `已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n` +
61
- `修复后删除该条覆盖即可恢复。`,
77
+ `修复后删除该条覆盖即可恢复。${reasonSuffix}`,
62
78
  )
63
79
  }
64
80
  } catch (error) {
@@ -82,9 +98,9 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
82
98
  * fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
83
99
  */
84
100
  export function installHostWatch(ctx: Context, q: Quarantine): void {
85
- ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
101
+ ctx.root.on('internal/status', (fiber: { state: number; name: string; _error?: unknown }) => {
86
102
  if (fiber.state !== FIBER_FAILED) return
87
103
  if (!isGuardedPlugin(fiber.name)) return
88
- void q.quarantine(fiber.name, 'host')
104
+ void q.quarantine(fiber.name, 'host', fiber._error)
89
105
  })
90
106
  }