@mzzsfy/dsh-maintain 0.9.0 → 0.11.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.md CHANGED
@@ -9,8 +9,8 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
9
9
  - 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
10
10
  - 一键安装:面板"安装"按钮全版本状态可点(升级、重装修复、回退均从面板直达,不再有"已是最新"禁用与 409 拒绝),点击弹出确认窗,窗内展示版本变更与将执行的实际命令(`{tag}` 已替换为追踪通道)及"安装完成后自动重启宿主"勾选项(默认勾选,本次安装会话级生效,不持久化),确认后才触发。版本变更按安装意图自动表述:命令模板含精确版本号(如 `@0.1.4`)即按"安装指定版本"表述并附改回 `{tag}` 的指引;否则按通道目标与运行版本分类为升级/重装/回退,信息不足(通道最新版未知或版本无法比较)时窗内明示无法判定,仍可确认安装。通道目标等于运行版本即重装(用于本体损坏或异常安装的修复);需要回退时把模板中的 `@{tag}` 改为指定版本号(如 `@0.1.4`),回退完成后改回 `{tag}` 形态,否则后续安装持续钉在该版本。安装期间串行化(含跨进程锁文件:宿主被外部重启后,残留安装子进程的锁在有效期内仍拒绝新安装),单次尝试超时 10 分钟强杀;安装后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面查看结果并按提示重启生效;继续运行路径下安装完成后自动重新检查版本。
11
11
  - 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
12
- - 安装结果校验:安装命令成功且宿主继续运行(手动直跑、本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
13
- - 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级确认弹窗勾选自动重启且命令成功(退出码 0)时,托管环境下延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启(弹窗内同步提示该环境约束);检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启。
12
+ - 安装结果校验:安装命令成功且宿主继续运行(本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
13
+ - 升级后自动重启:升级确认弹窗勾选自动重启且命令成功(退出码 0)时,延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),动作与运行环境无关:托管启动(pm2 / systemd / 容器等)由进程管理器拉起,手动终端启动则需你重新运行宿主(等同点击重启按钮后的流程)。落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新,手动终端启动下该等待按 30 秒总时长超时转人工(见重启须知)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启;宿主不具备就地退出能力(appExit 缺失)时同样不调度。
14
14
  - 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
15
15
  - 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
16
16
  - 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
@@ -33,7 +33,7 @@ node scripts/dev-link.mjs dsh-maintain # 仓库根执行:归一 profile 依赖
33
33
 
34
34
  工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。勿用 `link:` 或裸相对路径手工挂载——realpath 后 peer 从仓库目录解析会失败(早期"必须手工拷贝部署"结论的成因,对 dev-link junction 不成立,junction 内 peer 沿 profile 解析)。
35
35
 
36
- ## 设置(settings.yaml,热加载)
36
+ ## 设置(配置节,热加载)
37
37
 
38
38
  ```yaml
39
39
  maintain:
@@ -43,7 +43,7 @@ maintain:
43
43
  registryBase: https://registry.npmjs.org # 官方源不可达时可改为镜像地址
44
44
  ```
45
45
 
46
- 升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效 settings.yaml,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
46
+ 配置落在 profile 条目 `maintain` 的 config 节(即上例 settings.yaml 的 `maintain:` 块,经 dsh 配置迁移后为 profile patch 内该条目的 config 键):dsh 0.1.7+ 以插件静态 `Config` 导出生成节表单,四个字段均为 volatile,面板保存经 configEditor 写回、仅 volatile 变化时 loader 原地热更(不重启插件);legacy 宿主(≤0.1.6)经 settings 服务 `register/get/update` 命名空间语义等价存取。升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效配置节,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
47
47
 
48
48
  ## 重启须知
49
49
 
@@ -57,3 +57,7 @@ maintain:
57
57
  ```
58
58
  cd packages/dsh-maintain && npm test
59
59
  ```
60
+
61
+ ## dsh 版本兼容
62
+
63
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:版本与运维页控件完整、检查/升级/重启入口可用、激活 live。配置面双形态特性检测(照 dsh-llm-pi-gateway,不做版本硬编码):0.1.7-rc.1 走静态 Config 导出 + configEditor 写回 + volatile 原地热更,legacy(0.1.2–0.1.6)走 settings 命名空间 register/get/update;宿主 settings/configEditor 方法面缺失时对应能力守卫降级(面板提示),不阻塞页面。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-maintain",
3
3
  "description": "DSH 自身的版本管理:追踪 npm dist-tag 新版本、一键升级、安全重启",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -36,8 +36,7 @@
36
36
  "@mzzsfy/dsh-toast": "^0.1.2"
37
37
  },
38
38
  "peerDependencies": {
39
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2",
40
- "@deepseek-ai/schemastery": ">=3.18.0",
39
+ "@deepseek-ai/schemastery": ">=3.18.4",
41
40
  "react": "^18.2.0"
42
41
  },
43
42
  "files": [
package/src/client.js CHANGED
@@ -160,8 +160,6 @@ function restartPostLost(error) {
160
160
  const VERDICT_OUTDATED = 'outdated'
161
161
  const VERDICT_UP_TO_DATE = 'up-to-date'
162
162
  const VERDICT_UNKNOWN = 'unknown'
163
- // 与 host runtime.mjs RUNTIME_KINDS.MANUAL_START 镜像(parity 对拍,单侧改名即测试失败)
164
- const RUNTIME_KIND_MANUAL = 'manual-start-likely'
165
163
  // 与 host AUTO_RESTART_DELAY_MS 镜像(parity 对拍):弹窗文案引用,勿手抄数字
166
164
  const AUTO_RESTART_DELAY_SEC = 3
167
165
 
@@ -227,8 +225,9 @@ async function recheckUpgradeSettle(previousLast) {
227
225
  if (upgradeWatch.generation !== null) return
228
226
  if (final !== null) broadcastUpgradeStatus(final)
229
227
  const last = final !== null && final.upgrade ? final.upgrade.last : null
230
- if (last !== null && (last.autoRestartScheduled === true || last.requiresManualRestart === true)) showUpgradeFloat(last)
231
- else showUpgradeFloat(previousLast)
228
+ if (last !== null && last.autoRestartScheduled === true) showUpgradeFloat(last)
229
+ // 补查仍无落定结果(观察窗错过且宿主已换代):转状态未知,不再停留进行中文案
230
+ else showUpgradeFloat(previousLast !== null ? previousLast : 'unknown')
232
231
  }
233
232
 
234
233
  function subscribeUpgradeStatus(listener) {
@@ -258,10 +257,11 @@ function ensureUpgradeWatch() {
258
257
  const upgrade = next ? next.upgrade : null
259
258
  if (!upgrade || upgrade.running !== true) {
260
259
  upgradeWatch.generation = null
261
- // 快照归零(last 为空)不做失败渲染,保持进行中文案,由重启流程接管
260
+ // 落定拍(last 在)但未见调度标记不立即终判:继续运行路径的 stale 复读在
261
+ // 落定后异步完成,延迟宽限补查一拍再取终态;快照归零(last 空,宿主已换代)
262
+ // 同样补查,仍无结果即转状态未知,不留永久"进行中"
262
263
  if (upgrade && upgrade.last) {
263
- // 落定拍可能早于宿主置调度标记:未见标记不立即终判,延迟宽限后补查一拍
264
- if (upgrade.last.autoRestartScheduled === true || upgrade.last.requiresManualRestart === true) {
264
+ if (upgrade.last.autoRestartScheduled === true) {
265
265
  showUpgradeFloat(upgrade.last)
266
266
  return
267
267
  }
@@ -269,6 +269,11 @@ function ensureUpgradeWatch() {
269
269
  if (upgradeWatch.generation !== null) return
270
270
  void recheckUpgradeSettle(upgrade.last)
271
271
  }, UPGRADE_AUTO_RESTART_GRACE_MS)
272
+ } else if (upgrade) {
273
+ setTimeout(() => {
274
+ if (upgradeWatch.generation !== null) return
275
+ void recheckUpgradeSettle(null)
276
+ }, UPGRADE_AUTO_RESTART_GRACE_MS)
272
277
  }
273
278
  return
274
279
  }
@@ -306,7 +311,6 @@ function ensureUpgradeFloatStyle() {
306
311
  // LOGIC-BEGIN upgradeFinalText
307
312
  function upgradeFinalText(state) {
308
313
  if (state.stale === true) return '升级命令执行完成,但结果与安装意图不符,详情见版本与运维页'
309
- if (state.requiresManualRestart === true) return '升级完成,当前为手动直跑环境,请手动重启宿主后生效'
310
314
  if (state.autoRestartScheduled === true) return '升级完成,宿主将自动重启,页面恢复后可直接使用'
311
315
  return '升级完成,重启宿主后生效(版本与运维页可重启)'
312
316
  }
@@ -661,14 +665,14 @@ function UpgradeDialog(props) {
661
665
  label: '安装完成后自动重启宿主',
662
666
  checked: autoRestart,
663
667
  onChange: setAutoRestart,
668
+ disabled: status.canRestart !== true,
664
669
  }),
665
670
  h('div', { className: 'dm-dialog__outcome' },
666
- autoRestart
667
- ? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起,本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
668
- : '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
669
- status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
670
- ? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
671
- : null,
671
+ status.canRestart !== true
672
+ ? '当前启动方式不支持就地退出:无论是否勾选,安装完成后都需在宿主侧重启生效。'
673
+ : autoRestart
674
+ ? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出——等同点击重启按钮:托管启动时由进程管理器拉起,手动终端启动时需你重新运行宿主;本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
675
+ : '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
672
676
  ),
673
677
  h('div', { className: 'dm-row' },
674
678
  h('span', { className: 'dm-spacer' }),
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-maintain Host 半区:版本监测 + 一键升级 + 安全重启。
2
2
  // 双端模式照 dsh-usage-panel:webServer 具名路由供浏览器半区调用;
3
- // 设置持久化走 settings 命名空间 maintain,检查结果仅存内存,不落盘。
3
+ // 配置持久化双形态:legacy 走 settings 命名空间 maintain,0.1.7+ 走 profile
4
+ // 条目 config 节(Config 导出 + volatile 热更),检查结果仅存内存。
4
5
 
5
6
  import { readFile } from 'node:fs/promises'
6
7
  import { tmpdir } from 'node:os'
@@ -26,7 +27,6 @@ import {
26
27
  UPGRADE_FAIL_TRANSIENT_NETWORK,
27
28
  } from './core.mjs'
28
29
  import { runUpgrade } from './upgrade.mjs'
29
- import { detectRuntimeEnv, RUNTIME_KINDS } from './runtime.mjs'
30
30
 
31
31
  export const name = 'dsh-maintain'
32
32
 
@@ -208,16 +208,15 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
208
208
  return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
209
209
  }
210
210
 
211
- // 升级后自动重启守卫:四条件缺一不可——成功、非手动直跑、本次勾选 enabled、appExit 可用。
212
- // 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引,
213
- // 该指引是环境约束事实,不受勾选影响;enabled 由升级路由严格 boolean 校验保证
211
+ // 升级后自动重启守卫:三条件缺一不可——成功、本次勾选 enabled、appExit 可用。
212
+ // 宿主退出动作与面板"确认重启"完全同一调度入口,拉起责任归启动方式:
213
+ // 托管环境由进程管理器拉起,手动直跑等同点了重启按钮后自行再启动
214
214
  // 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
215
- export function judgeAutoRestart({ ok, runtimeKind, hasExit, enabled }) {
216
- if (ok !== true) return { schedule: false, requiresManualRestart: false }
217
- if (runtimeKind === RUNTIME_KINDS.MANUAL_START) return { schedule: false, requiresManualRestart: true }
218
- if (enabled !== true) return { schedule: false, requiresManualRestart: false }
219
- if (hasExit !== true) return { schedule: false, requiresManualRestart: false }
220
- return { schedule: true, requiresManualRestart: false }
215
+ export function judgeAutoRestart({ ok, hasExit, enabled }) {
216
+ if (ok !== true) return { schedule: false }
217
+ if (enabled !== true) return { schedule: false }
218
+ if (hasExit !== true) return { schedule: false }
219
+ return { schedule: true }
221
220
  }
222
221
 
223
222
  // 浏览器半区调用的 API 路径清单:client.js 同名常量与之对拍(parity),防单侧改路径生产 404
@@ -255,19 +254,26 @@ function upgradeLockStale(lock) {
255
254
  return lock === null || Date.now() - lock.startedAt > UPGRADE_LOCK_STALE_MS
256
255
  }
257
256
 
258
- const DEFAULT_CHANNEL = 'latest'
259
257
  // 默认值导出仅供 parity 测试作 host 侧锚点;行为入口全部经 readSettings 回落
258
+ export const DEFAULT_CHANNEL = 'latest'
260
259
  export const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
261
260
  export const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g ' + TARGET_PACKAGE + '@' + TAG_PLACEHOLDER
262
261
  export const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
263
262
 
264
263
  // 注册即声明 GUI 设置表单,schema 默认值即生效默认值(rs-workflow-config 先例)。
265
- const SETTINGS_SCHEMA = z.object({
264
+ // 0.1.7 宿主以静态 Config 导出生成节表单;legacy 宿主经 settings.register 注册同名命名空间。
265
+ // volatile 根包装(照 dsh-llm-pi-gateway volatileWrap 同构):0.1.7 下整节为 live 表单,
266
+ // 节写经 loader volatile 快速道原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
267
+ export const Config = volatileWrap(z.object({
266
268
  channel: z.string().default(DEFAULT_CHANNEL).description('追踪通道:npm dist-tag 名(latest/next/alpha 等,以检查返回的通道列表为准)'),
267
269
  pollIntervalSec: z.number().default(DEFAULT_POLL_INTERVAL_SEC).description('轮询间隔秒数,仅正数启用周期检查'),
268
270
  upgradeCommandTemplate: z.string().default(DEFAULT_UPGRADE_TEMPLATE).description('升级命令模板,{tag} 执行时替换为追踪通道,可整体自改为任意命令'),
269
271
  registryBase: z.string().default(DEFAULT_REGISTRY_BASE).description('npm registry 基地址,官方源不可达时改为镜像地址'),
270
- })
272
+ }))
273
+
274
+ function volatileWrap(schema) {
275
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
276
+ }
271
277
 
272
278
  function sendJson(res, status, payload) {
273
279
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
@@ -328,10 +334,17 @@ function readBody(req) {
328
334
  })
329
335
  }
330
336
 
331
- function readSettings(ctx) {
337
+ // volatile ref 动态解包(get 协议,官方 plainOptions 同构);legacy 宿主 config 为普通对象原样透传。
338
+ // Config 为根级 volatile 包装,解析产物是整节单 ref,解包一次即得字段明值
339
+ function unwrapVolatile(value) {
340
+ return typeof value?.get === 'function' ? value.get() : value
341
+ }
342
+
343
+ function readSettings(ctx, config) {
332
344
  const settings = ctx.get('settings')
333
- // 方法面守卫:settings 服务在但缺 get(宿主升级变更面)时回落默认值
334
- const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : undefined
345
+ // 双形态读(照 dsh-llm-pi-gateway,特性检测不硬编码版本):
346
+ // legacy:settings 命名空间 get;0.1.7+:apply 入参 config 整节解包
347
+ const value = typeof settings?.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
335
348
  return {
336
349
  channel: value && typeof value.channel === 'string' && value.channel.trim().length > 0 ? value.channel.trim() : DEFAULT_CHANNEL,
337
350
  pollIntervalSec:
@@ -350,7 +363,32 @@ function readSettings(ctx) {
350
363
  }
351
364
 
352
365
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
353
- export function apply(ctx) {
366
+ export function apply(ctx, config) {
367
+ // 配置面双形态(照 dsh-llm-pi-gateway,特性检测不硬编码版本),判定一律延迟到使用点:
368
+ // settings 服务挂载时序无保证,apply 时刻探测会误判形态。
369
+ // legacy(≤0.1.6):settings 服务 register/get/update 命名空间语义;
370
+ // 0.1.7+:静态 Config 导出生成节表单,读经 volatile ref 解包,写经 configEditor 落 profile 条目
371
+ const legacySettingsFace = () => {
372
+ const settings = ctx.get('settings')
373
+ return typeof settings?.register === 'function' && typeof settings?.update === 'function'
374
+ }
375
+ // 写路径分发。0.1.7:configEditor.edit 的 change 回调返回完整 raw config,
376
+ // 仅 volatile 字段变化时 loader 原地热更不重启插件,apply 无需重入。
377
+ // 服务面缺失发 500 后返回 false,业务异常抛出由路由统一归一 400
378
+ async function persistPatch(res, patch) {
379
+ if (legacySettingsFace()) {
380
+ await ctx.get('settings').update(NAMESPACE, patch)
381
+ return true
382
+ }
383
+ const editor = ctx.get('configEditor')
384
+ const entry = ctx.fiber?.entry
385
+ if (!editor || typeof editor.edit !== 'function' || !entry) {
386
+ sendJson(res, 500, { error: 'configEditor 服务不可用' })
387
+ return false
388
+ }
389
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
390
+ return true
391
+ }
354
392
  // 启动器在挂载前提供 appExit(有界退出,5 秒兜底强制);缺失时重启能力关闭
355
393
  const exit = ctx.get('appExit')
356
394
  if (typeof exit !== 'function') {
@@ -386,16 +424,6 @@ export function apply(ctx) {
386
424
  const runningVersionReady = resolveCurrentHostVersion()
387
425
  .then((version) => { runningVersion = version })
388
426
  .catch(() => {})
389
-
390
- // 运行环境:apply 时一次检测;探测异步,以 ready 链收口,异常回退 unknown(维持自动重启)
391
- let runtimeEnv = { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
392
- const runtimeEnvReady = detectRuntimeEnv({
393
- env: process.env,
394
- platform: process.platform,
395
- isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
396
- })
397
- .then((env) => { runtimeEnv = env })
398
- .catch(() => {})
399
427
  let upgrade = { running: false, last: null }
400
428
  let checkInFlight = null
401
429
  let nextDueAt = null
@@ -407,9 +435,9 @@ export function apply(ctx) {
407
435
  function runCheck() {
408
436
  if (checkInFlight) return checkInFlight
409
437
  checkInFlight = (async () => {
410
- const config = readSettings(ctx)
438
+ const cfg = readSettings(ctx, config)
411
439
  try {
412
- snapshot.tags = await fetchDistTags({ registryBase: config.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
440
+ snapshot.tags = await fetchDistTags({ registryBase: cfg.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
413
441
  snapshot.error = null
414
442
  } catch (error) {
415
443
  // registry 不可达:保留上次 tags,仅记录错误
@@ -432,7 +460,7 @@ export function apply(ctx) {
432
460
  }
433
461
 
434
462
  function scheduleNext() {
435
- const intervalSec = readSettings(ctx).pollIntervalSec
463
+ const intervalSec = readSettings(ctx, config).pollIntervalSec
436
464
  nextDueAt = intervalSec > 0 && intervalSec <= POLL_INTERVAL_MAX_SEC ? Date.now() + intervalSec * 1000 : null
437
465
  }
438
466
 
@@ -456,13 +484,13 @@ export function apply(ctx) {
456
484
 
457
485
  function judgeNow() {
458
486
  // verdict 以运行版本判定:用户关心"跑的是不是最新";已装版本供升级复读校验
459
- return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx).channel })
487
+ return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx, config).channel })
460
488
  }
461
489
 
462
490
  async function currentStatus() {
463
- // 等运行版本首读与运行环境检测落定:apply 即发起,此处仅吸收启动窗口的微小延迟
464
- await Promise.all([runningVersionReady, runtimeEnvReady])
465
- const config = readSettings(ctx)
491
+ // 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
492
+ await runningVersionReady
493
+ const cfg = readSettings(ctx, config)
466
494
  const judged = judgeNow()
467
495
  // running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
468
496
  const lock = upgrade.running === true ? { startedAt: Date.now() } : readUpgradeLock()
@@ -475,10 +503,10 @@ export function apply(ctx) {
475
503
  runningVersion,
476
504
  installedVersion: snapshot.installedVersion,
477
505
  restartPending: isVersionPendingRestart({ runningVersion, installedVersion: snapshot.installedVersion }),
478
- channel: config.channel,
479
- upgradeTemplate: config.upgradeCommandTemplate,
480
- pollIntervalSec: config.pollIntervalSec,
481
- registryBase: config.registryBase,
506
+ channel: cfg.channel,
507
+ upgradeTemplate: cfg.upgradeCommandTemplate,
508
+ pollIntervalSec: cfg.pollIntervalSec,
509
+ registryBase: cfg.registryBase,
482
510
  tags: snapshot.tags,
483
511
  channelLatest: judged.channelLatest,
484
512
  verdict: judged.verdict,
@@ -486,7 +514,6 @@ export function apply(ctx) {
486
514
  checkedAt: snapshot.checkedAt,
487
515
  checkError: snapshot.error,
488
516
  upgrade,
489
- runtimeEnv,
490
517
  autoRestartScheduled,
491
518
  activeWork: collectActiveWork(ctx),
492
519
  canRestart: typeof exit === 'function',
@@ -494,15 +521,15 @@ export function apply(ctx) {
494
521
  }
495
522
 
496
523
  function triggerUpgrade(autoRestart) {
497
- const config = readSettings(ctx)
524
+ const cfg = readSettings(ctx, config)
498
525
  // 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
499
- const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
526
+ const command = buildUpgradeCommand({ template: cfg.upgradeCommandTemplate, tag: cfg.channel })
500
527
  // 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
501
528
  const pinnedVersion = extractPinnedVersion(command)
502
529
  const last = {
503
530
  command,
504
531
  // 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
505
- pinnedVersion: pinnedVersion === config.channel ? null : pinnedVersion,
532
+ pinnedVersion: pinnedVersion === cfg.channel ? null : pinnedVersion,
506
533
  startedAt: Date.now(),
507
534
  ok: false,
508
535
  finishedAt: null,
@@ -523,7 +550,7 @@ export function apply(ctx) {
523
550
  upgrade = { running: true, last }
524
551
  writeUpgradeLock(last.startedAt)
525
552
  audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
526
- void performUpgrade(command, last, config.channel, autoRestart === true)
553
+ void performUpgrade(command, last, cfg.channel, autoRestart === true)
527
554
  }
528
555
 
529
556
  // 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
@@ -592,11 +619,9 @@ export function apply(ctx) {
592
619
  + (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
593
620
  audit('upgrade', last.ok === true ? 'ok' : 'failed',
594
621
  'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
595
- // 关机前置判定只取内存输入(启动期缓存的运行环境 + 本次会话勾选):升级命令刚
622
+ // 关机前置判定只取内存输入(本次会话勾选 + 启动器退出能力):升级命令刚
596
623
  // 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
597
- await runtimeEnvReady
598
- const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
599
- if (decision.requiresManualRestart === true) last.requiresManualRestart = true
624
+ const decision = judgeAutoRestart({ ok: last.ok === true, hasExit: typeof exit === 'function', enabled: autoRestart === true })
600
625
  if (decision.schedule === true) {
601
626
  // 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
602
627
  // last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
@@ -604,7 +629,7 @@ export function apply(ctx) {
604
629
  scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
605
630
  return
606
631
  }
607
- // 继续运行路径(失败/手动直跑/本次未勾选自动重启):复读磁盘版本标 stale,版本未前进提示保留
632
+ // 继续运行路径(失败/本次未勾选自动重启/appExit 缺失):复读磁盘版本标 stale,版本未前进提示保留
608
633
  if (settle !== null && settle.ok) {
609
634
  // 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
610
635
  // 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
@@ -665,12 +690,7 @@ export function apply(ctx) {
665
690
  sendJson(res, 400, { error: '通道 ' + channel + ' 不在当前 dist-tags 中' })
666
691
  return
667
692
  }
668
- const settings = ctx.get('settings')
669
- if (!settings) {
670
- sendJson(res, 500, { error: 'settings 服务不可用' })
671
- return
672
- }
673
- await settings.update(NAMESPACE, { channel })
693
+ if (!(await persistPatch(res, { channel }))) return
674
694
  // 切通道无需重查:dist-tags 与 channel 无关,白名单已用现有 snapshot 校验
675
695
  scheduleNext()
676
696
  sendJson(res, 200, await currentStatus())
@@ -685,12 +705,7 @@ export function apply(ctx) {
685
705
  sendJson(res, 400, { error: '升级命令不能为空' })
686
706
  return
687
707
  }
688
- const settings = ctx.get('settings')
689
- if (!settings) {
690
- sendJson(res, 500, { error: 'settings 服务不可用' })
691
- return
692
- }
693
- await settings.update(NAMESPACE, { upgradeCommandTemplate: template })
708
+ if (!(await persistPatch(res, { upgradeCommandTemplate: template }))) return
694
709
  sendJson(res, 200, await currentStatus())
695
710
  }),
696
711
  },
@@ -709,12 +724,7 @@ export function apply(ctx) {
709
724
  sendJson(res, 400, { error: '轮询间隔不能超过 ' + POLL_INTERVAL_MAX_SEC + ' 秒' })
710
725
  return
711
726
  }
712
- const settings = ctx.get('settings')
713
- if (!settings) {
714
- sendJson(res, 500, { error: 'settings 服务不可用' })
715
- return
716
- }
717
- await settings.update(NAMESPACE, { pollIntervalSec: seconds })
727
+ if (!(await persistPatch(res, { pollIntervalSec: seconds }))) return
718
728
  scheduleNext()
719
729
  sendJson(res, 200, await currentStatus())
720
730
  }),
@@ -728,12 +738,7 @@ export function apply(ctx) {
728
738
  sendJson(res, 400, { error: 'registry 基地址须为 http(s) 地址且不带查询串或锚点' })
729
739
  return
730
740
  }
731
- const settings = ctx.get('settings')
732
- if (!settings) {
733
- sendJson(res, 500, { error: 'settings 服务不可用' })
734
- return
735
- }
736
- await settings.update(NAMESPACE, { registryBase: base })
741
+ if (!(await persistPatch(res, { registryBase: base }))) return
737
742
  if (restartScheduled) {
738
743
  // 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
739
744
  sendJson(res, 200, await currentStatus())
@@ -858,14 +863,16 @@ export function apply(ctx) {
858
863
  }
859
864
 
860
865
  ctx.inject(['settings'], (settingsCtx) => {
861
- // 方法面防御对齐 timer 软依赖:宿主升级变更 settings 服务方法面时干净降级并留痕,
862
- // 不让 fiber 激活即抛错拖垮启动检查与全部写路由
863
- if (!settingsCtx.settings || typeof settingsCtx.settings.register !== 'function' || typeof settingsCtx.settings.update !== 'function') {
864
- console.warn('[dsh-maintain] settings 服务方法面不可用,版本检查停用,通道/设置保存不可用')
865
- return
866
+ // 方法面防御对齐 timer 软依赖:settings 服务缺 register 面(0.1.7 已移除)即走
867
+ // 新形态分支,不在此路径抛错拖垮启动检查
868
+ if (legacySettingsFace()) {
869
+ settingsCtx.settings.register(NAMESPACE, Config)
870
+ } else if (typeof settingsCtx.settings?.configure === 'function') {
871
+ // 0.1.7 形态:原生自动设置页与本包自定义面板重复,特性检测关闭
872
+ // (官方 dsh-agent-default-model 同构)
873
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
866
874
  }
867
- settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
868
- // 启动检查放在 settings 注册之后:命名空间未注册时 readSettings 只能拿默认值,
875
+ // 启动检查在 settings 注册之后:legacy 命名空间未注册时 readSettings 只能拿默认值,
869
876
  // 配置了镜像地址的部署会确定性检查失败
870
877
  runCheck().then(scheduleNext, scheduleNext)
871
878
  })
@@ -138,11 +138,11 @@ test('渲染冒烟: UpgradeDialog 工厂化执行不抛错且含版本变更行'
138
138
  const useState = (init) => [init, () => {}]
139
139
  const Switch = (propsArg) => h('label', propsArg)
140
140
  const dialog = new Function('h', 'useState', 'Switch', 'DEFAULT_UPGRADE_TEMPLATE', 'AUTO_RESTART_DELAY_SEC',
141
- 'RUNTIME_KIND_MANUAL', 'buildInstallChangeLine', 'buildInstallVersionHint',
141
+ 'buildInstallChangeLine', 'buildInstallVersionHint',
142
142
  'return (' + match[0] + ')')(
143
- h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3, 'manual-start-likely',
143
+ h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3,
144
144
  (status, command) => '变更:' + command, (status) => '提示:' + status.runningVersion)
145
- const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5', runtimeEnv: null }
145
+ const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5' }
146
146
  const tree = dialog({ status, onCancel: () => {}, onConfirm: () => {} })
147
147
  assert.equal(tree.type, 'div', '弹窗根为遮罩 div')
148
148
  assert.ok(calls.some((type) => type === 'pre'), '命令 pre 节点应在树中')
@@ -200,13 +200,6 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
200
200
  assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
201
201
  })
202
202
 
203
- // host 侧锚点直接 import runtime.mjs 实现,防测试内手抄字面量漂移假绿
204
- import { RUNTIME_KINDS } from '../src/runtime.mjs'
205
-
206
- test('parity: 手动直跑运行环境常量 client 与 host 一致', () => {
207
- assert.equal(extractConst('RUNTIME_KIND_MANUAL'), RUNTIME_KINDS.MANUAL_START)
208
- })
209
-
210
203
  test('parity: 弹窗文案自动重启延迟秒与 host AUTO_RESTART_DELAY_MS 换算一致', () => {
211
204
  assert.equal(extractNumberConst('AUTO_RESTART_DELAY_SEC'), AUTO_RESTART_DELAY_MS / 1000)
212
205
  })
@@ -255,12 +248,13 @@ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
255
248
  }
256
249
  })
257
250
 
258
- // 窗口关系对拍:重启等待总时长必须大于宿主退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
251
+ // 窗口关系对拍:重启等待总时长必须大于任一退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
259
252
  import { RESTART_DELAY_MS } from '../src/index.js'
260
253
 
261
- test('parity: 重启等待总时长大于宿主退出延迟', () => {
254
+ test('parity: 重启等待总时长大于宿主退出延迟(按钮与自动重启取大)', () => {
262
255
  const restartTimeoutMs = extractNumberConst('RESTART_TIMEOUT_MS')
263
- assert.ok(restartTimeoutMs > RESTART_DELAY_MS, 'RESTART_TIMEOUT_MS 必须大于 RESTART_DELAY_MS')
256
+ assert.ok(restartTimeoutMs > Math.max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS),
257
+ 'RESTART_TIMEOUT_MS 必须大于 max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS)')
264
258
  })
265
259
 
266
260
  test('parity: 升级观察上限覆盖宿主重试链上限(防抢跑转状态未知)', () => {
@@ -286,13 +280,12 @@ test('parity: GitHub releases 兜底链接与 host 拉取仓库同源', () => {
286
280
  assert.equal(extractConst('RELEASES_PAGE_URL'), 'https://github.com/' + RELEASE_REPO + '/releases')
287
281
  })
288
282
 
289
- test('parity: 落定补查宽限覆盖宿主自动重启调度延迟', () => {
290
- // 落定拍与调度置位之间存在宿主侧 await 窗口:client 补查宽限必须不小于调度延迟 + 观察裕量
283
+ test('parity: 落定补查宽限不小于观察轮询拍间隔', () => {
284
+ // 关机路径调度标记与 running=false 同拍置位,宽限不再竞态调度延迟;现职责是
285
+ // 给继续运行路径的 stale 异步复读留余量,下限锁定为不小于轮询拍间隔(补查不早于一拍)
291
286
  const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
292
- assert.ok(
293
- graceMs >= AUTO_RESTART_DELAY_MS + 1 * 1000,
294
- 'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于 AUTO_RESTART_DELAY_MS(' + AUTO_RESTART_DELAY_MS + ') 加观察裕量',
295
- )
287
+ assert.ok(graceMs >= 2 * 1000,
288
+ 'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于观察轮询拍间隔(2s)')
296
289
  })
297
290
 
298
291
  test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
@@ -197,24 +197,23 @@ test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=p
197
197
  assert.match(callSite[1], /readyStreak:\s*prev\.readyStreak/, 'restartTick 调用点缺少 readyStreak: prev.readyStreak 实参')
198
198
  })
199
199
 
200
- // 升级浮条终态文案:ok 落定后 stale / 手动直跑 / 自动重启三条终态分流,失败与未知不在此列
200
+ // 升级浮条终态文案:ok 落定后 stale / 自动重启两条终态分流,失败与未知不在此列
201
201
  const clientUpgradeFinalText = extractLogic('upgradeFinalText')
202
202
 
203
203
  // 重启确认态文案:armed 且有活跃计数时改「仍要重启」
204
204
  const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
205
205
 
206
- test('upgradeFinalText: stale 优先于两种重启指引,不得引导重启', () => {
207
- const text = clientUpgradeFinalText({ ok: true, stale: true, requiresManualRestart: true, autoRestartScheduled: true })
206
+ test('upgradeFinalText: stale 优先于重启指引,不得引导重启', () => {
207
+ const text = clientUpgradeFinalText({ ok: true, stale: true, autoRestartScheduled: true })
208
208
  // stale 口径泛化:未前进/未达目标/未落到目标/未装到指定版本统称与安装意图不符
209
209
  assert.match(text, /意图不符/)
210
210
  assert.doesNotMatch(text, /重启/)
211
211
  })
212
212
 
213
- test('upgradeFinalText: 手动直跑终态指向手动重启,自动重启终态指向页面自恢复', () => {
214
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: true, autoRestartScheduled: false }), /手动重启/)
215
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: true }), /自动重启/)
213
+ test('upgradeFinalText: 自动重启终态指向页面自恢复,默认终态指向手动重启', () => {
214
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: true }), /自动重启/)
216
215
  // 默认成功终态
217
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: false }), /重启宿主/)
216
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: false }), /重启宿主/)
218
217
  })
219
218
 
220
219
  test('restartConfirmLabel: armed 且活跃计数>0 才显示仍要重启文案', () => {
@@ -240,7 +239,7 @@ test('client 自动重启接管:单一守卫函数,订阅回调与初始加载
240
239
 
241
240
  test('client 落定补查:未见自动重启标记时延迟宽限补查一拍再终判', () => {
242
241
  const source = clientSource()
243
- // 落定拍 running 翻转与宿主置调度标记之间存在 await 窗口:未见标记不得立即终判
242
+ // 落定拍可见时继续运行路径的 stale 复读可能尚未完成:未见调度标记不得立即终判
244
243
  assert.match(source, /setTimeout\(\(\)\s*=>\s*\{[\s\S]*?recheckUpgradeSettle\(/, '落定拍未见标记必须延迟补查')
245
244
  assert.match(source, /async function recheckUpgradeSettle\([\s\S]*?broadcastUpgradeStatus\(final\)/, '补查必须广播以驱动接管守卫')
246
245
  assert.match(source, /upgradeWatch\.generation !== null\) return[\s\S]*?recheckUpgradeSettle|recheckUpgradeSettle\([\s\S]*?upgradeWatch\.generation !== null\) return/, '补查前后必须验让位新观察')