@gehennawu/dsh-service 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -9,9 +9,9 @@
9
9
  <em>DeepSeek Harness (DSH) Web 服务控制与运维插件。</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-1.8.0-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-1.9.0-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.1.6--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.1.6--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -94,7 +94,7 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
94
94
  - Uptime, memory, session count, active agents, and background jobs; a "Process and runtime" card shows platform, architecture, and Node version
95
95
  - Full diagnostics: session storage, workspace registry, backup storage, tar availability, file permissions, runtime environment, and Node version — rendered as a two-line check list (name + status dot / detail) with abnormal rows locally emphasized
96
96
  - **Plugin health checks**: checks for anomalies only (the official plugin page already provides the full inventory and toggles, so no duplicate list here) — a failed plugin or one waiting on dependencies marks the check as error/warning, while fresh pending/loading fibers receive a short startup grace period; disposed or unknown states are explicit informational rows and do not become warnings. Affected plugins are listed below the check list with clipped, redacted failure text and missing deps; failed plugins can be reloaded behind a two-step confirmation (only entries the host confirmed as failed); manually disabled plugins (built-in or custom) never count as an anomaly
97
- - **Plugin compatibility**: every enabled plugin is scanned against the interfaces DSH alpha versions have removed or changed (client suppliers, the SQLite persistence backend, chat/status-line style hashes, deprecated attributes) — a hit flags the plugin as "possibly incompatible" with the concrete reason (e.g. "Declares the removed client supplier @deepseek-ai/dsh-client-runtime"). Tiered verdict: only a real `require`/`import` reference is flagged "possibly incompatible"; a supplier that appears only in the manifest (unused in code) falls back to a gray "stale declaration" note — the official loader silently skips missing suppliers, so it is harmless and merely signals the author to clean up. Before upgrading to alpha or right after doing so, you can see whether third-party plugins kept up; fully local scan with cached results, zero network
97
+ - **Plugin compatibility**: every enabled plugin is scanned against the interfaces DSH alpha versions have removed or changed (client suppliers, the SQLite persistence backend, chat/status-line style hashes, deprecated attributes) — a hit flags the plugin as "possibly incompatible" with the concrete reason (e.g. "Declares the removed client supplier @deepseek-ai/dsh-client-runtime"). Tiered verdict: only a real `require`/`import` reference is flagged "possibly incompatible"; a supplier that appears only in the manifest (unused in code) falls back to a gray "stale declaration" note — the official loader silently skips missing suppliers, so it is harmless and merely signals the author to clean up; retired slots (e.g. settings.plugin.item) kept for dual-version compatibility or display-only migrations are downgraded to a blue "retired interface" notice that does not disrupt plugin execution or raise a yellow warning. Before upgrading to alpha or right after doing so, you can see whether third-party plugins kept up; fully local scan with cached results, zero network
98
98
  - File-permission deep scan and repair (two-step confirmation) behind a collapsed "Permissions & repair" section
99
99
  - Suspected manual launch → yellow "no restart assurance" caution; no backups is informational only and never lights the ⚠
100
100
 
@@ -106,6 +106,7 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
106
106
  - Per-model horizontal bars with a "Today / Last 7 days / All time" toggle
107
107
  - Last-24-hour model/tool errors (collapsed by default, rendered only when present)
108
108
  - Steps whose provider reports no token usage are excluded
109
+ - A session that cannot be read, migrated, or parsed no longer blocks other sessions. An all-projects warning shows successful/skipped counts and expandable session IDs, error categories, and safe summaries. Previously indexed data is retained and marked stale; first-time failures contribute nothing and are retried on the next refresh. Only global errors, such as an unavailable service, failed session listing, or failed index write, fail the entire refresh
109
110
 
110
111
  ### Quota lookup
111
112
 
@@ -169,7 +170,7 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
169
170
  - **Mode switching keeps the config** — the saved custom model (including its reasoning effort) and the fallback list survive switching between the three modes; a mode only decides whether the route applies, so switching back to "Custom" needs no re-selection (provider/model missing from the runtime catalog fall back to the first catalog entry)
170
171
  - **No shift on entry** — the page seeds its first frame from the most recent successful read, so it lands directly on the real mode instead of showing "Default" and then jumping to "Custom"; on a fresh install or a different browser (no cache) it briefly shows "Reading configuration…"
171
172
  - **Fallback models (in order)** — both Follow and Custom modes accept an ordered fallback list: when the primary route is unavailable (channel unloaded, or quota state marks it unserviceable), the next model is tried in order; if none works, delegations fall back to native inheritance instead of failing. Fallback entries pass the same allow-list check as the primary route, with an optional reasoning effort per entry
172
- - **Conversation-page visibility** — every turn that spawned subagents shows a small line under its last message listing the models those subagents actually ran on, e.g. `Subagent models: cpa/gpt-5.6-luna (xhigh) · opencode-go/deepseek-v4-flash (max)` — covering fallback hits, explicit routes, and inherited sources, so you can verify the custom route at a glance; a session-level aggregate line also sits under the composer (20s refresh, independent of turn data — when compaction folds the delegation tool calls the official counter and the per-turn line vanish together, and the aggregate line covers from the host dispatch records, visible in any view; switch it off independently from Maintenance → Subagent, keeping only the per-turn line); records live in host memory (survive page reloads, cleared on process restart). Works regardless of the official "assign subagent models" switch
173
+ - **Conversation-page visibility** — a session-level line sits under the composer listing the models your subagents actually ran on, e.g. `Subagents: cpa/gpt-5.6-luna (xhigh) · opencode-go/deepseek-v4-flash (max)` — covering fallback hits, explicit routes, and inherited sources, so you can verify the custom route at a glance. The line is mounted on the official `conversation.composer.dock` slot, occupying its own line right below the official stats instead of sharing their row; 20s refresh, independent of turn data (it keeps showing even when compaction folds the delegation tool calls), covered from the host dispatch records and visible in any view; switch it off independently from Maintenance → Subagent. Records live in host memory (survive page reloads, cleared on process restart). Works regardless of the official "assign subagent models" switch
173
174
  - Config stored in `$DSH_HOME/dsh-service-subagent-route.json` (atomic writes, `0600`); one-click reset
174
175
 
175
176
  ### Task notifications
@@ -189,6 +190,7 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
189
190
  - Sidebar becomes a drawer, details column is hidden on mobile (matching the official narrow-screen behavior), modals become full-screen panels, settings left nav a horizontal top strip
190
191
  - The model picker collapses to an icon on phones (≤480 px, mirroring the official narrow-container form; from 481 px up it still shows the model name and reasoning effort) — the plugin's mobile layout widens the composer column, so the official `@container (width<=360px)` collapse rule never fires on 428~440 px devices; this aligns with the official narrow-container form explicitly
191
192
  - The stats line under the composer ("turns/steps · tok/s | tok · cache hit") **stays on one line and uses the full width** on phones: this row's own horizontal padding is tightened (official 32 px → 2 px) along with its gaps, and both chips share the whole row — zero truncation from ~420 px up, proportionally less text cut than the official fixed truncation below that, with no wrapping and no horizontal scrolling (desktops unaffected)
193
+ - The session header is re-flowed on phones (≤560 px): the title row keeps only the **title + preset chip** (the chip hugs the "…" button on the right, the title stays nearly fully visible), while the "N subagents" and "N background jobs running" counter chips park as a pair on the right of the Conversation/Trajectory tab row (tab gap 36→20, tapping a chip still opens its menu); the official crumbs **hard-clip** is eliminated, and layouts ≥561 px keep the official flow
192
194
  - Scroll immersion: inside a conversation, swiping down auto-hides the header and composer for full-screen reading (the composer also yields its layout space so the transcript really fills the screen; on reveal, if you are still at the end of the conversation it re-aligns to the bottom). Swipe up, tapping the official "Back to bottom" button, or focusing the composer brings them back (no extra floating button); already sitting at the end of the conversation, a single small backward nudge reveals it right away (no full threshold needed), and after a back-to-bottom tap the resting position gets one extra bottom snap. Programmatic scrolling (streaming pinning, anchor jumps) never triggers it
193
195
  - Swipeable drawers: with both drawers closed, a horizontally dominant swipe rightward anywhere opens the sidebar drawer and a swipe leftward anywhere opens the official right sidebar (DSH 0.1.5+; stays inert when unavailable); once open, a swipe in the reverse direction anywhere closes them. Swipes starting at the screen edge additionally benefit from stolen-gesture completion for browser edge navigation, and horizontal scrolling inside editors never misfires
194
196
  - The "Back to bottom" button is shifted flush right on mobile (no more large empty strip); a matching circular up-arrow now sits above it on all platforms, jumping to the previous user message on each click for step-by-step back navigation. Targets outside the loaded history auto-trigger the official "load earlier" action; the button hides once you reach the very top and reappears when you scroll back down
package/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  <em>A service-control &amp; operations plugin for DeepSeek Harness (DSH) Web.</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-1.8.0-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-1.9.0-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.1.6--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.1.6--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -94,7 +94,7 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
94
94
  - 运行时间、内存、会话数、活跃 Agent 与后台任务;「进程与运行环境」卡显示平台、架构与 Node 版本
95
95
  - 完整诊断:会话存储、工作区注册表、备份目录、tar 可用性、文件权限、运行环境与 Node 版本——**两行检查清单**(检查名+状态点 / 详情),异常行局部淡染强调、正常行低对比
96
96
  - **插件健康检查**:只检查异常(官方插件页已有完整清单与开关,这里不做重复清单)——插件失败或依赖未就绪时检查项报错/警告,刚启动的 pending/loading 有短暂宽限;已释放或未知状态显式标为信息级,不误报为警告。检查清单下方列出异常插件(名称、已脱敏且限长的错误、缺失依赖);失败插件可**两段式确认后重新加载**(只作用于宿主已确认失败的条目);手动停用的插件(无论内置还是自定义)一律不算异常
97
- - **插件兼容性**:对照 DSH alpha 版本已移除/变更的接口(客户端供应商、SQLite 持久化后端、聊天/统计条样式哈希、弃用属性)扫描每个启用插件——命中即提示「可能不兼容」并给出具体原因(如「声明了已移除的客户端供应商 @deepseek-ai/dsh-client-runtime」)。分级判定:**代码真实 require/import 才报「可能不兼容」**;仅在 manifest 声明、代码未引用的供应商降为灰色「仅声明残留」(官方加载器对缺失供应商静默跳过,实际无害,提示作者清理即可)。升级 alpha 前或刚升级后看一眼就知道第三方插件有没有跟上;纯本地扫描、结果缓存、零网络
97
+ - **插件兼容性**:对照 DSH alpha 版本已移除/变更的接口(客户端供应商、SQLite 持久化后端、聊天/统计条样式哈希、弃用属性)扫描每个启用插件——命中即提示「可能不兼容」并给出具体原因(如「声明了已移除的客户端供应商 @deepseek-ai/dsh-client-runtime」)。分级判定:**代码真实 require/import 才报「可能不兼容」**;仅在 manifest 声明、代码未引用的供应商降为灰色「仅声明残留」(官方加载器对缺失供应商静默跳过,实际无害,提示作者清理即可);退役槽位(如 settings.plugin.item)等为双版本兼容保留或仅展示位变动的接口降为蓝色「已退役接口」提示,不影响插件运行亦不触发黄色警告。升级 alpha 前或刚升级后看一眼就知道第三方插件有没有跟上;纯本地扫描、结果缓存、零网络
98
98
  - 文件权限深检与修复(两段式确认)收敛在**默认折叠**的「权限与修复」区(有异常时按钮显示计数)
99
99
  - 疑似手动启动 → 黄色警示「重启无保障」;无备份属信息级提示,不点亮 ⚠
100
100
 
@@ -106,6 +106,7 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
106
106
  - 模型明细横条,列表头部「今日 / 近 7 天 / 累计」切换
107
107
  - 最近 24 小时模型 / 工具报错统计(默认折叠、仅非空渲染)
108
108
  - 提供方未上报 token 用量的步骤不纳入统计
109
+ - 单个会话无法读取、迁移或解析时继续统计其他会话,显示全项目成功/跳过数量与可展开的会话 ID、错误类别及安全摘要;失败会话有旧缓存时保留并标明过期,首次失败不计入,下次刷新重试。只有服务不可用、列表读取或索引写入失败等全局错误才让整次刷新失败
109
110
 
110
111
  ### 额度查询
111
112
 
@@ -171,7 +172,7 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
171
172
  - **模式切换保留配置**:已保存的自定义模型(含思考等级)与回退列表在三种模式间切换时不会被清除——模式只决定是否生效,切回「自定义」无需重新选择(供应商/模型已不在运行时清单时自动回落到清单首项)
172
173
  - **进入无位移**:本页以最近一次成功读取的配置作为首帧缓存,进入即直接落在真实模式上,不会先显示「初始」再跳到「自定义」;首次安装或换浏览器(无缓存)时先显示一行「读取配置…」
173
174
  - **回退模型(按顺序)**:跟随与自定义模式都可配置回退列表——第一路由不可用时(渠道已卸载、额度查询判定其不可服务)依次尝试后续模型;全部不可用则回落原生继承,不让派生失败。回退条目与主路由同一道白名单校验,思考等级逐条可选
174
- - **对话页可见性**:每个派生了子代理的回合,最后一条消息下方会显示一行小字列出该回合子代理实际使用的模型,如 `子代理模型:cpa/gpt-5.6-luna (xhigh) · opencode-go/deepseek-v4-flash (max)`——含回退命中、显式路由与继承来源,可直接核对自定义路由是否生效;**同时输入框下方常驻一行本会话累计模型行**(20s 刷新,不依赖回合数据——会话发生 compaction 折叠工具调用后官方计数会消失、回合尾行随之缺席,累计行由宿主派发记录兜底,任何视图都能看到;可在 维护 → 子代理 页用独立开关关闭,仅保留回合尾行);记录存宿主内存(页面刷新不丢、进程重启即清),官方「指派子代理模型」开关关闭与否都不影响本功能
175
+ - **对话页可见性**:输入框下方常驻一行本会话子代理实际使用的模型,如 `子代理:cpa/gpt-5.6-luna (xhigh) · opencode-go/deepseek-v4-flash (max)`——含回退命中、显式路由与继承来源,可直接核对自定义路由是否生效。行挂官方 `conversation.composer.dock` 槽位,独占官方统计行的下一行、不与统计胶囊/上下文圆环同行挤占;20s 刷新,不依赖回合数据(会话发生 compaction 折叠工具调用也照常显示),由宿主派发记录兜底,任何视图都能看到;可在 维护 → 子代理 页用独立开关关闭。记录存宿主内存(页面刷新不丢、进程重启即清),官方「指派子代理模型」开关关闭与否都不影响本功能
175
176
  - 配置存 `$DSH_HOME/dsh-service-subagent-route.json`(原子写入、`0600`),可一键重置
176
177
 
177
178
  ### 任务通知
@@ -204,6 +205,7 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
204
205
  - 侧栏变抽屉、详情列移动端隐藏(对齐官方窄屏语义)、模态变全屏面板、设置左列导航变顶部横滑
205
206
  - 模型选择按钮在手机上收成图标(≤480px,与官方窄容器同形态;481px 以上仍显示模型名与推理等级)——插件移动端把中列拉宽,官方 `@container (width<=360px)` 那条收起规则在 428~440px 机型上永远不会触发,故显式对齐官方窄容器形态
206
207
  - 统计条(输入框下方「轮/步/tok/s | tok/缓存命中」)在手机上**保持一行并把宽度用满**:收紧这一行自身的左右内边距(官方 32px→2px)与列间距,两枚统计按需分宽、共占满整行——≥~420px 视口零截断,更窄视口按比例各让一点(远少于官方的固定截断),不换行、不横向滚动(桌面不受影响)
208
+ - 会话顶栏手机端重排(≤560px):标题行只留**标题 + 模式芯片**(芯片右靠贴住「…」钮,标题基本完整可见),「N 个子代理」与「N 个后台任务运行中」两枚计数芯片泊到「对话/轨迹」标签行右缘成一对(标签间距 36→20,点芯片本体仍开各自菜单);官方 crumbs 放不下时的**拦腰硬裁**彻底消除,561px 以上与桌面保持官方原布局
207
209
  - 滑动沉浸:会话内下滑自动收起头部与输入框全屏阅读(收起时让出输入框占位,正文真正铺满整屏;回显时若仍停在会话末尾会自动对齐到底),上滑 / 点「回到底部」浮钮 / 聚焦输入框即恢复(无额外悬浮钮);已经停在会话末尾时往回一点即回显(不再等满阈值),点回底后停留位置会补一次贴底对齐;流式贴底、锚点跳转等程序化滚动绝不误触发
208
210
  - 滑动开合抽屉:双抽屉关闭时,横移主导的右滑任意位置打开侧栏抽屉、左滑任意位置打开官方右侧栏(DSH 0.1.5+,未就绪时该方向无效);开启后反向横滑任意位置关闭;从屏幕边缘起滑额外享有浏览器手势接管的补完加成,编辑器等真横滚区内的滑动不误触发
209
211
  - 「回到底部」浮钮右移贴边(不再空出大片右侧留白);其上方新增同款圆形上箭头(全平台生效,不含移动端),点击逐条跳转上一条用户回复、可连续向上回溯,目标在未加载历史时会自动点「加载更早」补齐,跳到最顶部后按钮自动隐藏、下滑即复现
@@ -204,6 +204,7 @@ function parseTar(expanded, options = {}) {
204
204
  seen.set(path, type)
205
205
  validateEntry(path, type, payload, state)
206
206
  if (options.collectEntries === true) entries.push({ path, type, data: type === 'file' ? Buffer.from(payload) : undefined })
207
+ else if (options.digestEntries === true) entries.push(type === 'file' ? { path, type, size: headerSize, sha256: createHash('sha256').update(payload).digest('hex') } : { path, type })
207
208
  else entries.push(null)
208
209
  }
209
210
 
@@ -276,6 +277,56 @@ async function hashFile(path) {
276
277
  return hash.digest('hex')
277
278
  }
278
279
 
280
+ // 归档与暂存树逐条目比对(仅 tar 报「读取期间有变化」时启用)。
281
+ // 退出码 1 只说明 tar 认为某个文件不是精确副本、不说明是哪个文件、更不说明归档是否完整;
282
+ // 唯一可靠的裁决是拿归档内容回头核对它打包的那棵树:条目集合、文件大小、文件 sha256
283
+ // 三者全等才算数,任何差异一律判为不完整归档并拒绝发布。
284
+ async function digestTree(root) {
285
+ const files = new Map()
286
+ const dirs = new Set()
287
+ const walk = async (dir, logical) => {
288
+ let entries
289
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch (error) {
290
+ if (error?.code === 'ENOENT') return
291
+ throw error
292
+ }
293
+ for (const entry of entries) {
294
+ const child = join(dir, entry.name)
295
+ const childLogical = logical === '' ? entry.name : `${logical}/${entry.name}`
296
+ const info = await lstat(child)
297
+ if (info.isSymbolicLink()) throw domainError('backup-source-unsafe', childLogical)
298
+ if (info.isDirectory()) {
299
+ dirs.add(childLogical)
300
+ await walk(child, childLogical)
301
+ continue
302
+ }
303
+ if (!info.isFile()) throw domainError('backup-source-unsafe', childLogical)
304
+ files.set(childLogical, { size: info.size, sha256: await hashFile(child) })
305
+ }
306
+ }
307
+ await walk(root, '')
308
+ return { files, dirs }
309
+ }
310
+
311
+ async function compareEntriesToTree(entries, root) {
312
+ const archivedFiles = new Map()
313
+ const archivedDirs = new Set()
314
+ for (const entry of entries) {
315
+ if (entry === null) continue
316
+ if (entry.type === 'directory') archivedDirs.add(entry.path)
317
+ else archivedFiles.set(entry.path, { size: entry.size, sha256: entry.sha256 })
318
+ }
319
+ const tree = await digestTree(root)
320
+ for (const [path, archived] of archivedFiles) {
321
+ const staged = tree.files.get(path)
322
+ if (staged === undefined) throw domainError('backup-archive-incomplete', path)
323
+ if (staged.size !== archived.size || staged.sha256 !== archived.sha256) throw domainError('backup-archive-incomplete', path)
324
+ }
325
+ for (const path of tree.files.keys()) if (!archivedFiles.has(path)) throw domainError('backup-archive-incomplete', path)
326
+ for (const path of archivedDirs) if (!tree.dirs.has(path)) throw domainError('backup-archive-incomplete', path)
327
+ for (const path of tree.dirs) if (!archivedDirs.has(path)) throw domainError('backup-archive-incomplete', path)
328
+ }
329
+
279
330
  async function assertDirectoryOrMissing(path) {
280
331
  try {
281
332
  const info = await lstat(path)
@@ -436,6 +487,19 @@ export function createBackupIntegrity(options) {
436
487
  return (await inspectArchive(source)).report
437
488
  }
438
489
 
490
+ // tar 报「读取期间有变化」时的完整性兜底:解析归档并把它逐条目核对回暂存树。
491
+ // 只有归档是暂存树的完整精确副本时才返回 true;任何缺失、截断或内容差异都判为不可发布。
492
+ async function verifyArchivedTree(source, root) {
493
+ await ensureRecovered()
494
+ const inspected = await inspectArchive(source, { digestEntries: true })
495
+ if (!inspected.report.validForRestore || inspected.parsed === null) {
496
+ const issue = inspected.report.issues?.[0]?.code
497
+ throw domainError(issue || 'backup-archive-invalid')
498
+ }
499
+ await compareEntriesToTree(inspected.parsed.entries, root)
500
+ return true
501
+ }
502
+
439
503
  async function prepareRestore(id) {
440
504
  return withLock(async () => {
441
505
  await ensureRecovered()
@@ -571,7 +635,7 @@ export function createBackupIntegrity(options) {
571
635
  plans.clear()
572
636
  }
573
637
 
574
- return { inspectBackup, prepareRestore, commitRestore, dispose }
638
+ return { inspectBackup, verifyArchivedTree, prepareRestore, commitRestore, dispose }
575
639
  }
576
640
 
577
641
  export { CONFIG_FILES, PLAN_TTL_MS }
@@ -0,0 +1,123 @@
1
+ // 备份与恢复(backup)的 RPC 端点:从 apply 的端点表按功能域拆出。
2
+ // 依赖显式注入(工厂解构名单即本模块完整依赖面);handler 体与拆分前逐字一致。
3
+ // 注意:客户端半有单产物约束,宿主半无此约束——兄弟 ESM 模块是本仓库既有惯例
4
+ // (quota-adapters.js / backup-integrity.js / plugin-health.js / plugin-compat.js)。
5
+
6
+ import { createBackupIntegrity } from './backup-integrity.js'
7
+ import { join } from 'node:path'
8
+
9
+ export function createBackupRoutes({
10
+ ctx,
11
+ backupIntegrity,
12
+ backupProgress,
13
+ clearBackupProgress,
14
+ downloadTokens,
15
+ dshHome,
16
+ setBackupProgress,
17
+ withBackupLock,
18
+ createBackup,
19
+ deleteBackup,
20
+ exportBackup,
21
+ formatBackupTimestamp,
22
+ importBackup,
23
+ listBackups,
24
+ name,
25
+ rpcFailure,
26
+ }) {
27
+ return {
28
+ 'backup-list': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
29
+ try {
30
+ return { ok: true, value: await listBackups(dshHome) }
31
+ } catch (error) {
32
+ return rpcFailure(error)
33
+ }
34
+
35
+ } },
36
+ 'backup-progress': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
37
+ return { ok: true, value: { ...backupProgress } }
38
+
39
+ } },
40
+ 'backup-create': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
41
+ try {
42
+ return { ok: true, value: await withBackupLock(() => {
43
+ const name = `dsh-backup-${formatBackupTimestamp(new Date())}.tar.gz`
44
+ const withValidator = async (source, task) => {
45
+ const validator = createBackupIntegrity({ dshHome, resolveBackup: async () => source })
46
+ try { return await task(validator) } finally { await validator.dispose() }
47
+ }
48
+ return createBackup(ctx, dshHome, join(dshHome, 'backups'), name, async (source) => {
49
+ return withValidator(source, (validator) => validator.inspectBackup(source.id))
50
+ }, setBackupProgress, async (source, root) => {
51
+ return withValidator(source, (validator) => validator.verifyArchivedTree(source, root))
52
+ }).finally(clearBackupProgress)
53
+ }) }
54
+ } catch (error) {
55
+ return rpcFailure(error)
56
+ }
57
+
58
+ } },
59
+ 'backup-export': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
60
+ try {
61
+ const value = await exportBackup(dshHome, downloadTokens, payload?.id)
62
+ if (value === undefined) return rpcFailure(new Error('unknown-backup'))
63
+ return { ok: true, value }
64
+ } catch (error) {
65
+ return rpcFailure(error)
66
+ }
67
+
68
+ } },
69
+ 'backup-delete': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
70
+ try {
71
+ const value = await deleteBackup(dshHome, payload?.id)
72
+ if (value === undefined) return rpcFailure(new Error('unknown-backup'))
73
+ return { ok: true, value }
74
+ } catch (error) {
75
+ return rpcFailure(error)
76
+ }
77
+
78
+ } },
79
+ 'backup-inspect': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
80
+ try {
81
+ const value = await backupIntegrity.inspectBackup(payload?.id)
82
+ if (value === undefined) return rpcFailure(new Error('unknown-backup'))
83
+ return { ok: true, value }
84
+ } catch (error) {
85
+ return rpcFailure(error)
86
+ }
87
+
88
+ } },
89
+ 'backup-restore-prepare': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
90
+ try {
91
+ return { ok: true, value: await backupIntegrity.prepareRestore(payload?.id) }
92
+ } catch (error) {
93
+ return rpcFailure(error)
94
+ }
95
+
96
+ } },
97
+ 'backup-restore-commit': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
98
+ try {
99
+ return { ok: true, value: await backupIntegrity.commitRestore(payload?.planId) }
100
+ } catch (error) {
101
+ return rpcFailure(error)
102
+ }
103
+
104
+ } },
105
+ 'backup-restore': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
106
+ return rpcFailure(new Error('restore-preflight-required'))
107
+
108
+ } },
109
+ 'backup-import': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
110
+ try {
111
+ const value = await importBackup(dshHome, payload?.name, payload?.data, async (source) => {
112
+ const validator = createBackupIntegrity({ dshHome, resolveBackup: async () => source })
113
+ try { return await validator.inspectBackup(source.id) } finally { await validator.dispose() }
114
+ })
115
+ if (value === undefined) return rpcFailure(new Error('invalid-backup'))
116
+ return { ok: true, value }
117
+ } catch (error) {
118
+ return rpcFailure(error)
119
+ }
120
+
121
+ } },
122
+ }
123
+ }