@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 +14 -12
- package/lib/index.d.ts +1 -1
- package/lib/index.js +15 -10
- package/package.json +2 -2
- package/src/client/health-tab.tsx +9 -1
- package/src/fallback-llm.ts +11 -2
- package/src/patch-file.ts +11 -7
- package/src/quarantine.ts +13 -4
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
|
|
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 [
|
|
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
|
|
354
|
-
if (
|
|
355
|
-
const headComment =
|
|
356
|
-
|
|
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 &&
|
|
365
|
-
const next =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|
package/src/fallback-llm.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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 = (
|
|
152
|
-
|
|
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 &&
|
|
168
|
-
const next =
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|