@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.
- package/lib/client.js +423 -29
- package/lib/index.d.ts +0 -3
- package/lib/index.js +230 -77
- package/package.json +15 -8
- package/src/client/client.ts +17 -0
- package/src/client/health-styles.ts +53 -0
- package/src/client/health-tab.tsx +303 -0
- package/src/config.ts +4 -2
- package/src/health-api.ts +120 -0
- package/src/index.ts +8 -0
- package/src/patch-file.ts +54 -0
- package/src/quarantine.ts +22 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|