@dsh-plus/lifeboat 0.2.10 → 0.2.12

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
@@ -50,28 +50,24 @@ window.__ModuleLoader__.load({
50
50
  }));
51
51
  //#endregion
52
52
  //#region ../shared/src/client/fetch.ts
53
- /**
54
- * 同源自定义端点的最小 fetch 封装(各插件 client/api.ts 的公共收编版)。
55
- * 仅同源(credentials: same-origin),错误统一抛 Error(body.error)。
56
- * @module @dsh-plus/shared/client/fetch
57
- */
58
- async function parse(res) {
53
+ async function parse(res, guard) {
59
54
  const body = await res.json();
60
55
  if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
56
+ if (guard !== void 0 && !guard(body)) throw new Error(`响应结构不符合预期(${res.url})`);
61
57
  return body;
62
58
  }
63
59
  /** GET 同源 JSON 端点。 */
64
- async function getJson(route) {
65
- return parse(await fetch(route, { credentials: "same-origin" }));
60
+ async function getJson(route, guard) {
61
+ return parse(await fetch(route, { credentials: "same-origin" }), guard);
66
62
  }
67
63
  /** POST 同源 JSON 端点(body 可省略)。 */
68
- async function postJson(route, body) {
64
+ async function postJson(route, body, guard) {
69
65
  return parse(await fetch(route, {
70
66
  method: "POST",
71
67
  credentials: "same-origin",
72
68
  headers: { "content-type": "application/json" },
73
- body: body === void 0 ? void 0 : JSON.stringify(body)
74
- }));
69
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
70
+ }), guard);
75
71
  }
76
72
  var init_fetch = __esmMin((() => {}));
77
73
  //#endregion
@@ -157,6 +153,12 @@ window.__ModuleLoader__.load({
157
153
  //#endregion
158
154
  //#region src/client/health-tab.tsx
159
155
  var health_tab_exports = /* @__PURE__ */ __exportAll({ mountHealthTab: () => mountHealthTab });
156
+ /** 端点应答形状守卫:三字段缺一即判非法(渲染期直接 map journal/quarantined)。 */
157
+ function isStatusResponse(value) {
158
+ if (typeof value !== "object" || value === null) return false;
159
+ const status = value;
160
+ return Array.isArray(status.journal) && Array.isArray(status.quarantined);
161
+ }
160
162
  function HealthTab(props) {
161
163
  const { t } = props;
162
164
  const [status, setStatus] = (0, react.useState)(null);
@@ -165,7 +167,7 @@ window.__ModuleLoader__.load({
165
167
  const [notice, setNotice] = (0, react.useState)(null);
166
168
  const [confirming, setConfirming] = (0, react.useState)(null);
167
169
  const load = (0, react.useCallback)(() => {
168
- getJson(ROUTE_STATUS).then((data) => {
170
+ getJson(ROUTE_STATUS, isStatusResponse).then((data) => {
169
171
  setStatus(data);
170
172
  setFailed(false);
171
173
  }).catch(() => setFailed(true));
package/lib/index.d.ts CHANGED
@@ -17,7 +17,7 @@ type LifeboatConfig = Schemastery.TypeT<typeof Config>;
17
17
  //#endregion
18
18
  //#region src/index.d.ts
19
19
  declare const name = "dsh-plus-lifeboat";
20
- declare const inject: readonly ["settings", "llm"];
20
+ declare const inject: readonly ['settings', 'llm'];
21
21
  declare function apply(ctx: Context, config: LifeboatConfig): void;
22
22
  //#endregion
23
23
  export { Config, apply, inject, name };
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
4
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
5
- import { parseDocument } from "yaml";
5
+ import { isSeq, parseDocument } from "yaml";
6
6
  import { dirname } from "node:path";
7
7
  //#region src/config.ts
8
8
  /**
@@ -223,7 +223,7 @@ function installLlmFallback(ctx, deps, graceMs = BOOT_GRACE_MS) {
223
223
  };
224
224
  const bootTimer = setTimeout(run, graceMs);
225
225
  ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
226
- ctx.on("ready", run);
226
+ ctx.events.on("ready", run);
227
227
  const WATCHED_NS = /* @__PURE__ */ new Set([
228
228
  "agent-default-model",
229
229
  "llm-pi-ai",
@@ -350,10 +350,10 @@ async function removeDisable(patchFile, targetId) {
350
350
  const root = doc.toJS();
351
351
  if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
352
352
  let removed = false;
353
- const items = doc.contents;
354
- if (items?.items !== void 0 && Array.isArray(items.items)) {
355
- const headComment = items.items[0]?.commentBefore;
356
- doc.contents.items = items.items.filter((item) => {
353
+ const contents = doc.contents;
354
+ if (isSeq(contents)) {
355
+ const headComment = contents.items[0]?.commentBefore;
356
+ contents.items = contents.items.filter((item) => {
357
357
  const value = item.toJS?.(doc);
358
358
  if (value !== void 0 && typeof value === "object" && value.id === targetId && value.disabled === true) {
359
359
  removed = true;
@@ -361,8 +361,8 @@ async function removeDisable(patchFile, targetId) {
361
361
  }
362
362
  return true;
363
363
  });
364
- if (removed && headComment !== void 0 && doc.contents.items.length > 0) {
365
- const next = doc.contents.items[0];
364
+ if (removed && headComment !== void 0 && contents.items.length > 0) {
365
+ const next = contents.items[0];
366
366
  if (next.commentBefore === void 0) next.commentBefore = headComment;
367
367
  }
368
368
  }
@@ -379,7 +379,11 @@ const FIBER_FAILED = 3;
379
379
  /** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
380
380
  const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
381
381
  const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
382
- /** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
382
+ /**
383
+ * 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。
384
+ * 返回布尔而非 `name is string`:后者会把「正则不匹配」的否定分支收窄成 never,
385
+ * 让调用方在 `||` 右侧失去 string 上的方法(health-api 已踩到)。
386
+ */
383
387
  function isGuardedPlugin(name) {
384
388
  return typeof name === "string" && GUARD_RE.test(name) && !EXCLUDED.has(name);
385
389
  }
@@ -429,7 +433,8 @@ function installHostWatch(ctx, q) {
429
433
  ctx.root.on("internal/status", (fiber) => {
430
434
  if (fiber.state !== FIBER_FAILED) return;
431
435
  if (!isGuardedPlugin(fiber.name)) return;
432
- q.quarantine(fiber.name, "host", fiber._error);
436
+ const reason = fiber._error;
437
+ q.quarantine(fiber.name, "host", reason);
433
438
  });
434
439
  }
435
440
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "yaml": "^2.9.0",
34
- "@dsh-plus/shared": "0.1.14"
34
+ "@dsh-plus/shared": "0.1.16"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.2",
@@ -8,6 +8,7 @@
8
8
  * @module lifeboat/client/health-tab
9
9
  */
10
10
 
11
+ import type { Context } from '@deepseek-ai/cordis'
11
12
  import { getJson, postJson } from '@dsh-plus/shared/client'
12
13
  import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
13
14
 
@@ -33,6 +34,13 @@ interface StatusResponse {
33
34
  quarantined: string[]
34
35
  }
35
36
 
37
+ /** 端点应答形状守卫:三字段缺一即判非法(渲染期直接 map journal/quarantined)。 */
38
+ function isStatusResponse(value: unknown): value is StatusResponse {
39
+ if (typeof value !== 'object' || value === null) return false
40
+ const status = value as { journal?: unknown; quarantined?: unknown }
41
+ return Array.isArray(status.journal) && Array.isArray(status.quarantined)
42
+ }
43
+
36
44
  interface InventoryRow {
37
45
  id: string
38
46
  enabled: boolean
@@ -129,7 +137,7 @@ function HealthTab(props: { t(key: string): string }): ReactElement {
129
137
  const [confirming, setConfirming] = useState<string | null>(null)
130
138
 
131
139
  const load = useCallback((): void => {
132
- getJson<StatusResponse>(ROUTE_STATUS)
140
+ getJson<StatusResponse>(ROUTE_STATUS, isStatusResponse)
133
141
  .then((data) => {
134
142
  setStatus(data)
135
143
  setFailed(false)
@@ -331,10 +331,19 @@ export function installLlmFallback(
331
331
  // 覆盖后续变化;宽限内的 settings/llm 事件同样被时间闸跳过,由收尾评估兜底。
332
332
  const bootTimer = setTimeout(run, graceMs)
333
333
  ctx.effect(() => () => clearTimeout(bootTimer), 'lifeboat: fallback boot timer')
334
- ctx.on('ready' as never, run)
334
+ // 走 events 的字符串重载而非 ctx.on:'ready' 不是平台声明的事件
335
+ // (cordis 4.0.2 内置仅 internal/*,dsh 0.1.5-rc.1 全树无任何声明或派发点),
336
+ // ctx.on 要求 keyof Events,只能靠 as never 硬塞。events.on(name: string|symbol)
337
+ // 是平台给动态事件名的正统入口,运行期同一事件总线、同一 fiber 归属、随 fiber
338
+ // 卸载自动移除(已实测)。注意:该监听目前**无生产者**,属遗留死监听,保留是
339
+ // 为不动运行期行为,待人工确认后决定是否删除。
340
+ ctx.events.on('ready', run)
335
341
  // 只响应与本判定相关的命名空间变化:journal 写自身命名空间,不过滤会自触发循环。
336
342
  const WATCHED_NS = new Set(['agent-default-model', 'llm-pi-ai', RAW_NS_LLM_PI])
337
- ctx.root.on('settings/updated' as never, (ns: unknown) => {
343
+ // settings/updated 由 @deepseek-ai/dsh-settings 声明(本文件已导入该包),
344
+ // 参数类型是品牌化的 SettingsNamespace;监听器按 unknown 收,保留原有
345
+ // typeof 字符串防御。
346
+ ctx.root.on('settings/updated', (ns: unknown) => {
338
347
  if (typeof ns === 'string' && WATCHED_NS.has(ns)) run()
339
348
  })
340
349
  // 事件名已核对:0.1.2-alpha.2 的 dsh-llm 保留 'llm/adapters-updated'
package/src/patch-file.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { copyFile, readFile, rename, writeFile } from 'node:fs/promises'
19
19
 
20
- import { parseDocument } from 'yaml'
20
+ import { isSeq, parseDocument } from 'yaml'
21
21
 
22
22
  /** patch 文件顶层条目(松散视图:只关心 id/disabled/insert 三个键)。 */
23
23
  export interface PatchEntry {
@@ -144,12 +144,16 @@ export async function removeDisable(patchFile: string, targetId: string): Promis
144
144
  const root = doc.toJS() as unknown
145
145
  if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
146
146
  let removed = false
147
- const items = doc.contents
148
- if (items?.items !== undefined && Array.isArray(items.items)) {
147
+ // 空值/形状防御与原实现等价:上面已确认 doc.toJS() 是数组,顶层数组文档的
148
+ // contents 必为 YAMLSeq(标量/别名/映射节点都不满足该条);isSeq 同时对
149
+ // null(空文档)与其它节点返回 false,不会让后续访问落到无 items 的节点上。
150
+ const contents = doc.contents
151
+ if (isSeq(contents)) {
149
152
  // 头注释挂在首个条目的 commentBefore 上:被移除项若为首项,注释迁移给
150
153
  // 新的首项,避免整文件头注释丢失。
151
- const headComment = (items.items[0] as unknown as { commentBefore?: string })?.commentBefore
152
- doc.contents.items = items.items.filter((item) => {
154
+ const headComment = (contents.items[0] as unknown as { commentBefore?: string })
155
+ ?.commentBefore
156
+ contents.items = contents.items.filter((item) => {
153
157
  const value = (item as unknown as { toJS?(doc: unknown): unknown }).toJS?.(doc) as
154
158
  | PatchEntry
155
159
  | undefined
@@ -164,8 +168,8 @@ export async function removeDisable(patchFile: string, targetId: string): Promis
164
168
  }
165
169
  return true
166
170
  })
167
- if (removed && headComment !== undefined && doc.contents.items.length > 0) {
168
- const next = doc.contents.items[0] as unknown as { commentBefore?: string }
171
+ if (removed && headComment !== undefined && contents.items.length > 0) {
172
+ const next = contents.items[0] as unknown as { commentBefore?: string }
169
173
  if (next.commentBefore === undefined) next.commentBefore = headComment
170
174
  }
171
175
  }
package/src/quarantine.ts CHANGED
@@ -17,8 +17,12 @@ const EXCLUDED = new Set(['dsh-plus-lifeboat', 'dsh-plus-bundle-main'])
17
17
 
18
18
  const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/
19
19
 
20
- /** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
21
- export function isGuardedPlugin(name: unknown): name is string {
20
+ /**
21
+ * 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。
22
+ * 返回布尔而非 `name is string`:后者会把「正则不匹配」的否定分支收窄成 never,
23
+ * 让调用方在 `||` 右侧失去 string 上的方法(health-api 已踩到)。
24
+ */
25
+ export function isGuardedPlugin(name: unknown): boolean {
22
26
  return typeof name === 'string' && GUARD_RE.test(name) && !EXCLUDED.has(name)
23
27
  }
24
28
 
@@ -98,9 +102,14 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
98
102
  * fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
99
103
  */
100
104
  export function installHostWatch(ctx: Context, q: Quarantine): void {
101
- ctx.root.on('internal/status', (fiber: { state: number; name: string; _error?: unknown }) => {
105
+ // 结构化类型只声明本函数真正读取的两个字段;Fiber 上 state 是 FiberState
106
+ // 字面量枚举,按 number 读取与既有 FIBER_FAILED 镜像常量比较。
107
+ ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
102
108
  if (fiber.state !== FIBER_FAILED) return
103
109
  if (!isGuardedPlugin(fiber.name)) return
104
- void q.quarantine(fiber.name, 'host', fiber._error)
110
+ // cordis 把加载错误挂在 fiber._error 上:类型声明为 private(类型层不可见),
111
+ // 运行期确实存在,故按运行期形状读取。
112
+ const reason = (fiber as unknown as { _error?: unknown })._error
113
+ void q.quarantine(fiber.name, 'host', reason)
105
114
  })
106
115
  }