@mzzsfy/dsh-maintain 0.10.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
@@ -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
 
@@ -60,4 +60,4 @@ cd packages/dsh-maintain && npm test
60
60
 
61
61
  ## dsh 版本兼容
62
62
 
63
- 三版本全部通过:版本与运维页控件完整、检查/升级/重启入口可用、激活 live。
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.10.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/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'
@@ -253,19 +254,26 @@ function upgradeLockStale(lock) {
253
254
  return lock === null || Date.now() - lock.startedAt > UPGRADE_LOCK_STALE_MS
254
255
  }
255
256
 
256
- const DEFAULT_CHANNEL = 'latest'
257
257
  // 默认值导出仅供 parity 测试作 host 侧锚点;行为入口全部经 readSettings 回落
258
+ export const DEFAULT_CHANNEL = 'latest'
258
259
  export const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
259
260
  export const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g ' + TARGET_PACKAGE + '@' + TAG_PLACEHOLDER
260
261
  export const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
261
262
 
262
263
  // 注册即声明 GUI 设置表单,schema 默认值即生效默认值(rs-workflow-config 先例)。
263
- 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({
264
268
  channel: z.string().default(DEFAULT_CHANNEL).description('追踪通道:npm dist-tag 名(latest/next/alpha 等,以检查返回的通道列表为准)'),
265
269
  pollIntervalSec: z.number().default(DEFAULT_POLL_INTERVAL_SEC).description('轮询间隔秒数,仅正数启用周期检查'),
266
270
  upgradeCommandTemplate: z.string().default(DEFAULT_UPGRADE_TEMPLATE).description('升级命令模板,{tag} 执行时替换为追踪通道,可整体自改为任意命令'),
267
271
  registryBase: z.string().default(DEFAULT_REGISTRY_BASE).description('npm registry 基地址,官方源不可达时改为镜像地址'),
268
- })
272
+ }))
273
+
274
+ function volatileWrap(schema) {
275
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
276
+ }
269
277
 
270
278
  function sendJson(res, status, payload) {
271
279
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
@@ -326,10 +334,17 @@ function readBody(req) {
326
334
  })
327
335
  }
328
336
 
329
- 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) {
330
344
  const settings = ctx.get('settings')
331
- // 方法面守卫:settings 服务在但缺 get(宿主升级变更面)时回落默认值
332
- 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)
333
348
  return {
334
349
  channel: value && typeof value.channel === 'string' && value.channel.trim().length > 0 ? value.channel.trim() : DEFAULT_CHANNEL,
335
350
  pollIntervalSec:
@@ -348,7 +363,32 @@ function readSettings(ctx) {
348
363
  }
349
364
 
350
365
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
351
- 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
+ }
352
392
  // 启动器在挂载前提供 appExit(有界退出,5 秒兜底强制);缺失时重启能力关闭
353
393
  const exit = ctx.get('appExit')
354
394
  if (typeof exit !== 'function') {
@@ -395,9 +435,9 @@ export function apply(ctx) {
395
435
  function runCheck() {
396
436
  if (checkInFlight) return checkInFlight
397
437
  checkInFlight = (async () => {
398
- const config = readSettings(ctx)
438
+ const cfg = readSettings(ctx, config)
399
439
  try {
400
- snapshot.tags = await fetchDistTags({ registryBase: config.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
440
+ snapshot.tags = await fetchDistTags({ registryBase: cfg.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
401
441
  snapshot.error = null
402
442
  } catch (error) {
403
443
  // registry 不可达:保留上次 tags,仅记录错误
@@ -420,7 +460,7 @@ export function apply(ctx) {
420
460
  }
421
461
 
422
462
  function scheduleNext() {
423
- const intervalSec = readSettings(ctx).pollIntervalSec
463
+ const intervalSec = readSettings(ctx, config).pollIntervalSec
424
464
  nextDueAt = intervalSec > 0 && intervalSec <= POLL_INTERVAL_MAX_SEC ? Date.now() + intervalSec * 1000 : null
425
465
  }
426
466
 
@@ -444,13 +484,13 @@ export function apply(ctx) {
444
484
 
445
485
  function judgeNow() {
446
486
  // verdict 以运行版本判定:用户关心"跑的是不是最新";已装版本供升级复读校验
447
- return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx).channel })
487
+ return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx, config).channel })
448
488
  }
449
489
 
450
490
  async function currentStatus() {
451
491
  // 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
452
492
  await runningVersionReady
453
- const config = readSettings(ctx)
493
+ const cfg = readSettings(ctx, config)
454
494
  const judged = judgeNow()
455
495
  // running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
456
496
  const lock = upgrade.running === true ? { startedAt: Date.now() } : readUpgradeLock()
@@ -463,10 +503,10 @@ export function apply(ctx) {
463
503
  runningVersion,
464
504
  installedVersion: snapshot.installedVersion,
465
505
  restartPending: isVersionPendingRestart({ runningVersion, installedVersion: snapshot.installedVersion }),
466
- channel: config.channel,
467
- upgradeTemplate: config.upgradeCommandTemplate,
468
- pollIntervalSec: config.pollIntervalSec,
469
- registryBase: config.registryBase,
506
+ channel: cfg.channel,
507
+ upgradeTemplate: cfg.upgradeCommandTemplate,
508
+ pollIntervalSec: cfg.pollIntervalSec,
509
+ registryBase: cfg.registryBase,
470
510
  tags: snapshot.tags,
471
511
  channelLatest: judged.channelLatest,
472
512
  verdict: judged.verdict,
@@ -481,15 +521,15 @@ export function apply(ctx) {
481
521
  }
482
522
 
483
523
  function triggerUpgrade(autoRestart) {
484
- const config = readSettings(ctx)
524
+ const cfg = readSettings(ctx, config)
485
525
  // 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
486
- const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
526
+ const command = buildUpgradeCommand({ template: cfg.upgradeCommandTemplate, tag: cfg.channel })
487
527
  // 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
488
528
  const pinnedVersion = extractPinnedVersion(command)
489
529
  const last = {
490
530
  command,
491
531
  // 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
492
- pinnedVersion: pinnedVersion === config.channel ? null : pinnedVersion,
532
+ pinnedVersion: pinnedVersion === cfg.channel ? null : pinnedVersion,
493
533
  startedAt: Date.now(),
494
534
  ok: false,
495
535
  finishedAt: null,
@@ -510,7 +550,7 @@ export function apply(ctx) {
510
550
  upgrade = { running: true, last }
511
551
  writeUpgradeLock(last.startedAt)
512
552
  audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
513
- void performUpgrade(command, last, config.channel, autoRestart === true)
553
+ void performUpgrade(command, last, cfg.channel, autoRestart === true)
514
554
  }
515
555
 
516
556
  // 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
@@ -650,12 +690,7 @@ export function apply(ctx) {
650
690
  sendJson(res, 400, { error: '通道 ' + channel + ' 不在当前 dist-tags 中' })
651
691
  return
652
692
  }
653
- const settings = ctx.get('settings')
654
- if (!settings) {
655
- sendJson(res, 500, { error: 'settings 服务不可用' })
656
- return
657
- }
658
- await settings.update(NAMESPACE, { channel })
693
+ if (!(await persistPatch(res, { channel }))) return
659
694
  // 切通道无需重查:dist-tags 与 channel 无关,白名单已用现有 snapshot 校验
660
695
  scheduleNext()
661
696
  sendJson(res, 200, await currentStatus())
@@ -670,12 +705,7 @@ export function apply(ctx) {
670
705
  sendJson(res, 400, { error: '升级命令不能为空' })
671
706
  return
672
707
  }
673
- const settings = ctx.get('settings')
674
- if (!settings) {
675
- sendJson(res, 500, { error: 'settings 服务不可用' })
676
- return
677
- }
678
- await settings.update(NAMESPACE, { upgradeCommandTemplate: template })
708
+ if (!(await persistPatch(res, { upgradeCommandTemplate: template }))) return
679
709
  sendJson(res, 200, await currentStatus())
680
710
  }),
681
711
  },
@@ -694,12 +724,7 @@ export function apply(ctx) {
694
724
  sendJson(res, 400, { error: '轮询间隔不能超过 ' + POLL_INTERVAL_MAX_SEC + ' 秒' })
695
725
  return
696
726
  }
697
- const settings = ctx.get('settings')
698
- if (!settings) {
699
- sendJson(res, 500, { error: 'settings 服务不可用' })
700
- return
701
- }
702
- await settings.update(NAMESPACE, { pollIntervalSec: seconds })
727
+ if (!(await persistPatch(res, { pollIntervalSec: seconds }))) return
703
728
  scheduleNext()
704
729
  sendJson(res, 200, await currentStatus())
705
730
  }),
@@ -713,12 +738,7 @@ export function apply(ctx) {
713
738
  sendJson(res, 400, { error: 'registry 基地址须为 http(s) 地址且不带查询串或锚点' })
714
739
  return
715
740
  }
716
- const settings = ctx.get('settings')
717
- if (!settings) {
718
- sendJson(res, 500, { error: 'settings 服务不可用' })
719
- return
720
- }
721
- await settings.update(NAMESPACE, { registryBase: base })
741
+ if (!(await persistPatch(res, { registryBase: base }))) return
722
742
  if (restartScheduled) {
723
743
  // 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
724
744
  sendJson(res, 200, await currentStatus())
@@ -843,14 +863,16 @@ export function apply(ctx) {
843
863
  }
844
864
 
845
865
  ctx.inject(['settings'], (settingsCtx) => {
846
- // 方法面防御对齐 timer 软依赖:宿主升级变更 settings 服务方法面时干净降级并留痕,
847
- // 不让 fiber 激活即抛错拖垮启动检查与全部写路由
848
- if (!settingsCtx.settings || typeof settingsCtx.settings.register !== 'function' || typeof settingsCtx.settings.update !== 'function') {
849
- console.warn('[dsh-maintain] settings 服务方法面不可用,版本检查停用,通道/设置保存不可用')
850
- 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))
851
874
  }
852
- settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
853
- // 启动检查放在 settings 注册之后:命名空间未注册时 readSettings 只能拿默认值,
875
+ // 启动检查在 settings 注册之后:legacy 命名空间未注册时 readSettings 只能拿默认值,
854
876
  // 配置了镜像地址的部署会确定性检查失败
855
877
  runCheck().then(scheduleNext, scheduleNext)
856
878
  })
@@ -6,7 +6,7 @@ import { test } from 'node:test'
6
6
  import assert from 'node:assert/strict'
7
7
  import { EventEmitter } from 'node:events'
8
8
 
9
- import { apply, RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS, UPGRADE_LOCK_PATH, RELEASE_NOTES_CACHE_MAX, collectActiveWork } from '../src/index.js'
9
+ import { apply, Config, DEFAULT_CHANNEL, DEFAULT_POLL_INTERVAL_SEC, DEFAULT_UPGRADE_TEMPLATE, DEFAULT_REGISTRY_BASE, RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS, UPGRADE_LOCK_PATH, RELEASE_NOTES_CACHE_MAX, collectActiveWork } from '../src/index.js'
10
10
  import { rmSync, readFileSync } from 'node:fs'
11
11
 
12
12
  // 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
@@ -35,16 +35,46 @@ function tagsBody(tags) {
35
35
 
36
36
  globalThis.fetch = async () => tagsBody(MOCK_TAGS)
37
37
 
38
- function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {} } = {}) {
38
+ function makeCtx({ appExit, settingsStore, settingsAvailable = true, legacySettings = false, configEditorAvailable = true, timerAvailable = true, services = {} } = {}) {
39
39
  const routes = new Map()
40
40
  let tick = null
41
- const store = settingsStore ?? {}
42
- const calls = { exits: [], registered: [], disposers: [] }
41
+ // 条目配置桩:settingsStore 对象即 apply 入参 config(0.1.7 形态的宿主解析配置);
42
+ // configEditor 桩经 change 合并后原样写回,模拟 loader 仅 volatile 变化的原地热更。
43
+ // legacy 形态下 settingsStore 别名 settings 命名空间存储,config 为空对象
44
+ const config = legacySettings ? {} : (settingsStore ?? {})
45
+ const legacyStore = legacySettings ? (settingsStore ?? {}) : {}
46
+ const calls = { exits: [], registered: [], disposers: [], editCalls: [], configureCalls: [], registerCalls: [] }
47
+ const settingsService = {
48
+ configure(presentation, owner) {
49
+ calls.configureCalls.push({ presentation, owner })
50
+ return () => {}
51
+ },
52
+ }
53
+ if (legacySettings) {
54
+ // legacy(≤0.1.6)settings 服务方法面:register 声明命名空间,get/update 命名空间语义
55
+ settingsService.register = (ns, schema) => {
56
+ calls.registerCalls.push({ ns, schema })
57
+ }
58
+ settingsService.get = (ns) => legacyStore
59
+ settingsService.update = async (ns, patch) => {
60
+ Object.assign(legacyStore, patch)
61
+ }
62
+ }
63
+ const configEditor = {
64
+ async edit(entry, change) {
65
+ calls.editCalls.push(entry)
66
+ const next = change({ ...config }, {})
67
+ for (const [key, value] of Object.entries(next)) config[key] = value
68
+ },
69
+ }
43
70
  const ctx = {
44
71
  calls,
72
+ // fiber 桩:0.1.7 写路径经 fiber.entry 定位 profile 条目
73
+ fiber: { entry: { options: { id: 'maintain', name: '@mzzsfy/dsh-maintain' } } },
45
74
  get(name) {
46
75
  if (name === 'appExit') return appExit
47
- if (name === 'settings') return settingsService
76
+ if (name === 'settings') return settingsAvailable ? settingsService : undefined
77
+ if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
48
78
  if (Object.prototype.hasOwnProperty.call(services, name)) return services[name]
49
79
  return undefined
50
80
  },
@@ -57,7 +87,11 @@ function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {}
57
87
  // timer 服务桩:模拟宿主 timer 激活后的 interval(返回 disposer 同官方契约);
58
88
  // timerAvailable=false 模拟服务缺失
59
89
  fn({
60
- settings: settingsService,
90
+ settings: settingsAvailable ? settingsService : undefined,
91
+ effect(stubEffect) {
92
+ const disposer = stubEffect()
93
+ if (typeof disposer === 'function') calls.disposers.push(disposer)
94
+ },
61
95
  interval: timerAvailable
62
96
  ? (intervalFn) => {
63
97
  tick = intervalFn
@@ -79,16 +113,8 @@ function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {}
79
113
  for (const disposer of calls.disposers) disposer()
80
114
  },
81
115
  }
82
- const settingsService = {
83
- register() {},
84
- get() {
85
- return store
86
- },
87
- async update(ns, patch) {
88
- Object.assign(store, patch)
89
- },
90
- }
91
- return { ctx, routes }
116
+ apply(ctx, config)
117
+ return { ctx, routes, config }
92
118
  }
93
119
 
94
120
  function makeReq({ method = 'POST', body, headers = {} } = {}) {
@@ -139,7 +165,6 @@ const realSleep = (ms) => new Promise((resolve) => {
139
165
 
140
166
  test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
141
167
  const { ctx, routes } = makeCtx()
142
- apply(ctx)
143
168
  assert.equal(routes.size, 9)
144
169
  assert.deepEqual(
145
170
  [...routes.keys()].sort(),
@@ -168,9 +193,69 @@ test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
168
193
  assert.equal(res.payload.pollRunning, true, 'timer 服务激活时自动轮询应武装')
169
194
  })
170
195
 
196
+ test('Config 导出契约:根级 volatile 包装,validate 产出整节单 ref,默认值对拍 DEFAULT_* 常量', () => {
197
+ const resolved = Config['~standard'].validate({})
198
+ assert.equal(resolved.issues, undefined)
199
+ const value = resolved.value
200
+ assert.equal(typeof value?.get, 'function', '根级 volatile 须产出整节单 ref(get 协议)')
201
+ const section = value.get()
202
+ assert.equal(section.channel, DEFAULT_CHANNEL)
203
+ assert.equal(section.pollIntervalSec, DEFAULT_POLL_INTERVAL_SEC)
204
+ assert.equal(section.upgradeCommandTemplate, DEFAULT_UPGRADE_TEMPLATE)
205
+ assert.equal(section.registryBase, DEFAULT_REGISTRY_BASE)
206
+ const provided = Config['~standard'].validate({ channel: 'next' }).value.get()
207
+ assert.equal(provided.channel, 'next')
208
+ })
209
+
210
+ test('0.1.7 形态:configure 关闭原生自动页,owner 为本插件 fiber', () => {
211
+ const { ctx } = makeCtx()
212
+ assert.deepEqual(ctx.calls.configureCalls, [{ presentation: { auto: false }, owner: ctx.fiber }])
213
+ })
214
+
215
+ test('0.1.7 形态:通道切换经 configEditor.edit 定位 maintain 条目并合并写回', async () => {
216
+ const store = {}
217
+ const { ctx, routes } = makeCtx({ settingsStore: store })
218
+ const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
219
+ assert.equal(switched.status, 200)
220
+ assert.equal(ctx.calls.editCalls.length, 1)
221
+ assert.equal(ctx.calls.editCalls[0].options.id, 'maintain')
222
+ assert.equal(ctx.calls.editCalls[0].options.name, '@mzzsfy/dsh-maintain')
223
+ assert.equal(store.channel, 'next')
224
+ })
225
+
226
+ test('0.1.7 形态:settings 缺席干净降级——挂载照常、自动页策略缺席、写走 configEditor', async () => {
227
+ const store = {}
228
+ const { ctx, routes } = makeCtx({ settingsAvailable: false, settingsStore: store })
229
+ assert.deepEqual(ctx.calls.configureCalls, [])
230
+ const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
231
+ assert.equal(switched.status, 200)
232
+ assert.equal(store.channel, 'next')
233
+ })
234
+
235
+ test('0.1.7 形态:configEditor 缺失即 500,条目配置不被改动', async () => {
236
+ const store = {}
237
+ const { routes } = makeCtx({ configEditorAvailable: false, settingsStore: store })
238
+ const denied = await post(routes, '/api/maintain/channel', { channel: 'next' })
239
+ assert.equal(denied.status, 500)
240
+ assert.deepEqual(denied.payload, { error: 'configEditor 服务不可用' })
241
+ assert.equal(store.channel, undefined)
242
+ })
243
+
244
+ test('legacy 形态:register 注册 maintain 命名空间,get 读预设,写走 settings.update', async () => {
245
+ const store = { channel: 'next' }
246
+ const { ctx, routes } = makeCtx({ legacySettings: true, settingsStore: store })
247
+ assert.deepEqual(ctx.calls.registerCalls.map((row) => row.ns), ['maintain'])
248
+ assert.equal(ctx.calls.registerCalls[0].schema, Config, 'legacy register 须复用 Config 导出')
249
+ const status = await get(routes, '/api/maintain/status')
250
+ assert.equal(status.payload.channel, 'next', 'legacy 形态读 settings 命名空间')
251
+ const switched = await post(routes, '/api/maintain/channel', { channel: 'latest' })
252
+ assert.equal(switched.status, 200)
253
+ assert.equal(store.channel, 'latest')
254
+ assert.equal(ctx.calls.editCalls.length, 0, 'legacy 形态不得触碰 configEditor')
255
+ })
256
+
171
257
  test('timer 服务缺失:自动轮询降级,面板状态照常响应', async () => {
172
258
  const { ctx, routes } = makeCtx({ timerAvailable: false })
173
- apply(ctx)
174
259
  const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
175
260
  assert.equal(res.status, 200)
176
261
  assert.equal(res.payload.pollRunning, false)
@@ -182,7 +267,6 @@ test('timer 服务缺失:自动轮询降级,面板状态照常响应', async ()
182
267
 
183
268
  test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval 回归)', async () => {
184
269
  const { ctx, routes } = makeCtx()
185
- apply(ctx)
186
270
  assert.equal(ctx.calls.disposers.length > 0, true, 'interval dispose 必须经 ctx.effect 挂回插件 fiber')
187
271
  // 等启动检查落定,排除其 checkedAt 变化对断言的干扰
188
272
  let baseline = null
@@ -200,7 +284,6 @@ test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval
200
284
 
201
285
  test('方法守卫:全部路由错误方法一律 405', async () => {
202
286
  const { ctx, routes } = makeCtx()
203
- apply(ctx)
204
287
  // GET 端点(status/release-notes)以 POST 拒绝,其余以 GET 拒绝
205
288
  const readPaths = ['/api/maintain/status', '/api/maintain/release-notes']
206
289
  for (const path of routes.keys()) {
@@ -212,7 +295,6 @@ test('方法守卫:全部路由错误方法一律 405', async () => {
212
295
 
213
296
  test('跨源守卫:Origin 与 Host 不符即 403;同源放行;Host 大小写归一', async () => {
214
297
  const { ctx, routes } = makeCtx()
215
- apply(ctx)
216
298
  const evil = await post(routes, '/api/maintain/refresh', undefined, { origin: 'https://evil.example', host: 'localhost:3000' })
217
299
  assert.equal(evil.status, 403)
218
300
  const ok = await post(routes, '/api/maintain/refresh', undefined, { origin: 'http://localhost:3000' })
@@ -223,7 +305,6 @@ test('跨源守卫:Origin 与 Host 不符即 403;同源放行;Host 大小写归
223
305
 
224
306
  test('refresh:触发检查并返回 200 快照', async () => {
225
307
  const { ctx, routes } = makeCtx()
226
- apply(ctx)
227
308
  const res = await post(routes, '/api/maintain/refresh')
228
309
  assert.equal(res.status, 200)
229
310
  assert.ok(res.payload.checkedAt !== null)
@@ -232,7 +313,6 @@ test('refresh:触发检查并返回 200 快照', async () => {
232
313
  test('channel:空值 400;非法字符 400;不在 tags 400;合法通道走白名单放行', async () => {
233
314
  const store = {}
234
315
  const { ctx, routes } = makeCtx({ settingsStore: store })
235
- apply(ctx)
236
316
 
237
317
  const empty = await post(routes, '/api/maintain/channel', { channel: ' ' })
238
318
  assert.equal(empty.status, 400)
@@ -254,7 +334,6 @@ test('channel:空值 400;非法字符 400;不在 tags 400;合法通道走白名
254
334
  test('upgrade-template:空值 400;合法值持久化', async () => {
255
335
  const store = {}
256
336
  const { ctx, routes } = makeCtx({ settingsStore: store })
257
- apply(ctx)
258
337
  const empty = await post(routes, '/api/maintain/upgrade-template', { template: '' })
259
338
  assert.equal(empty.status, 400)
260
339
  const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
@@ -268,7 +347,6 @@ test('upgrade-template:空值 400;合法值持久化', async () => {
268
347
  test('poll-interval:负数 400;合法值持久化', async () => {
269
348
  const store = {}
270
349
  const { ctx, routes } = makeCtx({ settingsStore: store })
271
- apply(ctx)
272
350
  const bad = await post(routes, '/api/maintain/poll-interval', { seconds: -1 })
273
351
  assert.equal(bad.status, 400)
274
352
  const wide = await post(routes, '/api/maintain/poll-interval', { seconds: '60' })
@@ -281,7 +359,6 @@ test('poll-interval:负数 400;合法值持久化', async () => {
281
359
  test('registry-base:非法 scheme 400;合法值持久化', async () => {
282
360
  const store = {}
283
361
  const { ctx, routes } = makeCtx({ settingsStore: store })
284
- apply(ctx)
285
362
  const bad = await post(routes, '/api/maintain/registry-base', { base: 'ftp://mirror.example' })
286
363
  assert.equal(bad.status, 400)
287
364
  const ok = await post(routes, '/api/maintain/registry-base', { base: 'https://mirror.example' })
@@ -297,7 +374,6 @@ test('upgrade:运行版本已是通道最新放行(重装/回退场景),不再 4
297
374
  settingsStore: store,
298
375
  services: { hostVersionProbe: () => Promise.resolve('5.4.3') },
299
376
  })
300
- apply(ctx)
301
377
  // 注入运行版本远高于假目标版本:verdict 应转 up-to-date,安装入口照常放行
302
378
  const originalFetch = globalThis.fetch
303
379
  globalThis.fetch = async () => tagsBody({ latest: '0.0.1', next: '0.0.2' })
@@ -331,8 +407,7 @@ test('upgrade:verdict unknown 放行(tags 未就绪不得 409 误拒)', async ()
331
407
  globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
332
408
  try {
333
409
  const { ctx, routes } = makeCtx({ settingsStore: store })
334
- apply(ctx)
335
- let snapshot = null
410
+ let snapshot = null
336
411
  for (let waited = 0; waited < 5000 && snapshot === null; waited += 25) {
337
412
  const poll = await get(routes, '/api/maintain/status').then((r) => r.payload)
338
413
  if (poll.checkedAt !== null) snapshot = poll
@@ -359,7 +434,6 @@ test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => { //
359
434
  // 门闩语义由"真实挂起命令"用例覆盖,此处锁定保存侧空白拒绝
360
435
  const store = {}
361
436
  const { ctx, routes } = makeCtx({ settingsStore: store })
362
- apply(ctx)
363
437
  const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
364
438
  assert.equal(blank.status, 400)
365
439
  assert.equal(store.upgradeCommandTemplate, undefined)
@@ -368,7 +442,6 @@ test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => { //
368
442
  test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查', async () => {
369
443
  const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
370
444
  const { ctx, routes } = makeCtx({ settingsStore: store })
371
- apply(ctx)
372
445
  const baseline = await get(routes, '/api/maintain/status').then((r) => r.payload)
373
446
  const first = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
374
447
  assert.equal(first.status, 200)
@@ -402,8 +475,7 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
402
475
  t.mock.timers.enable({ apis: ['setTimeout'] })
403
476
  try {
404
477
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
405
- apply(ctx)
406
- // 等启动检查落定,固定网络请求基线(mock timers 域内以 realSleep 自旋让出事件循环)
478
+ // 等启动检查落定,固定网络请求基线(mock timers 域内以 realSleep 自旋让出事件循环)
407
479
  let baseline = null
408
480
  for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
409
481
  const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
@@ -443,7 +515,6 @@ test('restart:升级进行中 409 拒绝且不调度退出', async () => {
443
515
  const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
444
516
  const exits = []
445
517
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
446
- apply(ctx)
447
518
  const upgrade = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
448
519
  assert.equal(upgrade.status, 200)
449
520
  const denied = await post(routes, '/api/maintain/restart')
@@ -463,7 +534,6 @@ test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
463
534
  t.mock.timers.enable({ apis: ['setTimeout'] })
464
535
  const exits = []
465
536
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
466
- apply(ctx)
467
537
  const restart = await post(routes, '/api/maintain/restart')
468
538
  assert.equal(restart.status, 200)
469
539
  const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
@@ -486,8 +556,7 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
486
556
  try {
487
557
  const exits = []
488
558
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
489
- apply(ctx)
490
- let baseline = null
559
+ let baseline = null
491
560
  for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
492
561
  const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
493
562
  if (poll && poll.checkedAt !== null) baseline = fetchCalls
@@ -512,7 +581,6 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
512
581
 
513
582
  test('status:快照携带 bootAt 实例代际', async () => {
514
583
  const { ctx, routes } = makeCtx()
515
- apply(ctx)
516
584
  const status = await get(routes, '/api/maintain/status')
517
585
  assert.equal(status.status, 200)
518
586
  assert.equal(typeof status.payload.bootAt, 'number')
@@ -521,7 +589,6 @@ test('status:快照携带 bootAt 实例代际', async () => {
521
589
 
522
590
  test('status:运行版本与已装版本双字段,verdict 以运行版本为准', async () => {
523
591
  const { ctx, routes } = makeCtx()
524
- apply(ctx)
525
592
  // 等启动检查落定:installedVersion 由检查快照填充
526
593
  let ready = null
527
594
  for (let waited = 0; waited < 5000 && ready === null; waited += 25) {
@@ -545,7 +612,6 @@ test('status:运行版本与已装版本双字段,verdict 以运行版本为准'
545
612
  test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
546
613
  const store = {}
547
614
  const { ctx, routes } = makeCtx({ settingsStore: store })
548
- apply(ctx)
549
615
  const huge = await post(routes, '/api/maintain/poll-interval', { seconds: 1e308 })
550
616
  assert.equal(huge.status, 400)
551
617
  assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
@@ -561,7 +627,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
561
627
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
562
628
  })
563
629
  t.mock.timers.enable({ apis: ['setTimeout'] })
564
- apply(ctx)
565
630
  // 排空启动期微任务链,探针读序此后稳定
566
631
  await drainMicrotasks()
567
632
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
@@ -598,7 +663,6 @@ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参
598
663
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
599
664
  })
600
665
  t.mock.timers.enable({ apis: ['setTimeout'] })
601
- apply(ctx)
602
666
  await drainMicrotasks()
603
667
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
604
668
  assert.equal(trigger.status, 200)
@@ -630,7 +694,6 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
630
694
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
631
695
  })
632
696
  t.mock.timers.enable({ apis: ['setTimeout'] })
633
- apply(ctx)
634
697
  await drainMicrotasks()
635
698
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
636
699
  assert.equal(trigger.status, 200)
@@ -658,7 +721,6 @@ test('upgrade:autoRestart 缺失或非 boolean 一律 400', async () => {
658
721
  // 假命令兜底:本用例期待 400,但若校验意外放行,真实默认模板会当场触发 npm install
659
722
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
660
723
  const { ctx, routes } = makeCtx({ settingsStore: store })
661
- apply(ctx)
662
724
  const missing = await post(routes, '/api/maintain/upgrade')
663
725
  assert.equal(missing.status, 400, '缺失 autoRestart 必须拒绝,防静默翻转关机行为')
664
726
  const stringInput = await post(routes, '/api/maintain/upgrade', { autoRestart: 'true' })
@@ -673,7 +735,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
673
735
  t.mock.timers.enable({ apis: ['setTimeout'] })
674
736
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
675
737
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: () => {} })
676
- apply(ctx)
677
738
  await post(routes, '/api/maintain/restart')
678
739
  const denied = await post(routes, '/api/maintain/upgrade')
679
740
  assert.equal(denied.status, 400, 'body 非法时即使门控(重启已调度)命中也必须 400')
@@ -682,7 +743,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
682
743
  test('registry-base:带 query 或 hash 的输入 400', async () => {
683
744
  const store = {}
684
745
  const { ctx, routes } = makeCtx({ settingsStore: store })
685
- apply(ctx)
686
746
  const withQuery = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com?mirror=1' })
687
747
  assert.equal(withQuery.status, 400)
688
748
  const withHash = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com#frag' })
@@ -692,14 +752,12 @@ test('registry-base:带 query 或 hash 的输入 400', async () => {
692
752
 
693
753
  test('restart:缺失 appExit 500;响应立即返回,延迟退出', async (t) => {
694
754
  const withoutExit = makeCtx()
695
- apply(withoutExit.ctx)
696
755
  const denied = await post(withoutExit.routes, '/api/maintain/restart')
697
756
  assert.equal(denied.status, 500)
698
757
 
699
758
  t.mock.timers.enable({ apis: ['setTimeout'] })
700
759
  const exits = []
701
760
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
702
- apply(ctx)
703
761
  const ok = await post(routes, '/api/maintain/restart')
704
762
  assert.equal(ok.status, 200)
705
763
  assert.equal(ok.payload.restarting, true)
@@ -713,7 +771,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
713
771
  t.mock.timers.enable({ apis: ['setTimeout'] })
714
772
  const exits = []
715
773
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
716
- apply(ctx)
717
774
  const first = await post(routes, '/api/maintain/restart')
718
775
  assert.equal(first.status, 200)
719
776
  const second = await post(routes, '/api/maintain/restart')
@@ -725,7 +782,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
725
782
 
726
783
  test('readBody 超限:路由归一 400', async () => {
727
784
  const { ctx, routes } = makeCtx()
728
- apply(ctx)
729
785
  const req = makeReq({ method: 'POST' })
730
786
  const res = makeRes()
731
787
  const done = routes.get('/api/maintain/channel')(req, res)
@@ -806,7 +862,6 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
806
862
  const jobs = { list: () => [] }
807
863
  const terminals = { list: () => [] }
808
864
  const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
809
- apply(ctx)
810
865
  const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
811
866
  assert.equal(denied.status, 409)
812
867
  assert.match(denied.payload.error, /活跃工作/)
@@ -822,7 +877,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
822
877
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
823
878
  const jobs = { list: () => [] }
824
879
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code), services: { agents, jobs } })
825
- apply(ctx)
826
880
  const denied = await post(routes, '/api/maintain/restart')
827
881
  assert.equal(denied.status, 409)
828
882
  assert.match(denied.payload.error, /活跃工作/)
@@ -835,7 +889,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
835
889
 
836
890
  test('status:activeWork 概要进快照,服务缺失标 detectionAvailable=false', async () => {
837
891
  const { ctx, routes } = makeCtx()
838
- apply(ctx)
839
892
  const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
840
893
  assert.equal(status.activeWork.total, 0)
841
894
  assert.equal(status.activeWork.detectionAvailable, false)
@@ -847,7 +900,6 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
847
900
  t.mock.timers.enable({ apis: ['setTimeout'] })
848
901
  const exits = []
849
902
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
850
- apply(ctx)
851
903
  const ok = await post(routes, '/api/maintain/restart', 'null')
852
904
  assert.equal(ok.status, 200, 'null 体须按空体处理,不得以内部错误形态 400/500 泄漏')
853
905
  t.mock.timers.tick(RESTART_DELAY_MS + 1)
@@ -870,8 +922,7 @@ test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调
870
922
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
871
923
  const exits = []
872
924
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
873
- apply(ctx)
874
- await post(routes, '/api/maintain/upgrade', { autoRestart: true })
925
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
875
926
  let settled = null
876
927
  for (let i = 0; i < 50 && settled === null; i += 1) {
877
928
  await new Promise((resolve) => setTimeout(resolve, 100))
@@ -896,8 +947,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
896
947
  try {
897
948
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
898
949
  const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
899
- apply(ctx)
900
- await post(routes, '/api/maintain/upgrade', { autoRestart: true })
950
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
901
951
  assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
902
952
  await post(routes, '/api/maintain/restart')
903
953
  assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
@@ -911,7 +961,6 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
911
961
  const plainCtx = makeCtx({ settingsStore: { upgradeCommandTemplate: 'node -e "process.exit(0)"' }, appExit: () => {} })
912
962
  console.warn = (text) => plainWarns.push(String(text))
913
963
  try {
914
- apply(plainCtx.ctx)
915
964
  await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
916
965
  for (let i = 0; i < 50; i += 1) {
917
966
  await new Promise((resolve) => setTimeout(resolve, 100))
@@ -979,8 +1028,7 @@ test('release-notes:默认取通道最新版,200 返回发布说明', async () =
979
1028
  const restore = installDualUpstream({ requestedUrls })
980
1029
  try {
981
1030
  const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
982
- apply(ctx)
983
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1031
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
984
1032
  const res = await get(routes, RELEASE_PATH)
985
1033
  assert.equal(res.status, 200)
986
1034
  assert.deepEqual(res.payload, {
@@ -1001,8 +1049,7 @@ test('release-notes:缓存生效,切通道后按新通道版本拉取', async ()
1001
1049
  const restore = installDualUpstream({ requestedUrls })
1002
1050
  try {
1003
1051
  const { ctx, routes } = makeCtx()
1004
- apply(ctx)
1005
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1052
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1006
1053
  const first = await get(routes, RELEASE_PATH)
1007
1054
  assert.equal(first.status, 200)
1008
1055
  assert.equal(first.payload.version, '9.9.9')
@@ -1028,8 +1075,7 @@ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', asy
1028
1075
  globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
1029
1076
  try {
1030
1077
  const { ctx, routes } = makeCtx()
1031
- apply(ctx)
1032
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1078
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1033
1079
  const res = await get(routes, RELEASE_PATH)
1034
1080
  assert.equal(res.status, 400)
1035
1081
  assert.match(res.payload.error, /检查更新/)
@@ -1051,8 +1097,7 @@ test('release-notes:通道标签非 semver,构建上游标签前归一 400', asy
1051
1097
  }
1052
1098
  try {
1053
1099
  const { ctx, routes } = makeCtx()
1054
- apply(ctx)
1055
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1100
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1056
1101
  const res = await get(routes, RELEASE_PATH)
1057
1102
  assert.equal(res.status, 400)
1058
1103
  assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
@@ -1068,8 +1113,7 @@ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t)
1068
1113
  const restore = installDualUpstream({ requestedUrls })
1069
1114
  try {
1070
1115
  const { ctx, routes } = makeCtx({ appExit: () => {} })
1071
- apply(ctx)
1072
- const restart = await post(routes, '/api/maintain/restart')
1116
+ const restart = await post(routes, '/api/maintain/restart')
1073
1117
  assert.equal(restart.status, 200)
1074
1118
  const res = await get(routes, RELEASE_PATH)
1075
1119
  assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
@@ -1097,8 +1141,7 @@ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
1097
1141
  }
1098
1142
  try {
1099
1143
  const { ctx, routes } = makeCtx()
1100
- apply(ctx)
1101
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1144
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1102
1145
  for (const [name, version] of Object.entries(channels)) {
1103
1146
  const switched = await post(routes, '/api/maintain/channel', { channel: name })
1104
1147
  assert.equal(switched.status, 200)
@@ -1127,8 +1170,7 @@ test('release-notes:上游失败 400 带错误信息', async () => {
1127
1170
  const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
1128
1171
  try {
1129
1172
  const { ctx, routes } = makeCtx()
1130
- apply(ctx)
1131
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1173
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1132
1174
  const res = await get(routes, RELEASE_PATH)
1133
1175
  assert.equal(res.status, 400)
1134
1176
  assert.match(res.payload.error, /ECONNRESET/)