@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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.
Files changed (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1022 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-restart-guard — 2.6 红线守卫:**只拦母体重启**
4
+ //
5
+ // 硬边界(CONTRACTS §1.2 / §1.3):
6
+ // * 只服务母体重启;分身重启 = forker.reap() + 重新 fork(),不经过本插件。
7
+ // * v3.1.x 默认「决策 + 审计 + 审批 + 授权清单」,**不退出**进程。
8
+ // 只有三重门禁同时满足时才把执行权交出去:mode==='enforce' ∧ allowProcessRestart===true
9
+ // ∧ 外部 supervisor 在位(FANG_RESTART_SUPERVISOR 环境变量,或 watchdog 写的
10
+ // <stateDir>/supervisor.present 且 execute===true、未过期、写入进程存活)。
11
+ // 三者缺一:只写清单、execution.executed 恒 false,进程继续运行。
12
+ // 契约见 src/lib/restart-contract.mjs 与 docs/RESTART-SUPERVISOR-CONTRACT.md。
13
+ // * 单飞状态机 idle → collecting → awaiting → executing → idle;并发请求的第二个直接 busy 拒绝。
14
+ // * 决策矩阵移植自 dsh-hot-update/packages/restart-guard/index.js 的 classifyRisk/findGrant/decide
15
+ // (policy.mode = 授权模式 manual|temp|auto,与 policy() 里的守卫层 mode=observe|enforce 不是同一个字段)。
16
+ // * needs-approval 时通过 ctx.get(SERVICE.approver)?.request(...) 走审批;审批服务缺失或抛错 => fail-closed。
17
+ import path from 'node:path'
18
+ import fs from 'node:fs'
19
+ import { AUDIT, EXIT, GUARD_STATE, RISK, SERVICE, bus, newId, newUuid, riskAtLeast, z } from '../kernel/index.mjs'
20
+ import {
21
+ appendJsonl,
22
+ ensureDir,
23
+ readJson,
24
+ readTextSafe,
25
+ resolveFangLayout,
26
+ writeJsonAtomic,
27
+ } from '../kernel/index.mjs'
28
+ import { roleGate } from '../kernel/index.mjs'
29
+ import {
30
+ APPROVAL_KIND,
31
+ CONTRACT_VERSION,
32
+ EXECUTION_KIND,
33
+ LATEST_KIND,
34
+ approvalDir,
35
+ approvalFile,
36
+ buildApprovalManifest,
37
+ executionDir,
38
+ inspectApproval,
39
+ latestApprovalFile,
40
+ listApprovalIds,
41
+ pruneRestartState,
42
+ receiptFile,
43
+ reconcileRestartState,
44
+ readCrashLoop,
45
+ detectCrashLoop,
46
+ resolveHmacSecret,
47
+ summarizeApproval,
48
+ summarizeApprovalCounts,
49
+ } from '../lib/restart-contract.mjs'
50
+
51
+ export const name = 'fang-restart-guard'
52
+ export const inject = []
53
+
54
+ const DEFAULTS = Object.freeze({
55
+ mode: 'observe',
56
+ authMode: 'manual',
57
+ strictRedLine: true,
58
+ approvalTimeoutMs: 300000,
59
+ allowProcessRestart: false,
60
+ autoPromoteAfterHours: 24,
61
+ dataRoot: '',
62
+ manifestTtlMs: 60000,
63
+ })
64
+
65
+ export const Config = z.object({
66
+ mode: z.union(['observe', 'enforce']).default('observe')
67
+ .description('守卫执行层:observe 只决策不退出;enforce 是与 allowProcessRestart + supervisor 在位 共同组成的三重门禁'),
68
+ authMode: z.union(['manual', 'temp', 'auto']).default('manual')
69
+ .description('授权模式:manual 逐次询问 / temp 临时授权 / auto 非红自动放行(决策矩阵的 policy.mode)'),
70
+ strictRedLine: z.boolean().default(true)
71
+ .description('红灯是否必须人工确认'),
72
+ approvalTimeoutMs: z.number().min(1000).max(86400000).default(300000)
73
+ .description('审批等待上限(毫秒);审批服务本身也会收到 ttlMs'),
74
+ allowProcessRestart: z.boolean().default(false)
75
+ .description('第二把钥匙:默认 false。只有 enforce + true + supervisor 在位时才在批准后 exit(75) 交接执行权'),
76
+ autoPromoteAfterHours: z.union([6, 12, 24]).default(24)
77
+ .description('保留字段(bundle 配置兼容):自动晋升归属编排器/晋升流程,本插件不消费'),
78
+ dataRoot: z.string().default('')
79
+ .description('Fang 数据根目录;留空 = resolveFangLayout() 默认。状态存 <dataDir>/restart/state.json'),
80
+ manifestTtlMs: z.number().min(1000).max(900000).default(60000)
81
+ .description('授权清单(supervisor 契约)的有效期(毫秒);过期清单不能再被执行'),
82
+ })
83
+
84
+ export const RISK_VALUES = [RISK.BLUE, RISK.YELLOW, RISK.RED]
85
+ const RANK = { blue: 1, yellow: 2, red: 3 }
86
+ const REQUESTED_TOPIC = 'fang/restart-requested'
87
+ const RESOLVED_TOPIC = 'fang/restart-resolved'
88
+ const APPROVED_TOPIC = 'fang/restart-approved'
89
+
90
+ const errText = (e) => String(e?.message ?? e)
91
+ const safeGet = (ctx, key) => { try { return ctx.get(key) } catch { return undefined } }
92
+ const isRisk = (v) => typeof v === 'string' && Object.prototype.hasOwnProperty.call(RANK, v)
93
+
94
+ // ───────────────────────── 决策矩阵(与参考实现同构,parity 守住) ─────────────────────────
95
+ /** 风险分级:origin/scope/级联灯色 → blue|yellow|red;没有 digests 时升一级(看不见内容就不许判蓝)。 */
96
+ export function classifyRisk(input = {}) {
97
+ let raw
98
+ if (input.origin === 'supervisor' || input.origin === 'cron') raw = RISK.RED
99
+ else if (String(input.origin ?? '').startsWith('cli:kill')) raw = RISK.RED
100
+ else if (input.originLight === 'red') raw = RISK.RED
101
+ else if (input.scope === 'system' || input.scope === 'process') raw = RISK.RED
102
+ else if (input.scope === 'session') raw = RISK.YELLOW
103
+ else if (input.origin === 'cli' || input.origin === 'user-quit') raw = RISK.YELLOW
104
+ else if (input.originLight === 'yellow') raw = RISK.YELLOW
105
+ else raw = RISK.BLUE
106
+ if ((input.digests ?? []).length > 0) return raw
107
+ return raw === RISK.BLUE ? RISK.YELLOW : RISK.RED
108
+ }
109
+
110
+ /** 临时授权(temp 模式):scope/target/maxRisk/digests 全部匹配且未过期才算数。 */
111
+ export function findGrant(grants, req, now = Date.now()) {
112
+ return (grants ?? []).find(g =>
113
+ g.expiresAt > now &&
114
+ g.scope === req.scope &&
115
+ (g.target === undefined || g.target === req.target) &&
116
+ RANK[g.maxRisk] >= RANK[req.risk] &&
117
+ (req.digests ?? []).every(d => (g.digests ?? []).includes(d)))
118
+ }
119
+
120
+ /** 决策:forcedApproval 一票否决 → 授权模式(policy.mode=manual|temp|auto)→ strictRedLine。 */
121
+ export function decide(req, policy = {}, now = Date.now()) {
122
+ if (req.forcedApproval) return 'needs-approval'
123
+ if (policy.mode === 'manual') return req.risk === RISK.BLUE ? 'auto-allow' : 'needs-approval'
124
+ if (policy.mode === 'temp') {
125
+ if (req.risk === RISK.BLUE) return 'auto-allow'
126
+ return findGrant(policy.grants, req, now) ? 'auto-allow' : 'needs-approval'
127
+ }
128
+ if (!riskAtLeast(req.risk, RISK.RED)) return 'auto-allow' // blue / yellow
129
+ return policy.strictRedLine ? 'needs-approval' : 'auto-allow'
130
+ }
131
+
132
+ export function apply(ctx, config) {
133
+ const gate = roleGate('restartGuard', { cfgRole: config?.role, logger: ctx.logger })
134
+ // v2 deployment matrix: this plugin is not deployed in this role — register nothing.
135
+ if (!gate.active) return
136
+ let validated
137
+ try { validated = Config(config ?? {}) } catch (e) {
138
+ throw new Error(`[fang-restart-guard] config invalid: ${errText(e).replace(/^\$\./, '')}`)
139
+ }
140
+ const cfg = { ...DEFAULTS, ...validated }
141
+ /** 决策矩阵用授权策略;注意它的 mode 是 manual|temp|auto(不是守卫层 observe|enforce)。 */
142
+ const authPolicy = { mode: cfg.authMode, grants: [], strictRedLine: cfg.strictRedLine, approvalTimeoutMs: cfg.approvalTimeoutMs }
143
+ const tempEffectiveManual = cfg.authMode === 'temp' && authPolicy.grants.length === 0
144
+ const log = (level, msg) => { try { ctx.logger?.[level]?.(`[fang-restart-guard] ${msg}`) } catch { /* 日志不许破状态机 */ } }
145
+ if (tempEffectiveManual) log('warn', 'authMode=temp has no grants; effective mode is manual (temp-effective-manual)')
146
+ if (cfg.strictRedLine === false) log('warn', 'strictRedLine=false: red requests may be auto-allowed in auto mode')
147
+
148
+ /** 单飞状态机:pending 只描述「正在处理的唯一请求」。 */
149
+ const machine = { state: GUARD_STATE.IDLE, pending: null }
150
+ const timers = new Set()
151
+ /** Set while awaiting an approval; dispose() calls it so the outer request settles. */
152
+ let abortActiveApproval = null
153
+
154
+ const layout = resolveFangLayout({ dataRoot: cfg.dataRoot })
155
+ const stateDir = path.join(layout.dataDir, 'restart')
156
+ const stateFile = path.join(stateDir, 'state.json')
157
+ // supervisor 契约落盘目录(详见 src/lib/restart-contract.mjs 与 docs/RESTART-SUPERVISOR-CONTRACT.md)
158
+ const approvedDir = approvalDir(stateDir)
159
+ const executedDir = executionDir(stateDir)
160
+ const instanceId = newUuid()
161
+ const pidFile = layout.pidFile
162
+ const supervisorMarkerFile = path.join(stateDir, 'supervisor.present')
163
+ const bootStateFile = path.join(stateDir, 'boot-state.json')
164
+ const crashLedgerFile = path.join(stateDir, 'runtime-crash.jsonl')
165
+ const safeModeFile = path.join(stateDir, 'safe-mode.json')
166
+ const readSafeMode = () => {
167
+ const doc = readJson(safeModeFile, null)
168
+ return doc && doc.active === true
169
+ ? { active: true, reason: String(doc.reason ?? 'unknown'), at: Number(doc.at) || 0, crashCount: Number(doc.crashCount) || 0, file: safeModeFile }
170
+ : { active: false, reason: null, at: 0, crashCount: 0, file: safeModeFile }
171
+ }
172
+ let safeMode = readSafeMode()
173
+ const crashLoopAtBoot = readCrashLoop(crashLedgerFile)
174
+ if (crashLoopAtBoot.active && !safeMode.active) {
175
+ safeMode = {
176
+ active: true,
177
+ reason: 'crash-loop',
178
+ at: Date.now(),
179
+ crashCount: crashLoopAtBoot.count,
180
+ file: safeModeFile,
181
+ windowMs: crashLoopAtBoot.windowMs,
182
+ threshold: crashLoopAtBoot.threshold,
183
+ }
184
+ try {
185
+ ensureDir(stateDir)
186
+ writeJsonAtomic(safeModeFile, { version: 1, active: true, reason: 'crash-loop', at: safeMode.at, crashCount: safeMode.crashCount, windowMs: crashLoopAtBoot.windowMs, threshold: crashLoopAtBoot.threshold })
187
+ } catch { /* 安全模式落盘失败也不能阻断启动;内存 active 仍生效 */ }
188
+ log('warn', 'crash loop detected (' + crashLoopAtBoot.count + '/' + crashLoopAtBoot.threshold + ' in ' + crashLoopAtBoot.windowMs + 'ms): restart handoff disabled until manual clear')
189
+ }
190
+ const SUPERVISOR_MARKER_TTL_MS = 60000
191
+ const pidAlive = (pid) => {
192
+ if (!Number.isInteger(pid) || pid <= 0) return false
193
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
194
+ }
195
+
196
+ // 运行时崩溃账本:只监听 uncaughtExceptionMonitor(不改变宿主退出行为),记录后供下次启动/
197
+ // launcher 判断“上次是不是启动期崩溃”。真正的 apply/import 硬失败仍由宿主决定是否退出。
198
+ const previousBoot = readJson(bootStateFile, null)
199
+ const lastUncleanBoot = previousBoot
200
+ && (previousBoot.state === 'booting' || previousBoot.state === 'crashed')
201
+ && Number(previousBoot.pid) !== process.pid
202
+ && !pidAlive(Number(previousBoot.pid))
203
+ ? { state: String(previousBoot.state), pid: Number(previousBoot.pid) || null, at: Number(previousBoot.at) || 0, error: previousBoot.error ?? null }
204
+ : null
205
+ const writeBootState = (state, extra = {}) => {
206
+ try {
207
+ ensureDir(stateDir)
208
+ writeJsonAtomic(bootStateFile, { state, pid: process.pid, at: Date.now(), ...extra })
209
+ } catch { /* boot state must never break boot */ }
210
+ }
211
+ writeBootState('booting', lastUncleanBoot ? { previous: lastUncleanBoot } : {})
212
+ const bootTimer = setTimeout(() => writeBootState('running'), 10000)
213
+ bootTimer.unref?.()
214
+ const onUncaughtException = (error, origin) => {
215
+ try {
216
+ appendJsonl(crashLedgerFile, {
217
+ at: Date.now(), pid: process.pid, origin: String(origin ?? 'uncaughtException'),
218
+ message: String(error?.message ?? error).slice(0, 500),
219
+ stack: String(error?.stack ?? '').slice(0, 4000),
220
+ })
221
+ } catch { /* crash ledger must never throw */ }
222
+ writeBootState('crashed', { error: String(error?.message ?? error).slice(0, 500), origin: String(origin ?? '') })
223
+ }
224
+ process.on('uncaughtExceptionMonitor', onUncaughtException)
225
+ ctx.effect(() => () => {
226
+ try { clearTimeout(bootTimer) } catch { /* ignore */ }
227
+ try { process.off('uncaughtExceptionMonitor', onUncaughtException) } catch { /* ignore */ }
228
+ writeBootState('clean')
229
+ })
230
+ /** 母体 pid 文件:外部看护(watchdog)用它判断「哪个宿主死了」。 */
231
+ const writeHostPid = () => {
232
+ try {
233
+ ensureDir(path.dirname(pidFile))
234
+ writeJsonAtomic(pidFile, { version: 1, pid: process.pid, role: gate.role, at: Date.now() })
235
+ } catch (error) {
236
+ log('warn', `pid file write failed: ${errText(error)}`)
237
+ }
238
+ }
239
+ const clearHostPid = () => {
240
+ try {
241
+ const raw = readJson(pidFile, null)
242
+ if (raw && Number(raw.pid) === process.pid) fs.unlinkSync(pidFile)
243
+ } catch { /* pid 文件清理失败不影响退出 */ }
244
+ }
245
+ /**
246
+ * 外部看护是否在位。只认两种可信来源:
247
+ * 1) 环境变量 FANG_RESTART_SUPERVISOR(由用户/启动脚本显式设置);
248
+ * 2) <stateDir>/supervisor.present,且 execute===true、未过 TTL、写入进程仍存活。
249
+ * 幽灵 marker(watchdog 崩溃)不会被当成 supervisor。
250
+ */
251
+ const detectSupervisor = () => {
252
+ const envValue = String(process.env.FANG_RESTART_SUPERVISOR ?? '').trim()
253
+ if (envValue) return { present: true, via: 'env', id: envValue }
254
+ const marker = readJson(supervisorMarkerFile, null)
255
+ if (!marker || marker.execute !== true) return { present: false, via: null, id: null }
256
+ const at = Number(marker.at)
257
+ if (!Number.isFinite(at) || Date.now() - at > SUPERVISOR_MARKER_TTL_MS) return { present: false, via: null, id: null }
258
+ if (!pidAlive(Number(marker.pid))) return { present: false, via: null, id: null }
259
+ return { present: true, via: 'file', id: String(marker.supervisor ?? 'watchdog') }
260
+ }
261
+ /** 三重门禁:enforce ∧ allowProcessRestart ∧ supervisor 在位,才允许把执行权交出去。 */
262
+ const executionArmed = () => {
263
+ const supervisor = detectSupervisor()
264
+ const crashLoop = readCrashLoop(crashLedgerFile)
265
+ const sm = readSafeMode()
266
+ if (sm.active) safeMode = sm
267
+ return {
268
+ // v3.3.0:崩溃循环/安全模式下即使三重门禁全绿,也绝不交接执行权。
269
+ armed: cfg.mode === 'enforce' && cfg.allowProcessRestart === true && supervisor.present === true && safeMode.active !== true,
270
+ supervisor,
271
+ mode: cfg.mode,
272
+ allowProcessRestart: cfg.allowProcessRestart === true,
273
+ safeMode: { ...safeMode },
274
+ crashLoop,
275
+ }
276
+ }
277
+ writeHostPid()
278
+ /** 本进程最近一次成功写出的授权清单(仅供 state() 展示,不是执行依据)。 */
279
+ let lastManifest = null
280
+ let lastInterrupted = null
281
+ const persistState = (extra = {}) => {
282
+ try {
283
+ ensureDir(stateDir)
284
+ writeJsonAtomic(stateFile, {
285
+ version: 1,
286
+ state: machine.state,
287
+ pending: machine.pending ? { ...machine.pending } : null,
288
+ at: Date.now(),
289
+ ...(lastInterrupted ? { lastInterrupted } : {}),
290
+ ...extra,
291
+ })
292
+ } catch (error) {
293
+ log('warn', `state persist failed: ${errText(error)}`)
294
+ }
295
+ }
296
+ const readState = () => {
297
+ const raw = readTextSafe(stateFile, '')
298
+ if (!raw) return null
299
+ try { return JSON.parse(raw) } catch { return null }
300
+ }
301
+
302
+ const audit = (kind, data) => {
303
+ try {
304
+ const auditor = safeGet(ctx, SERVICE.auditor)
305
+ if (auditor && typeof auditor.append === 'function') return auditor.append(kind, data) ?? null
306
+ } catch (e) {
307
+ log('warn', `audit ${kind} degraded: ${errText(e)}`)
308
+ }
309
+ return null
310
+ }
311
+ const emit = (topic, payload) => {
312
+ try { return bus.emit(topic, payload) } catch (e) { log('warn', `emit ${topic} failed: ${errText(e)}`); return 0 }
313
+ }
314
+
315
+ // 清单签名 key(env 优先;否则 stateDir/restart-hmac.key,0600,首次生成)。
316
+ const hmac = resolveHmacSecret(stateDir)
317
+ if (!hmac.ok) log('warn', `manifest HMAC unavailable (${hmac.reason}); manifests remain digest-only`)
318
+ const hmacSecret = hmac.ok ? hmac.secret : null
319
+ const inspectOptions = (now = Date.now()) => ({ now, hmacSecret, requireSignature: hmacSecret !== null })
320
+
321
+ // 启动对账 + 旧清单 prune:不信任「上次进程还活着」的残留状态。
322
+ try {
323
+ const reconciled = reconcileRestartState(stateDir, { hmacSecret })
324
+ if (reconciled.checked > 0) {
325
+ log('info', `startup reconcile: checked=${reconciled.checked} armed=${reconciled.counts.armed} executed=${reconciled.counts.executed} stale=${reconciled.counts.stale} invalid=${reconciled.counts.invalid}`)
326
+ audit('restart-reconcile', { checked: reconciled.checked, counts: reconciled.counts, hmac: hmac.ok ? hmac.source : 'unavailable' })
327
+ }
328
+ const pruned = pruneRestartState(stateDir, { hmacSecret })
329
+ if (pruned.removed.length > 0) {
330
+ log('info', `startup prune: removed ${pruned.removed.length} old manifest(s)`)
331
+ audit('restart-manifest-pruned', { removed: pruned.removed, kept: pruned.kept })
332
+ }
333
+ } catch (error) {
334
+ log('warn', `startup reconcile/prune failed: ${errText(error)}`)
335
+ }
336
+
337
+ // Crash/interrupted recovery: the old Promise no longer exists. Mark it interrupted,
338
+ // pair the audit trail, and never pretend the request is still pending.
339
+ const previousState = readState()
340
+ if (previousState && previousState.state && previousState.state !== GUARD_STATE.IDLE) {
341
+ const old = previousState.pending ?? {}
342
+ const interruptedId = String(old.id ?? newId('rst'))
343
+ const interruptedRisk = isRisk(old.risk) ? old.risk : RISK.RED
344
+ lastInterrupted = {
345
+ id: interruptedId,
346
+ risk: interruptedRisk,
347
+ previousState: previousState.state,
348
+ previousPhase: old.phase ?? null,
349
+ approvalId: old.approvalId ?? null,
350
+ at: Date.now(),
351
+ }
352
+ audit(AUDIT.RESTART_RESOLVED, {
353
+ id: interruptedId,
354
+ outcome: 'interrupted',
355
+ decision: 'needs-approval',
356
+ risk: interruptedRisk,
357
+ reason: String(old.reason ?? 'process-restarted'),
358
+ deniedReason: 'process-restarted',
359
+ by: 'restart',
360
+ approvalId: old.approvalId ?? null,
361
+ previousState: previousState.state,
362
+ previousPhase: old.phase ?? null,
363
+ })
364
+ emit(RESOLVED_TOPIC, {
365
+ id: interruptedId,
366
+ outcome: 'interrupted',
367
+ reason: String(old.reason ?? 'process-restarted'),
368
+ risk: interruptedRisk,
369
+ deniedReason: 'process-restarted',
370
+ approvalId: old.approvalId ?? null,
371
+ })
372
+ persistState()
373
+ }
374
+
375
+ /**
376
+ * 影响面:从 forker.list() 与 buffer.state() 收集;服务缺失/抛错一律降级为空(绝不因收集失败拒绝请求)。
377
+ * pendingPromotes 来自 approver.pending() 里的 promote 工单;activeSessions 在 v1.0.0 没有可读会话服务 → 0。
378
+ */
379
+ const impact = () => {
380
+ const report = { activeForks: [], pendingPromotes: [], dirtyBuffers: [], activeSessions: 0 }
381
+
382
+ const forker = safeGet(ctx, SERVICE.forker)
383
+ if (forker && typeof forker.list === 'function') {
384
+ try {
385
+ const list = forker.list() ?? []
386
+ report.activeForks = list
387
+ .filter(f => f && (f.running === true || f.status === 'running'))
388
+ .map(f => String(f.forkId))
389
+ } catch (e) { log('warn', `impact forker degraded: ${errText(e)}`) }
390
+ }
391
+
392
+ const buffer = safeGet(ctx, SERVICE.buffer)
393
+ if (buffer && typeof buffer.state === 'function') {
394
+ try {
395
+ const st = buffer.state() ?? {}
396
+ report.dirtyBuffers = Array.isArray(st.pending) ? st.pending.map(String) : []
397
+ } catch (e) { log('warn', `impact buffer degraded: ${errText(e)}`) }
398
+ }
399
+
400
+ const approver = safeGet(ctx, SERVICE.approver)
401
+ if (approver && typeof approver.pending === 'function') {
402
+ try {
403
+ const items = approver.pending() ?? []
404
+ report.pendingPromotes = items
405
+ .filter(i => String(i?.kind ?? '').includes('promote'))
406
+ .map(i => String(i?.id ?? ''))
407
+ } catch (e) { log('warn', `impact approver degraded: ${errText(e)}`) }
408
+ }
409
+
410
+ return report
411
+ }
412
+
413
+ /** 显式 risk 只能提高风险,不能压过 classifyRisk;低报不再能绕过升级。 */
414
+ const normalizeRisk = (input = {}) => {
415
+ const classified = classifyRisk(input)
416
+ if (!isRisk(input.risk)) return classified
417
+ return RANK[input.risk] > RANK[classified] ? input.risk : classified
418
+ }
419
+
420
+ /** 审批等待的兜底计时器:审批服务永不 resolve 时也要回到 idle,不许把状态机卡死。 */
421
+ const withDeadline = (promise, ms) => new Promise((resolve, reject) => {
422
+ let settled = false
423
+ const timer = setTimeout(() => finish(resolve, { outcome: 'timeout', by: 'guard-timeout', at: Date.now() }), Math.max(1, Number(ms) || 1))
424
+ timers.add(timer)
425
+ function finish(fn, value) {
426
+ if (settled) return
427
+ settled = true
428
+ timers.delete(timer)
429
+ clearTimeout(timer)
430
+ fn(value)
431
+ }
432
+ Promise.resolve(promise).then(v => finish(resolve, v), e => finish(reject, e))
433
+ })
434
+
435
+ const release = () => {
436
+ machine.state = GUARD_STATE.IDLE
437
+ const wasPending = machine.pending
438
+ machine.pending = null
439
+ persistState()
440
+ return wasPending
441
+ }
442
+
443
+ /**
444
+ * 组装并原子落盘授权清单(supervisor 契约的阶段 1)。
445
+ * 这是「批准」唯一的外化凭据:没有清单 = supervisor 不得执行。
446
+ */
447
+ const writeApprovalManifest = (req, { by, outcome, approvalId, approvedAt }) => {
448
+ const manifest = buildApprovalManifest({
449
+ id: req.id,
450
+ risk: req.risk,
451
+ outcome,
452
+ approvalId,
453
+ approvedBy: by,
454
+ reason: req.reason,
455
+ scope: req.scope,
456
+ origin: req.origin,
457
+ digests: req.digests,
458
+ impactSource: req.impactSource ?? null,
459
+ requestedAt: req.requestedAt,
460
+ approvedAt,
461
+ ttlMs: cfg.manifestTtlMs,
462
+ hmacSecret,
463
+ guard: {
464
+ mode: cfg.mode,
465
+ authMode: cfg.authMode,
466
+ strictRedLine: cfg.strictRedLine,
467
+ allowProcessRestart: cfg.allowProcessRestart,
468
+ pid: process.pid,
469
+ instanceId,
470
+ },
471
+ })
472
+ const file = approvalFile(stateDir, manifest.id)
473
+ writeJsonAtomic(file, manifest)
474
+ // latest.json 只是给外部入口的便利指针,不是执行依据;清单本身才是。
475
+ try {
476
+ writeJsonAtomic(latestApprovalFile(stateDir), {
477
+ version: CONTRACT_VERSION,
478
+ kind: LATEST_KIND,
479
+ id: manifest.id,
480
+ file: path.basename(file),
481
+ digest: manifest.digest,
482
+ approvedAt: manifest.approvedAt,
483
+ expiresAt: manifest.expiresAt,
484
+ at: Date.now(),
485
+ })
486
+ } catch (error) {
487
+ log('warn', `latest approval pointer write failed: ${errText(error)}`)
488
+ }
489
+ return manifest
490
+ }
491
+
492
+ /**
493
+ * executing 阶段:默认只记录状态(不退出/不重启)。
494
+ * 批准必须落盘授权清单;写不进去 => fail-closed,按拒绝处理。
495
+ * 三重门禁(enforce + allowProcessRestart + supervisor 在位)齐了才把执行权交出去:
496
+ * 清单/审计/事件落盘后 exit(75),由 watchdog 拉起新宿主并写回执。
497
+ */
498
+ const finishApproved = (req, { decision, by = null, outcome, approvalId = null, t0 }) => {
499
+ const approvedAt = Date.now()
500
+ let manifest
501
+ try {
502
+ manifest = writeApprovalManifest(req, { by, outcome, approvalId, approvedAt })
503
+ } catch (error) {
504
+ // 契约层失败:不能对外声称存在一份可执行的批准。
505
+ return finishDenied(req, 'manifest-write-failed', 'denied', {
506
+ by, approvalId, error: errText(error), ms: Date.now() - t0,
507
+ })
508
+ }
509
+ machine.state = GUARD_STATE.EXECUTING
510
+ if (machine.pending) machine.pending.phase = GUARD_STATE.EXECUTING
511
+ const gateState = executionArmed()
512
+ const handoff = gateState.armed === true
513
+ persistState(handoff ? {
514
+ handoff: { to: 'supervisor', via: gateState.supervisor.via, at: Date.now(), exitCode: EXIT.RESTART },
515
+ } : {})
516
+ const execution = {
517
+ executed: false,
518
+ reason: handoff ? 'supervisor-handoff' : 'v3.1.x-decision-only',
519
+ guardMode: cfg.mode,
520
+ allowProcessRestart: cfg.allowProcessRestart === true,
521
+ plannedExitCode: EXIT.RESTART, // 与 forker.classifyExit 的 restart-request 契约对齐
522
+ manifestDigest: manifest.digest,
523
+ manifestExpiresAt: manifest.expiresAt,
524
+ handoff: handoff ? {
525
+ requested: true, to: 'supervisor', via: gateState.supervisor.via,
526
+ id: gateState.supervisor.id, exitCode: EXIT.RESTART,
527
+ } : null,
528
+ }
529
+ const authorization = {
530
+ contractVersion: CONTRACT_VERSION,
531
+ manifestId: manifest.id,
532
+ nonce: manifest.nonce,
533
+ digest: manifest.digest,
534
+ expiresAt: manifest.expiresAt,
535
+ manifestPath: approvalFile(stateDir, manifest.id),
536
+ receiptPath: receiptFile(stateDir, manifest.id),
537
+ }
538
+ lastManifest = {
539
+ id: manifest.id, digest: manifest.digest, expiresAt: manifest.expiresAt, path: authorization.manifestPath,
540
+ }
541
+ const result = {
542
+ id: req.id, outcome, decision, risk: req.risk, by, approvalId,
543
+ impactSource: req.impactSource ?? null,
544
+ requestedAt: req.requestedAt, execution, authorization, ms: Date.now() - t0,
545
+ }
546
+ audit(AUDIT.RESTART_RESOLVED, {
547
+ id: req.id, outcome, decision, risk: req.risk, reason: req.reason, scope: req.scope,
548
+ origin: req.origin, by, approvalId, execution, guardMode: cfg.mode,
549
+ impactSource: req.impactSource ?? null, ms: result.ms,
550
+ manifestDigest: manifest.digest, manifestExpiresAt: manifest.expiresAt,
551
+ manifestPath: authorization.manifestPath,
552
+ handoff: handoff ? { to: 'supervisor', via: gateState.supervisor.via, exitCode: EXIT.RESTART } : null,
553
+ })
554
+ emit(RESOLVED_TOPIC, { id: req.id, outcome, reason: req.reason, risk: req.risk, approvalId })
555
+ // Supervisor contract: this event is advisory only. Durability lives in the manifest;
556
+ // a supervisor must verify the manifest and write an execution receipt with
557
+ // execution.executed === true before a restart counts as executed.
558
+ if (outcome === 'approved' || outcome === 'auto-allow') {
559
+ emit(APPROVED_TOPIC, {
560
+ id: req.id, outcome, decision, risk: req.risk, reason: req.reason,
561
+ scope: req.scope, origin: req.origin, approvalId,
562
+ impactSource: req.impactSource ?? null, execution, authorization,
563
+ handoff: execution.handoff,
564
+ })
565
+ }
566
+ if (handoff) {
567
+ // 三重门禁齐了才走到这里:清单/审计/事件已经落盘,交执行权给外部 supervisor。
568
+ // 25ms 让调用方 Promise 结算、审计行 flush;之后宿主退出码 75,由 watchdog 拉起。
569
+ log('warn', `restart ${req.id} approved and armed; handing off to supervisor (${gateState.supervisor.via}); exit(${EXIT.RESTART})`)
570
+ const exitTimer = setTimeout(() => {
571
+ process.exitCode = EXIT.RESTART
572
+ process.exit(EXIT.RESTART)
573
+ }, 25)
574
+ timers.add(exitTimer)
575
+ } else {
576
+ release()
577
+ }
578
+ return result
579
+ }
580
+
581
+ const finishDenied = (req, reason, outcome, extra = {}) => {
582
+ const result = {
583
+ id: req.id, outcome, decision: 'needs-approval', risk: req.risk,
584
+ reason, by: extra.by ?? null, approvalId: extra.approvalId ?? null, ms: extra.ms ?? 0,
585
+ impactSource: req.impactSource ?? null,
586
+ ...(extra.error ? { error: extra.error } : {}),
587
+ }
588
+ audit(AUDIT.RESTART_RESOLVED, {
589
+ id: req.id, outcome, decision: 'needs-approval', risk: req.risk, reason: req.reason,
590
+ scope: req.scope, origin: req.origin, deniedReason: reason,
591
+ by: result.by, approvalId: result.approvalId, guardMode: cfg.mode,
592
+ impactSource: req.impactSource ?? null,
593
+ ...(extra.error ? { error: extra.error } : {}),
594
+ })
595
+ emit(RESOLVED_TOPIC, {
596
+ id: req.id, outcome, reason: req.reason, risk: req.risk,
597
+ deniedReason: reason, approvalId: extra.approvalId ?? null,
598
+ })
599
+ release()
600
+ return result
601
+ }
602
+
603
+ /**
604
+ * request({reason, risk, scope, origin, digests?, forcedApproval?, impact?})
605
+ * -> Promise<{id, outcome:'approved'|'denied'|'timeout'|'auto-allow', decision, risk, ...}>
606
+ * 单飞:非 idle 时第二个请求直接 {outcome:'denied', reason:'busy'}(不排队)。
607
+ */
608
+ const request = async (rawInput = {}) => {
609
+ const input = rawInput && typeof rawInput === 'object' ? rawInput : {}
610
+ const t0 = Date.now()
611
+ const risk = normalizeRisk(input)
612
+ const reason = String(input.reason ?? 'unspecified')
613
+ const scope = String(input.scope ?? 'mother')
614
+ const origin = String(input.origin ?? 'unknown')
615
+ if (machine.state !== GUARD_STATE.IDLE) {
616
+ const busyId = newId('rst')
617
+ const activeId = machine.pending?.id ?? null
618
+ const activeSince = machine.pending?.since ?? null
619
+ const retryAfterMs = Number.isFinite(machine.pending?.deadlineAt)
620
+ ? Math.max(0, machine.pending.deadlineAt - Date.now())
621
+ : null
622
+ audit(AUDIT.RESTART_REQUESTED, {
623
+ id: busyId, risk, reason, scope, origin, phase: 'rejected-busy',
624
+ activeState: machine.state, activeId, activeSince, retryAfterMs,
625
+ })
626
+ audit(AUDIT.RESTART_RESOLVED, {
627
+ id: busyId, outcome: 'denied', deniedReason: 'busy', risk,
628
+ activeId, activeState: machine.state, retryAfterMs,
629
+ })
630
+ return {
631
+ id: busyId, outcome: 'denied', reason: 'busy', decision: 'needs-approval', risk,
632
+ activeId, activeState: machine.state, retryAfterMs, retryable: true,
633
+ }
634
+ }
635
+ // 单飞认领:在这行之后任何并发 request 都会看到非 idle(async 体在首个 await 前同步执行)。
636
+ machine.state = GUARD_STATE.COLLECTING
637
+ let impactFromCaller = false
638
+ let impactSource = 'service'
639
+ const req = {
640
+ id: newId('rst'),
641
+ risk, reason, scope, origin,
642
+ digests: Array.isArray(input.digests) ? input.digests.map(String) : [],
643
+ forcedApproval: input.forcedApproval === true,
644
+ impactSource,
645
+ requestedAt: t0,
646
+ deadlineAt: t0 + cfg.approvalTimeoutMs,
647
+ }
648
+ let impactReport
649
+ try {
650
+ impactFromCaller = input.impact !== undefined && input.impact !== null
651
+ impactSource = impactFromCaller ? 'caller' : 'service'
652
+ req.impactSource = impactSource
653
+ impactReport = impactFromCaller ? input.impact : impact()
654
+ } catch (e) {
655
+ // A throwing caller-supplied impact getter must not leave the guard stuck in COLLECTING.
656
+ return finishDenied(req, 'impact-failed', 'denied', { error: errText(e), ms: Date.now() - t0 })
657
+ }
658
+ machine.pending = {
659
+ id: req.id, risk: req.risk, reason, scope, origin,
660
+ phase: GUARD_STATE.COLLECTING, since: t0,
661
+ deadlineAt: req.deadlineAt, approvalId: null,
662
+ }
663
+ persistState()
664
+
665
+ audit(AUDIT.RESTART_REQUESTED, {
666
+ id: req.id, risk: req.risk, reason, scope, origin, digests: req.digests,
667
+ forcedApproval: req.forcedApproval, guardMode: cfg.mode, authMode: cfg.authMode,
668
+ strictRedLine: cfg.strictRedLine, tempEffectiveManual,
669
+ impactSource, impact: impactReport,
670
+ })
671
+ emit(REQUESTED_TOPIC, { id: req.id, risk: req.risk })
672
+
673
+ let decision
674
+ try {
675
+ decision = decide(req, authPolicy, Date.now())
676
+ } catch (e) {
677
+ return finishDenied(req, 'internal-error', 'denied', { error: errText(e), ms: Date.now() - t0 })
678
+ }
679
+
680
+ if (decision === 'auto-allow') {
681
+ return finishApproved(req, { decision, outcome: 'auto-allow', by: 'policy', t0 })
682
+ }
683
+
684
+ // needs-approval:进入 awaiting,走审批服务;服务缺失/抛错 => fail-closed。
685
+ machine.state = GUARD_STATE.AWAITING
686
+ if (machine.pending) machine.pending.phase = GUARD_STATE.AWAITING
687
+ persistState()
688
+ const approver = safeGet(ctx, SERVICE.approver)
689
+ if (!approver || typeof approver.request !== 'function') {
690
+ return finishDenied(req, 'approver-unavailable', 'denied', { ms: Date.now() - t0 })
691
+ }
692
+
693
+ let answer
694
+ let aborted = false
695
+ const abortPromise = new Promise((resolve) => {
696
+ abortActiveApproval = () => { aborted = true; resolve({ outcome: 'guard-disposed' }) }
697
+ })
698
+ try {
699
+ answer = await Promise.race([
700
+ withDeadline(approver.request({
701
+ kind: 'restart',
702
+ risk: req.risk,
703
+ title: `母体重启:${reason}`,
704
+ detail: `origin=${origin} scope=${scope} risk=${req.risk} guardMode=${cfg.mode}`,
705
+ impact: impactReport,
706
+ impactSource,
707
+ ttlMs: cfg.approvalTimeoutMs,
708
+ }), cfg.approvalTimeoutMs),
709
+ abortPromise,
710
+ ])
711
+ } catch (e) {
712
+ return finishDenied(req, 'approver-error', 'denied', { error: errText(e), ms: Date.now() - t0 })
713
+ } finally {
714
+ abortActiveApproval = null
715
+ }
716
+ if (aborted) {
717
+ return finishDenied(req, 'guard-disposed', 'denied', { ms: Date.now() - t0 })
718
+ }
719
+
720
+ const outcome = answer?.outcome ?? 'timeout'
721
+ const approvalId = answer?.id ?? null
722
+ const by = answer?.by ?? null
723
+ if (outcome === 'approved') {
724
+ return finishApproved(req, { decision, by, outcome: 'approved', approvalId, t0 })
725
+ }
726
+ if (outcome === 'timeout') {
727
+ return finishDenied(req, 'approval-timeout', 'timeout', { approvalId, by, ms: Date.now() - t0 })
728
+ }
729
+ return finishDenied(req, 'approval-denied', 'denied', { approvalId, by, ms: Date.now() - t0 })
730
+ }
731
+
732
+ /** 只决策、不进状态机、不审计(给其它插件做预检/面板用)。 */
733
+ const decideOnly = (input = {}) => {
734
+ const risk = normalizeRisk(input)
735
+ const req = {
736
+ ...input,
737
+ risk,
738
+ digests: Array.isArray(input.digests) ? input.digests.map(String) : [],
739
+ forcedApproval: input.forcedApproval === true,
740
+ }
741
+ return { decision: decide(req, authPolicy, Date.now()), risk }
742
+ }
743
+
744
+ const state = () => {
745
+ const supervisor = detectSupervisor()
746
+ const gateState = executionArmed()
747
+ return {
748
+ state: machine.state,
749
+ pending: machine.pending ? { ...machine.pending } : null,
750
+ mode: cfg.mode, // 守卫执行层 observe|enforce
751
+ authMode: cfg.authMode, // 决策矩阵授权模式 manual|temp|auto
752
+ strictRedLine: cfg.strictRedLine,
753
+ redLineAutoAllowed: cfg.strictRedLine === false,
754
+ tempEffectiveManual,
755
+ grants: authPolicy.grants.length,
756
+ allowProcessRestart: cfg.allowProcessRestart === true,
757
+ restartExitCode: EXIT.RESTART,
758
+ stateFile,
759
+ pidFile,
760
+ lastInterrupted,
761
+ // supervisor 契约(只读展示):清单目录、回执目录、最近一次授权
762
+ contractVersion: CONTRACT_VERSION,
763
+ manifestTtlMs: cfg.manifestTtlMs,
764
+ approvalDir: approvedDir,
765
+ executionDir: executedDir,
766
+ lastManifest,
767
+ supervisorPresent: supervisor.present === true,
768
+ supervisor,
769
+ executionArmed: gateState.armed === true,
770
+ safeMode: { ...gateState.safeMode },
771
+ crashLoop: gateState.crashLoop,
772
+ execution: { executed: false, decisionOnly: true },
773
+ }
774
+ }
775
+
776
+ const policy = () => {
777
+ const supervisor = detectSupervisor()
778
+ const gateState = executionArmed()
779
+ return {
780
+ mode: cfg.mode, // observe|enforce(守卫执行层,不是决策矩阵的授权模式)
781
+ authMode: cfg.authMode, // manual|temp|auto
782
+ strictRedLine: cfg.strictRedLine,
783
+ redLineAutoAllowed: cfg.strictRedLine === false,
784
+ tempEffectiveManual,
785
+ grants: authPolicy.grants.length,
786
+ approvalTimeoutMs: cfg.approvalTimeoutMs,
787
+ allowProcessRestart: cfg.allowProcessRestart === true,
788
+ restartExitCode: EXIT.RESTART,
789
+ decisionOnly: true,
790
+ manifestTtlMs: cfg.manifestTtlMs,
791
+ supervisorPresent: supervisor.present === true,
792
+ supervisor,
793
+ executionArmed: gateState.armed === true,
794
+ safeMode: { ...gateState.safeMode },
795
+ crashLoop: gateState.crashLoop,
796
+ }
797
+ }
798
+
799
+ /**
800
+ * supervisor 契约(阶段 1 授权清单 / 阶段 2 执行回执)。
801
+ * 默认决策层不退出;三重门禁齐了才 exit(75) 交接给外部 watchdog。
802
+ * 任何「已执行」结论只认回执 execution.executed === true。
803
+ */
804
+ const executionContract = () => {
805
+ const gateState = executionArmed()
806
+ return {
807
+ version: CONTRACT_VERSION,
808
+ decisionOnly: true,
809
+ guardExecuted: false,
810
+ plannedExitCode: EXIT.RESTART,
811
+ manifestTtlMs: cfg.manifestTtlMs,
812
+ supervisorPresent: gateState.supervisor.present === true,
813
+ executionArmed: gateState.armed === true,
814
+ safeMode: { ...gateState.safeMode },
815
+ crashLoop: gateState.crashLoop,
816
+ approval: {
817
+ kind: APPROVAL_KIND,
818
+ dir: approvedDir,
819
+ pattern: '<requestId>.json',
820
+ latest: latestApprovalFile(stateDir),
821
+ },
822
+ receipt: {
823
+ kind: EXECUTION_KIND,
824
+ dir: executedDir,
825
+ pattern: '<requestId>.json',
826
+ },
827
+ supervisor: {
828
+ armedWhen: [
829
+ 'manifest.guard.mode === "enforce"',
830
+ 'manifest.guard.allowProcessRestart === true',
831
+ ],
832
+ handoffWhen: [
833
+ 'config.mode === "enforce"',
834
+ 'config.allowProcessRestart === true',
835
+ 'supervisor present (FANG_RESTART_SUPERVISOR env or live <stateDir>/supervisor.present with execute === true)',
836
+ ],
837
+ handoff: 'guard writes the manifest + audit, then exits 75; watchdog relaunches and writes the receipt',
838
+ mayExecuteWhen: [
839
+ 'manifest digest valid (sha256 over canonical body)',
840
+ 'manifest not expired (expiresAt > now)',
841
+ 'manifest.execution.executed === false (the guard never restarts by itself)',
842
+ 'manifest.execution.decisionOnly === true',
843
+ 'manifest is armed (guard.mode=enforce and allowProcessRestart=true)',
844
+ 'no execution receipt exists for this request id',
845
+ ],
846
+ mustWriteReceipt: true,
847
+ receiptRequirements: [
848
+ 'receipt.execution.executed === true',
849
+ 'receipt.nonce equals manifest.nonce',
850
+ 'receipt.manifestDigest equals manifest.digest',
851
+ ],
852
+ },
853
+ rule: '「已执行」只认回执里的 execution.executed === true;授权清单里恒为 false。',
854
+ note: gateState.armed
855
+ ? 'armed: the next approved restart writes the manifest and exits 75 for the supervisor to relaunch.'
856
+ : 'disarmed: decision-only. No supervisor handoff happens until enforce + allowProcessRestart + supervisorPresent are all true.',
857
+ }
858
+ }
859
+
860
+ /** 只读:列出最近的授权清单摘要(含状态:authorized/executed/stale/invalid)。 */
861
+ const approvals = ({ limit = 50, now = Date.now() } = {}) => {
862
+ const max = Math.min(200, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 50))
863
+ const rows = listApprovalIds(stateDir)
864
+ .map(id => summarizeApproval(inspectApproval(stateDir, id, inspectOptions(now))))
865
+ .sort((a, b) => (b.approvedAt ?? 0) - (a.approvedAt ?? 0))
866
+ return {
867
+ contractVersion: CONTRACT_VERSION,
868
+ items: rows.slice(0, max),
869
+ counts: summarizeApprovalCounts(rows),
870
+ }
871
+ }
872
+
873
+ /** 只读:单份授权的完整检查(清单 + 回执 + 校验结果),不写盘、不审计。 */
874
+ const executionStatus = (id, { now = Date.now() } = {}) => inspectApproval(stateDir, id, inspectOptions(now))
875
+
876
+ /** 只读:当前这份授权是否可以交给 supervisor 执行(唯一放行语义)。 */
877
+ const verifyApproval = (id, { now = Date.now() } = {}) => {
878
+ const inspected = inspectApproval(stateDir, id, inspectOptions(now))
879
+ return {
880
+ id,
881
+ found: inspected.found,
882
+ ok: inspected.authorized === true,
883
+ reason: inspected.blockedReason ?? null,
884
+ expired: inspected.expired,
885
+ digestValid: inspected.manifestVerification?.digestValid === true,
886
+ armed: inspected.armed === true,
887
+ authorized: inspected.authorized === true,
888
+ executed: inspected.executed === true,
889
+ stale: inspected.stale === true,
890
+ receiptState: inspected.receiptState,
891
+ }
892
+ }
893
+
894
+ /**
895
+ * 启动自检(只读):让 UI/CLI 在重启前看到“重启契约是否健康”。
896
+ * block 级问题意味着“现在不可能安全重启”;warn 表示可重启但会降级(如无 HMAC)。
897
+ */
898
+ const selfCheck = () => {
899
+ const findings = []
900
+ const add = (code, level, detail = '') => findings.push({ code, level, detail })
901
+ try {
902
+ fs.accessSync(stateDir, fs.constants.R_OK | fs.constants.W_OK)
903
+ add('state-dir-writable', 'ok', stateDir)
904
+ } catch (error) {
905
+ add('state-dir-not-writable', 'block', String(error?.message ?? error))
906
+ }
907
+ const stateFile = path.join(stateDir, 'state.json')
908
+ const rawState = readJson(stateFile, null)
909
+ if (rawState !== null && (typeof rawState !== 'object' || Array.isArray(rawState))) {
910
+ add('state-json-invalid', 'warn', stateFile)
911
+ }
912
+ if (!hmac.ok) add('hmac-unavailable', 'warn', String(hmac.reason ?? 'unavailable'))
913
+ else add('hmac-available', 'ok', String(hmac.source ?? 'available'))
914
+
915
+ const approvalIds = (() => { try { return listApprovalIds(stateDir) } catch (error) { add('approvals-unreadable', 'warn', String(error?.message ?? error)); return [] } })()
916
+ let armed = 0
917
+ let executed = 0
918
+ let invalid = 0
919
+ for (const id of approvalIds) {
920
+ try {
921
+ const inspected = inspectApproval(stateDir, id, inspectOptions())
922
+ if (!inspected?.ok) invalid += 1
923
+ else if (inspected.state === 'executed') executed += 1
924
+ else if (inspected.state === 'armed') armed += 1
925
+ } catch (error) {
926
+ invalid += 1
927
+ add('approval-inspect-failed', 'warn', `${id}: ${String(error?.message ?? error).slice(0, 160)}`)
928
+ }
929
+ }
930
+ if (invalid > 0) add('approval-manifests-invalid', 'warn', `${invalid}/${approvalIds.length}`)
931
+ add('approvals-summary', 'ok', `total=${approvalIds.length} armed=${armed} executed=${executed} invalid=${invalid}`)
932
+
933
+ const marker = (() => { try { return readJson(supervisorMarkerFile, null) } catch { return null } })()
934
+ if (marker && marker.execute === true) {
935
+ const at = Number(marker.at)
936
+ const fresh = Number.isFinite(at) && Date.now() - at <= SUPERVISOR_MARKER_TTL_MS
937
+ const alive = pidAlive(Number(marker.pid))
938
+ add('supervisor-marker', fresh && alive ? 'ok' : 'warn', `pid=${marker.pid ?? 'n/a'} fresh=${fresh} alive=${alive}`)
939
+ } else {
940
+ add('supervisor-marker', 'warn', 'absent')
941
+ }
942
+ const pidDoc = (() => { try { return readJson(pidFile, null) } catch { return null } })()
943
+ if (pidDoc && Number(pidDoc.pid) !== process.pid && pidAlive(Number(pidDoc.pid))) {
944
+ add('pid-file-foreign-live', 'warn', `pid=${pidDoc.pid}`)
945
+ }
946
+ if (lastUncleanBoot) add('unclean-boot', 'warn', `${lastUncleanBoot.state} pid=${lastUncleanBoot.pid ?? 'n/a'}`)
947
+ if (safeMode.active) add('safe-mode-active', 'block', `${safeMode.reason} at=${safeMode.at || 'n/a'} crashCount=${safeMode.crashCount || 0}`)
948
+ if (crashLoopAtBoot.active) add('crash-loop', 'warn', `${crashLoopAtBoot.count}/${crashLoopAtBoot.threshold} in ${crashLoopAtBoot.windowMs}ms`)
949
+ let crashCount = 0
950
+ try {
951
+ const raw = readTextSafe(crashLedgerFile, '')
952
+ crashCount = raw.split(/\r?\n/).filter((line) => line.trim()).length
953
+ } catch { /* ignore */ }
954
+ if (crashCount > 0) add('runtime-crash-ledger', 'warn', `${crashCount} record(s)`)
955
+ const block = findings.filter((finding) => finding.level === 'block').length
956
+ const warn = findings.filter((finding) => finding.level === 'warn').length
957
+ return {
958
+ ok: block === 0, block, warn, at: Date.now(),
959
+ stateDir, hmac: hmac.ok ? { source: hmac.source } : { unavailable: hmac.reason ?? 'unavailable' },
960
+ approvals: { total: approvalIds.length, armed, executed, invalid },
961
+ findings,
962
+ }
963
+ }
964
+
965
+ /** 人工解除安全模式:归档 crash ledger,避免下一次启动按旧证据再次触发。 */
966
+ const clearSafeMode = (options = {}) => {
967
+ if (!safeMode.active) return { ok: true, cleared: false, reason: 'not-active' }
968
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
969
+ let archived = null
970
+ try {
971
+ if (fs.existsSync(crashLedgerFile)) {
972
+ archived = `${crashLedgerFile}.cleared-${stamp}`
973
+ fs.renameSync(crashLedgerFile, archived)
974
+ }
975
+ } catch (error) {
976
+ return { ok: false, cleared: false, reason: 'archive-failed', error: errText(error) }
977
+ }
978
+ try {
979
+ ensureDir(stateDir)
980
+ writeJsonAtomic(safeModeFile, {
981
+ version: 1, active: false, reason: 'cleared',
982
+ at: Date.now(), clearedAt: Date.now(),
983
+ clearedBy: options.by ? String(options.by).slice(0, 200) : null,
984
+ note: options.note ? String(options.note).slice(0, 500) : null,
985
+ archived,
986
+ })
987
+ } catch (error) {
988
+ return { ok: false, cleared: false, reason: 'persist-failed', error: errText(error) }
989
+ }
990
+ safeMode = { active: false, reason: null, at: 0, crashCount: 0, file: safeModeFile }
991
+ audit('restart-safe-mode-cleared', { by: options.by ?? null, note: options.note ?? null, archived })
992
+ log('warn', `safe mode cleared manually (by=${String(options.by ?? 'unknown').slice(0, 80)})`)
993
+ return { ok: true, cleared: true, archived, file: safeModeFile }
994
+ }
995
+
996
+ const service = {
997
+ request, decideOnly, state, impact, policy, executionContract, approvals, executionStatus, verifyApproval, selfCheck,
998
+ safeMode: () => ({ ...safeMode, crashLoop: readCrashLoop(crashLedgerFile) }),
999
+ clearSafeMode,
1000
+ runtimeGuard: () => ({
1001
+ lastUncleanBoot,
1002
+ bootStateFile,
1003
+ crashLedgerFile,
1004
+ bootState: readJson(bootStateFile, null),
1005
+ }),
1006
+ }
1007
+ ctx.effect(() => ctx.provide(SERVICE.restart, service))
1008
+
1009
+ // 定时器清理:审批兜底计时器必须随插件卸载一起清掉;退出交接触发器一并取消(fail-safe:不退出)。
1010
+ ctx.effect(() => () => {
1011
+ try { abortActiveApproval?.() } catch { /* dispose must not throw */ }
1012
+ abortActiveApproval = null
1013
+ for (const t of timers) clearTimeout(t)
1014
+ timers.clear()
1015
+ machine.state = GUARD_STATE.IDLE
1016
+ machine.pending = null
1017
+ persistState({ disposed: true })
1018
+ clearHostPid()
1019
+ })
1020
+
1021
+ return service
1022
+ }