@dsh-plus/lifeboat 0.1.6 → 0.2.0

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/index.js CHANGED
@@ -61,6 +61,16 @@ const NS_AGENT_DEFAULT = settingsNamespace("agent-default-model");
61
61
  const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
62
62
  /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
63
63
  const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
64
+ /**
65
+ * master 官方 llm-pi-ai 的 COMPAT_GATES(llm-pi-ai/src/catalog.ts):每个 compat
66
+ * 字段按协议门控,写时经 assertOfferedCompatFields 拒绝不适配字段——thinkingFormat /
67
+ * supportsReasoningEffort 仅 openai-completions 提供。翻译按推断 api 过滤,避免
68
+ * 整段 compat 写入被官方校验拒绝。
69
+ */
70
+ const COMPAT_OFFER_APIS = {
71
+ thinkingFormat: /* @__PURE__ */ new Set(["openai-completions"]),
72
+ supportsReasoningEffort: /* @__PURE__ */ new Set(["openai-completions"])
73
+ };
64
74
  /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
65
75
  const REASONING_LEVELS = /* @__PURE__ */ new Set([
66
76
  "off",
@@ -112,8 +122,14 @@ function translateProvider(routeKey, raw) {
112
122
  if (raw.thinkingBudgets !== void 0) out.thinkingBudgets = raw.thinkingBudgets;
113
123
  if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0) out.defaultInput = raw.defaultInput;
114
124
  if (raw.compat !== void 0) {
125
+ const api = inferApi(routeKey, raw);
115
126
  const compat = {};
116
- for (const key of COMPAT_WHITELIST) if (raw.compat[key] !== void 0) compat[key] = raw.compat[key];
127
+ for (const key of COMPAT_WHITELIST) {
128
+ const value = raw.compat[key];
129
+ if (value === void 0 || value === null) continue;
130
+ if (!COMPAT_OFFER_APIS[key]?.has(api)) continue;
131
+ compat[key] = value;
132
+ }
117
133
  if (Object.keys(compat).length > 0) out.compat = compat;
118
134
  }
119
135
  if (Array.isArray(raw.models) && raw.models.length > 0) out.models = raw.models.map((m) => {
@@ -139,14 +155,12 @@ async function readRawSection(settingsFile, ns) {
139
155
  if (typeof section !== "object" || section === null) return void 0;
140
156
  return section;
141
157
  }
142
- /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。
143
- * 冲突判定双条件:instanceof 覆盖同版本副本;error.code 覆盖跨版本副本
144
- * (插件自带 rc.7 依赖与 host rc.8 抛错时 instanceof 为 false)。 */
158
+ /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
145
159
  async function writeWithRetry(write) {
146
160
  try {
147
161
  await write();
148
162
  } catch (error) {
149
- if (!(error instanceof SettingsConflictError || typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT")) throw error;
163
+ if (!(error instanceof SettingsConflictError)) throw error;
150
164
  await write();
151
165
  }
152
166
  }
@@ -165,11 +179,16 @@ function installLlmFallback(ctx, deps) {
165
179
  }
166
180
  const translated = translateProviders(providers);
167
181
  const fbProvider = `${provider}-fb`;
168
- await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }));
169
- await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
170
- provider: fbProvider,
171
- model
172
- }));
182
+ try {
183
+ await writes.update(NS_PI_AI, { providers: translated });
184
+ await writes.update(NS_AGENT_DEFAULT, {
185
+ provider: fbProvider,
186
+ model
187
+ });
188
+ } catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ throw new Error(`翻译失败(官方 llm-pi-ai 拒绝写入翻译后的配置): ${message}`);
191
+ }
173
192
  await deps.writeState({
174
193
  active: true,
175
194
  originalProvider: provider,
@@ -237,6 +256,16 @@ function installLlmFallback(ctx, deps) {
237
256
  * 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
238
257
  * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
239
258
  * 绝不写半个文件、绝不改动既有条目内容。
259
+ *
260
+ * 2026-08-30 事故教训(保险丝必须先于负载可靠):
261
+ * 1. 并发写竞争:多个插件同一毫秒 FAILED → 多路 appendDisable 共享固定 tmp 名
262
+ * → rename ENOENT / 读改写丢更新。修复:进程内按文件串行化(promise 链锁)
263
+ * + tmp 名带 pid/随机后缀。
264
+ * 2. YAML 带错仍强行 stringify:parseDocument 对语法错误不抛而收集进
265
+ * doc.errors,后续 toString 抛 "Document with errors cannot be stringified"
266
+ * → 隔离永远写不进去。修复:写前校验 doc.errors;文件已坏时走重建通道
267
+ * (备份坏文件 → 容错打捞既有 disabled 条目 → 重写干净文档),
268
+ * 隔离落盘优先于保留坏文件。
240
269
  * @module lifeboat/patch-file
241
270
  */
242
271
  /** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
@@ -247,66 +276,116 @@ function hasDisable(entries, targetId) {
247
276
  }
248
277
  return false;
249
278
  }
279
+ /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
280
+ function listDisabled(entries) {
281
+ const out = [];
282
+ for (const entry of entries) if (typeof entry.id === "string" && entry.disabled === true) out.push(entry.id);
283
+ return out;
284
+ }
285
+ /** 进程内按文件串行化:并发隔离写同一 patch 文件时消除读改写竞争。 */
286
+ const fileLocks = /* @__PURE__ */ new Map();
287
+ function withFileLock(file, fn) {
288
+ const next = (fileLocks.get(file) ?? Promise.resolve()).then(fn, fn);
289
+ fileLocks.set(file, next.catch(() => {}));
290
+ return next;
291
+ }
292
+ /** 唯一 tmp 名(并发/重入不留共享路径),rename 原子落盘。 */
293
+ async function atomicWrite(patchFile, content) {
294
+ const tmp = `${patchFile}.lifeboat.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
295
+ await writeFile(tmp, content, "utf-8");
296
+ await rename(tmp, patchFile);
297
+ }
298
+ /**
299
+ * 容错打捞坏 YAML 文本中既有的顶层 disabled 条目(best-effort):
300
+ * `- id: <name>` 与同条块内的 `disabled: true` 配对即认定。
301
+ * 坏文件里可能躺着此前已隔离的插件——重建时漏掉它们等于重新放行故障插件。
302
+ */
303
+ function salvageDisabled(text) {
304
+ const out = [];
305
+ for (const match of text.matchAll(/-\s+id:\s*(\S+)([^\0]*?)(?=\n\s*-\s+id:|$)/g)) {
306
+ const id = match[1];
307
+ const block = match[2] ?? "";
308
+ if (id !== void 0 && /\bdisabled:\s*true\b/.test(block)) out.push(id);
309
+ }
310
+ return out;
311
+ }
312
+ /**
313
+ * 重建通道:原文件 YAML 已坏(doc.errors 非空)时的隔离写入。
314
+ * 备份坏文件(保留现场)→ 打捞既有 disabled → 重写干净文档。
315
+ * 即使目标已在打捞集中也照样重写——坏文件对 loader 等于不存在,
316
+ * 修复文件本身与写入禁用同等重要;返回值仅报告目标集合是否变化。
317
+ */
318
+ async function appendDisableRebuild(patchFile, text, targetId) {
319
+ const salvaged = salvageDisabled(text);
320
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-");
321
+ await copyFile(patchFile, `${patchFile}.broken-${stamp}.bak`);
322
+ const entries = (salvaged.includes(targetId) ? salvaged : [...salvaged, targetId]).map((id) => `- id: ${id}\n disabled: true`);
323
+ await atomicWrite(patchFile, [
324
+ "# 本文件由 lifeboat 重建:原文件存在 YAML 语法错误,无法安全改写。",
325
+ `# 原件已备份为同目录 .broken-${stamp}.bak,请人工合并残留自定义条目。`,
326
+ ...entries,
327
+ ""
328
+ ].join("\n"));
329
+ return !salvaged.includes(targetId);
330
+ }
250
331
  /**
251
332
  * 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
333
+ * 原文件 YAML 已坏时转重建通道(隔离落盘优先,原件备份待人工合并)。
252
334
  * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
253
335
  */
254
336
  async function appendDisable(patchFile, targetId) {
255
- const text = await readFile(patchFile, "utf-8");
256
- const doc = parseDocument(text);
257
- const root = doc.toJS();
258
- if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
259
- if (hasDisable(root, targetId)) return false;
260
- await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
261
- doc.add({
262
- id: targetId,
263
- disabled: true
337
+ return withFileLock(patchFile, async () => {
338
+ const text = await readFile(patchFile, "utf-8");
339
+ const doc = parseDocument(text);
340
+ if (doc.errors.length > 0) return appendDisableRebuild(patchFile, text, targetId);
341
+ const root = doc.toJS();
342
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
343
+ if (hasDisable(root, targetId)) return false;
344
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
345
+ doc.add({
346
+ id: targetId,
347
+ disabled: true
348
+ });
349
+ await atomicWrite(patchFile, doc.toString());
350
+ return true;
264
351
  });
265
- const tmp = `${patchFile}.lifeboat.tmp`;
266
- await writeFile(tmp, doc.toString(), "utf-8");
267
- await rename(tmp, patchFile);
268
- return true;
269
- }
270
- /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
271
- function listDisabled(entries) {
272
- const out = [];
273
- for (const entry of entries) if (typeof entry.id === "string" && entry.disabled === true) out.push(entry.id);
274
- return out;
275
352
  }
276
353
  /**
277
354
  * 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
278
355
  * 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
279
356
  * 其他条目)原样不动;无该覆盖时幂等返回 false。
280
- * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
357
+ * 原文件 YAML 已坏时不猜测改写,抛错指引人工处理(恢复操作有使用者在场)。
358
+ * @throws 文件不可读/顶层不是数组/YAML 语法错误/落盘失败——调用方负责告警。
281
359
  */
282
360
  async function removeDisable(patchFile, targetId) {
283
- const text = await readFile(patchFile, "utf-8");
284
- const doc = parseDocument(text);
285
- const root = doc.toJS();
286
- if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
287
- let removed = false;
288
- const items = doc.contents;
289
- if (items?.items !== void 0 && Array.isArray(items.items)) {
290
- const headComment = items.items[0]?.commentBefore;
291
- doc.contents.items = items.items.filter((item) => {
292
- const value = item.toJS?.(doc);
293
- if (value !== void 0 && typeof value === "object" && value.id === targetId && value.disabled === true) {
294
- removed = true;
295
- return false;
361
+ return withFileLock(patchFile, async () => {
362
+ const text = await readFile(patchFile, "utf-8");
363
+ const doc = parseDocument(text);
364
+ if (doc.errors.length > 0) throw new Error(`patch 文件存在 YAML 语法错误,请人工修复后重试: ${patchFile}`);
365
+ const root = doc.toJS();
366
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
367
+ let removed = false;
368
+ const items = doc.contents;
369
+ if (items?.items !== void 0 && Array.isArray(items.items)) {
370
+ const headComment = items.items[0]?.commentBefore;
371
+ doc.contents.items = items.items.filter((item) => {
372
+ const value = item.toJS?.(doc);
373
+ if (value !== void 0 && typeof value === "object" && value.id === targetId && value.disabled === true) {
374
+ removed = true;
375
+ return false;
376
+ }
377
+ return true;
378
+ });
379
+ if (removed && headComment !== void 0 && doc.contents.items.length > 0) {
380
+ const next = doc.contents.items[0];
381
+ if (next.commentBefore === void 0) next.commentBefore = headComment;
296
382
  }
297
- return true;
298
- });
299
- if (removed && headComment !== void 0 && doc.contents.items.length > 0) {
300
- const next = doc.contents.items[0];
301
- if (next.commentBefore === void 0) next.commentBefore = headComment;
302
383
  }
303
- }
304
- if (!removed) return false;
305
- await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
306
- const tmp = `${patchFile}.lifeboat.tmp`;
307
- await writeFile(tmp, doc.toString(), "utf-8");
308
- await rename(tmp, patchFile);
309
- return true;
384
+ if (!removed) return false;
385
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
386
+ await atomicWrite(patchFile, doc.toString());
387
+ return true;
388
+ });
310
389
  }
311
390
  //#endregion
312
391
  //#region src/quarantine.ts
@@ -375,6 +454,7 @@ function installHostWatch(ctx, q) {
375
454
  * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
376
455
  * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
377
456
  * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
457
+ * (0.1.2-alpha.1 基线:用户 patch 层热应用机制保留,行为不变。)
378
458
  * @module lifeboat/health-api
379
459
  */
380
460
  const ROUTE_STATUS = "/dsh-plus/lifeboat/status";
@@ -439,7 +519,7 @@ function registerHealthApi(ctx, deps) {
439
519
  }
440
520
  const written = await removeDisable(deps.patchFile, body.name);
441
521
  deps.journal("quarantine-restore", written ? `${body.name}:已移除用户 patch 层的禁用覆盖(热应用即时恢复)` : `${body.name}:无需恢复(patch 层无禁用覆盖)`);
442
- deps.alert("[DSH] 插件恢复", `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${written ? "rc8 热应用即时生效,无需重启。" : "patch 层本无覆盖,可能已被手动恢复。"}`);
522
+ deps.alert("[DSH] 插件恢复", `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${written ? "patch 层热应用即时生效,无需重启。" : "patch 层本无覆盖,可能已被手动恢复。"}`);
443
523
  sendJson$1(res, 200, {
444
524
  ok: true,
445
525
  written
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,29 +23,29 @@
23
23
  "dsh": {
24
24
  "client": {
25
25
  "inject": [
26
- "@deepseek-ai/dsh-client-runtime",
27
- "@deepseek-ai/dsh-client-locale"
26
+ "@deepseek-ai/dsh-client-locale",
27
+ "@deepseek-ai/dsh-client-ui-renderer"
28
28
  ],
29
29
  "platform": "web"
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
33
  "yaml": "^2.6.0",
34
- "@dsh-plus/shared": "0.1.5"
34
+ "@dsh-plus/shared": "0.1.6"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.1",
38
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
39
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
40
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
38
+ "@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.1",
39
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.1",
40
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.1",
41
41
  "@deepseek-ai/schemastery": "^3.18.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/cordis": "4.0.1",
45
- "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
46
- "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
47
- "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
48
- "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
45
+ "@deepseek-ai/dsh-home-paths": "0.1.2-alpha.1",
46
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.1",
47
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.1",
48
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.1",
49
49
  "@deepseek-ai/schemastery": "3.18.1",
50
50
  "@types/react": "~18.3.1",
51
51
  "react": "^18.2.0",
@@ -35,6 +35,17 @@ export const FALLBACK_SUFFIX = '-fb'
35
35
  /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
36
36
  const COMPAT_WHITELIST = ['thinkingFormat', 'supportsReasoningEffort'] as const
37
37
 
38
+ /**
39
+ * master 官方 llm-pi-ai 的 COMPAT_GATES(llm-pi-ai/src/catalog.ts):每个 compat
40
+ * 字段按协议门控,写时经 assertOfferedCompatFields 拒绝不适配字段——thinkingFormat /
41
+ * supportsReasoningEffort 仅 openai-completions 提供。翻译按推断 api 过滤,避免
42
+ * 整段 compat 写入被官方校验拒绝。
43
+ */
44
+ const COMPAT_OFFER_APIS: Readonly<Record<string, ReadonlySet<string>>> = {
45
+ thinkingFormat: new Set(['openai-completions']),
46
+ supportsReasoningEffort: new Set(['openai-completions']),
47
+ }
48
+
38
49
  /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
39
50
  const REASONING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
40
51
 
@@ -124,9 +135,15 @@ export function translateProvider(routeKey: string, raw: RawProvider): Translate
124
135
  if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
125
136
  out.defaultInput = raw.defaultInput
126
137
  if (raw.compat !== undefined) {
138
+ const api = inferApi(routeKey, raw)
127
139
  const compat: Record<string, unknown> = {}
128
140
  for (const key of COMPAT_WHITELIST) {
129
- if (raw.compat[key] !== undefined) compat[key] = raw.compat[key]
141
+ const value = raw.compat[key]
142
+ // 空值同样丢弃:官方写时拒绝 valueless 键(catalog.ts assertOfferedCompatFields)
143
+ if (value === undefined || value === null) continue
144
+ // 按推断 api 门控:仅写入该协议 offer 的字段,其余丢弃(官方写时拒绝不适配字段)
145
+ if (!COMPAT_OFFER_APIS[key]?.has(api)) continue
146
+ compat[key] = value
130
147
  }
131
148
  if (Object.keys(compat).length > 0) out.compat = compat
132
149
  }
@@ -178,19 +195,12 @@ interface SettingsWrite {
178
195
  ): Promise<void>
179
196
  }
180
197
 
181
- /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。
182
- * 冲突判定双条件:instanceof 覆盖同版本副本;error.code 覆盖跨版本副本
183
- * (插件自带 rc.7 依赖与 host rc.8 抛错时 instanceof 为 false)。 */
198
+ /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
184
199
  async function writeWithRetry(write: () => Promise<void>): Promise<void> {
185
200
  try {
186
201
  await write()
187
202
  } catch (error) {
188
- const conflict =
189
- error instanceof SettingsConflictError ||
190
- (typeof error === 'object' &&
191
- error !== null &&
192
- (error as { code?: unknown }).code === 'SETTINGS_CONFLICT')
193
- if (!conflict) throw error
203
+ if (!(error instanceof SettingsConflictError)) throw error
194
204
  await write()
195
205
  }
196
206
  }
@@ -225,8 +235,16 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
225
235
  }
226
236
  const translated = translateProviders(providers)
227
237
  const fbProvider = `${provider}${FALLBACK_SUFFIX}`
228
- await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }))
229
- await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, { provider: fbProvider, model }))
238
+ try {
239
+ // 官方 llm-pi-ai 的 installSettingsSection 注册了 validate(assertServiceable),
240
+ // 写时拒绝不适配的翻译结果;update 无 expectedRevision 本就无条件写,不做冲突
241
+ // 重试——失败一律分类为「翻译失败」。
242
+ await writes.update(NS_PI_AI, { providers: translated })
243
+ await writes.update(NS_AGENT_DEFAULT, { provider: fbProvider, model })
244
+ } catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error)
246
+ throw new Error(`翻译失败(官方 llm-pi-ai 拒绝写入翻译后的配置): ${message}`)
247
+ }
230
248
  await deps.writeState({
231
249
  active: true,
232
250
  originalProvider: provider,
@@ -306,5 +324,9 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
306
324
  ctx.root.on('settings/updated' as never, (ns: unknown) => {
307
325
  if (typeof ns === 'string' && WATCHED_NS.has(ns)) run()
308
326
  })
309
- ctx.root.on('llm/adapters-updated' as never, run)
327
+ // 事件名已核对:master 0.1.2-alpha.1 的 dsh-llm 保留 'llm/adapters-updated'
328
+ // (packages/llm/llm/src/types.ts:23 module augmentation,index.ts:344 于每次
329
+ // adapter 注册/注销提交点 dispatch);@deepseek-ai/dsh-llm 的类型导入已携带该
330
+ // 声明,无需 as never。
331
+ ctx.root.on('llm/adapters-updated', run)
310
332
  }
package/src/health-api.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
4
4
  * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
5
5
  * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
6
+ * (0.1.2-alpha.1 基线:用户 patch 层热应用机制保留,行为不变。)
6
7
  * @module lifeboat/health-api
7
8
  */
8
9
  import { readFile } from 'node:fs/promises'
@@ -106,7 +107,7 @@ export function registerHealthApi(ctx: Context, deps: HealthDeps): void {
106
107
  deps.alert(
107
108
  '[DSH] 插件恢复',
108
109
  `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${
109
- written ? 'rc8 热应用即时生效,无需重启。' : 'patch 层本无覆盖,可能已被手动恢复。'
110
+ written ? 'patch 层热应用即时生效,无需重启。' : 'patch 层本无覆盖,可能已被手动恢复。'
110
111
  }`,
111
112
  )
112
113
  sendJson(res, 200, { ok: true, written })
package/src/patch-file.ts CHANGED
@@ -3,6 +3,16 @@
3
3
  * 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
4
4
  * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
5
5
  * 绝不写半个文件、绝不改动既有条目内容。
6
+ *
7
+ * 2026-08-30 事故教训(保险丝必须先于负载可靠):
8
+ * 1. 并发写竞争:多个插件同一毫秒 FAILED → 多路 appendDisable 共享固定 tmp 名
9
+ * → rename ENOENT / 读改写丢更新。修复:进程内按文件串行化(promise 链锁)
10
+ * + tmp 名带 pid/随机后缀。
11
+ * 2. YAML 带错仍强行 stringify:parseDocument 对语法错误不抛而收集进
12
+ * doc.errors,后续 toString 抛 "Document with errors cannot be stringified"
13
+ * → 隔离永远写不进去。修复:写前校验 doc.errors;文件已坏时走重建通道
14
+ * (备份坏文件 → 容错打捞既有 disabled 条目 → 重写干净文档),
15
+ * 隔离落盘优先于保留坏文件。
6
16
  * @module lifeboat/patch-file
7
17
  */
8
18
  import { copyFile, readFile, rename, writeFile } from 'node:fs/promises'
@@ -26,24 +36,6 @@ export function hasDisable(entries: PatchEntry[], targetId: string): boolean {
26
36
  return false
27
37
  }
28
38
 
29
- /**
30
- * 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
31
- * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
32
- */
33
- export async function appendDisable(patchFile: string, targetId: string): Promise<boolean> {
34
- const text = await readFile(patchFile, 'utf-8')
35
- const doc = parseDocument(text)
36
- const root = doc.toJS() as unknown
37
- if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
38
- if (hasDisable(root as PatchEntry[], targetId)) return false
39
- await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
40
- doc.add({ id: targetId, disabled: true })
41
- const tmp = `${patchFile}.lifeboat.tmp`
42
- await writeFile(tmp, doc.toString(), 'utf-8')
43
- await rename(tmp, patchFile)
44
- return true
45
- }
46
-
47
39
  /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
48
40
  export function listDisabled(entries: PatchEntry[]): string[] {
49
41
  const out: string[] = []
@@ -53,47 +45,133 @@ export function listDisabled(entries: PatchEntry[]): string[] {
53
45
  return out
54
46
  }
55
47
 
48
+ /** 进程内按文件串行化:并发隔离写同一 patch 文件时消除读改写竞争。 */
49
+ const fileLocks = new Map<string, Promise<unknown>>()
50
+
51
+ function withFileLock<T>(file: string, fn: () => Promise<T>): Promise<T> {
52
+ const prev = fileLocks.get(file) ?? Promise.resolve()
53
+ const next = prev.then(fn, fn)
54
+ fileLocks.set(
55
+ file,
56
+ next.catch(() => {}),
57
+ )
58
+ return next
59
+ }
60
+
61
+ /** 唯一 tmp 名(并发/重入不留共享路径),rename 原子落盘。 */
62
+ async function atomicWrite(patchFile: string, content: string): Promise<void> {
63
+ const tmp = `${patchFile}.lifeboat.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`
64
+ await writeFile(tmp, content, 'utf-8')
65
+ await rename(tmp, patchFile)
66
+ }
67
+
68
+ /**
69
+ * 容错打捞坏 YAML 文本中既有的顶层 disabled 条目(best-effort):
70
+ * `- id: <name>` 与同条块内的 `disabled: true` 配对即认定。
71
+ * 坏文件里可能躺着此前已隔离的插件——重建时漏掉它们等于重新放行故障插件。
72
+ */
73
+ export function salvageDisabled(text: string): string[] {
74
+ const out: string[] = []
75
+ const blockRe = /-\s+id:\s*(\S+)([^\0]*?)(?=\n\s*-\s+id:|$)/g
76
+ for (const match of text.matchAll(blockRe)) {
77
+ const id = match[1]
78
+ const block = match[2] ?? ''
79
+ if (id !== undefined && /\bdisabled:\s*true\b/.test(block)) out.push(id)
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * 重建通道:原文件 YAML 已坏(doc.errors 非空)时的隔离写入。
86
+ * 备份坏文件(保留现场)→ 打捞既有 disabled → 重写干净文档。
87
+ * 即使目标已在打捞集中也照样重写——坏文件对 loader 等于不存在,
88
+ * 修复文件本身与写入禁用同等重要;返回值仅报告目标集合是否变化。
89
+ */
90
+ async function appendDisableRebuild(
91
+ patchFile: string,
92
+ text: string,
93
+ targetId: string,
94
+ ): Promise<boolean> {
95
+ const salvaged = salvageDisabled(text)
96
+ const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-')
97
+ await copyFile(patchFile, `${patchFile}.broken-${stamp}.bak`)
98
+ const ids = salvaged.includes(targetId) ? salvaged : [...salvaged, targetId]
99
+ const entries = ids.map((id) => `- id: ${id}\n disabled: true`)
100
+ const content = [
101
+ '# 本文件由 lifeboat 重建:原文件存在 YAML 语法错误,无法安全改写。',
102
+ `# 原件已备份为同目录 .broken-${stamp}.bak,请人工合并残留自定义条目。`,
103
+ ...entries,
104
+ '',
105
+ ].join('\n')
106
+ await atomicWrite(patchFile, content)
107
+ return !salvaged.includes(targetId)
108
+ }
109
+
110
+ /**
111
+ * 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
112
+ * 原文件 YAML 已坏时转重建通道(隔离落盘优先,原件备份待人工合并)。
113
+ * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
114
+ */
115
+ export async function appendDisable(patchFile: string, targetId: string): Promise<boolean> {
116
+ return withFileLock(patchFile, async () => {
117
+ const text = await readFile(patchFile, 'utf-8')
118
+ const doc = parseDocument(text)
119
+ if (doc.errors.length > 0) return appendDisableRebuild(patchFile, text, targetId)
120
+ const root = doc.toJS() as unknown
121
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
122
+ if (hasDisable(root as PatchEntry[], targetId)) return false
123
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
124
+ doc.add({ id: targetId, disabled: true })
125
+ await atomicWrite(patchFile, doc.toString())
126
+ return true
127
+ })
128
+ }
129
+
56
130
  /**
57
131
  * 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
58
132
  * 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
59
133
  * 其他条目)原样不动;无该覆盖时幂等返回 false。
60
- * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
134
+ * 原文件 YAML 已坏时不猜测改写,抛错指引人工处理(恢复操作有使用者在场)。
135
+ * @throws 文件不可读/顶层不是数组/YAML 语法错误/落盘失败——调用方负责告警。
61
136
  */
62
137
  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
138
+ return withFileLock(patchFile, async () => {
139
+ const text = await readFile(patchFile, 'utf-8')
140
+ const doc = parseDocument(text)
141
+ if (doc.errors.length > 0) {
142
+ throw new Error(`patch 文件存在 YAML 语法错误,请人工修复后重试: ${patchFile}`)
143
+ }
144
+ const root = doc.toJS() as unknown
145
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
146
+ let removed = false
147
+ const items = doc.contents
148
+ if (items?.items !== undefined && Array.isArray(items.items)) {
149
+ // 头注释挂在首个条目的 commentBefore 上:被移除项若为首项,注释迁移给
150
+ // 新的首项,避免整文件头注释丢失。
151
+ const headComment = (items.items[0] as unknown as { commentBefore?: string })?.commentBefore
152
+ doc.contents.items = items.items.filter((item) => {
153
+ const value = (item as unknown as { toJS?(doc: unknown): unknown }).toJS?.(doc) as
154
+ | PatchEntry
155
+ | undefined
156
+ if (
157
+ value !== undefined &&
158
+ typeof value === 'object' &&
159
+ value.id === targetId &&
160
+ value.disabled === true
161
+ ) {
162
+ removed = true
163
+ return false
164
+ }
165
+ return true
166
+ })
167
+ if (removed && headComment !== undefined && doc.contents.items.length > 0) {
168
+ const next = doc.contents.items[0] as unknown as { commentBefore?: string }
169
+ if (next.commentBefore === undefined) next.commentBefore = headComment
85
170
  }
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
171
  }
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
172
+ if (!removed) return false
173
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
174
+ await atomicWrite(patchFile, doc.toString())
175
+ return true
176
+ })
99
177
  }