/** * 看门狗核心(startup-guard-core.mjs)—— 纯函数 + 零依赖 + 零 IO。 * * 本文件是 scripts/startup-guard.mjs 的「大脑」:所有**判断**都在这里, * 且全部是可单测的纯函数;文件系统、子进程、网络都在 startup-guard.mjs 里 * 以薄封装注入。铁律:本文件**不 import 任何东西**(连 node: 内置都不用), * 保证它在任何 Node ≥22 环境里都能被加载、被测试。 * * ## 这个看门狗解决什么问题(决策 R-1) * `nodeagent rollback` 是 RPC 命令,**被控端进程完全起不来时无监听可连**, * CLI 救不了(ARCH A-P0-6)。故加一层启动看护:启动连续失败 N 次, * 自动从 LKG(最后已知可用版本)恢复入口文件。 * * ## 与 DELIVERY-PIPELINE §6 的关系 * §6 原取舍「不做自动回滚」的三问(无法归因/回滚循环/失控)在这里的答复: * - 无法归因 → 只看**启动期**死亡(存活 < STARTUP_GRACE_MS), * 运行期死亡不计(计划内重启杀的是长命进程,见 decideAfterExit); * - 回滚循环 → 恢复后计数归零,且恢复目标只有一个(LKG),不滚动; * - 失控 → 恢复动作全量记 JSONL 日志,且**无 LKG/无匹配时绝不擦除 install-dir**。 */ /** * 启动宽限期(ms):子进程存活超过它再死,视为**运行期事件**而非启动失败。 * * 为什么需要:本项目的受控端重启机制(system.agent.restart)是 * 「detached 进程杀旧起新」——旧进程是被 kill -9/taskkill 打死的**长命**进程。 * 若不区分启动期/运行期,一次计划内重启就会被记成「启动失败」, * 累积 N 次后触发**误回滚**(把好版本回滚掉,比坏版本停摆更糟)。 * 30s 的依据:agent 正常启动(含首次生成证书)在秒级完成; * 坏版本(语法错/缺模块/端口冲突)在 1-2s 内死亡。 */ export const STARTUP_GRACE_MS = 30_000; /** * 看门狗日志轮换阈值(字节):超过即滚存一份。 * * 为什么需要:日志是 `appendFileSync` 无界追加的 —— 每次启动失败/恢复各写一行, * 长期运行(尤其「坏版本 + supervisor 反复重启」)会把磁盘写满,而看门狗自己 * 就常驻在这台机器上,磁盘满会连带把 agent 的审计写入一起拖死。 * 1MB × 4 份(当前 + 3 份历史)≈ 4MB 上限,对排障足够(最近几百次启动)。 */ export const LOG_MAX_BYTES = 1_048_576; /** 保留的历史份数(`.1` 最新 … `.N` 最旧)。 */ export const LOG_KEEP = 3; /** 是否需要轮换(纯函数):严格大于阈值才轮换,避免恰好等于阈值时抖动着反复滚。 */ export function shouldRotate(sizeBytes, maxBytes = LOG_MAX_BYTES) { return Number.isFinite(sizeBytes) && sizeBytes > maxBytes; } /** 轮换目标文件名(从新到旧):`.1` … `.N`。 */ export function rotationTargets(logPath, keep = LOG_KEEP) { const out = []; for (let i = 1; i <= keep; i += 1) out.push(`${logPath}.${i}`); return out; } /** 全新状态(文件缺失时的起点)。 */ export function initialState(nowIso = '') { return { failures: 0, updated_at: nowIso }; } /** 启动成功后的状态:计数归零(与 agent 侧 listen 成功后写状态同口径)。 */ export function zeroFailures(nowIso = '') { return { failures: 0, updated_at: nowIso }; } /** * 解析状态文件文本 → `{failures, updated_at}`。 * * 缺失/损坏/字段非法一律按 0 —— **不猜、但也不因此拒绝服务**: * 状态文件只是计数,坏了的代价从「重新数」开始,而不是「不启动」。 * nowIso 仅在文本合法时保留原 updated_at;调用方需要时自行覆盖。 */ export function readState(rawText) { if (typeof rawText !== 'string' || rawText.trim() === '') return initialState(); let obj; try { obj = JSON.parse(rawText); } catch { return initialState(); } if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return initialState(); // 只认 number 型的非负整数:字符串 "3"、布尔、null 一律按 0(不猜) const n = obj.failures; const failures = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : 0; const updatedAt = typeof obj.updated_at === 'string' ? obj.updated_at : ''; return { failures, updated_at: updatedAt }; } /** 失败一次后的新状态(nowIso 由调用方注入,保证可测)。 */ export function nextFailures(state, nowIso = '') { const prev = Number.isInteger(state?.failures) && state.failures >= 0 ? state.failures : 0; return { failures: prev + 1, updated_at: nowIso }; } /** * 是否达到恢复阈值:连续失败 **>= threshold** 才恢复。 * * 阈值非法(非正整数)→ 不恢复(放行启动)—— 守卫自身配置错误时, * 宁可多试几次,也不能乱动入口文件。 */ export function decideRestore(state, threshold) { const t = Number(threshold); if (!Number.isInteger(t) || t < 1) return false; return (state?.failures ?? 0) >= t; } /** * 子进程退出后的处置决策(纯函数,本模块最重要的判断)。 * * @param {object} input * @param {number|null} [input.code] 子进程退出码(被信号打死时为 null) * @param {string|null} [input.signal] 打死子进程的信号名(正常退出为 null) * @param {number} input.livedMs 子进程存活时长 * @param {boolean} input.stopping 本 guard 是否正在被要求停止 * (systemctl stop / 任务停止 → 已转发信号给子进程) * @param {boolean} input.serviceUp 端口是否已被(别的实例)服务 * @param {object} input.state 进入本次决策前的状态 * @param {string} input.nowIso 当前时间(注入) * @returns {{state: object, counted: boolean, persist: boolean, exitCode: number, reason: string}} * * 优先级(从高到低): * 1. serviceUp —— 服务已由别的实例提供(撞车/接管):使命达成,归零退出 0; * 2. stopping —— 本 guard 被要求停:不计数,按子进程退出码退出; * 3. 干净退出 0 —— 成功,归零退出 0; * 4. 运行期死亡(livedMs >= 宽限期)—— 不计「启动失败」,退出码交回外层 supervisor; * 5. 启动期死亡且无人接管 —— 连续启动失败 +1,退出码交回外层 supervisor。 * * persist 表示「本次决策是否要落盘」(stopping / runtime-exit 不写: * 状态没变,写了反而把 updated_at 搅乱)。 */ export function decideAfterExit(input) { const { code = 0, signal = null, livedMs = 0, stopping = false, serviceUp = false, state = initialState(), nowIso = '', } = input ?? {}; if (serviceUp) { return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'service-up' }; } if (stopping) { return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'stopping' }; } if (!signal && code === 0) { return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'clean-exit' }; } const lived = Number.isFinite(livedMs) ? livedMs : 0; if (lived >= STARTUP_GRACE_MS) { return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'runtime-exit' }; } return { state: nextFailures(state, nowIso), counted: true, persist: true, exitCode: code ?? 1, reason: 'startup-failure' }; } /** * 备份文件名匹配:`.bak`(deploy 固定名)与 `.bak-`(selfupdate 时间戳名)。 * * 与控制端 `apps/cli/src/cmd/rollback.ts` 的 `backupNameRegex` **同口径** —— * 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致。 * (tests/unit/startup-guard.test.mjs 有跨端一致性守卫。) * 文件名里的正则元字符一律转义;Windows 反斜杠不当分隔符处理(文件名内不出现)。 */ export function backupNameRegex(entryName) { const base = String(entryName ?? ''); const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`^${esc}\\.bak(-.*)?$`); } /** * 指纹比对:全量相等,或老 LKG(12 位)按前缀比。 * * 与 CLI 的 `hashMatches` 同口径:LkgRecord 老数据只有 12 位 hash * (ARCH A-P0-3/R-2),不得要求被控端先升级才能被看门狗恢复。 */ export function hashMatches(got, want) { if (typeof got !== 'string' || typeof want !== 'string' || !got || !want) return false; if (got === want) return true; return want.length < got.length && got.startsWith(want); } /** * 从「已算好指纹」的备份候选里挑恢复源(纯函数;mtime 新→早的排序在外层做)。 * * @param {Array<{path: string, hash: string, mtime?: number}>} cands * @param {string} wantHash LKG 指纹(hash_full ?? hash) * @returns {{path: string, hash: string}|null} 第一个匹配者;无匹配返回 null */ export function planRestore(cands, wantHash) { if (!wantHash || !Array.isArray(cands)) return null; for (const c of cands) { if (c && hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash }; } return null; }