@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.
@@ -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)
@@ -424,10 +496,8 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
424
496
  // 关机路径零复读:版本复读属升级后磁盘读取,是明确的故障源,砍掉
425
497
  assert.equal(settled.upgrade.last.installedVersion, null, '关机路径禁止复读磁盘版本')
426
498
  assert.equal(settled.upgrade.last.stale, null, '关机路径无 stale 判定')
427
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
428
499
  assert.equal(settled.autoRestartScheduled, true, '命令成功即调度关机(stale 不再抑制)')
429
500
  assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
430
- assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
431
501
  // 落定钩子零网络:观察窗口内 fetch 计数不得增长(runCheck 已随关机路径移除)
432
502
  await realSleep(300)
433
503
  assert.equal(fetchCalls, baseline, '落定钩子禁止网络请求')
@@ -445,7 +515,6 @@ test('restart:升级进行中 409 拒绝且不调度退出', async () => {
445
515
  const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
446
516
  const exits = []
447
517
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
448
- apply(ctx)
449
518
  const upgrade = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
450
519
  assert.equal(upgrade.status, 200)
451
520
  const denied = await post(routes, '/api/maintain/restart')
@@ -465,7 +534,6 @@ test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
465
534
  t.mock.timers.enable({ apis: ['setTimeout'] })
466
535
  const exits = []
467
536
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
468
- apply(ctx)
469
537
  const restart = await post(routes, '/api/maintain/restart')
470
538
  assert.equal(restart.status, 200)
471
539
  const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
@@ -488,8 +556,7 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
488
556
  try {
489
557
  const exits = []
490
558
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
491
- apply(ctx)
492
- let baseline = null
559
+ let baseline = null
493
560
  for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
494
561
  const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
495
562
  if (poll && poll.checkedAt !== null) baseline = fetchCalls
@@ -514,7 +581,6 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
514
581
 
515
582
  test('status:快照携带 bootAt 实例代际', async () => {
516
583
  const { ctx, routes } = makeCtx()
517
- apply(ctx)
518
584
  const status = await get(routes, '/api/maintain/status')
519
585
  assert.equal(status.status, 200)
520
586
  assert.equal(typeof status.payload.bootAt, 'number')
@@ -523,7 +589,6 @@ test('status:快照携带 bootAt 实例代际', async () => {
523
589
 
524
590
  test('status:运行版本与已装版本双字段,verdict 以运行版本为准', async () => {
525
591
  const { ctx, routes } = makeCtx()
526
- apply(ctx)
527
592
  // 等启动检查落定:installedVersion 由检查快照填充
528
593
  let ready = null
529
594
  for (let waited = 0; waited < 5000 && ready === null; waited += 25) {
@@ -547,7 +612,6 @@ test('status:运行版本与已装版本双字段,verdict 以运行版本为准'
547
612
  test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
548
613
  const store = {}
549
614
  const { ctx, routes } = makeCtx({ settingsStore: store })
550
- apply(ctx)
551
615
  const huge = await post(routes, '/api/maintain/poll-interval', { seconds: 1e308 })
552
616
  assert.equal(huge.status, 400)
553
617
  assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
@@ -563,7 +627,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
563
627
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
564
628
  })
565
629
  t.mock.timers.enable({ apis: ['setTimeout'] })
566
- apply(ctx)
567
630
  // 排空启动期微任务链,探针读序此后稳定
568
631
  await drainMicrotasks()
569
632
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
@@ -581,7 +644,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
581
644
  assert.equal(settled.upgrade.last.stale, true, '继续运行路径必须复读并标注 stale')
582
645
  assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0, 'stale 必须带原因')
583
646
  assert.equal(settled.autoRestartScheduled, false, '未勾选不得置调度标记')
584
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '托管环境未勾选不标手动指引')
585
647
  // 推过完整调度延迟:若误调度,延迟窗口内 exit 必被调用
586
648
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
587
649
  assert.deepEqual(exits, [], '未勾选自动重启时禁止调度任何宿主退出')
@@ -601,7 +663,6 @@ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参
601
663
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
602
664
  })
603
665
  t.mock.timers.enable({ apis: ['setTimeout'] })
604
- apply(ctx)
605
666
  await drainMicrotasks()
606
667
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
607
668
  assert.equal(trigger.status, 200)
@@ -633,7 +694,6 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
633
694
  services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
634
695
  })
635
696
  t.mock.timers.enable({ apis: ['setTimeout'] })
636
- apply(ctx)
637
697
  await drainMicrotasks()
638
698
  const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
639
699
  assert.equal(trigger.status, 200)
@@ -647,9 +707,8 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
647
707
  assert.ok(settled, '升级应在假命令退出后落定')
648
708
  assert.equal(settled.upgrade.last.ok, true)
649
709
  assert.equal(settled.upgrade.last.stale, null, '关机路径零复读,stale 不再判定')
650
- // 落定可见与调度置位之间隔 runtimeEnvReady 的 await:await 一次 status 让微任务链走完再断言
651
- const scheduled = await get(routes, '/api/maintain/status').then((r) => r.payload)
652
- assert.equal(scheduled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
710
+ // 调度标记与 running=false 同拍置位,落定快照直接可断言
711
+ assert.equal(settled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
653
712
  assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
654
713
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
655
714
  assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
@@ -662,7 +721,6 @@ test('upgrade:autoRestart 缺失或非 boolean 一律 400', async () => {
662
721
  // 假命令兜底:本用例期待 400,但若校验意外放行,真实默认模板会当场触发 npm install
663
722
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
664
723
  const { ctx, routes } = makeCtx({ settingsStore: store })
665
- apply(ctx)
666
724
  const missing = await post(routes, '/api/maintain/upgrade')
667
725
  assert.equal(missing.status, 400, '缺失 autoRestart 必须拒绝,防静默翻转关机行为')
668
726
  const stringInput = await post(routes, '/api/maintain/upgrade', { autoRestart: 'true' })
@@ -677,7 +735,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
677
735
  t.mock.timers.enable({ apis: ['setTimeout'] })
678
736
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
679
737
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: () => {} })
680
- apply(ctx)
681
738
  await post(routes, '/api/maintain/restart')
682
739
  const denied = await post(routes, '/api/maintain/upgrade')
683
740
  assert.equal(denied.status, 400, 'body 非法时即使门控(重启已调度)命中也必须 400')
@@ -686,7 +743,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
686
743
  test('registry-base:带 query 或 hash 的输入 400', async () => {
687
744
  const store = {}
688
745
  const { ctx, routes } = makeCtx({ settingsStore: store })
689
- apply(ctx)
690
746
  const withQuery = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com?mirror=1' })
691
747
  assert.equal(withQuery.status, 400)
692
748
  const withHash = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com#frag' })
@@ -696,14 +752,12 @@ test('registry-base:带 query 或 hash 的输入 400', async () => {
696
752
 
697
753
  test('restart:缺失 appExit 500;响应立即返回,延迟退出', async (t) => {
698
754
  const withoutExit = makeCtx()
699
- apply(withoutExit.ctx)
700
755
  const denied = await post(withoutExit.routes, '/api/maintain/restart')
701
756
  assert.equal(denied.status, 500)
702
757
 
703
758
  t.mock.timers.enable({ apis: ['setTimeout'] })
704
759
  const exits = []
705
760
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
706
- apply(ctx)
707
761
  const ok = await post(routes, '/api/maintain/restart')
708
762
  assert.equal(ok.status, 200)
709
763
  assert.equal(ok.payload.restarting, true)
@@ -717,7 +771,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
717
771
  t.mock.timers.enable({ apis: ['setTimeout'] })
718
772
  const exits = []
719
773
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
720
- apply(ctx)
721
774
  const first = await post(routes, '/api/maintain/restart')
722
775
  assert.equal(first.status, 200)
723
776
  const second = await post(routes, '/api/maintain/restart')
@@ -729,7 +782,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
729
782
 
730
783
  test('readBody 超限:路由归一 400', async () => {
731
784
  const { ctx, routes } = makeCtx()
732
- apply(ctx)
733
785
  const req = makeReq({ method: 'POST' })
734
786
  const res = makeRes()
735
787
  const done = routes.get('/api/maintain/channel')(req, res)
@@ -810,7 +862,6 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
810
862
  const jobs = { list: () => [] }
811
863
  const terminals = { list: () => [] }
812
864
  const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
813
- apply(ctx)
814
865
  const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
815
866
  assert.equal(denied.status, 409)
816
867
  assert.match(denied.payload.error, /活跃工作/)
@@ -826,7 +877,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
826
877
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
827
878
  const jobs = { list: () => [] }
828
879
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code), services: { agents, jobs } })
829
- apply(ctx)
830
880
  const denied = await post(routes, '/api/maintain/restart')
831
881
  assert.equal(denied.status, 409)
832
882
  assert.match(denied.payload.error, /活跃工作/)
@@ -839,7 +889,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
839
889
 
840
890
  test('status:activeWork 概要进快照,服务缺失标 detectionAvailable=false', async () => {
841
891
  const { ctx, routes } = makeCtx()
842
- apply(ctx)
843
892
  const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
844
893
  assert.equal(status.activeWork.total, 0)
845
894
  assert.equal(status.activeWork.detectionAvailable, false)
@@ -851,30 +900,29 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
851
900
  t.mock.timers.enable({ apis: ['setTimeout'] })
852
901
  const exits = []
853
902
  const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
854
- apply(ctx)
855
903
  const ok = await post(routes, '/api/maintain/restart', 'null')
856
904
  assert.equal(ok.status, 200, 'null 体须按空体处理,不得以内部错误形态 400/500 泄漏')
857
905
  t.mock.timers.tick(RESTART_DELAY_MS + 1)
858
906
  assert.deepEqual(exits, [0], 'null 体等价空体:正常调度退出')
859
907
  })
860
908
 
861
- test('自动重启接线:落定链消费运行环境并分流调度与手动指引(源码形态锁定)', () => {
909
+ test('自动重启接线:落定链只取内存输入并直接分流调度(源码形态锁定)', () => {
862
910
  const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
863
911
  const settle = source.match(/const decision = judgeAutoRestart\(\{([\s\S]*?)\}\)/)
864
912
  assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
865
- assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
866
- assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
913
+ assert.doesNotMatch(settle[1], /runtimeKind|runtimeEnv/, '落定判定禁止消费运行环境:升级即调度与启动形态无关')
914
+ assert.doesNotMatch(source, /requiresManualRestart/, '手动直跑指引链必须整体移除,不得残留回写')
867
915
  assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
868
916
  })
869
917
 
870
- test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
918
+ test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调度关机)', async () => {
919
+ // DSH_MAINTAIN_RUNTIME_ENV 已无消费者:注入历史值证明判定与运行环境彻底解耦
871
920
  process.env.DSH_MAINTAIN_RUNTIME_ENV = 'manual'
872
921
  try {
873
922
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
874
923
  const exits = []
875
924
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
876
- apply(ctx)
877
- await post(routes, '/api/maintain/upgrade', { autoRestart: true })
925
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
878
926
  let settled = null
879
927
  for (let i = 0; i < 50 && settled === null; i += 1) {
880
928
  await new Promise((resolve) => setTimeout(resolve, 100))
@@ -882,11 +930,9 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
882
930
  if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
883
931
  }
884
932
  assert.ok(settled, '升级应在假命令退出后落定')
885
- assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
886
- assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
887
- assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
888
- // 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
889
- assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
933
+ assert.equal(settled.autoRestartScheduled, true, '升级成功勾选即调度,运行环境不得抑制')
934
+ assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '手动指引链已移除')
935
+ assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
890
936
  } finally {
891
937
  delete process.env.DSH_MAINTAIN_RUNTIME_ENV
892
938
  }
@@ -901,8 +947,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
901
947
  try {
902
948
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
903
949
  const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
904
- apply(ctx)
905
- await post(routes, '/api/maintain/upgrade', { autoRestart: true })
950
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
906
951
  assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
907
952
  await post(routes, '/api/maintain/restart')
908
953
  assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
@@ -916,7 +961,6 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
916
961
  const plainCtx = makeCtx({ settingsStore: { upgradeCommandTemplate: 'node -e "process.exit(0)"' }, appExit: () => {} })
917
962
  console.warn = (text) => plainWarns.push(String(text))
918
963
  try {
919
- apply(plainCtx.ctx)
920
964
  await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
921
965
  for (let i = 0; i < 50; i += 1) {
922
966
  await new Promise((resolve) => setTimeout(resolve, 100))
@@ -984,8 +1028,7 @@ test('release-notes:默认取通道最新版,200 返回发布说明', async () =
984
1028
  const restore = installDualUpstream({ requestedUrls })
985
1029
  try {
986
1030
  const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
987
- apply(ctx)
988
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1031
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
989
1032
  const res = await get(routes, RELEASE_PATH)
990
1033
  assert.equal(res.status, 200)
991
1034
  assert.deepEqual(res.payload, {
@@ -1006,8 +1049,7 @@ test('release-notes:缓存生效,切通道后按新通道版本拉取', async ()
1006
1049
  const restore = installDualUpstream({ requestedUrls })
1007
1050
  try {
1008
1051
  const { ctx, routes } = makeCtx()
1009
- apply(ctx)
1010
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1052
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1011
1053
  const first = await get(routes, RELEASE_PATH)
1012
1054
  assert.equal(first.status, 200)
1013
1055
  assert.equal(first.payload.version, '9.9.9')
@@ -1033,8 +1075,7 @@ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', asy
1033
1075
  globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
1034
1076
  try {
1035
1077
  const { ctx, routes } = makeCtx()
1036
- apply(ctx)
1037
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1078
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1038
1079
  const res = await get(routes, RELEASE_PATH)
1039
1080
  assert.equal(res.status, 400)
1040
1081
  assert.match(res.payload.error, /检查更新/)
@@ -1056,8 +1097,7 @@ test('release-notes:通道标签非 semver,构建上游标签前归一 400', asy
1056
1097
  }
1057
1098
  try {
1058
1099
  const { ctx, routes } = makeCtx()
1059
- apply(ctx)
1060
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1100
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1061
1101
  const res = await get(routes, RELEASE_PATH)
1062
1102
  assert.equal(res.status, 400)
1063
1103
  assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
@@ -1073,8 +1113,7 @@ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t)
1073
1113
  const restore = installDualUpstream({ requestedUrls })
1074
1114
  try {
1075
1115
  const { ctx, routes } = makeCtx({ appExit: () => {} })
1076
- apply(ctx)
1077
- const restart = await post(routes, '/api/maintain/restart')
1116
+ const restart = await post(routes, '/api/maintain/restart')
1078
1117
  assert.equal(restart.status, 200)
1079
1118
  const res = await get(routes, RELEASE_PATH)
1080
1119
  assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
@@ -1102,8 +1141,7 @@ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
1102
1141
  }
1103
1142
  try {
1104
1143
  const { ctx, routes } = makeCtx()
1105
- apply(ctx)
1106
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1144
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1107
1145
  for (const [name, version] of Object.entries(channels)) {
1108
1146
  const switched = await post(routes, '/api/maintain/channel', { channel: name })
1109
1147
  assert.equal(switched.status, 200)
@@ -1132,8 +1170,7 @@ test('release-notes:上游失败 400 带错误信息', async () => {
1132
1170
  const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
1133
1171
  try {
1134
1172
  const { ctx, routes } = makeCtx()
1135
- apply(ctx)
1136
- assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1173
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1137
1174
  const res = await get(routes, RELEASE_PATH)
1138
1175
  assert.equal(res.status, 400)
1139
1176
  assert.match(res.payload.error, /ECONNRESET/)
@@ -12,7 +12,6 @@ import {
12
12
  UPGRADE_FAIL_NPM_MISSING,
13
13
  UPGRADE_FAIL_TIMEOUT,
14
14
  } from '../src/core.mjs'
15
- import { RUNTIME_KINDS } from '../src/runtime.mjs'
16
15
 
17
16
  // 脚本体一律单引号:Windows shell 化 spawn 经 cmd.exe,双层双引号会被截断。
18
17
  const NODE = 'node'
@@ -219,29 +218,20 @@ test('重试:假命令真实进程 文件锁失败后重试成功', async () =>
219
218
  }
220
219
  })
221
220
 
222
- test('自动重启:四条件守卫(成功/非手动直跑/enabled/appExit 可用)', () => {
223
- // 成功+托管(含 unknown)+enabled=true → 调度
224
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
225
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
226
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
221
+ test('自动重启:三条件守卫(成功/enabled/appExit 可用),运行环境不参与判定', () => {
222
+ // 成功+enabled → 调度:宿主退出动作与重启按钮同一入口,拉起责任在启动方式
223
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: true }), { schedule: true })
227
224
  // 失败不调度
228
- assert.deepEqual(judgeAutoRestart({ ok: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: false })
229
- // 手动直跑 → 手动指引,不调度
230
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: true })
225
+ assert.deepEqual(judgeAutoRestart({ ok: false, hasExit: true, enabled: true }), { schedule: false })
231
226
  // appExit 缺失不调度
232
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: false })
233
- // 手动直跑与 appExit 缺失并存 → 仍以手动指引标记(指引面板,与退出能力无关)
234
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: true })
227
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: false, enabled: true }), { schedule: false })
235
228
  })
236
229
 
237
230
  test('自动重启:enabled 严格 boolean,仅 true 调度', () => {
238
- // 显式 false:托管(含 unknown)不调度、不标手动指引
239
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
240
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
241
- // 手动直跑指引是环境约束事实,不受勾选影响
242
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: true })
231
+ // 显式 false 不调度
232
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: false }), { schedule: false })
243
233
  // 非 true 一律不调度:调用点经升级路由严格 boolean 校验,此处锁定纯函数自身契约
244
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: undefined }), { schedule: false, requiresManualRestart: false })
245
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: null }), { schedule: false, requiresManualRestart: false })
246
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: 'true' }), { schedule: false, requiresManualRestart: false })
234
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: undefined }), { schedule: false })
235
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: null }), { schedule: false })
236
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: 'true' }), { schedule: false })
247
237
  })