@dsh-plus/lifeboat 0.1.7 → 0.2.1
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 +111 -51
- package/package.json +15 -15
- package/src/config.ts +2 -3
- package/src/fallback-llm.ts +9 -11
- package/src/health-api.ts +1 -1
- package/src/patch-file.ts +131 -53
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
2
|
-
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
2
|
import z from "@deepseek-ai/schemastery";
|
|
4
3
|
import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
5
5
|
import { parseDocument } from "yaml";
|
|
6
6
|
//#endregion
|
|
7
7
|
//#region src/config.ts
|
|
@@ -13,8 +13,8 @@ import { parseDocument } from "yaml";
|
|
|
13
13
|
* schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
|
|
14
14
|
* @module lifeboat/config
|
|
15
15
|
*/
|
|
16
|
-
/** settings
|
|
17
|
-
const SETTINGS_NS =
|
|
16
|
+
/** settings 命名空间(字面量直供 settings 服务注册/读写,0.1.2-alpha.2 起编译期校验语法)。 */
|
|
17
|
+
const SETTINGS_NS = "dsh-plus-lifeboat";
|
|
18
18
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
19
19
|
const Config = z.object({
|
|
20
20
|
enabled: z.boolean().description("故障隔离总开关").default(true),
|
|
@@ -56,8 +56,8 @@ const JournalSchema = z.object({
|
|
|
56
56
|
* 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
|
|
57
57
|
* @module lifeboat/fallback-llm
|
|
58
58
|
*/
|
|
59
|
-
const NS_PI_AI =
|
|
60
|
-
const NS_AGENT_DEFAULT =
|
|
59
|
+
const NS_PI_AI = "llm-pi-ai";
|
|
60
|
+
const NS_AGENT_DEFAULT = "agent-default-model";
|
|
61
61
|
const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
|
|
62
62
|
/** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
|
|
63
63
|
const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
|
|
@@ -256,6 +256,16 @@ function installLlmFallback(ctx, deps) {
|
|
|
256
256
|
* 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
|
|
257
257
|
* 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
|
|
258
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
|
+
* 隔离落盘优先于保留坏文件。
|
|
259
269
|
* @module lifeboat/patch-file
|
|
260
270
|
*/
|
|
261
271
|
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
@@ -266,66 +276,116 @@ function hasDisable(entries, targetId) {
|
|
|
266
276
|
}
|
|
267
277
|
return false;
|
|
268
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
|
+
}
|
|
269
331
|
/**
|
|
270
332
|
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
333
|
+
* 原文件 YAML 已坏时转重建通道(隔离落盘优先,原件备份待人工合并)。
|
|
271
334
|
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
272
335
|
*/
|
|
273
336
|
async function appendDisable(patchFile, targetId) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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;
|
|
283
351
|
});
|
|
284
|
-
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
285
|
-
await writeFile(tmp, doc.toString(), "utf-8");
|
|
286
|
-
await rename(tmp, patchFile);
|
|
287
|
-
return true;
|
|
288
|
-
}
|
|
289
|
-
/** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
|
|
290
|
-
function listDisabled(entries) {
|
|
291
|
-
const out = [];
|
|
292
|
-
for (const entry of entries) if (typeof entry.id === "string" && entry.disabled === true) out.push(entry.id);
|
|
293
|
-
return out;
|
|
294
352
|
}
|
|
295
353
|
/**
|
|
296
354
|
* 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
|
|
297
355
|
* 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
|
|
298
356
|
* 其他条目)原样不动;无该覆盖时幂等返回 false。
|
|
299
|
-
*
|
|
357
|
+
* 原文件 YAML 已坏时不猜测改写,抛错指引人工处理(恢复操作有使用者在场)。
|
|
358
|
+
* @throws 文件不可读/顶层不是数组/YAML 语法错误/落盘失败——调用方负责告警。
|
|
300
359
|
*/
|
|
301
360
|
async function removeDisable(patchFile, targetId) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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;
|
|
315
382
|
}
|
|
316
|
-
return true;
|
|
317
|
-
});
|
|
318
|
-
if (removed && headComment !== void 0 && doc.contents.items.length > 0) {
|
|
319
|
-
const next = doc.contents.items[0];
|
|
320
|
-
if (next.commentBefore === void 0) next.commentBefore = headComment;
|
|
321
383
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
await rename(tmp, patchFile);
|
|
328
|
-
return true;
|
|
384
|
+
if (!removed) return false;
|
|
385
|
+
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
386
|
+
await atomicWrite(patchFile, doc.toString());
|
|
387
|
+
return true;
|
|
388
|
+
});
|
|
329
389
|
}
|
|
330
390
|
//#endregion
|
|
331
391
|
//#region src/quarantine.ts
|
|
@@ -394,7 +454,7 @@ function installHostWatch(ctx, q) {
|
|
|
394
454
|
* - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
|
|
395
455
|
* - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
|
|
396
456
|
* rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
|
|
397
|
-
* (0.1.2-alpha.
|
|
457
|
+
* (0.1.2-alpha.2 基线复核:用户 patch 层热应用机制保留,行为不变。)
|
|
398
458
|
* @module lifeboat/health-api
|
|
399
459
|
*/
|
|
400
460
|
const ROUTE_STATUS = "/dsh-plus/lifeboat/status";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/lifeboat",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -30,26 +30,26 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"yaml": "^2.
|
|
34
|
-
"@dsh-plus/shared": "0.1.
|
|
33
|
+
"yaml": "^2.9.0",
|
|
34
|
+
"@dsh-plus/shared": "0.1.7"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
38
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.
|
|
39
|
-
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.
|
|
40
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.
|
|
38
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.2",
|
|
39
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
|
|
40
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
41
41
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@deepseek-ai/cordis": "4.0.
|
|
45
|
-
"@deepseek-ai/dsh-home-paths": "0.1.2-alpha.
|
|
46
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.
|
|
47
|
-
"@deepseek-ai/dsh-llm": "0.1.2-alpha.
|
|
48
|
-
"@deepseek-ai/dsh-settings": "0.1.2-alpha.
|
|
49
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
50
|
-
"@types/
|
|
51
|
-
"react": "
|
|
52
|
-
"
|
|
44
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
45
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-alpha.2",
|
|
46
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.2",
|
|
47
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
|
|
48
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
|
|
49
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
50
|
+
"@types/node": "26.4.0",
|
|
51
|
+
"@types/react": "~18.3.31",
|
|
52
|
+
"react": "^18.2.0"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|
package/src/config.ts
CHANGED
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
* @module lifeboat/config
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
11
10
|
import z from '@deepseek-ai/schemastery'
|
|
12
11
|
|
|
13
12
|
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
14
13
|
|
|
15
|
-
/** settings
|
|
16
|
-
export const SETTINGS_NS =
|
|
14
|
+
/** settings 命名空间(字面量直供 settings 服务注册/读写,0.1.2-alpha.2 起编译期校验语法)。 */
|
|
15
|
+
export const SETTINGS_NS = NS_LITERAL
|
|
17
16
|
|
|
18
17
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
19
18
|
export const Config = z.object({
|
package/src/fallback-llm.ts
CHANGED
|
@@ -15,18 +15,16 @@ import { readFile } from 'node:fs/promises'
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
18
|
-
import {
|
|
19
|
-
SettingsConflictError,
|
|
20
|
-
type SettingsProvider,
|
|
21
|
-
settingsNamespace,
|
|
22
|
-
} from '@deepseek-ai/dsh-settings'
|
|
18
|
+
import { SettingsConflictError, type SettingsProvider } from '@deepseek-ai/dsh-settings'
|
|
23
19
|
import { parseDocument } from 'yaml'
|
|
24
20
|
|
|
25
21
|
import type { FallbackStateT } from './config.ts'
|
|
26
22
|
import type { Alerter } from './notify.ts'
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
// 0.1.2-alpha.2 起 settings 命名空间直接以字面量使用(编译期校验语法),
|
|
25
|
+
// 不再需要 settingsNamespace() 品牌化。
|
|
26
|
+
const NS_PI_AI = 'llm-pi-ai'
|
|
27
|
+
const NS_AGENT_DEFAULT = 'agent-default-model'
|
|
30
28
|
const RAW_NS_LLM_PI = 'dsh-plus-llm-pi'
|
|
31
29
|
|
|
32
30
|
/** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
|
|
@@ -184,9 +182,9 @@ export async function readRawSection(
|
|
|
184
182
|
}
|
|
185
183
|
|
|
186
184
|
interface SettingsWrite {
|
|
187
|
-
update(ns:
|
|
185
|
+
update(ns: string, patch: object): Promise<void>
|
|
188
186
|
mutate(
|
|
189
|
-
ns:
|
|
187
|
+
ns: string,
|
|
190
188
|
ops: readonly {
|
|
191
189
|
op: 'set' | 'unset'
|
|
192
190
|
path: readonly string[]
|
|
@@ -236,7 +234,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
236
234
|
const translated = translateProviders(providers)
|
|
237
235
|
const fbProvider = `${provider}${FALLBACK_SUFFIX}`
|
|
238
236
|
try {
|
|
239
|
-
// 官方 llm-pi-ai 的
|
|
237
|
+
// 官方 llm-pi-ai 的 installSection 注册了 validate(assertServiceable),
|
|
240
238
|
// 写时拒绝不适配的翻译结果;update 无 expectedRevision 本就无条件写,不做冲突
|
|
241
239
|
// 重试——失败一律分类为「翻译失败」。
|
|
242
240
|
await writes.update(NS_PI_AI, { providers: translated })
|
|
@@ -324,7 +322,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
324
322
|
ctx.root.on('settings/updated' as never, (ns: unknown) => {
|
|
325
323
|
if (typeof ns === 'string' && WATCHED_NS.has(ns)) run()
|
|
326
324
|
})
|
|
327
|
-
// 事件名已核对:
|
|
325
|
+
// 事件名已核对:0.1.2-alpha.2 的 dsh-llm 保留 'llm/adapters-updated'
|
|
328
326
|
// (packages/llm/llm/src/types.ts:23 module augmentation,index.ts:344 于每次
|
|
329
327
|
// adapter 注册/注销提交点 dispatch);@deepseek-ai/dsh-llm 的类型导入已携带该
|
|
330
328
|
// 声明,无需 as never。
|
package/src/health-api.ts
CHANGED
|
@@ -3,7 +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.
|
|
6
|
+
* (0.1.2-alpha.2 基线复核:用户 patch 层热应用机制保留,行为不变。)
|
|
7
7
|
* @module lifeboat/health-api
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
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
|
-
*
|
|
134
|
+
* 原文件 YAML 已坏时不猜测改写,抛错指引人工处理(恢复操作有使用者在场)。
|
|
135
|
+
* @throws 文件不可读/顶层不是数组/YAML 语法错误/落盘失败——调用方负责告警。
|
|
61
136
|
*/
|
|
62
137
|
export async function removeDisable(patchFile: string, targetId: string): Promise<boolean> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
value
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|