@dsh-plus/lifeboat 0.1.3 → 0.1.5

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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * lifeboat 健康页:settings.plugins.tab 第三页「救生艇」。
3
+ * - 应急翻译状态 banner(激活中显示源路由/临时路由/时间)
4
+ * - 已隔离插件卡片 + 「恢复」按钮(两段确认)
5
+ * - journal 时间线(kind 徽标着色)
6
+ * - dsh-plus 兄弟插件状态点(pluginInventory.list)
7
+ * 依赖隔离:本模块被 client.ts 延迟 require,react 系异常不拖垮哨兵。
8
+ * @module lifeboat/client/health-tab
9
+ */
10
+
11
+ import { getJson, postJson } from '@dsh-plus/shared/client'
12
+ import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
13
+
14
+ import { injectHealthStyle } from './health-styles.ts'
15
+
16
+ interface JournalEntry {
17
+ at: string
18
+ kind: string
19
+ detail: string
20
+ }
21
+
22
+ interface FallbackState {
23
+ active: boolean
24
+ originalProvider: string
25
+ originalModel: string
26
+ fallbackProvider: string
27
+ at: string
28
+ }
29
+
30
+ interface StatusResponse {
31
+ journal: JournalEntry[]
32
+ llmFallback: FallbackState | null
33
+ quarantined: string[]
34
+ }
35
+
36
+ interface InventoryRow {
37
+ id: string
38
+ enabled: boolean
39
+ status?: number
40
+ }
41
+
42
+ interface RemoteLike {
43
+ pluginInventory: { list(): Promise<{ entries?: InventoryRow[] }> }
44
+ }
45
+
46
+ interface SlotsLike {
47
+ inject(key: string, callback: () => unknown): unknown
48
+ register(options: Record<string, unknown>, component: unknown): () => void
49
+ }
50
+
51
+ interface LocaleLike {
52
+ register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
53
+ bind(ns: string): (key: string) => string
54
+ }
55
+
56
+ interface HealthContext {
57
+ slots: SlotsLike
58
+ locale: LocaleLike
59
+ get(key: 'remote'): RemoteLike
60
+ effect(execute: () => () => void, label?: string): unknown
61
+ }
62
+
63
+ const NS = 'dsh-plus-lifeboat'
64
+ const ROUTE_STATUS = '/dsh-plus/lifeboat/status'
65
+ const ROUTE_RESTORE = '/dsh-plus/lifeboat/restore'
66
+
67
+ /** journal kind → 徽标语义色。 */
68
+ const KIND_STYLES: Record<string, string> = {
69
+ alert: 'dlb-kindAlert',
70
+ quarantine: 'dlb-kindQuarantine',
71
+ 'quarantine-restore': 'dlb-kindRestore',
72
+ 'llm-fallback': 'dlb-kindFallback',
73
+ 'llm-fallback-revert': 'dlb-kindRestore',
74
+ }
75
+
76
+ const zhDict = {
77
+ tab: '救生艇',
78
+ fallbackActive: 'LLM 应急翻译进行中',
79
+ fallbackFrom: '源路由',
80
+ fallbackTo: '临时路由',
81
+ fallbackAt: '启用时间',
82
+ quarantined: '已隔离插件',
83
+ restore: '恢复',
84
+ restoring: '恢复中…',
85
+ restoreConfirm: '确认恢复该插件?将移除用户 patch 层的禁用覆盖,热应用即时生效。',
86
+ journalTitle: '操作日志',
87
+ pluginsTitle: 'dsh-plus 插件状态',
88
+ pluginFailed: 'FAILED',
89
+ pluginOk: '正常',
90
+ pluginDisabled: '已禁用',
91
+ emptyJournal: '暂无记录。',
92
+ emptyQuarantined: '当前无隔离插件。',
93
+ loadFailed: '状态加载失败:',
94
+ retry: '重试',
95
+ restored: '已恢复,热应用即时生效。',
96
+ restoreFailed: '恢复失败:',
97
+ }
98
+
99
+ const enDict = {
100
+ tab: 'Lifeboat',
101
+ fallbackActive: 'LLM emergency translation active',
102
+ fallbackFrom: 'Source route',
103
+ fallbackTo: 'Fallback route',
104
+ fallbackAt: 'Since',
105
+ quarantined: 'Quarantined plugins',
106
+ restore: 'Restore',
107
+ restoring: 'Restoring…',
108
+ restoreConfirm:
109
+ 'Restore this plugin? The disable override in the user patch layer will be removed and hot-applied.',
110
+ journalTitle: 'Journal',
111
+ pluginsTitle: 'dsh-plus plugin status',
112
+ pluginFailed: 'FAILED',
113
+ pluginOk: 'OK',
114
+ pluginDisabled: 'Disabled',
115
+ emptyJournal: 'No entries yet.',
116
+ emptyQuarantined: 'No quarantined plugins.',
117
+ loadFailed: 'Failed to load: ',
118
+ retry: 'Retry',
119
+ restored: 'Restored; hot-applied immediately.',
120
+ restoreFailed: 'Restore failed: ',
121
+ }
122
+
123
+ function HealthTab(props: { t(key: string): string }): ReactElement {
124
+ const { t } = props
125
+ const [status, setStatus] = useState<StatusResponse | null>(null)
126
+ const [failed, setFailed] = useState(false)
127
+ const [busyName, setBusyName] = useState<string | null>(null)
128
+ const [notice, setNotice] = useState<string | null>(null)
129
+ const [confirming, setConfirming] = useState<string | null>(null)
130
+
131
+ const load = useCallback((): void => {
132
+ getJson<StatusResponse>(ROUTE_STATUS)
133
+ .then((data) => {
134
+ setStatus(data)
135
+ setFailed(false)
136
+ })
137
+ .catch(() => setFailed(true))
138
+ }, [])
139
+
140
+ useEffect(() => {
141
+ load()
142
+ }, [load])
143
+
144
+ const onRestore = (name: string): void => {
145
+ setBusyName(name)
146
+ setConfirming(null)
147
+ postJson<{ ok: boolean; error?: string }>(ROUTE_RESTORE, { name })
148
+ .then(() => {
149
+ setNotice(t('restored'))
150
+ load()
151
+ })
152
+ .catch((error: unknown) => {
153
+ setNotice(`${t('restoreFailed')}${error instanceof Error ? error.message : ''}`)
154
+ })
155
+ .finally(() => setBusyName(null))
156
+ }
157
+
158
+ const journalGroups = useMemo(() => {
159
+ if (status === null) return []
160
+ const groups = new Map<string, JournalEntry[]>()
161
+ for (const entry of [...status.journal].reverse()) {
162
+ const day = entry.at.slice(0, 10)
163
+ const list = groups.get(day) ?? []
164
+ list.push(entry)
165
+ groups.set(day, list)
166
+ }
167
+ return [...groups.entries()]
168
+ }, [status])
169
+
170
+ if (status === null) {
171
+ return (
172
+ <div className="dlb-tab">
173
+ <p className="dlb-empty">{failed ? t('loadFailed') : t('pluginOk') === 'OK' ? '…' : '…'}</p>
174
+ {failed ? (
175
+ <button type="button" className="dlb-btn dlb-btnGhost" onClick={load}>
176
+ {t('retry')}
177
+ </button>
178
+ ) : null}
179
+ </div>
180
+ )
181
+ }
182
+
183
+ return (
184
+ <div className="dlb-tab">
185
+ {notice !== null ? (
186
+ <p className="dlb-notice" role="status">
187
+ {notice}
188
+ </p>
189
+ ) : null}
190
+
191
+ {status.llmFallback?.active === true ? (
192
+ <div className="dlb-fallback" role="status">
193
+ <p className="dlb-fallbackTitle">{t('fallbackActive')}</p>
194
+ <div className="dlb-kv">
195
+ <span>{t('fallbackFrom')}</span>
196
+ <code>
197
+ {status.llmFallback.originalProvider}/{status.llmFallback.originalModel}
198
+ </code>
199
+ </div>
200
+ <div className="dlb-kv">
201
+ <span>{t('fallbackTo')}</span>
202
+ <code>{status.llmFallback.fallbackProvider}</code>
203
+ </div>
204
+ <div className="dlb-kv">
205
+ <span>{t('fallbackAt')}</span>
206
+ <code>{status.llmFallback.at.slice(0, 19).replace('T', ' ')}</code>
207
+ </div>
208
+ </div>
209
+ ) : null}
210
+
211
+ <h3 className="dlb-title">{t('quarantined')}</h3>
212
+ {status.quarantined.length === 0 ? (
213
+ <p className="dlb-empty">{t('emptyQuarantined')}</p>
214
+ ) : (
215
+ <div className="dlb-cards">
216
+ {status.quarantined.map((name) => (
217
+ <div className="dlb-quarantineCard" key={name}>
218
+ <code className="dlb-pluginName">{name}</code>
219
+ {confirming === name ? (
220
+ <div className="dlb-confirm">
221
+ <span className="dlb-confirmText">{t('restoreConfirm')}</span>
222
+ <button
223
+ type="button"
224
+ className="dlb-btn dlb-btnGhost"
225
+ disabled={busyName === name}
226
+ onClick={() => setConfirming(null)}
227
+ >
228
+ ×
229
+ </button>
230
+ <button
231
+ type="button"
232
+ className="dlb-btn dlb-btnPrimary"
233
+ disabled={busyName === name}
234
+ onClick={() => onRestore(name)}
235
+ >
236
+ {busyName === name ? t('restoring') : t('restore')}
237
+ </button>
238
+ </div>
239
+ ) : (
240
+ <button
241
+ type="button"
242
+ className="dlb-btn dlb-btnPrimary"
243
+ disabled={busyName === name}
244
+ onClick={() => setConfirming(name)}
245
+ >
246
+ {busyName === name ? t('restoring') : t('restore')}
247
+ </button>
248
+ )}
249
+ </div>
250
+ ))}
251
+ </div>
252
+ )}
253
+
254
+ <h3 className="dlb-title">{t('journalTitle')}</h3>
255
+ {journalGroups.length === 0 ? (
256
+ <p className="dlb-empty">{t('emptyJournal')}</p>
257
+ ) : (
258
+ <div className="dlb-journal">
259
+ {journalGroups.map(([day, entries]) => (
260
+ <div className="dlb-journalDay" key={day}>
261
+ <span className="dlb-dayLabel">{day}</span>
262
+ {entries.map((entry) => (
263
+ <div className="dlb-entry" key={`${entry.at}-${entry.kind}`}>
264
+ <span className={`dlb-kind ${KIND_STYLES[entry.kind] ?? 'dlb-kindInfo'}`}>
265
+ {entry.kind}
266
+ </span>
267
+ <span className="dlb-entryAt">{entry.at.slice(11, 19)}</span>
268
+ <span className="dlb-entryDetail">{entry.detail}</span>
269
+ </div>
270
+ ))}
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
278
+
279
+ /** 挂载健康页(由 client.ts 延迟 require;任何异常由调用方捕获)。 */
280
+ export function mountHealthTab(ctx: Context): void {
281
+ const c = ctx as unknown as HealthContext
282
+ const tag = injectHealthStyle()
283
+ c.effect(
284
+ () => () => {
285
+ tag?.remove()
286
+ },
287
+ 'lifeboat: health style',
288
+ )
289
+ c.effect(() => c.locale.register(NS, { zh: zhDict, en: enDict }), 'lifeboat: locale')
290
+ const t = c.locale.bind(NS)
291
+ c.slots.inject('settings.plugins.tab', () =>
292
+ c.slots.register(
293
+ {
294
+ name: 'settings.plugins.tab',
295
+ id: 'dsh-plus-lifeboat',
296
+ order: 20,
297
+ label: () => t('tab'),
298
+ inject: () => ({ t }),
299
+ },
300
+ HealthTab,
301
+ ),
302
+ )
303
+ }
package/src/config.ts CHANGED
@@ -34,7 +34,8 @@ export const Config = z.object({
34
34
  export type LifeboatConfig = Schemastery.TypeT<typeof Config>
35
35
 
36
36
  /** journal 单条记录。 */
37
- const JournalEntry = z.object({
37
+ // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性——嵌套 schema 精确类型经消费方断开 cosmokit 推断链
38
+ const JournalEntry: any = z.object({
38
39
  at: z.string(),
39
40
  kind: z.string(),
40
41
  detail: z.string(),
@@ -51,7 +52,8 @@ const FallbackState = z.object({
51
52
  })
52
53
 
53
54
  /** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
54
- export const JournalSchema = z.object({
55
+ // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性(同 JournalEntry;上次成功构建未触发是因旧 lockfile,现同 usage-panel 口径)
56
+ export const JournalSchema: any = z.object({
55
57
  journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
56
58
  llmFallback: z
57
59
  .union([FallbackState, z.const(null)])
@@ -0,0 +1,120 @@
1
+ /**
2
+ * lifeboat 健康面板 host 接收面:
3
+ * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
4
+ * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
5
+ * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
6
+ * @module lifeboat/health-api
7
+ */
8
+ import { readFile } from 'node:fs/promises'
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type {} from '@deepseek-ai/dsh-host-webserver'
12
+ import { parseDocument } from 'yaml'
13
+ import type { Alerter } from './notify.ts'
14
+ import { listDisabled, type PatchEntry, removeDisable } from './patch-file.ts'
15
+ import { isGuardedPlugin } from './quarantine.ts'
16
+
17
+ const ROUTE_STATUS = '/dsh-plus/lifeboat/status'
18
+ const ROUTE_RESTORE = '/dsh-plus/lifeboat/restore'
19
+
20
+ export interface HealthDeps {
21
+ patchFile: string
22
+ journal: (kind: string, detail: string) => void
23
+ alert: Alerter
24
+ /** journal 文档(settings 命名空间内,最新在前的尾插列表)。 */
25
+ readJournal(): Array<{ at: string; kind: string; detail: string }>
26
+ readFallback(): {
27
+ active: boolean
28
+ originalProvider: string
29
+ originalModel: string
30
+ fallbackProvider: string
31
+ at: string
32
+ } | null
33
+ }
34
+
35
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
37
+ res.end(JSON.stringify(body))
38
+ }
39
+
40
+ function readBody(req: IncomingMessage): Promise<string> {
41
+ return new Promise((resolve, reject) => {
42
+ const chunks: Buffer[] = []
43
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
44
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
45
+ req.on('error', reject)
46
+ })
47
+ }
48
+
49
+ /** 解析用户 patch 层中已被禁用且属于 dsh-plus 兄弟域的插件名。 */
50
+ export async function readQuarantined(patchFile: string): Promise<string[]> {
51
+ let text: string
52
+ try {
53
+ text = await readFile(patchFile, 'utf-8')
54
+ } catch {
55
+ return []
56
+ }
57
+ const root = parseDocument(text).toJS() as unknown
58
+ if (!Array.isArray(root)) return []
59
+ const ids = listDisabled(root as PatchEntry[])
60
+ return ids.filter((id) => isGuardedPlugin(id) || id.startsWith('dsh-plus-'))
61
+ }
62
+
63
+ /** 注册健康面板路由(webServer 缺席时由调用方保证不调用)。 */
64
+ export function registerHealthApi(ctx: Context, deps: HealthDeps): void {
65
+ const logger = ctx.logger('lifeboat')
66
+ ctx.webServer.register({
67
+ kind: 'prefix',
68
+ path: ROUTE_STATUS,
69
+ handler: async (_req: IncomingMessage, res: ServerResponse) => {
70
+ try {
71
+ const quarantined = await readQuarantined(deps.patchFile)
72
+ sendJson(res, 200, {
73
+ journal: deps.readJournal(),
74
+ llmFallback: deps.readFallback(),
75
+ quarantined,
76
+ })
77
+ } catch (error) {
78
+ logger.warn(
79
+ `status endpoint failed: ${error instanceof Error ? error.message : String(error)}`,
80
+ )
81
+ sendJson(res, 500, { error: 'internal' })
82
+ }
83
+ },
84
+ })
85
+ ctx.webServer.register({
86
+ kind: 'prefix',
87
+ path: ROUTE_RESTORE,
88
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
89
+ try {
90
+ if (req.method !== 'POST') {
91
+ sendJson(res, 405, { error: 'POST only' })
92
+ return
93
+ }
94
+ const body = JSON.parse(await readBody(req)) as { name?: unknown }
95
+ if (typeof body.name !== 'string' || !body.name.startsWith('dsh-plus-')) {
96
+ sendJson(res, 400, { error: 'invalid-name' })
97
+ return
98
+ }
99
+ const written = await removeDisable(deps.patchFile, body.name)
100
+ deps.journal(
101
+ 'quarantine-restore',
102
+ written
103
+ ? `${body.name}:已移除用户 patch 层的禁用覆盖(热应用即时恢复)`
104
+ : `${body.name}:无需恢复(patch 层无禁用覆盖)`,
105
+ )
106
+ deps.alert(
107
+ '[DSH] 插件恢复',
108
+ `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${
109
+ written ? 'rc8 热应用即时生效,无需重启。' : 'patch 层本无覆盖,可能已被手动恢复。'
110
+ }`,
111
+ )
112
+ sendJson(res, 200, { ok: true, written })
113
+ } catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error)
115
+ logger.warn(`restore endpoint failed: ${message}`)
116
+ sendJson(res, 500, { error: message })
117
+ }
118
+ },
119
+ })
120
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  SETTINGS_NS,
25
25
  } from './config.ts'
26
26
  import { installLlmFallback } from './fallback-llm.ts'
27
+ import { registerHealthApi } from './health-api.ts'
27
28
  import { installAlerter } from './notify.ts'
28
29
  import { createQuarantine, installHostWatch } from './quarantine.ts'
29
30
  import { registerQuarantineApi } from './quarantine-api.ts'
@@ -80,6 +81,13 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
80
81
  installHostWatch(ctx, quarantine)
81
82
  ctx.inject(['webServer'], (webCtx) => {
82
83
  registerQuarantineApi(webCtx, quarantine)
84
+ registerHealthApi(webCtx, {
85
+ patchFile,
86
+ journal,
87
+ alert,
88
+ readJournal: () => doc.journal,
89
+ readFallback: () => doc.llmFallback,
90
+ })
83
91
  })
84
92
  }
85
93
 
package/src/patch-file.ts CHANGED
@@ -43,3 +43,57 @@ export async function appendDisable(patchFile: string, targetId: string): Promis
43
43
  await rename(tmp, patchFile)
44
44
  return true
45
45
  }
46
+
47
+ /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
48
+ export function listDisabled(entries: PatchEntry[]): string[] {
49
+ const out: string[] = []
50
+ for (const entry of entries) {
51
+ if (typeof entry.id === 'string' && entry.disabled === true) out.push(entry.id)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
58
+ * 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
59
+ * 其他条目)原样不动;无该覆盖时幂等返回 false。
60
+ * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
61
+ */
62
+ export async function removeDisable(patchFile: string, targetId: string): Promise<boolean> {
63
+ const text = await readFile(patchFile, 'utf-8')
64
+ const doc = parseDocument(text)
65
+ const root = doc.toJS() as unknown
66
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
67
+ let removed = false
68
+ const items = doc.contents
69
+ if (items?.items !== undefined && Array.isArray(items.items)) {
70
+ // 头注释挂在首个条目的 commentBefore 上:被移除项若为首项,注释迁移给
71
+ // 新的首项,避免整文件头注释丢失。
72
+ const headComment = (items.items[0] as unknown as { commentBefore?: string })?.commentBefore
73
+ doc.contents.items = items.items.filter((item) => {
74
+ const value = (item as unknown as { toJS?(doc: unknown): unknown }).toJS?.(doc) as
75
+ | PatchEntry
76
+ | undefined
77
+ if (
78
+ value !== undefined &&
79
+ typeof value === 'object' &&
80
+ value.id === targetId &&
81
+ value.disabled === true
82
+ ) {
83
+ removed = true
84
+ return false
85
+ }
86
+ return true
87
+ })
88
+ if (removed && headComment !== undefined && doc.contents.items.length > 0) {
89
+ const next = doc.contents.items[0] as unknown as { commentBefore?: string }
90
+ if (next.commentBefore === undefined) next.commentBefore = headComment
91
+ }
92
+ }
93
+ if (!removed) return false
94
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
95
+ const tmp = `${patchFile}.lifeboat.tmp`
96
+ await writeFile(tmp, doc.toString(), 'utf-8')
97
+ await rename(tmp, patchFile)
98
+ return true
99
+ }
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
  }