@dsh-plus/lifeboat 0.2.20 → 0.2.22
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.d.ts +9 -1
- package/lib/index.js +47 -31
- package/package.json +12 -9
- package/src/config.ts +1 -1
- package/src/index.ts +36 -18
- package/src/patch-file.ts +10 -7
- package/src/state-file.ts +7 -7
package/lib/index.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ type LifeboatConfig = Schemastery.TypeT<typeof Config>;
|
|
|
18
18
|
//#region src/index.d.ts
|
|
19
19
|
declare const name = "dsh-plus-lifeboat";
|
|
20
20
|
declare const inject: readonly ['settings', 'llm'];
|
|
21
|
+
/**
|
|
22
|
+
* 隔离目标 patch 文件解析:显式配置优先;缺省取当前 profile 的用户 patch 层
|
|
23
|
+
* (`profileContext.patchPath`——CLI 的 web profile 与桌面端的 desktop profile
|
|
24
|
+
* 同源)。两者皆无(非 dsh 启动的裸 cordis 树)返回 undefined:隔离停用,
|
|
25
|
+
* 绝不回退到硬编码 profile 路径——那会把 disabled 覆盖写进别的 profile。
|
|
26
|
+
*/
|
|
27
|
+
/** 解析隔离目标 patch 文件(导出供单测;语义见注释)。 */
|
|
28
|
+
declare function resolvePatchFile(ctx: Context, config: LifeboatConfig): string | undefined;
|
|
21
29
|
declare function apply(ctx: Context, config: LifeboatConfig): void;
|
|
22
30
|
//#endregion
|
|
23
|
-
export { Config, apply, inject, name };
|
|
31
|
+
export { Config, apply, inject, name, resolvePatchFile };
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
2
1
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { copyFile,
|
|
2
|
+
import { copyFile, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
4
|
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
5
5
|
import { isSeq, parseDocument } from "yaml";
|
|
6
|
-
import {
|
|
6
|
+
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
7
7
|
//#region src/config.ts
|
|
8
8
|
/**
|
|
9
9
|
* 配置单一事实源。
|
|
@@ -15,7 +15,7 @@ import { dirname } from "node:path";
|
|
|
15
15
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
16
16
|
const Config = z.object({
|
|
17
17
|
enabled: z.boolean().description("故障隔离总开关").default(true),
|
|
18
|
-
patchFile: z.string().description("隔离写入的 profile 用户 patch
|
|
18
|
+
patchFile: z.string().description("隔离写入的 profile 用户 patch 文件绝对路径;留空自动取当前 profile 的 cordis.patch.yml(profileContext.patchPath,CLI 与桌面端 desktop profile 同源)").default(""),
|
|
19
19
|
llmFallback: z.boolean().description("llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型").default(true),
|
|
20
20
|
alertCooldownMs: z.natural().description("同一插件重复告警的最小间隔毫秒数").default(3e5)
|
|
21
21
|
});
|
|
@@ -239,7 +239,8 @@ function installLlmFallback(ctx, deps, graceMs = BOOT_GRACE_MS) {
|
|
|
239
239
|
/**
|
|
240
240
|
* profile 用户 patch 文件(cordis.patch.yml)的安全写入。
|
|
241
241
|
* 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
|
|
242
|
-
*
|
|
242
|
+
* 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(官方 dsh-atomic-write
|
|
243
|
+
* writeFileAtomic:Windows 瞬态重试 + 0o600),失败抛错由调用方告警,
|
|
243
244
|
* 绝不写半个文件、绝不改动既有条目内容。
|
|
244
245
|
*
|
|
245
246
|
* 2026-08-30 事故教训(保险丝必须先于负载可靠):
|
|
@@ -274,11 +275,12 @@ function withFileLock(file, fn) {
|
|
|
274
275
|
fileLocks.set(file, next.catch(() => {}));
|
|
275
276
|
return next;
|
|
276
277
|
}
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
/**
|
|
279
|
+
* 原子落盘(委托官方 dsh-atomic-write:随机后缀 wx 临时文件、Windows
|
|
280
|
+
* EACCES/EBUSY/EPERM 瞬态重试、0o600 收窄)。失败抛错由调用方告警。
|
|
281
|
+
*/
|
|
282
|
+
function atomicWrite(patchFile, content) {
|
|
283
|
+
return writeFileAtomic(patchFile, content, { mode: 384 });
|
|
282
284
|
}
|
|
283
285
|
/**
|
|
284
286
|
* 容错打捞坏 YAML 文本中既有的顶层 disabled 条目(best-effort):
|
|
@@ -664,13 +666,13 @@ async function loadState() {
|
|
|
664
666
|
};
|
|
665
667
|
}
|
|
666
668
|
}
|
|
667
|
-
/**
|
|
669
|
+
/**
|
|
670
|
+
* 原子写状态文件(委托官方 dsh-atomic-write:Windows 瞬态重试 + 0o600 收窄,
|
|
671
|
+
* 父目录自动创建)。写入失败仅告警不抛出(最后防线不得因自身数据问题阻断动作)。
|
|
672
|
+
*/
|
|
668
673
|
async function saveState(doc, warn) {
|
|
669
674
|
try {
|
|
670
|
-
await
|
|
671
|
-
const tmp = `${STATE_FILE}.tmp`;
|
|
672
|
-
await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf-8");
|
|
673
|
-
await rename(tmp, STATE_FILE);
|
|
675
|
+
await writeFileAtomic(STATE_FILE, `${JSON.stringify(doc, null, 2)}\n`, { mode: 384 });
|
|
674
676
|
} catch (error) {
|
|
675
677
|
warn(`状态文件写入失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
676
678
|
}
|
|
@@ -690,6 +692,17 @@ function appendJournal(doc, kind, detail) {
|
|
|
690
692
|
//#region src/index.ts
|
|
691
693
|
const name = "dsh-plus-lifeboat";
|
|
692
694
|
const inject = ["settings", "llm"];
|
|
695
|
+
/**
|
|
696
|
+
* 隔离目标 patch 文件解析:显式配置优先;缺省取当前 profile 的用户 patch 层
|
|
697
|
+
* (`profileContext.patchPath`——CLI 的 web profile 与桌面端的 desktop profile
|
|
698
|
+
* 同源)。两者皆无(非 dsh 启动的裸 cordis 树)返回 undefined:隔离停用,
|
|
699
|
+
* 绝不回退到硬编码 profile 路径——那会把 disabled 覆盖写进别的 profile。
|
|
700
|
+
*/
|
|
701
|
+
/** 解析隔离目标 patch 文件(导出供单测;语义见注释)。 */
|
|
702
|
+
function resolvePatchFile(ctx, config) {
|
|
703
|
+
if (config.patchFile.length > 0) return config.patchFile;
|
|
704
|
+
return ctx.get("profileContext")?.patchPath;
|
|
705
|
+
}
|
|
693
706
|
function apply(ctx, config) {
|
|
694
707
|
const logger = ctx.logger("lifeboat");
|
|
695
708
|
const warn = (message) => logger.warn(message);
|
|
@@ -706,24 +719,27 @@ function apply(ctx, config) {
|
|
|
706
719
|
};
|
|
707
720
|
const alert = installAlerter(ctx, journal);
|
|
708
721
|
if (config.enabled) {
|
|
709
|
-
const patchFile =
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
journal,
|
|
714
|
-
alert
|
|
715
|
-
});
|
|
716
|
-
installHostWatch(ctx, quarantine);
|
|
717
|
-
ctx.inject(["webServer"], (webCtx) => {
|
|
718
|
-
registerQuarantineApi(webCtx, quarantine);
|
|
719
|
-
registerHealthApi(webCtx, {
|
|
722
|
+
const patchFile = resolvePatchFile(ctx, config);
|
|
723
|
+
if (patchFile === void 0) warn("故障隔离停用:当前组合树无 profileContext,无法定位 profile 用户 patch 层");
|
|
724
|
+
else {
|
|
725
|
+
const quarantine = createQuarantine(ctx, {
|
|
720
726
|
patchFile,
|
|
727
|
+
alertCooldownMs: config.alertCooldownMs,
|
|
721
728
|
journal,
|
|
722
|
-
alert
|
|
723
|
-
readJournal: () => doc.journal,
|
|
724
|
-
readFallback: () => doc.llmFallback
|
|
729
|
+
alert
|
|
725
730
|
});
|
|
726
|
-
|
|
731
|
+
installHostWatch(ctx, quarantine);
|
|
732
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
733
|
+
registerQuarantineApi(webCtx, quarantine);
|
|
734
|
+
registerHealthApi(webCtx, {
|
|
735
|
+
patchFile,
|
|
736
|
+
journal,
|
|
737
|
+
alert,
|
|
738
|
+
readJournal: () => doc.journal,
|
|
739
|
+
readFallback: () => doc.llmFallback
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
}
|
|
727
743
|
}
|
|
728
744
|
if (config.llmFallback) installLlmFallback(ctx, {
|
|
729
745
|
journal,
|
|
@@ -739,4 +755,4 @@ function apply(ctx, config) {
|
|
|
739
755
|
});
|
|
740
756
|
}
|
|
741
757
|
//#endregion
|
|
742
|
-
export { Config, apply, inject, name };
|
|
758
|
+
export { Config, apply, inject, name, resolvePatchFile };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/lifeboat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -31,21 +31,24 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"yaml": "^2.9.0",
|
|
34
|
-
"@dsh-plus/shared": "0.1.
|
|
34
|
+
"@dsh-plus/shared": "0.1.27"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
38
|
-
"@deepseek-ai/dsh-
|
|
39
|
-
"@deepseek-ai/dsh-
|
|
40
|
-
"@deepseek-ai/dsh-
|
|
38
|
+
"@deepseek-ai/dsh-atomic-write": "^0.2.0-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
|
|
41
42
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@deepseek-ai/cordis": "4.0.4",
|
|
45
|
-
"@deepseek-ai/dsh-
|
|
46
|
-
"@deepseek-ai/dsh-
|
|
47
|
-
"@deepseek-ai/dsh-
|
|
48
|
-
"@deepseek-ai/dsh-
|
|
46
|
+
"@deepseek-ai/dsh-atomic-write": "0.2.0-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-app-boot": "0.2.0-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
49
52
|
"@deepseek-ai/schemastery": "3.18.4",
|
|
50
53
|
"@types/node": "26.4.0",
|
|
51
54
|
"@types/react": "~18.3.31",
|
package/src/config.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const Config = z.object({
|
|
|
14
14
|
patchFile: z
|
|
15
15
|
.string()
|
|
16
16
|
.description(
|
|
17
|
-
'隔离写入的 profile 用户 patch
|
|
17
|
+
'隔离写入的 profile 用户 patch 文件绝对路径;留空自动取当前 profile 的 cordis.patch.yml(profileContext.patchPath,CLI 与桌面端 desktop profile 同源)',
|
|
18
18
|
)
|
|
19
19
|
.default(''),
|
|
20
20
|
llmFallback: z
|
package/src/index.ts
CHANGED
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
* 接线约束:inject 声明须覆盖本模块全部 ctx.<service> 直接访问(fallback-llm
|
|
15
15
|
* 读 settings 命名空间与 llm.listProviders),漏掉任一即运行期
|
|
16
16
|
* "cannot get property without inject" → 插件树加载失败 → dsh boot 中止。
|
|
17
|
+
* 可选事实(profileContext)经 ctx.get 读取,缺失即降级而非失败。
|
|
17
18
|
* @module @dsh-plus/lifeboat
|
|
18
19
|
*/
|
|
19
20
|
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
-
|
|
21
|
+
// 平台类型面:profileContext(当前 profile 的 dir/patchPath/name 等运行期事实,
|
|
22
|
+
// CLI 与桌面端经同一 profile-boot 提供)。纯类型导入,按开发规范仅需 devDeps。
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-app-boot'
|
|
21
24
|
|
|
22
25
|
import { Config, type FallbackStateT, type LifeboatConfig } from './config.ts'
|
|
23
26
|
import { installLlmFallback } from './fallback-llm.ts'
|
|
@@ -33,6 +36,18 @@ export const inject = ['settings', 'llm'] as const
|
|
|
33
36
|
|
|
34
37
|
export { Config }
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 隔离目标 patch 文件解析:显式配置优先;缺省取当前 profile 的用户 patch 层
|
|
41
|
+
* (`profileContext.patchPath`——CLI 的 web profile 与桌面端的 desktop profile
|
|
42
|
+
* 同源)。两者皆无(非 dsh 启动的裸 cordis 树)返回 undefined:隔离停用,
|
|
43
|
+
* 绝不回退到硬编码 profile 路径——那会把 disabled 覆盖写进别的 profile。
|
|
44
|
+
*/
|
|
45
|
+
/** 解析隔离目标 patch 文件(导出供单测;语义见注释)。 */
|
|
46
|
+
export function resolvePatchFile(ctx: Context, config: LifeboatConfig): string | undefined {
|
|
47
|
+
if (config.patchFile.length > 0) return config.patchFile
|
|
48
|
+
return ctx.get('profileContext')?.patchPath
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
37
52
|
const logger = ctx.logger('lifeboat')
|
|
38
53
|
|
|
@@ -52,27 +67,30 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
|
52
67
|
const alert = installAlerter(ctx, journal)
|
|
53
68
|
|
|
54
69
|
if (config.enabled) {
|
|
55
|
-
const patchFile =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
journal,
|
|
63
|
-
alert,
|
|
64
|
-
})
|
|
65
|
-
installHostWatch(ctx, quarantine)
|
|
66
|
-
ctx.inject(['webServer'], (webCtx) => {
|
|
67
|
-
registerQuarantineApi(webCtx, quarantine)
|
|
68
|
-
registerHealthApi(webCtx, {
|
|
70
|
+
const patchFile = resolvePatchFile(ctx, config)
|
|
71
|
+
if (patchFile === undefined) {
|
|
72
|
+
// 无 profileContext 的组合树(非 dsh 启动):不猜路径、不写任何 patch,
|
|
73
|
+
// 隔离停用但告警/journal/LLM 应急翻译照常(它们不依赖 patch 文件)。
|
|
74
|
+
warn('故障隔离停用:当前组合树无 profileContext,无法定位 profile 用户 patch 层')
|
|
75
|
+
} else {
|
|
76
|
+
const quarantine = createQuarantine(ctx, {
|
|
69
77
|
patchFile,
|
|
78
|
+
alertCooldownMs: config.alertCooldownMs,
|
|
70
79
|
journal,
|
|
71
80
|
alert,
|
|
72
|
-
readJournal: () => doc.journal,
|
|
73
|
-
readFallback: () => doc.llmFallback,
|
|
74
81
|
})
|
|
75
|
-
|
|
82
|
+
installHostWatch(ctx, quarantine)
|
|
83
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
84
|
+
registerQuarantineApi(webCtx, quarantine)
|
|
85
|
+
registerHealthApi(webCtx, {
|
|
86
|
+
patchFile,
|
|
87
|
+
journal,
|
|
88
|
+
alert,
|
|
89
|
+
readJournal: () => doc.journal,
|
|
90
|
+
readFallback: () => doc.llmFallback,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
if (config.llmFallback) {
|
package/src/patch-file.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* profile 用户 patch 文件(cordis.patch.yml)的安全写入。
|
|
3
3
|
* 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
|
|
4
|
-
*
|
|
4
|
+
* 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(官方 dsh-atomic-write
|
|
5
|
+
* writeFileAtomic:Windows 瞬态重试 + 0o600),失败抛错由调用方告警,
|
|
5
6
|
* 绝不写半个文件、绝不改动既有条目内容。
|
|
6
7
|
*
|
|
7
8
|
* 2026-08-30 事故教训(保险丝必须先于负载可靠):
|
|
@@ -15,8 +16,9 @@
|
|
|
15
16
|
* 隔离落盘优先于保留坏文件。
|
|
16
17
|
* @module lifeboat/patch-file
|
|
17
18
|
*/
|
|
18
|
-
import { copyFile, readFile
|
|
19
|
+
import { copyFile, readFile } from 'node:fs/promises'
|
|
19
20
|
|
|
21
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
20
22
|
import { isSeq, parseDocument } from 'yaml'
|
|
21
23
|
|
|
22
24
|
/** patch 文件顶层条目(松散视图:只关心 id/disabled/insert 三个键)。 */
|
|
@@ -58,11 +60,12 @@ function withFileLock<T>(file: string, fn: () => Promise<T>): Promise<T> {
|
|
|
58
60
|
return next
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
/**
|
|
64
|
+
* 原子落盘(委托官方 dsh-atomic-write:随机后缀 wx 临时文件、Windows
|
|
65
|
+
* EACCES/EBUSY/EPERM 瞬态重试、0o600 收窄)。失败抛错由调用方告警。
|
|
66
|
+
*/
|
|
67
|
+
function atomicWrite(patchFile: string, content: string): Promise<void> {
|
|
68
|
+
return writeFileAtomic(patchFile, content, { mode: 0o600 })
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
package/src/state-file.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* @module lifeboat/state-file
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
12
12
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
13
13
|
import type { FallbackStateT, JournalEntryT } from './config.ts'
|
|
14
14
|
|
|
@@ -73,13 +73,13 @@ export async function loadState(): Promise<StateDoc> {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* 原子写状态文件(委托官方 dsh-atomic-write:Windows 瞬态重试 + 0o600 收窄,
|
|
78
|
+
* 父目录自动创建)。写入失败仅告警不抛出(最后防线不得因自身数据问题阻断动作)。
|
|
79
|
+
*/
|
|
77
80
|
export async function saveState(doc: StateDoc, warn: (message: string) => void): Promise<void> {
|
|
78
81
|
try {
|
|
79
|
-
await
|
|
80
|
-
const tmp = `${STATE_FILE}.tmp`
|
|
81
|
-
await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8')
|
|
82
|
-
await rename(tmp, STATE_FILE)
|
|
82
|
+
await writeFileAtomic(STATE_FILE, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 })
|
|
83
83
|
} catch (error) {
|
|
84
84
|
warn(`状态文件写入失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
85
85
|
}
|