dsh-config-manager 0.1.72 → 0.1.76

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.
Files changed (153) hide show
  1. package/README.md +40 -4
  2. package/README.zh-CN.md +40 -4
  3. package/lib/adapters/index.d.ts +1 -0
  4. package/lib/adapters/index.js +5 -0
  5. package/lib/adapters/messages.d.ts +3 -0
  6. package/lib/adapters/messages.js +7 -0
  7. package/lib/adapters/plugin-data.d.ts +114 -0
  8. package/lib/adapters/plugin-data.js +309 -0
  9. package/lib/adapters/self.d.ts +1 -1
  10. package/lib/adapters/self.js +2 -1
  11. package/lib/adapters/test-helpers.d.ts +9 -0
  12. package/lib/adapters/test-helpers.js +14 -0
  13. package/lib/client.d.ts +103 -10
  14. package/lib/client.js +10748 -10119
  15. package/lib/core/analyzer.d.ts +11 -2
  16. package/lib/core/analyzer.js +31 -8
  17. package/lib/core/backup-plan.d.ts +10 -0
  18. package/lib/core/backup-plan.js +21 -3
  19. package/lib/core/backup.d.ts +20 -1
  20. package/lib/core/backup.js +48 -3
  21. package/lib/core/command-rollback.d.ts +6 -2
  22. package/lib/core/command-rollback.js +6 -2
  23. package/lib/core/messages-core.d.ts +2 -0
  24. package/lib/core/messages-core.js +4 -0
  25. package/lib/core/messages.d.ts +2 -1
  26. package/lib/core/messages.js +4 -2
  27. package/lib/core/plugin-data-registry.d.ts +124 -0
  28. package/lib/core/plugin-data-registry.js +198 -0
  29. package/lib/core/rollback-gate.d.ts +33 -0
  30. package/lib/core/rollback-gate.js +57 -0
  31. package/lib/core/rollback-ledger.d.ts +274 -0
  32. package/lib/core/rollback-ledger.js +1400 -0
  33. package/lib/core/rollback-point.d.ts +406 -5
  34. package/lib/core/rollback-point.js +1453 -306
  35. package/lib/core/rollback.js +6 -4
  36. package/lib/core/types.d.ts +19 -0
  37. package/lib/foreign/registry.d.ts +1 -1
  38. package/lib/foreign/registry.js +1 -1
  39. package/lib/index.d.ts +5 -1
  40. package/lib/index.js +83 -13
  41. package/lib/routes/messages.d.ts +1 -0
  42. package/lib/routes/messages.js +2 -0
  43. package/lib/routes/prefs.js +26 -3
  44. package/lib/routes/recovery.d.ts +21 -0
  45. package/lib/routes/recovery.js +127 -7
  46. package/lib/routes/sync.js +11 -1
  47. package/lib/schema/section-registry.d.ts +1 -1
  48. package/lib/schema/section-registry.js +32 -3
  49. package/lib/schema/types.d.ts +1 -1
  50. package/lib/sync/messages.d.ts +1 -0
  51. package/lib/sync/messages.js +3 -0
  52. package/lib/sync/sync-engine.d.ts +21 -7
  53. package/lib/sync/sync-engine.js +108 -18
  54. package/lib/sync/ui-prefs.d.ts +22 -0
  55. package/lib/sync/ui-prefs.js +28 -0
  56. package/lib/ui/diff-view.d.ts +10 -0
  57. package/lib/ui/diff-view.js +17 -0
  58. package/lib/ui/export-flow.js +2 -0
  59. package/lib/ui/i18n.d.ts +5 -3
  60. package/lib/ui/i18n.js +10 -6
  61. package/lib/ui/rollback-view.d.ts +59 -0
  62. package/lib/ui/rollback-view.js +14 -0
  63. package/lib/ui/test-helpers.js +2 -1
  64. package/lib/utils/bundle-scan.d.ts +5 -3
  65. package/lib/utils/bundle-scan.js +5 -3
  66. package/lib/utils/line-diff.d.ts +7 -1
  67. package/lib/utils/line-diff.js +8 -2
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +5 -0
  70. package/src/adapters/messages.ts +7 -0
  71. package/src/adapters/plugin-data.test.ts +225 -0
  72. package/src/adapters/plugin-data.ts +351 -0
  73. package/src/adapters/self.ts +2 -1
  74. package/src/adapters/test-helpers.ts +15 -0
  75. package/src/client/chat/ChatEntryView.tsx +59 -10
  76. package/src/client/chat/RollbackDiffTab.tsx +49 -2
  77. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  78. package/src/client/chat/RollbackReviewDialog.tsx +20 -4
  79. package/src/client/chat/chat-entry-view.test.ts +164 -1
  80. package/src/client/chat/chat-entry-view.ts +127 -10
  81. package/src/client/chat/chat-node-registration.ts +40 -6
  82. package/src/client/chat/chat-point-window.ts +11 -1
  83. package/src/client/chat/rollback-diff-tab.ts +13 -3
  84. package/src/client/chat/rollback-entry-view.ts +5 -0
  85. package/src/client/chat/sidebar-slots.ts +24 -2
  86. package/src/client/common/DiffTable.tsx +54 -29
  87. package/src/client/common/client-route-parity.test.ts +6 -0
  88. package/src/client/common/host-primitives.ts +78 -0
  89. package/src/client/common/routes.ts +10 -0
  90. package/src/client/common/section-labels.test.ts +1 -0
  91. package/src/client/common/section-labels.ts +2 -0
  92. package/src/client/config-manager.module.css +138 -14
  93. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  94. package/src/client/index.ts +54 -2
  95. package/src/client/library/RestorePlanView.tsx +0 -1
  96. package/src/client/locales.ts +8 -6
  97. package/src/client/recovery/RollbackCard.tsx +201 -7
  98. package/src/client/recovery/RollbackDiffPane.tsx +26 -8
  99. package/src/client/recovery/recovery-api.ts +28 -0
  100. package/src/client/recovery/recovery-locales.ts +36 -6
  101. package/src/client/recovery/rollback-enabled.ts +41 -0
  102. package/src/client/recovery/rollback-file-changes.test.ts +77 -0
  103. package/src/client/sync/PushPreviewCard.tsx +57 -0
  104. package/src/client/sync/SyncPanel.tsx +2 -37
  105. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  106. package/src/client/sync/sync-push-preview.test.ts +14 -0
  107. package/src/client/sync/sync-view.test.ts +1 -1
  108. package/src/client/sync/sync-view.ts +9 -1
  109. package/src/core/analyzer.ts +31 -9
  110. package/src/core/backup-plan.test.ts +20 -5
  111. package/src/core/backup-plan.ts +22 -3
  112. package/src/core/backup.ts +50 -4
  113. package/src/core/command-rollback.ts +6 -2
  114. package/src/core/messages-core.ts +4 -0
  115. package/src/core/messages.ts +4 -2
  116. package/src/core/plugin-data-registry.test.ts +113 -0
  117. package/src/core/plugin-data-registry.ts +261 -0
  118. package/src/core/rollback-gate.test.ts +66 -0
  119. package/src/core/rollback-gate.ts +61 -0
  120. package/src/core/rollback-ledger.test.ts +655 -0
  121. package/src/core/rollback-ledger.ts +1430 -0
  122. package/src/core/rollback-point.test.ts +866 -41
  123. package/src/core/rollback-point.ts +1582 -291
  124. package/src/core/rollback.ts +5 -3
  125. package/src/core/types.ts +19 -0
  126. package/src/foreign/registry.test.ts +1 -1
  127. package/src/foreign/registry.ts +1 -1
  128. package/src/index.ts +73 -10
  129. package/src/routes/messages.ts +2 -0
  130. package/src/routes/prefs.ts +31 -2
  131. package/src/routes/recovery.ts +122 -7
  132. package/src/routes/sync.ts +15 -1
  133. package/src/schema/manifest-schema.test.ts +4 -4
  134. package/src/schema/registry.test.ts +9 -2
  135. package/src/schema/section-registry.ts +32 -3
  136. package/src/schema/types.ts +2 -1
  137. package/src/sync/messages.ts +3 -0
  138. package/src/sync/sync-credentials.test.ts +119 -0
  139. package/src/sync/sync-engine.ts +106 -18
  140. package/src/sync/sync-preview-push.test.ts +158 -0
  141. package/src/sync/transport.test.ts +1 -0
  142. package/src/sync/ui-prefs.test.ts +44 -1
  143. package/src/sync/ui-prefs.ts +39 -0
  144. package/src/ui/diff-view.test.ts +9 -1
  145. package/src/ui/diff-view.ts +22 -0
  146. package/src/ui/export-flow.ts +2 -0
  147. package/src/ui/i18n.ts +10 -6
  148. package/src/ui/rollback-view.test.ts +17 -0
  149. package/src/ui/rollback-view.ts +66 -0
  150. package/src/ui/test-helpers.ts +2 -1
  151. package/src/utils/bundle-scan.ts +5 -3
  152. package/src/utils/line-diff.test.ts +12 -0
  153. package/src/utils/line-diff.ts +9 -3
@@ -7,7 +7,7 @@
7
7
  * 如实标记 failed + manualHint(人工补录);
8
8
  * - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
9
9
  */
10
- import { resolveFileTarget } from './backup.ts';
10
+ import { fsForSection, resolveFileTarget } from './backup.ts';
11
11
  import { isPatchLayerRel, USER_PATCH_FILE } from './patch-layers.ts';
12
12
  import { msgOf } from './messages.ts';
13
13
  import type {
@@ -88,13 +88,15 @@ async function compensateOne(
88
88
  }
89
89
  case 'file': {
90
90
  try {
91
+ // 门面按分区选(pluginData / pluginAssets 在系统 home 根;见 backup.ts 的 fsForSection)
92
+ const fsFacade = fsForSection(ctx, entry.adapter);
91
93
  const abs = resolveFileTarget(ctx, entry.adapter, entry.ref);
92
94
  if (entry.existed && entry.copiedTo && store) {
93
95
  const data = await store.readBlob(entry.snapshotId ?? '', entry.copiedTo);
94
- await ctx.fs.writeFile(abs, data);
96
+ await fsFacade.writeFile(abs, data);
95
97
  } else if (!entry.existed) {
96
98
  // 原文件不存在 → 删除导入写入的文件
97
- if (await ctx.fs.exists(abs)) await ctx.fs.remove(abs);
99
+ if (await fsFacade.exists(abs)) await fsFacade.remove(abs);
98
100
  }
99
101
  return null;
100
102
  } catch (err) {
package/src/core/types.ts CHANGED
@@ -525,6 +525,25 @@ export interface HostContext {
525
525
  workspace: WorkspaceFacade;
526
526
  patchFile: PatchFileFacade;
527
527
  fs: FileSystemFacade;
528
+ /**
529
+ * **系统 home 的绝对路径**(宿主解析;缺省 = 进程 `homedir()`)。
530
+ *
531
+ * 为什么需要:一部分第三方插件把数据放在 **$DSH_HOME 之外**(`~/.dsh-wallpaper-engine/`、
532
+ * `~/.mnemon/`、`~/.fastctx/` …),而 `fs` 被**限制在 $DSH_HOME 内**(越界即抛
533
+ * `host.fsPathEscape`)。`pluginData` / `pluginAssets` 两个分区的 ref 因此以**系统 home 为根**
534
+ * (见 `core/backup.ts` 的 `OS_HOME_FILE_SECTIONS`)。
535
+ *
536
+ * 判定纪律:**路径计算**用 `ctx.osHomeDir ?? homedir()`(`~` 的定义就是它,不是猜);
537
+ * **真正的读写**必须经 `homeFs`,缺了它一律显式失败 —— 绝不退回 $DSH_HOME 写错位置。
538
+ */
539
+ osHomeDir?: string;
540
+ /**
541
+ * 以**系统 home 为根**的文件门面(与 `fs` 同一套方法、同样越界即抛;缺省 undefined)。
542
+ *
543
+ * 只允许 `pluginData` / `pluginAssets` 使用;其余分区一律走 `fs`($DSH_HOME 内)。
544
+ * 缺省 = 宿主未提供(旧宿主)→ 这两个分区**不收集也不写入**,由 adapter 如实报码。
545
+ */
546
+ homeFs?: FileSystemFacade;
528
547
  /**
529
548
  * 会话存储端口(issue #45 会话归位;宿主注入对应 `ctx.sessionPersistence`)。
530
549
  *
@@ -207,7 +207,7 @@ test('契约:外来源单元 id → foreign:<source>:<section>:<unit>,且不
207
207
  test('契约:外来源前缀与 SectionId 无重叠(按构造互斥,不是靠约定)', () => {
208
208
  assert.equal(FOREIGN_UNIT_ID_PREFIX, 'foreign');
209
209
  assert.ok(!DSH_UNIT_ID_NAMESPACES.includes(FOREIGN_UNIT_ID_PREFIX));
210
- // 15 个 SectionId 逐个都不能等于 foreign——用真实 DSH 形态反证
210
+ // 每个 SectionId 都不能等于 foreign——用真实 DSH 形态反证
211
211
  for (const { ns } of DSH_UNIT_IDS) {
212
212
  assert.notEqual(ns, FOREIGN_UNIT_ID_PREFIX, ns);
213
213
  }
@@ -336,7 +336,7 @@ export function isForeignUnitId(id: string): boolean {
336
336
  }
337
337
 
338
338
  /**
339
- * DSH 既有单元 id 的命名空间:分区 id(15 个 SectionId)或 plugin/patch/workspace;
339
+ * DSH 既有单元 id 的命名空间:分区 id(17 个 SectionId)或 plugin/patch/workspace;
340
340
  * 外来源形态(首段 = foreign)与无法识别的前缀一律 **null**。
341
341
  *
342
342
  * 两个方向都不误判是这条契约的核心(§8.3):`foreign` 不在 SectionId 词表里、也不等于
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
41
41
  import fs from 'node:fs/promises'
42
42
  import type { IncomingMessage, ServerResponse } from 'node:http'
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
44
+ import { homedir } from 'node:os'
44
45
  import { promisify } from 'node:util'
45
46
 
46
47
  import type { Context } from '@deepseek-ai/cordis'
@@ -78,6 +79,7 @@ import { registerModelTools } from './core/model-tools.ts'
78
79
  import { msgOf, zhMsg } from './core/messages.ts'
79
80
  import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.ts'
80
81
  import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
82
+ import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.ts'
81
83
  import type { RollbackForkCarrier, RollbackRewindResult } from './core/rollback-point.ts'
82
84
  // t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
83
85
  import { agentSessionFactsOf, registerRollbackCommand } from './core/command-rollback.ts'
@@ -122,6 +124,7 @@ import { SyncSessionStore } from './sync/sync-session.ts'
122
124
  import { AutoSyncScheduler } from './sync/autosync-scheduler.ts'
123
125
  import { BackupScheduler } from './sync/backup-scheduler.ts'
124
126
  import { readBackupSchedule } from './sync/backup-schedule-config.ts'
127
+ import { readRollbackEnabledSync } from './sync/ui-prefs.ts'
125
128
  import { AUTO_BACKUP_PREFIX, isValidExportFileName, resolveNonCollidingExportName, writeBackupNote } from './sync/backup-files.ts'
126
129
  import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './sync/retention-policy.ts'
127
130
  import type { RetentionPolicy } from './sync/retention-policy.ts'
@@ -164,6 +167,8 @@ import { parseZip } from './utils/zip.ts'
164
167
  import { isSameOrChild } from './utils/paths.ts'
165
168
  import { isRecord } from './utils/guards.ts'
166
169
  import { createLogger, parseLogLevel, type Logger } from './utils/logger.ts'
170
+ // 与 HTTP 路径**同一份**「运行中对话」判据(命令入口绝不能各写一套)
171
+ import { sessionActiveDenied, sessionActiveGateApplies } from './routes/recovery.ts'
167
172
 
168
173
  /* ---------------------------------------------------------------- identity */
169
174
 
@@ -174,7 +179,7 @@ export const name = 'config-manager'
174
179
  export const inject = ['settings', 'credentials']
175
180
 
176
181
  /** Plugin version, kept in sync with package.json ("version"). */
177
- export const PLUGIN_VERSION = '0.1.72'
182
+ export const PLUGIN_VERSION = '0.1.76'
178
183
 
179
184
  /** Plugin own package name — excluded from its own exported plugins list. */
180
185
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -2006,6 +2011,10 @@ export class ConfigManagerHostContext implements HostContext {
2006
2011
  readonly workspace: WorkspaceFacade
2007
2012
  readonly patchFile: PatchFileFacade
2008
2013
  readonly fs: FileSystemFacade
2014
+ /** 系统 home(第三方插件数据分区 pluginData / pluginAssets 的根;见 HostContext 的说明) */
2015
+ readonly osHomeDir: string
2016
+ /** 以系统 home 为根的文件门面(同样越界即抛;只有上面两个分区能用) */
2017
+ readonly homeFs: FileSystemFacade
2009
2018
  /** 会话存储端口(issue #45 会话归位;对 ctx.sessionPersistence 的薄适配) */
2010
2019
  readonly sessions: SessionStoreFacade
2011
2020
  /**
@@ -2083,6 +2092,10 @@ export class ConfigManagerHostContext implements HostContext {
2083
2092
  this.plugins = new DshPluginsFacade(homeDir, profile, this.patchFile, undefined, this.msg)
2084
2093
  this.workspace = new DshWorkspaceFacade(ctx, this.msg)
2085
2094
  this.fs = new DshFileSystemFacade(homeDir, this.msg)
2095
+ // 系统 home:一部分第三方插件把数据放在 $DSH_HOME 之外(~/.dsh-wallpaper-engine、~/.mnemon …),
2096
+ // 那两个分区必须换根读写;门面本身与 $DSH_HOME 门面同一实现、同样被限制在它自己的根内。
2097
+ this.osHomeDir = homedir()
2098
+ this.homeFs = new DshFileSystemFacade(this.osHomeDir, this.msg)
2086
2099
  this.sessions = new DshSessionStoreFacade(ctx, homeDir, this.msg, this.log)
2087
2100
  // F-2:Cordis 的 fetch 服务(DSH 自己在用它转发 /api)。缺省回落进程全局 fetch(Node ≥18 恒有)。
2088
2101
  const fetchService = readService<unknown>(ctx, 'fetch')
@@ -4299,6 +4312,14 @@ export function apply(ctx: Context, config?: Config): void {
4299
4312
  const tmpDir = join(dataDir, 'tmp')
4300
4313
  const snapshotsDir = join(dataDir, 'snapshots')
4301
4314
  const syncDir = join(dataDir, 'sync')
4315
+ /**
4316
+ * issue #88 / #89:回合自动留点(回滚点)**缺省关闭**。
4317
+ *
4318
+ * 同步读一次偏好(几百字节),因为「关闭」必须在**注册 `session/event` 订阅之前**就定下来 ——
4319
+ * 晚一步注册就等于「订阅了什么都做」(见 src/core/rollback-gate.ts 的三条设计)。
4320
+ * 关闭后本进程不再留点、不再写台账;台账与回退路由本身仍可用(已有回滚点仍能查看 / 回退 / 清理)。
4321
+ */
4322
+ setRollbackEnabled(readRollbackEnabledSync(syncDir))
4302
4323
  const marketDir = join(dataDir, 'market')
4303
4324
  // Phase 6:迁移历史审计目录(统一历史引擎;加入 RESERVED_INTERNAL_PREFIXES 防 F23 投毒链)
4304
4325
  const historyDir = join(dataDir, MIGRATION_HISTORY_DIR)
@@ -4611,9 +4632,10 @@ export function apply(ctx: Context, config?: Config): void {
4611
4632
  }, 'config-manager: profile runtime heartbeat')
4612
4633
  // ------------------------------------------- S1:回合自动留点(宿主真接线)
4613
4634
  //
4614
- // 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed),
4615
- // 每见到一个 `turn/end` 就为**当前会话**留一个回滚点 = 工作区快照(内容寻址入对象仓)
4616
- // + 会话游标(事件数 / fork 边界)。没有手动「拍点」入口 —— 留点完全由回合驱动。
4635
+ // 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed)。
4636
+ // **留点在 `turn/start` 拍**(点的「工作区态」必须是该回合开始**之前**的样子 —— 这条口径不能动,
4637
+ // 见 rollback-point.ts RollbackAutoTracker.noteEvent);`turn/end` 只更新「缺省边界」,不再拍点。
4638
+ // 没有手动「拍点」入口 —— 留点完全由回合驱动。
4617
4639
  //
4618
4640
  // 纪律(AGENTS.md 铁律 + captain 要求):**订阅与每个回调都 try/catch 包裹**,任何异常
4619
4641
  // 只记 warn:改坏宿主启动 = blocker。事件回调用一条串行链喂给留点器,保证同一会话的
@@ -4632,7 +4654,9 @@ export function apply(ctx: Context, config?: Config): void {
4632
4654
  return { ok: false as const, code: 'session-fork-unavailable' as const, detail: 'facade has no forkSession' }
4633
4655
  }
4634
4656
  const result = await forkSession.call(forkPort, sessionId, boundarySeq)
4635
- if ('childSessionId' in result) {
4657
+ // 形状加固:`in` 对 null / 原始值会抛 TypeError —— 端口漂移时应当是**结构化失败**,
4658
+ // 而不是一个没人接的异常(宿主版本换形状时这条最先生效)。
4659
+ if (result !== null && typeof result === 'object' && 'childSessionId' in result) {
4636
4660
  // warning 一路带到回退结果(引擎落成 rollback.guidance.sessionNotAttached):
4637
4661
  // 归位失败**不静默** —— 会话确实回退了,但它会出现在侧栏「未分组」。
4638
4662
  return result.warning !== undefined
@@ -4648,10 +4672,24 @@ export function apply(ctx: Context, config?: Config): void {
4648
4672
  // 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
4649
4673
  // 该点本身仍带 incomplete=true + workspace.reasonCode='no-chunks',界面能如实展示。
4650
4674
  const chunklessLogged = new Set<string>()
4675
+ /** 子代理会话只 warn 一次(见下面 'subagent-session' 分支) */
4676
+ const inertSessionsLogged = new Set<string>()
4651
4677
  const listener = (session: unknown, event: unknown): void => {
4678
+ // issue #88/#89 第一道门:关闭时回调什么都不做。正常路径下关闭时压根没有订阅
4679
+ //(detach 已解绑),这里是「解绑与已派发事件之间」的竞态兜底。
4680
+ if (!isRollbackEnabled()) return
4652
4681
  try {
4653
4682
  const bindingResult = rollbackSessionBindingOf(session)
4654
4683
  if (!bindingResult.ok) {
4684
+ if (bindingResult.code === 'subagent-session') {
4685
+ // 子代理会话按设计不留点:**每个会话只记一次**(否则每个事件刷一行 warn),
4686
+ // 但仍然留痕 —— 不让「这个会话为什么没有点」变成无解释的沉默。
4687
+ if (!inertSessionsLogged.has(bindingResult.detail)) {
4688
+ inertSessionsLogged.add(bindingResult.detail)
4689
+ host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail)
4690
+ }
4691
+ return
4692
+ }
4655
4693
  // 形状不匹配**不静默**:给显式机器码,宿主日志留痕
4656
4694
  host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail)
4657
4695
  return
@@ -4676,13 +4714,32 @@ export function apply(ctx: Context, config?: Config): void {
4676
4714
  }
4677
4715
  }
4678
4716
  let off: (() => boolean) | null = null
4679
- try {
4680
- off = ctx.on('session/event', listener)
4681
- } catch (error) {
4682
- host.log.warn('rollback auto point subscribe failed: ' + (error instanceof Error ? error.message : String(error)))
4717
+ /**
4718
+ * 订阅 / 解绑成对:开关关闭时**真的解绑**(不是「订阅了什么都不做」—— issue #88 明确要求
4719
+ * 「关闭后不再订阅 session/event」)。attach 幂等;订阅抛错只丢这一个入口,不打断宿主启动。
4720
+ */
4721
+ const attach = (): void => {
4722
+ if (off !== null) return
4723
+ try {
4724
+ off = ctx.on('session/event', listener)
4725
+ } catch (error) {
4726
+ off = null
4727
+ host.log.warn('rollback auto point subscribe failed: ' + (error instanceof Error ? error.message : String(error)))
4728
+ }
4729
+ }
4730
+ const detach = (): void => {
4731
+ const current = off
4732
+ off = null
4733
+ if (current === null) return
4734
+ try { current() } catch { /* 解绑失败不影响卸载 */ }
4683
4735
  }
4736
+ if (isRollbackEnabled()) attach()
4737
+ // 活切换:写偏好的一方改开关,这里即时 attach / detach。事件回调**不读文件** ——
4738
+ // 每回合读一次磁盘正是本 issue 要消灭的开销形态。
4739
+ const unlisten = onRollbackEnabledChange((on) => { if (on) attach(); else detach() })
4684
4740
  return () => {
4685
- try { off?.() } catch { /* 解绑失败不影响卸载 */ }
4741
+ unlisten()
4742
+ detach()
4686
4743
  rollbackTracker.stop()
4687
4744
  }
4688
4745
  }, 'config-manager: rollback auto points')
@@ -4725,6 +4782,12 @@ export function apply(ctx: Context, config?: Config): void {
4725
4782
  warnings: preview.warnings,
4726
4783
  } satisfies RollbackRewindResult
4727
4784
  }
4785
+ // ①b 与 HTTP 路径**同一道**「运行中对话」门:命令由会话循环派发、日志刚被 append,
4786
+ // `session.active` 通常为真;缺这道门就会出现「HTTP 会 409 拒绝的『运行中改工作区』
4787
+ // 被命令入口放行」—— 写回的文件随即被运行中的步骤覆盖(撕裂态)。
4788
+ if (preview.session.active === true && sessionActiveGateApplies(preview, true, host.safeModeIsBlocked?.() ?? false)) {
4789
+ return sessionActiveDenied(id, preview)
4790
+ }
4728
4791
  // ② 真执行:userConfirmed **恒 true** —— 用户敲下 /rollback <id> 就是那次确认
4729
4792
  // (命令入口没有第二个确认按钮;其余门全量生效)。
4730
4793
  return await rollbackEngine.rewind({
@@ -56,6 +56,7 @@ export const zh = {
56
56
  'route.recoverySessionLayoutPartial': '会话布局归位未全部完成(成功 {applied},失败 {failed},跳过 {skipped})',
57
57
  'route.rollbackPointRewind': '回滚到回滚点:{outcome}',
58
58
  'route.rollbackPointDelete': '删除回滚点 {id}',
59
+ 'route.rollbackPointCleanup': '清理回滚点台账:删除 {records} 个点',
59
60
  'route.recoveryOperationSummary': '恢复操作 {action}',
60
61
  } as const;
61
62
 
@@ -95,6 +96,7 @@ export const en: Record<keyof typeof zh, string> = {
95
96
  'route.recoverySessionLayoutPartial': 'Session layout relocation did not fully complete ({applied} succeeded, {failed} failed, {skipped} skipped)',
96
97
  'route.rollbackPointRewind': 'Rewind to rollback point: {outcome}',
97
98
  'route.rollbackPointDelete': 'Deleted rollback point {id}',
99
+ 'route.rollbackPointCleanup': 'Cleaned up rollback-point ledger: {records} point(s) deleted',
98
100
  'route.recoveryOperationSummary': 'Recovery operation {action}',
99
101
  };
100
102
 
@@ -7,7 +7,9 @@
7
7
  * path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
8
8
  */
9
9
 
10
+ import type { ServerResponse } from 'node:http'
10
11
  import { endpoint, requireJsonObject, writeJson } from './kit.ts'
12
+ import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
11
13
  import type { WebRoute } from './kit.ts'
12
14
  import type { RoutesEnv } from './context.ts'
13
15
  import { PLUGIN_VERSION, STAR_PROMPT_REPO_URL } from '../index.ts'
@@ -16,10 +18,35 @@ import { UpdateChecker, wantsForcedUpdateCheck } from '../core/update-check.ts'
16
18
 
17
19
  export function prefsRoutes(env: RoutesEnv): WebRoute[] {
18
20
  const {
21
+ host,
19
22
  syncDir,
20
23
  selfUpdate,
21
24
  withMutationGate,
22
25
  } = env
26
+ /**
27
+ * 偏好写必须与其它写路径**同门**(④A):ui-prefs.json 由 star-prompt / release-notes-prompt /
28
+ * 回滚开关三个端点共写。`updateUiPrefs` 虽是 read→merge→write,但两个端点**并发**时仍会互相覆盖
29
+ * 字段(典型:刚打开的 rollbackEnabled 被另一个端点的旧快照抹掉 ⇒ 重启后功能自己关掉,fail-closed)。
30
+ * 被 SAFE MODE / 环境锁挡住时直接回 423 并返回 null(调用方 return,零写入)。
31
+ */
32
+ const writePrefsOrBlocked = async (
33
+ res: ServerResponse,
34
+ patch: Parameters<typeof updateUiPrefs>[1],
35
+ ): Promise<Awaited<ReturnType<typeof updateUiPrefs>> | null> => {
36
+ try {
37
+ return await runWithMutationLock(
38
+ host.mutationLock,
39
+ { op: 'ui-prefs', target: 'star/release-notes', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
40
+ async () => await updateUiPrefs(syncDir, patch),
41
+ )
42
+ } catch (error) {
43
+ if (error instanceof EnvironmentLockUnavailableError) {
44
+ writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
45
+ return null
46
+ }
47
+ throw error
48
+ }
49
+ }
23
50
  // 版本更新检查器:每进程一个(进程内缓存 10 分钟;用户点「重新检查」走 ?force=1)。
24
51
  // 网络失败/超时/响应畸形一律结构化回传(ok:false + 原因),绝不「失败当最新」。
25
52
  const updateChecker = new UpdateChecker(PLUGIN_VERSION)
@@ -58,7 +85,8 @@ export function prefsRoutes(env: RoutesEnv): WebRoute[] {
58
85
  if (body['clicked'] === true) {
59
86
  patch['starPromptClicked'] = true
60
87
  }
61
- const next = await updateUiPrefs(syncDir, patch)
88
+ const next = await writePrefsOrBlocked(res, patch)
89
+ if (next === null) return
62
90
  writeJson(res, 200, {
63
91
  ok: true,
64
92
  firstSeenAt: next.starPromptFirstSeenAt,
@@ -98,7 +126,8 @@ export function prefsRoutes(env: RoutesEnv): WebRoute[] {
98
126
  if (body['dismissed'] === true) {
99
127
  patch['releaseNotesDismissed'] = true
100
128
  }
101
- const next = await updateUiPrefs(syncDir, patch)
129
+ const next = await writePrefsOrBlocked(res, patch)
130
+ if (next === null) return
102
131
  writeJson(res, 200, {
103
132
  ok: true,
104
133
  lastSeenVersion: next.releaseNotesLastSeenVersion,
@@ -12,7 +12,9 @@ import { engineMsg } from './messages.ts'
12
12
  import type { WebRoute } from './kit.ts'
13
13
  import type { RoutesEnv } from './context.ts'
14
14
  import { isValidOperationId } from '../core/journal.ts'
15
- import { RollbackPointEngine } from '../core/rollback-point.ts'
15
+ import { RollbackLedgerRefusedError, RollbackPointEngine } from '../core/rollback-point.ts'
16
+ import { isRollbackEnabled, setRollbackEnabled } from '../core/rollback-gate.ts'
17
+ import { readRollbackEnabledSync, updateUiPrefs } from '../sync/ui-prefs.ts'
16
18
  import type { RollbackForkSeam, RollbackPreviewResult, RollbackRewindInput, RollbackRewindResult } from '../core/rollback-point.ts'
17
19
  import { reconcileForkSessions, type ForkReconcileChild, type ForkReconcileOutcome } from '../core/fork-reconcile.ts'
18
20
  import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
@@ -102,7 +104,7 @@ function rewindTouchesWorkspace(input: { mode?: string; segments?: readonly stri
102
104
  *
103
105
  * 两道门(R1 预检 / R2 锁内二道保险)共用这一份判据,绝不各写一套。
104
106
  */
105
- function sessionActiveGateApplies(
107
+ export function sessionActiveGateApplies(
106
108
  preview: RollbackPreviewResult,
107
109
  userConfirmed: boolean,
108
110
  safeModeBlocked: boolean,
@@ -119,7 +121,7 @@ function sessionActiveGateApplies(
119
121
  * 判据来源只有一个:**preview 已算出的 `session.active`**(引擎按会话日志 mtime 静默窗算)——
120
122
  * 路由**绝不自己 stat 会话日志**(那会变成第二份判据,两处迟早漂移)。
121
123
  */
122
- function sessionActiveDenied(id: string, preview: RollbackPreviewResult): RollbackRewindResult {
124
+ export function sessionActiveDenied(id: string, preview: RollbackPreviewResult): RollbackRewindResult {
123
125
  return {
124
126
  ok: false,
125
127
  outcome: 'denied',
@@ -219,12 +221,19 @@ function forkSeamOf(sessions: unknown): RollbackForkSeam | undefined {
219
221
  if (sessions === null || typeof sessions !== 'object') return undefined
220
222
  const candidate = (sessions as { forkSession?: unknown }).forkSession
221
223
  if (typeof candidate !== 'function') return undefined
222
- const fork = candidate as (sessionId: string, boundarySeq: number) => Promise<{ childSessionId: string } | { error: string }>
224
+ const fork = candidate as (sessionId: string, boundarySeq: number) => Promise<{ childSessionId: string; warning?: string } | { error: string }>
223
225
  return async ({ sessionId, boundarySeq }) => {
224
226
  try {
225
227
  const result = await fork.call(sessions, sessionId, boundarySeq)
226
228
  const childId = result !== null && typeof result === 'object' ? (result as { childSessionId?: unknown }).childSessionId : undefined
227
- if (typeof childId === 'string' && childId !== '') return { ok: true, childSessionId: childId }
229
+ if (typeof childId === 'string' && childId !== '') {
230
+ // 归位失败(宿主三档都没命中)必须一路带到结果里:丢掉它就会出现「HTTP 入口说已归位、
231
+ // /rollback 命令说没归位」的矛盾(子会话实际停在侧栏「未分组」)。
232
+ const warning = (result as { warning?: unknown }).warning
233
+ return typeof warning === 'string' && warning !== ''
234
+ ? { ok: true, childSessionId: childId, warning }
235
+ : { ok: true, childSessionId: childId }
236
+ }
228
237
  const error = result !== null && typeof result === 'object' ? (result as { error?: unknown }).error : undefined
229
238
  return { ok: false, code: 'fork-failed', detail: typeof error === 'string' ? error : 'fork-returned-no-id' }
230
239
  } catch (err) {
@@ -528,6 +537,9 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
528
537
  log: (line) => host.log.warn(line),
529
538
  ...(forkSeam !== undefined ? { fork: forkSeam } : {}),
530
539
  })
540
+ // 插件偏好文件与 /sync/ui-prefs 同一份(dataDir/sync/ui-prefs.json);回滚点开关存在那里,
541
+ // 所以这里派生同一个目录,绝不另立第二个偏好文件。
542
+ const syncDir = join(dataDir, 'sync')
531
543
  const sub = segments.slice(1)
532
544
  const methodNotAllowed = (): void => { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }) }
533
545
  const reply = (value: { ok?: boolean; code?: string; outcome?: string }): void => {
@@ -553,14 +565,117 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
553
565
  * 仍读不到就不加该字段(界面回落显示会话 id),**绝不从首条消息编造标题**。
554
566
  */
555
567
  const titled = await applySessionLogTitles(points, join(sessionHealth.homeDir, 'sessions'))
556
- writeJson(res, listed.storage.available ? 200 : 503, { ...listed, points: titled })
568
+ // 列表响应里的 storage 也带上开关与台账占用:界面**只读这一个响应**就能画开关、
569
+ // 画占用、并决定聊天入口是否收起(少一次请求,也少一处口径分叉)。
570
+ const storageWithPrefs = {
571
+ ...listed.storage,
572
+ rollbackEnabled: readRollbackEnabledSync(syncDir),
573
+ rollbackActive: isRollbackEnabled(),
574
+ }
575
+ writeJson(res, listed.storage.available ? 200 : 503, { ...listed, storage: storageWithPrefs, points: titled })
557
576
  return
558
577
  }
559
578
  // 存储栈状态(缺失时给结构化组合指引;**不崩**)
579
+ // issue #89 P2:随状态一并回传**台账占用**(记录数 / 字节 / 预算 / 已淘汰数,见 engine.storageStatus 的 ledger)
580
+ // 与开关(rollbackEnabled = 磁盘偏好;rollbackActive = 本进程当前是否真的在留点)。
560
581
  if (sub.length === 1 && sub[0] === 'storage') {
561
582
  if (req.method !== 'GET') { methodNotAllowed(); return }
562
583
  const status = await engine.storageStatus()
563
- writeJson(res, status.available ? 200 : 503, { ok: status.available, ...status })
584
+ writeJson(res, status.available ? 200 : 503, {
585
+ ok: status.available,
586
+ rollbackEnabled: readRollbackEnabledSync(syncDir),
587
+ rollbackActive: isRollbackEnabled(),
588
+ ...status,
589
+ })
590
+ return
591
+ }
592
+ // 回合自动留点的总开关(issue #88 / #89)。
593
+ //
594
+ // 为什么要读写两个语义:磁盘偏好(rollbackEnabled)是**用户意图**、随 self 分区进备份;
595
+ // 进程 gate(rollbackActive)是**此刻真正生效**的东西(关闭后订阅已解绑)。两者正常一致,
596
+ // 只在「另一进程改了文件」这类边界上短暂分叉 —— 分歧就如实分开报,不合并成一个布尔。
597
+ //
598
+ // 写路径过 mutation gate:它改的是「宿主会不会每回合写盘」的行为语义,与 rewind / delete 同级
599
+ //(都在同一把锁里,拒绝一律发生在写任何字节之前)。
600
+ if (sub.length === 1 && sub[0] === 'prefs') {
601
+ if (req.method === 'GET') {
602
+ writeJson(res, 200, { ok: true, rollbackEnabled: readRollbackEnabledSync(syncDir), rollbackActive: isRollbackEnabled() })
603
+ return
604
+ }
605
+ if (req.method !== 'POST') { methodNotAllowed(); return }
606
+ try {
607
+ const body = await readJsonBody(req)
608
+ if (body === undefined) { writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' }); return }
609
+ const wanted = body['rollbackEnabled']
610
+ if (typeof wanted !== 'boolean') {
611
+ writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'rollbackEnabled must be a boolean' })
612
+ return
613
+ }
614
+ await runWithMutationLock(
615
+ host.mutationLock,
616
+ { op: 'rollback-prefs', target: 'rollbackEnabled', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
617
+ async () => {
618
+ await updateUiPrefs(syncDir, { rollbackEnabled: wanted })
619
+ // 写盘成功才切进程 gate:写失败却把功能打开,等于「界面说开着、实际不留点」
620
+ setRollbackEnabled(wanted)
621
+ },
622
+ )
623
+ writeJson(res, 200, { ok: true, rollbackEnabled: wanted, rollbackActive: isRollbackEnabled() })
624
+ } catch (error) {
625
+ if (error instanceof EnvironmentLockUnavailableError) {
626
+ host.log.warn('mutation lock blocked: op=rollback-prefs reason=' + error.reason)
627
+ writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
628
+ return
629
+ }
630
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
631
+ }
632
+ return
633
+ }
634
+ // 台账清理(issue #89 P1/P2):把「已经涨上去的占用」收回来。
635
+ //
636
+ // 为什么单独一条子路径而不是并进「磁盘占用清理」:那条清理的候选集被四条不变量钉死
637
+ // (只有可重建区 + 显式勾选的过期导出;snapshots/sync 永不进候选)。回滚点是**数据**不是
638
+ // 可重建区,用户没显式勾选就不能被任何「一键清理」顺走 —— 所以它是自己这条显式入口,
639
+ // 且默认只删自动点(救援点要显式勾选)。语义与单条 delete 一致,只是作用域是整个台账。
640
+ if (sub.length === 1 && sub[0] === 'cleanup') {
641
+ if (req.method !== 'POST') { methodNotAllowed(); return }
642
+ try {
643
+ const body = await readJsonBody(req)
644
+ if (body === undefined) { writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' }); return }
645
+ // 与单条 delete 同一条 fail-closed 确认门:必须**恰好** true,缺省不放行
646
+ if (body['userConfirmed'] !== true) {
647
+ writeJson(res, 409, { ok: false, code: 'confirmation-required' })
648
+ return
649
+ }
650
+ const includeGuards = body['includeGuards'] === true
651
+ const result = await runWithMutationLock(
652
+ host.mutationLock,
653
+ { op: 'rollback-cleanup', target: includeGuards ? 'auto+guard' : 'auto', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
654
+ async () => await engine.purgeLedger({ includeGuards }),
655
+ )
656
+ await tryAppendHistory({
657
+ kind: 'recovery',
658
+ result: 'success',
659
+ sections: [],
660
+ source: 'recovery',
661
+ summary: engineMsg('route.rollbackPointCleanup', { records: String(result.removedRecords) }),
662
+ })
663
+ writeJson(res, 200, { ok: true, ...result })
664
+ } catch (error) {
665
+ if (error instanceof EnvironmentLockUnavailableError) {
666
+ host.log.warn('mutation lock blocked: op=rollback-cleanup reason=' + error.reason)
667
+ writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
668
+ return
669
+ }
670
+ // 引擎侧的**显式拒绝**带机器码(存储不可用 / 台账读不出):按码映射 HTTP,
671
+ // 不回落成泛化 500 —— 界面要能说清「是存储栈缺了」还是「台账真读不出来」。
672
+ if (error instanceof RollbackLedgerRefusedError) {
673
+ host.log.warn('rollback cleanup refused: ' + error.code + ' ' + error.message)
674
+ writeJson(res, error.code === 'storage-unavailable' ? 503 : 500, { ok: false, code: error.code, detail: error.message })
675
+ return
676
+ }
677
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
678
+ }
564
679
  return
565
680
  }
566
681
  // 单条详情(只读:两态摘要 + 完整记录)
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { endpoint, requireJsonObject, readJsonBody, writeJson } from './kit.ts'
9
+ import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
9
10
  import type { WebRoute } from './kit.ts'
10
11
  import type { RoutesEnv } from './context.ts'
11
12
  import { FileSnapshotStore } from '../core/index.ts'
@@ -320,9 +321,22 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
320
321
  const body = await requireJsonObject(req)
321
322
  try {
322
323
  const channel: UiPrefsChannel | undefined = parseSyncChannel(body['lastSyncChannel'])
323
- await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) })
324
+ // ④A:与回滚开关端点**同一道写入门**。ui-prefs.json 由三个端点共写,`updateUiPrefs` 虽是
325
+ // read→merge→write,但两个不同端点并发时仍会互相覆盖字段(例如把刚打开的 rollbackEnabled 抹掉,
326
+ // 重启后功能自己关掉)。同类写串行是既有架构口径。
327
+ await runWithMutationLock(
328
+ host.mutationLock,
329
+ { op: 'ui-prefs', target: 'lastSyncChannel', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
330
+ async () => {
331
+ await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) })
332
+ },
333
+ )
324
334
  writeJson(res, 200, { ok: true, lastSyncChannel: channel })
325
335
  } catch (error) {
336
+ if (error instanceof EnvironmentLockUnavailableError) {
337
+ writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
338
+ return
339
+ }
326
340
  writeSyncRouteError(res, error)
327
341
  }
328
342
  }),
@@ -281,7 +281,7 @@ test('K-04 x-* 扩展键仅作为根级文档说明存在,且分区 id 表与
281
281
  assert.ok(xKeys.includes('x-semantic-constraints'), '应显式记录 JSON Schema 无法表达的语义约束');
282
282
  assert.deepEqual(
283
283
  SCHEMA['x-section-ids'], [...SECTION_IDS],
284
- 'schema 记录的 15 个分区 id 必须与 SECTION_IDS 完全一致(防止分区增删后文档漂移)',
284
+ 'schema 记录的分区 id 必须与 SECTION_IDS 完全一致(防止分区增删后文档漂移)',
285
285
  );
286
286
  assert.equal(SCHEMA['x-format-version'], 'Bundle Format v1');
287
287
  assert.equal(SCHEMA['x-schema-version-constant'], CURRENT_SCHEMA_VERSION);
@@ -322,10 +322,10 @@ test('S-01 真实 buildManifest 产物满足 schema 声明的全部必需字段
322
322
  assert.deepEqual(errorPaths(m), [], '且必须零 validateManifest error');
323
323
  });
324
324
 
325
- test('S-02 sections 恒含全部 15 个已知分区 id(规格 §2.5),值均为布尔', () => {
325
+ test('S-02 sections 恒含全部已知分区 id(规格 §2.5),值均为布尔', () => {
326
326
  const m = baselineManifest();
327
327
  const keys = Object.keys(m.sections).sort();
328
- assert.equal(SECTION_IDS.length, 15, 'SectionId 共 15 个(含无 adapter 的 secrets)');
328
+ assert.equal(SECTION_IDS.length, 17, 'SectionId 共 17 个(含无 adapter 的 secrets)');
329
329
  assert.deepEqual(keys, [...SECTION_IDS].sort(), 'sections 键集合必须等于 SECTION_IDS');
330
330
  for (const key of keys) {
331
331
  assert.equal(typeof m.sections[key as (typeof SECTION_IDS)[number]], 'boolean', `sections.${key} 必须是布尔`);
@@ -333,7 +333,7 @@ test('S-02 sections 恒含全部 15 个已知分区 id(规格 §2.5),值
333
333
  assert.deepEqual(validateAgainstSchema(SCHEMA, m), [], '全量 sections 必须满足 schema');
334
334
  });
335
335
 
336
- test('S-03 全部 15 个分区置 true 的 manifest 仍满足 schema(sections 是开放布尔映射)', () => {
336
+ test('S-03 全部分区置 true 的 manifest 仍满足 schema(sections 是开放布尔映射)', () => {
337
337
  const all = sectionsAllFalse();
338
338
  for (const id of SECTION_IDS) all[id] = true;
339
339
  const m = buildManifest({
@@ -47,6 +47,9 @@ const HISTORICAL_SECTION_IDS: readonly SectionId[] = [
47
47
  'settings', 'ui', 'providers', 'plugins', 'mcp', 'prompts',
48
48
  'skills', 'agentPresets', 'agentInstructions', 'workspaces', 'pluginFiles',
49
49
  'credentialsStatus', 'secrets', 'sessions', 'self',
50
+ // 2026-10-14 追加:第三方插件数据(pluginData 配置类 / pluginAssets 大资产类)——
51
+ // 历史字面量到此为止的部分逐字未改,新分区只允许**追加**在末尾。
52
+ 'pluginData', 'pluginAssets',
50
53
  ];
51
54
  const HISTORICAL_JSON_PATHS: ReadonlyArray<readonly [SectionId, string]> = [
52
55
  ['settings', 'config/settings.json'],
@@ -65,16 +68,18 @@ const HISTORICAL_FILE_PREFIXES: ReadonlyArray<readonly [SectionId, string]> = [
65
68
  ['pluginFiles', 'plugin-files/'],
66
69
  ['sessions', 'sessions/'],
67
70
  ['self', 'self/'],
71
+ ['pluginData', 'plugin-data/'],
72
+ ['pluginAssets', 'plugin-assets/'],
68
73
  ];
69
74
 
70
75
  /* ---------------- 行为等价(顺序 + 内容) ---------------- */
71
76
 
72
77
  test('registry: SECTION_IDS 与历史数组逐项一致(顺序 = applyOrder 升序)', () => {
73
78
  assert.deepEqual([...SECTION_IDS], [...HISTORICAL_SECTION_IDS]);
74
- assert.equal(SECTION_IDS.length, 15);
79
+ assert.equal(SECTION_IDS.length, 17);
75
80
  const orders = SECTION_IDS.map((id) => SECTION_REGISTRY[id].applyOrder);
76
81
  assert.deepEqual(orders, [...orders].sort((a, b) => a - b), 'applyOrder 必须与 SECTION_IDS 顺序一致');
77
- assert.deepEqual(orders, Array.from({ length: 15 }, (_, i) => i + 1), 'applyOrder 连续且从 1 起');
82
+ assert.deepEqual(orders, Array.from({ length: 17 }, (_, i) => i + 1), 'applyOrder 连续且从 1 起');
78
83
  });
79
84
 
80
85
  test('registry: SECTION_JSON_PATHS / SECTION_FILE_PREFIXES 与历史字面量逐项一致(含键顺序)', () => {
@@ -219,6 +224,8 @@ const HISTORICAL_RISK_TIERS: Readonly<Record<string, SectionRiskTier>> = {
219
224
  skills: 'low', agentPresets: 'low', agentInstructions: 'low',
220
225
  pluginFiles: 'high', sessions: 'high', self: 'low',
221
226
  credentialsStatus: 'high', secrets: 'high',
227
+ // 第三方插件数据:内容是任意文件(可能含凭据),且换机后位置可能不存在 → high
228
+ pluginData: 'high', pluginAssets: 'high',
222
229
  };
223
230
 
224
231
  test('registry: 每个分区都声明 riskTier,且与改造前的分级逐项一致(行为等价)', () => {