@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 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, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
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 { dirname } from "node:path";
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 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml").default(""),
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
- * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
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
- /** 唯一 tmp 名(并发/重入不留共享路径),rename 原子落盘。 */
278
- async function atomicWrite(patchFile, content) {
279
- const tmp = `${patchFile}.lifeboat.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
280
- await writeFile(tmp, content, "utf-8");
281
- await rename(tmp, patchFile);
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
- /** 原子写状态文件:临时文件 + rename,写入失败仅告警不抛出。 */
669
+ /**
670
+ * 原子写状态文件(委托官方 dsh-atomic-write:Windows 瞬态重试 + 0o600 收窄,
671
+ * 父目录自动创建)。写入失败仅告警不抛出(最后防线不得因自身数据问题阻断动作)。
672
+ */
668
673
  async function saveState(doc, warn) {
669
674
  try {
670
- await mkdir(dirname(STATE_FILE), { recursive: true });
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 = config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml");
710
- const quarantine = createQuarantine(ctx, {
711
- patchFile,
712
- alertCooldownMs: config.alertCooldownMs,
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.20",
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.24"
34
+ "@dsh-plus/shared": "0.1.27"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.4",
38
- "@deepseek-ai/dsh-home-paths": "^0.1.7-rc.1",
39
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
40
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
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-home-paths": "0.1.7-rc.1",
46
- "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.1",
47
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
48
- "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
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 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml',
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
- import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
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
- config.patchFile.length > 0
57
- ? config.patchFile
58
- : dshHomePath('profiles', 'web', 'cordis.patch.yml')
59
- const quarantine = createQuarantine(ctx, {
60
- patchFile,
61
- alertCooldownMs: config.alertCooldownMs,
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
- * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
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, rename, writeFile } from 'node:fs/promises'
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
- /** 唯一 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)
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 { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
11
- import { dirname } from 'node:path'
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
- /** 原子写状态文件:临时文件 + rename,写入失败仅告警不抛出。 */
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 mkdir(dirname(STATE_FILE), { recursive: true })
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
  }