@mzzsfy/dsh-maintain 0.5.2 → 0.6.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,8 +6,8 @@ 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, UPGRADE_LOCK_PATH } from '../src/index.js'
10
- import { rmSync } from 'node:fs'
9
+ import { apply, RESTART_DELAY_MS, UPGRADE_LOCK_PATH, collectActiveWork } from '../src/index.js'
10
+ import { rmSync, readFileSync } from 'node:fs'
11
11
 
12
12
  // 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
13
13
  rmSync(UPGRADE_LOCK_PATH, { force: true })
@@ -15,21 +15,27 @@ rmSync(UPGRADE_LOCK_PATH, { force: true })
15
15
  // 全局 fetch 拦截:core.fetchDistTags 默认绑定全局 fetch,测试期返回与 dist-tags.test
16
16
  // 同形的流式响应(tags 就绪),防止启动检查/refresh 触发真实网络请求
17
17
  const MOCK_TAGS = { latest: '9.9.9', next: '10.0.0' }
18
- globalThis.fetch = async () => ({
19
- ok: true,
20
- status: 200,
21
- body: {
22
- getReader: () => {
23
- const chunks = [new TextEncoder().encode(JSON.stringify(MOCK_TAGS))]
24
- return {
25
- read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
26
- cancel: async () => {},
27
- }
18
+
19
+ // 与文件顶部全局 fetch 拦截同形的流式响应体
20
+ function tagsBody(tags) {
21
+ return {
22
+ ok: true,
23
+ status: 200,
24
+ body: {
25
+ getReader: () => {
26
+ const chunks = [new TextEncoder().encode(JSON.stringify(tags))]
27
+ return {
28
+ read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
29
+ cancel: async () => {},
30
+ }
31
+ },
28
32
  },
29
- },
30
- })
33
+ }
34
+ }
35
+
36
+ globalThis.fetch = async () => tagsBody(MOCK_TAGS)
31
37
 
32
- function makeCtx({ appExit, settingsStore, timerAvailable = true } = {}) {
38
+ function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {} } = {}) {
33
39
  const routes = new Map()
34
40
  let tick = null
35
41
  const store = settingsStore ?? {}
@@ -39,6 +45,7 @@ function makeCtx({ appExit, settingsStore, timerAvailable = true } = {}) {
39
45
  get(name) {
40
46
  if (name === 'appExit') return appExit
41
47
  if (name === 'settings') return settingsService
48
+ if (Object.prototype.hasOwnProperty.call(services, name)) return services[name]
42
49
  return undefined
43
50
  },
44
51
  // effect 桩:执行装配函数并捕获其返回的 disposer,供测试模拟 fiber 停用
@@ -91,12 +98,11 @@ function makeReq({ method = 'POST', body, headers = {} } = {}) {
91
98
  req.destroy = () => {
92
99
  req.destroyed = true
93
100
  }
94
- if (body !== undefined) {
95
- queueMicrotask(() => {
96
- req.emit('data', Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)))
97
- req.emit('end')
98
- })
99
- }
101
+ // 无 body 也须发 end:路由侧读体(如 restart 的 force)对空体解析为空对象
102
+ queueMicrotask(() => {
103
+ if (body !== undefined) req.emit('data', Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)))
104
+ req.emit('end')
105
+ })
100
106
  return req
101
107
  }
102
108
 
@@ -280,8 +286,63 @@ test('registry-base:非法 scheme 400;合法值持久化', async () => {
280
286
  assert.equal(store.registryBase, 'https://mirror.example')
281
287
  })
282
288
 
283
- test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => {
284
- // 默认模板是真实 npm install 命令,POST upgrade 的默认路径禁止在测试中触发;
289
+ test('upgrade:运行版本已是通道最新 409 拒绝(防降级),unknown 放行', async () => {
290
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
291
+ // 版本探测注入受控值:CI 无 dsh 本体,真实盘读回 null 会把 verdict 打成 unknown,
292
+ // 防降级门控(核心断言)在 CI 恒不触发
293
+ const { ctx, routes } = makeCtx({
294
+ settingsStore: store,
295
+ services: { hostVersionProbe: () => Promise.resolve('5.4.3') },
296
+ })
297
+ apply(ctx)
298
+ // 注入运行版本远低于假目标版本:verdict 应转 up-to-date,升级入口拒绝
299
+ const originalFetch = globalThis.fetch
300
+ globalThis.fetch = async () => tagsBody({ latest: '0.0.1', next: '0.0.2' })
301
+ try {
302
+ const refreshed = await post(routes, '/api/maintain/refresh')
303
+ assert.equal(refreshed.status, 200)
304
+ assert.equal(refreshed.payload.verdict, 'up-to-date', '前置:注入运行版本应高于 0.0.1 假目标')
305
+ const denied = await post(routes, '/api/maintain/upgrade')
306
+ assert.equal(denied.status, 409)
307
+ assert.match(denied.payload.error, /已是通道最新版/)
308
+ assert.equal(store.upgradeCommandTemplate, 'node -e "process.exit(0)"', '拒绝路径不得触发升级')
309
+ } finally {
310
+ globalThis.fetch = originalFetch
311
+ }
312
+ })
313
+
314
+ test('upgrade:verdict unknown 放行(tags 未就绪不得 409 误拒)', async () => {
315
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
316
+ // fetch 在 apply 前替换:启动检查即失败,tags 保持 null,verdict 恒 unknown
317
+ const originalFetch = globalThis.fetch
318
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
319
+ try {
320
+ const { ctx, routes } = makeCtx({ settingsStore: store })
321
+ apply(ctx)
322
+ let snapshot = null
323
+ for (let waited = 0; waited < 5000 && snapshot === null; waited += 25) {
324
+ const poll = await get(routes, '/api/maintain/status').then((r) => r.payload)
325
+ if (poll.checkedAt !== null) snapshot = poll
326
+ else await new Promise((resolve) => setTimeout(resolve, 25))
327
+ }
328
+ assert.ok(snapshot, '启动检查 5 秒内未完成')
329
+ assert.equal(snapshot.tags, null, '前置:registry 不可达,tags 未就绪')
330
+ assert.equal(snapshot.verdict, 'unknown', '前置:verdict 应为 unknown')
331
+ const allowed = await post(routes, '/api/maintain/upgrade')
332
+ assert.equal(allowed.status, 200, 'verdict unknown 必须放行,不得 409 误拒')
333
+ assert.equal(allowed.payload.upgrade.running, true)
334
+ // 等假命令落定:防残留升级锁毒化后续用例
335
+ for (let i = 0; i < 50; i += 1) {
336
+ await new Promise((resolve) => setTimeout(resolve, 100))
337
+ const s = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
338
+ if (s && s.upgrade && s.upgrade.running === false && s.upgrade.last !== null) break
339
+ }
340
+ } finally {
341
+ globalThis.fetch = originalFetch
342
+ }
343
+ })
344
+
345
+ test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => { // 默认模板是真实 npm install 命令,POST upgrade 的默认路径禁止在测试中触发;
285
346
  // 门闩语义由"真实挂起命令"用例覆盖,此处锁定保存侧空白拒绝
286
347
  const store = {}
287
348
  const { ctx, routes } = makeCtx({ settingsStore: store })
@@ -316,6 +377,49 @@ test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查',
316
377
  assert.ok(settled.checkedAt !== null)
317
378
  })
318
379
 
380
+ test('upgrade:落定后复读磁盘版本,假命令未升级版本时标 stale', async () => {
381
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
382
+ const exits = []
383
+ const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
384
+ apply(ctx)
385
+ const first = await post(routes, '/api/maintain/upgrade')
386
+ assert.equal(first.status, 200)
387
+ let settled = null
388
+ for (let i = 0; i < 50 && settled === null; i += 1) {
389
+ await new Promise((resolve) => setTimeout(resolve, 100))
390
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
391
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
392
+ }
393
+ assert.ok(settled, '升级应在假命令退出后落定')
394
+ assert.equal(settled.upgrade.last.ok, true)
395
+ assert.ok('previousVersion' in settled.upgrade.last, '触发前磁盘版本快照必须进 status')
396
+ assert.equal(settled.upgrade.last.installedVersion, settled.upgrade.last.previousVersion, '假命令不改磁盘,复读版本应与快照一致')
397
+ assert.equal(settled.upgrade.last.stale, true, '版本未前进必须标 stale(镜像滞后/静默未升)')
398
+ assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0)
399
+ // stale 抑制自动重启:不调度退出、不标手动指引;重启互斥同步释放
400
+ assert.equal(settled.autoRestartScheduled, false)
401
+ assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
402
+ assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
403
+ const restart = await post(routes, '/api/maintain/restart')
404
+ assert.equal(restart.status, 200, 'stale 落定后重启互斥必须已释放')
405
+ })
406
+
407
+ test('upgrade:stale 落定不调度自动重启(宿主退出零触发)', async () => {
408
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
409
+ const exits = []
410
+ const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
411
+ apply(ctx)
412
+ await post(routes, '/api/maintain/upgrade')
413
+ for (let i = 0; i < 50; i += 1) {
414
+ await new Promise((resolve) => setTimeout(resolve, 100))
415
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
416
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) break
417
+ }
418
+ // 观察窗口覆盖自动重启延迟:若误调度,延迟窗口内 exit 会被调用
419
+ await new Promise((resolve) => setTimeout(resolve, 200))
420
+ assert.deepEqual(exits, [], 'stale 时禁止调度任何宿主退出')
421
+ })
422
+
319
423
  test('restart:升级进行中 409 拒绝且不调度退出', async () => {
320
424
  const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
321
425
  const exits = []
@@ -359,6 +463,29 @@ test('status:快照携带 bootAt 实例代际', async () => {
359
463
  assert.ok(Number.isFinite(status.payload.bootAt) && status.payload.bootAt > 0)
360
464
  })
361
465
 
466
+ test('status:运行版本与已装版本双字段,verdict 以运行版本为准', async () => {
467
+ const { ctx, routes } = makeCtx()
468
+ apply(ctx)
469
+ // 等启动检查落定:installedVersion 由检查快照填充
470
+ let ready = null
471
+ for (let waited = 0; waited < 5000 && ready === null; waited += 25) {
472
+ const poll = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
473
+ if (poll.payload.checkedAt !== null) ready = poll.payload
474
+ else await new Promise((resolve) => setTimeout(resolve, 25))
475
+ }
476
+ assert.ok(ready, '启动检查 5 秒内未完成')
477
+ assert.ok('runningVersion' in ready, 'status 必须返回 runningVersion')
478
+ assert.ok('installedVersion' in ready, 'status 必须返回 installedVersion')
479
+ assert.equal(ready.currentVersion, undefined, '旧 currentVersion 字段移除,不保留兼容层')
480
+ // 静态磁盘下两读一致;磁盘领先运行版本(升级后未重启)才置 restartPending
481
+ assert.equal(ready.installedVersion, ready.runningVersion)
482
+ assert.equal(ready.restartPending, false)
483
+ // 运行版本与已装版本来源不同(apply 缓存 vs 检查快照):缺失时 verdict 未知
484
+ if (ready.runningVersion === null) {
485
+ assert.equal(ready.verdict, 'unknown')
486
+ }
487
+ })
488
+
362
489
  test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
363
490
  const store = {}
364
491
  const { ctx, routes } = makeCtx({ settingsStore: store })
@@ -422,4 +549,168 @@ test('readBody 超限:路由归一 400', async () => {
422
549
  await done
423
550
  assert.equal(res.status, 400)
424
551
  assert.match(res.payload.error, /上限/)
552
+ })
553
+
554
+ // ---- 活跃工作门控(S10)----
555
+
556
+ test('collectActiveWork:agents/jobs/terminals 计活与去重', () => {
557
+ const terminals = {
558
+ list: (owner) => owner && owner.id === 'a1'
559
+ ? [{ sessionId: 't1', status: { kind: 'running' } }, { sessionId: 't3', status: { kind: 'exited' } }]
560
+ : [{ sessionId: 't1', status: { kind: 'running' } }, { sessionId: 't2', status: { kind: 'running' } }],
561
+ }
562
+ const agentA = { id: 'a1', status: 'running', ctx: { get: (n) => (n === 'terminals' ? terminals : undefined) } }
563
+ const agentB = { id: 'a2', status: 'idle' }
564
+ const agents = { list: () => [agentA, agentB] }
565
+ const jobs = {
566
+ list: (caller) => caller === undefined
567
+ ? [{ id: 'j1', status: 'running' }]
568
+ : [{ id: 'j1', status: 'running' }, { id: 'j2', status: 'stopping' }, { id: 'j3', status: 'done' }],
569
+ }
570
+ const result = collectActiveWork({ get: (n) => (n === 'agents' ? agents : n === 'jobs' ? jobs : n === 'terminals' ? terminals : undefined) })
571
+ // agents:a1 running;a jobs:j1 双 caller 去重计一,j2 stopping 计活,j3 不计;terminals:t1 去重,t2 计,t3 非运行不计
572
+ assert.deepEqual({ agents: result.agents, jobs: result.jobs, terminals: result.terminals }, { agents: 1, jobs: 2, terminals: 2 })
573
+ assert.equal(result.total, 5)
574
+ assert.equal(result.detectionAvailable, true)
575
+ })
576
+
577
+ test('collectActiveWork:服务缺失或异常 fail-open 降级', () => {
578
+ const empty = collectActiveWork({ get: () => undefined })
579
+ assert.deepEqual([empty.agents, empty.jobs, empty.terminals], [0, 0, 0])
580
+ assert.equal(empty.detectionAvailable, false)
581
+ const throwing = { get: () => ({ list: () => { throw new Error('boom') } }) }
582
+ const degraded = collectActiveWork(throwing)
583
+ assert.equal(degraded.total, 0)
584
+ assert.equal(degraded.detectionAvailable, false)
585
+ // 根作用域 terminals 缺失但 agent realm 有,不算降级(realm 隔离常态)
586
+ const agent = { id: 'a1', status: 'idle', ctx: { get: (n) => (n === 'terminals' ? { list: () => [{ sessionId: 't1', status: { kind: 'running' } }] } : undefined) } }
587
+ const realmOnly = collectActiveWork({ get: (n) => (n === 'agents' ? { list: () => [agent] } : n === 'jobs' ? { list: () => [] } : undefined) })
588
+ assert.equal(realmOnly.terminals, 1)
589
+ assert.equal(realmOnly.detectionAvailable, true)
590
+ })
591
+
592
+ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
593
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
594
+ const agents = { list: () => [{ id: 'a1', status: 'running' }] }
595
+ const jobs = { list: () => [] }
596
+ const terminals = { list: () => [] }
597
+ const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
598
+ apply(ctx)
599
+ const denied = await post(routes, '/api/maintain/upgrade')
600
+ assert.equal(denied.status, 409)
601
+ assert.match(denied.payload.error, /活跃工作/)
602
+ assert.equal(denied.payload.items.agents, 1)
603
+ assert.equal(denied.payload.detectionAvailable, true)
604
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
605
+ assert.equal(status.upgrade.running, false, '拒绝路径不得触发升级')
606
+ })
607
+
608
+ test('restart:活跃工作 409,force 越过', async (t) => {
609
+ t.mock.timers.enable({ apis: ['setTimeout'] })
610
+ const exits = []
611
+ const agents = { list: () => [{ id: 'a1', status: 'running' }] }
612
+ const jobs = { list: () => [] }
613
+ const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code), services: { agents, jobs } })
614
+ apply(ctx)
615
+ const denied = await post(routes, '/api/maintain/restart')
616
+ assert.equal(denied.status, 409)
617
+ assert.match(denied.payload.error, /活跃工作/)
618
+ assert.deepEqual(exits, [])
619
+ const forced = await post(routes, '/api/maintain/restart', { force: true })
620
+ assert.equal(forced.status, 200)
621
+ t.mock.timers.tick(RESTART_DELAY_MS + 1)
622
+ assert.deepEqual(exits, [0], 'force 越过后必须正常调度退出')
623
+ })
624
+
625
+ test('status:activeWork 概要进快照,服务缺失标 detectionAvailable=false', async () => {
626
+ const { ctx, routes } = makeCtx()
627
+ apply(ctx)
628
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
629
+ assert.equal(status.activeWork.total, 0)
630
+ assert.equal(status.activeWork.detectionAvailable, false)
631
+ })
632
+
633
+ // ---- 批 2 审查建议落地 ----
634
+
635
+ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
636
+ t.mock.timers.enable({ apis: ['setTimeout'] })
637
+ const exits = []
638
+ const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
639
+ apply(ctx)
640
+ const ok = await post(routes, '/api/maintain/restart', 'null')
641
+ assert.equal(ok.status, 200, 'null 体须按空体处理,不得以内部错误形态 400/500 泄漏')
642
+ t.mock.timers.tick(RESTART_DELAY_MS + 1)
643
+ assert.deepEqual(exits, [0], 'null 体等价空体:正常调度退出')
644
+ })
645
+
646
+ test('自动重启接线:落定链消费运行环境并分流调度与手动指引(源码形态锁定)', () => {
647
+ const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
648
+ const settle = source.match(/const decision = judgeAutoRestart\(\{([\s\S]*?)\}\)/)
649
+ assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
650
+ assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
651
+ assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
652
+ assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleAutoRestart\(\)/, '调度链必须置位标记并调用 scheduleAutoRestart')
653
+ })
654
+
655
+ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
656
+ process.env.DSH_MAINTAIN_RUNTIME_ENV = 'manual'
657
+ try {
658
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
659
+ const exits = []
660
+ const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
661
+ apply(ctx)
662
+ await post(routes, '/api/maintain/upgrade')
663
+ let settled = null
664
+ for (let i = 0; i < 50 && settled === null; i += 1) {
665
+ await new Promise((resolve) => setTimeout(resolve, 100))
666
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
667
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
668
+ }
669
+ assert.ok(settled, '升级应在假命令退出后落定')
670
+ assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
671
+ assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
672
+ assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
673
+ // stale 优先:版本未前进时不引导手动重启(重启无意义)
674
+ assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
675
+ } finally {
676
+ delete process.env.DSH_MAINTAIN_RUNTIME_ENV
677
+ }
678
+ })
679
+
680
+ // ---- 审计日志(S12)----
681
+
682
+ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
683
+ const warns = []
684
+ const originalWarn = console.warn
685
+ console.warn = (text) => warns.push(String(text))
686
+ try {
687
+ const agents = { list: () => [{ id: 'a1', status: 'running' }] }
688
+ const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
689
+ apply(ctx)
690
+ await post(routes, '/api/maintain/upgrade')
691
+ assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
692
+ await post(routes, '/api/maintain/restart')
693
+ assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
694
+ await post(routes, '/api/maintain/restart', { force: true })
695
+ assert.equal(warns.some((text) => /audit endpoint=restart outcome=triggered\b/.test(text) && text.includes('forced=true')), true, 'force 触发须留审计行')
696
+ } finally {
697
+ console.warn = originalWarn
698
+ }
699
+ // 升级触发与落定:假命令真实进程,落定行带 durationMs 与 code
700
+ const plainWarns = []
701
+ const plainCtx = makeCtx({ settingsStore: { upgradeCommandTemplate: 'node -e "process.exit(0)"' }, appExit: () => {} })
702
+ console.warn = (text) => plainWarns.push(String(text))
703
+ try {
704
+ apply(plainCtx.ctx)
705
+ await post(plainCtx.routes, '/api/maintain/upgrade')
706
+ for (let i = 0; i < 50; i += 1) {
707
+ await new Promise((resolve) => setTimeout(resolve, 100))
708
+ const status = await get(plainCtx.routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
709
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) break
710
+ }
711
+ } finally {
712
+ console.warn = originalWarn
713
+ }
714
+ assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=triggered/.test(text)), true, '升级触发须留审计行')
715
+ assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=(ok|failed) durationMs=[0-9]+ code=/.test(text)), true, '升级落定须留审计行')
425
716
  })
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { detectRuntimeEnv, RUNTIME_KINDS } from '../src/runtime.mjs'
4
+
5
+ const POSIX = 'linux'
6
+ const WIN32 = 'win32'
7
+ const BOTH_TTY = { stdin: true, stdout: true }
8
+ const NO_TTY = { stdin: false, stdout: false }
9
+ const noProbes = { existsImpl: async () => false, readFileImpl: async () => { throw new Error('ENOENT') } }
10
+
11
+ test('环境检测:env 覆盖最高优先', async () => {
12
+ const managed = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'managed', pm_id: '0' }, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
13
+ assert.deepEqual(managed, { kind: RUNTIME_KINDS.DECLARED_MANAGED, declared: true })
14
+ const manual = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'manual', pm_id: '0' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
15
+ assert.deepEqual(manual, { kind: RUNTIME_KINDS.MANUAL_START, declared: false })
16
+ // 非法覆盖值不生效,继续走常规判定
17
+ const bogus = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'sideways' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
18
+ assert.deepEqual(bogus, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
19
+ })
20
+
21
+ test('环境检测:env 表命中各托管形态', async () => {
22
+ const table = [
23
+ [{ pm_id: '0' }, RUNTIME_KINDS.PM2],
24
+ [{ PM2_HOME: 'C:/pm2' }, RUNTIME_KINDS.PM2],
25
+ [{ pm_uptime: '123' }, RUNTIME_KINDS.PM2],
26
+ [{ INVOCATION_ID: 'x' }, RUNTIME_KINDS.SYSTEMD],
27
+ [{ JOURNAL_STREAM: '9' }, RUNTIME_KINDS.SYSTEMD],
28
+ [{ NOTIFY_SOCKET: '/run/notify' }, RUNTIME_KINDS.SYSTEMD],
29
+ [{ SUPERVISOR_ENABLED: '1' }, RUNTIME_KINDS.SUPERVISORD],
30
+ [{ SUPERVISOR_PROCESS_NAME: 'dsh' }, RUNTIME_KINDS.SUPERVISORD],
31
+ [{ KUBERNETES_SERVICE_HOST: '10.0.0.1' }, RUNTIME_KINDS.KUBERNETES],
32
+ ]
33
+ for (const [env, kind] of table) {
34
+ const result = await detectRuntimeEnv({ env, platform: POSIX, isTTY: NO_TTY, ...noProbes })
35
+ assert.deepEqual(result, { kind, declared: true }, JSON.stringify(env))
36
+ }
37
+ // 空串环境值不构成声明
38
+ const empty = await detectRuntimeEnv({ env: { pm_id: '' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
39
+ assert.deepEqual(empty, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
40
+ })
41
+
42
+ test('环境检测:POSIX 容器探测', async () => {
43
+ const docker = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async (p) => p === '/.dockerenv', readFileImpl: async () => { throw new Error('ENOENT') } })
44
+ assert.deepEqual(docker, { kind: RUNTIME_KINDS.DOCKER, declared: true })
45
+ for (const [marker, kind] of [['/docker/', RUNTIME_KINDS.DOCKER], ['kubepods', RUNTIME_KINDS.KUBERNETES], ['containerd', RUNTIME_KINDS.CONTAINER], ['lxc', RUNTIME_KINDS.CONTAINER], ['podman', RUNTIME_KINDS.CONTAINER]]) {
46
+ const result = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async () => false, readFileImpl: async () => '12:pids:/sys/fs/cgroup/' + marker + 'abc' })
47
+ assert.deepEqual(result, { kind, declared: true }, marker)
48
+ }
49
+ // 探测器异常不致命,继续后续判定
50
+ const broken = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async () => { throw new Error('EACCES') }, readFileImpl: async () => { throw new Error('EACCES') } })
51
+ assert.deepEqual(broken, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
52
+ })
53
+
54
+ test('环境检测:win32 跳过 POSIX 探测', async () => {
55
+ const win = await detectRuntimeEnv({ env: {}, platform: WIN32, isTTY: NO_TTY, existsImpl: async () => true, readFileImpl: async () => '/docker/xyz' })
56
+ assert.deepEqual(win, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
57
+ })
58
+
59
+ test('环境检测:双 TTY 判手动直跑,单 TTY 不构成', async () => {
60
+ const both = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
61
+ assert.deepEqual(both, { kind: RUNTIME_KINDS.MANUAL_START, declared: false })
62
+ const stdinOnly = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: { stdin: true, stdout: false }, ...noProbes })
63
+ assert.deepEqual(stdinOnly, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
64
+ // 托管声明优先于 TTY
65
+ const declaredWithTty = await detectRuntimeEnv({ env: { pm_id: '0' }, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
66
+ assert.deepEqual(declaredWithTty, { kind: RUNTIME_KINDS.PM2, declared: true })
67
+ })
@@ -1,7 +1,18 @@
1
1
  import { test } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
+ import { rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
3
6
 
4
7
  import { runUpgrade } from '../src/upgrade.mjs'
8
+ import { runUpgradeWithRetry, judgeAutoRestart, UPGRADE_MAX_ATTEMPTS, UPGRADE_RETRY_BACKOFF_MS } from '../src/index.js'
9
+ import {
10
+ UPGRADE_FAIL_FILE_LOCKED,
11
+ UPGRADE_FAIL_TRANSIENT_NETWORK,
12
+ UPGRADE_FAIL_NPM_MISSING,
13
+ UPGRADE_FAIL_TIMEOUT,
14
+ } from '../src/core.mjs'
15
+ import { RUNTIME_KINDS } from '../src/runtime.mjs'
5
16
 
6
17
  // 脚本体一律单引号:Windows shell 化 spawn 经 cmd.exe,双层双引号会被截断。
7
18
  const NODE = 'node'
@@ -63,3 +74,164 @@ test('场景:命令不存在失败不抛错', async () => {
63
74
  const result = await runUpgrade({ command: 'definitely-not-exist-cmd-xyz --version', timeoutMs: 10 * 1000 })
64
75
  assert.equal(result.ok, false)
65
76
  })
77
+
78
+ // ---- S2 限次重试:尝试循环以注入式执行器单测,退避零等待;末尾附真实假命令端到端 ----
79
+
80
+ const FILE_LOCKED_FAIL = {
81
+ ok: false,
82
+ code: 1,
83
+ timedOut: false,
84
+ stillRunning: false,
85
+ stdoutTail: '',
86
+ stderrTail: 'npm error code EBUSY\nnpm error syscall rename',
87
+ }
88
+ const NETWORK_FAIL = {
89
+ ok: false,
90
+ code: 1,
91
+ timedOut: false,
92
+ stillRunning: false,
93
+ stdoutTail: '',
94
+ stderrTail: 'npm error code ECONNRESET',
95
+ }
96
+ const OK_RESULT = { ok: true, code: 0, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail: '' }
97
+
98
+ function makeHarness(results) {
99
+ const queue = results.slice()
100
+ const sleeps = []
101
+ let attemptStarts = 0
102
+ return {
103
+ sleeps,
104
+ runImpl: async () => (queue.length > 1 ? queue.shift() : queue[0]),
105
+ sleepImpl: async (ms) => { sleeps.push(ms) },
106
+ onAttemptStart: () => { attemptStarts += 1 },
107
+ get starts() { return attemptStarts },
108
+ }
109
+ }
110
+
111
+ const ATTEMPT_KEYS = ['startedAt', 'finishedAt', 'ok', 'code', 'timedOut', 'kind'].sort()
112
+
113
+ test('重试:可重试失败按退避序列重试至成功', async () => {
114
+ const harness = makeHarness([FILE_LOCKED_FAIL, NETWORK_FAIL, OK_RESULT])
115
+ const settle = await runUpgradeWithRetry({ command: 'fake', runImpl: harness.runImpl, sleepImpl: harness.sleepImpl, onAttemptStart: harness.onAttemptStart })
116
+ assert.equal(settle.ok, true)
117
+ assert.equal(settle.attempts.length, 3)
118
+ assert.equal(settle.kind, null)
119
+ assert.deepEqual(settle.attempts.map((a) => a.kind), [UPGRADE_FAIL_FILE_LOCKED, UPGRADE_FAIL_TRANSIENT_NETWORK, null])
120
+ // 退避按 kind 取序列对应位:file-locked 首退避、transient-network 次退避
121
+ assert.deepEqual(harness.sleeps, [UPGRADE_RETRY_BACKOFF_MS[UPGRADE_FAIL_FILE_LOCKED][0], UPGRADE_RETRY_BACKOFF_MS[UPGRADE_FAIL_TRANSIENT_NETWORK][1]])
122
+ assert.equal(harness.starts, 2, '首次尝试不经 onAttemptStart,每次重试各触发一次(锁覆写点)')
123
+ })
124
+
125
+ test('重试:尝试条目形态锁定为裁剪后字段集,尾流仅在落定结果上', async () => {
126
+ const harness = makeHarness([FILE_LOCKED_FAIL, OK_RESULT])
127
+ const settle = await runUpgradeWithRetry({ command: 'fake', runImpl: harness.runImpl, sleepImpl: harness.sleepImpl })
128
+ assert.deepEqual(Object.keys(settle.attempts[0]).sort(), ATTEMPT_KEYS)
129
+ assert.equal(settle.attempts[0].code, 1)
130
+ assert.equal(settle.attempts[0].timedOut, false)
131
+ assert.ok(settle.attempts[0].finishedAt >= settle.attempts[0].startedAt)
132
+ // 末次尝试的尾流上浮到落定结果,供 last 直接消费
133
+ assert.equal(settle.stdoutTail, '')
134
+ assert.equal(settle.stderrTail, '')
135
+ })
136
+
137
+ test('重试:不可重试类立即落定,不睡眠不重试', async () => {
138
+ for (const [fail, kind] of [
139
+ [{ ...FILE_LOCKED_FAIL, stderrTail: 'spawn npmm ENOENT' }, UPGRADE_FAIL_NPM_MISSING],
140
+ [{ ok: false, code: null, timedOut: true, stillRunning: false, stdoutTail: '', stderrTail: '' }, UPGRADE_FAIL_TIMEOUT],
141
+ [{ ok: false, code: 3, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail: 'boom-fail' }, 'unknown'],
142
+ ]) {
143
+ const harness = makeHarness([fail])
144
+ const settle = await runUpgradeWithRetry({ command: 'fake', runImpl: harness.runImpl, sleepImpl: harness.sleepImpl })
145
+ assert.equal(settle.ok, false)
146
+ assert.equal(settle.kind, kind, JSON.stringify(fail))
147
+ assert.equal(settle.attempts.length, 1, JSON.stringify(fail))
148
+ assert.deepEqual(harness.sleeps, [], JSON.stringify(fail))
149
+ }
150
+ })
151
+
152
+ test('重试:持续可重试失败收敛于次数上限,末次不安排退避', async () => {
153
+ const harness = makeHarness([NETWORK_FAIL])
154
+ const settle = await runUpgradeWithRetry({ command: 'fake', runImpl: harness.runImpl, sleepImpl: harness.sleepImpl })
155
+ assert.equal(settle.ok, false)
156
+ assert.equal(settle.kind, UPGRADE_FAIL_TRANSIENT_NETWORK)
157
+ assert.equal(settle.attempts.length, UPGRADE_MAX_ATTEMPTS)
158
+ assert.equal(harness.sleeps.length, UPGRADE_MAX_ATTEMPTS - 1, '末次尝试后不得再安排退避')
159
+ assert.equal(settle.attempts.every((a) => a.kind === UPGRADE_FAIL_TRANSIENT_NETWORK), true)
160
+ })
161
+
162
+ test('重试:退避序列缺失按不可重试落定,不得零间隔轰击', async () => {
163
+ // 注入空退避表模拟"可重试分类缺少序列"的配置缺口:宁可不重试,不可无间隔重试
164
+ const warns = []
165
+ const originalWarn = console.warn
166
+ console.warn = (text) => { warns.push(String(text)) }
167
+ try {
168
+ const harness = makeHarness([FILE_LOCKED_FAIL])
169
+ const settle = await runUpgradeWithRetry({ command: 'fake', backoff: {}, runImpl: harness.runImpl, sleepImpl: harness.sleepImpl })
170
+ assert.equal(settle.ok, false)
171
+ assert.equal(settle.kind, UPGRADE_FAIL_FILE_LOCKED)
172
+ assert.equal(settle.attempts.length, 1, '序列缺失不得进入下一轮尝试')
173
+ assert.deepEqual(harness.sleeps, [], '序列缺失不得安排退避')
174
+ assert.equal(warns.some((text) => text.includes('退避')), true, '序列缺失必须留痕')
175
+ } finally {
176
+ console.warn = originalWarn
177
+ }
178
+ })
179
+
180
+ test('重试:执行器抛错收敛为失败落定不重试', async () => {
181
+ const settle = await runUpgradeWithRetry({
182
+ command: 'fake',
183
+ runImpl: async () => { throw new Error('spawn exploded') },
184
+ sleepImpl: async () => {},
185
+ })
186
+ assert.equal(settle.ok, false)
187
+ assert.equal(settle.error, 'spawn exploded')
188
+ assert.equal(settle.attempts.length, 1)
189
+ })
190
+
191
+ test('重试:常量形态为计算式导出', () => {
192
+ assert.equal(UPGRADE_MAX_ATTEMPTS, 3)
193
+ assert.deepEqual(UPGRADE_RETRY_BACKOFF_MS[UPGRADE_FAIL_FILE_LOCKED], [5 * 1000, 15 * 1000])
194
+ assert.deepEqual(UPGRADE_RETRY_BACKOFF_MS[UPGRADE_FAIL_TRANSIENT_NETWORK], [3 * 1000, 9 * 1000])
195
+ })
196
+
197
+ test('重试:假命令真实进程 文件锁失败后重试成功', async () => {
198
+ const statePath = join(tmpdir(), 'dsh-maintain-retry-test-' + process.pid + '.flag')
199
+ rmSync(statePath, { force: true })
200
+ process.env.DSH_MAINTAIN_TEST_RETRY_STATE = statePath
201
+ try {
202
+ const script = "const fs=require('fs');const p=process.env.DSH_MAINTAIN_TEST_RETRY_STATE;if(fs.existsSync(p)){process.exit(0)}fs.writeFileSync(p,'1');console.error('npm error code EBUSY');console.error('npm error syscall rename');process.exit(1)"
203
+ let attemptStarts = 0
204
+ const settle = await runUpgradeWithRetry({
205
+ command: NODE + ' -e "' + script + '"',
206
+ runImpl: (opts) => runUpgrade(opts),
207
+ sleepImpl: async () => {},
208
+ onAttemptStart: () => { attemptStarts += 1 },
209
+ })
210
+ assert.equal(settle.ok, true)
211
+ assert.equal(settle.attempts.length, 2)
212
+ assert.equal(settle.attempts[0].ok, false)
213
+ assert.equal(settle.attempts[0].kind, UPGRADE_FAIL_FILE_LOCKED, '真实进程 stderr 应命中文件锁特征: ' + settle.attempts[0].stderrTail)
214
+ assert.equal(settle.attempts[1].ok, true)
215
+ assert.equal(settle.attempts[1].kind, null)
216
+ assert.equal(attemptStarts, 1, '仅重试尝试覆写锁')
217
+ } finally {
218
+ rmSync(statePath, { force: true })
219
+ }
220
+ })
221
+
222
+ test('自动重启:四条件守卫(成功/非 stale/非手动直跑/appExit 可用)', () => {
223
+ // 成功+托管(含 unknown)→ 调度
224
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: true, requiresManualRestart: false })
225
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true }), { schedule: true, requiresManualRestart: false })
226
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true }), { schedule: true, requiresManualRestart: false })
227
+ // 失败不调度
228
+ assert.deepEqual(judgeAutoRestart({ ok: false, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: false, requiresManualRestart: false })
229
+ // stale 不调度
230
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: false, requiresManualRestart: false })
231
+ // 手动直跑 → 手动指引,不调度
232
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true }), { schedule: false, requiresManualRestart: true })
233
+ // appExit 缺失不调度
234
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false }), { schedule: false, requiresManualRestart: false })
235
+ // 手动直跑与 appExit 缺失并存 → 仍以手动指引标记(指引面板,与退出能力无关)
236
+ assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false }), { schedule: false, requiresManualRestart: true })
237
+ })