@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,752 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // restart-contract — 「重启守卫 ↔ 外部 supervisor」两阶段契约(纯函数 + 只读磁盘辅助)。
5
+ //
6
+ // 为什么要有契约:restart-guard 在默认配置下是决策层,**不退出**进程;只有三重门禁
7
+ // (mode=enforce ∧ allowProcessRestart=true ∧ supervisor 在位)齐了,它才会在批准后
8
+ // exit(75) 把执行权交出去。supervisor 不能只看一个 bus 事件(事件不落盘、重启后丢失),
9
+ // 也不能只凭进程退出码(任何人都能 exit(75))。所以守卫在批准时写一份 durable 的
10
+ // **授权清单**,supervisor 只按清单执行,执行完再写一份 **执行回执**。
11
+ //
12
+ // 阶段 1 授权清单(守卫写) <dataDir>/restart/approved/<id>.json
13
+ // execution.executed === false // 守卫没有执行,执行权仍在 supervisor
14
+ // 阶段 2 执行回执(supervisor 写) <dataDir>/restart/executed/<id>.json
15
+ // execution.executed === true // 只有真的完成了重启才允许写
16
+ //
17
+ // 硬规则:
18
+ // * 「已经执行」只认回执里的 `execution.executed === true`;授权清单里的 executed
19
+ // 恒为 false。把 false 当成「已执行」或「可以不再执行」都是违约。
20
+ // * supervisor 只有在清单 digest 匹配、未过期、execution.executed 为 false、
21
+ // decisionOnly 为 true、清单已武装(guard.mode=enforce ∧ allowProcessRestart=true)、
22
+ // 且没有已存在的回执时才允许执行。
23
+ // * digest 只提供**完整性**(防截断/手滑改坏/字段被换序影响),不提供**真实性**:
24
+ // 能在本机写这个目录的人也能写一份假清单。信任边界是本机文件系统 ACL,不是
25
+ // digest。签名/HMAC 是后续加固项,当前版本没有。
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
29
+
30
+ import { EXIT, RISK_RANK } from '../kernel/core.mjs'
31
+ import { sha256 } from '../kernel/fsx.mjs'
32
+ import { appendChained, canonical, verifyChain } from '../kernel/ledger.mjs'
33
+
34
+ export const CONTRACT_VERSION = 1
35
+ export const APPROVAL_KIND = 'fang-restart-approval'
36
+ export const EXECUTION_KIND = 'fang-restart-execution'
37
+ export const LATEST_KIND = 'fang-restart-approval-latest'
38
+ export const PLANNED_EXIT_CODE = EXIT.RESTART
39
+ export const APPROVAL_DIRNAME = 'approved'
40
+ export const EXECUTION_DIRNAME = 'executed'
41
+ export const LATEST_FILENAME = 'latest.json'
42
+
43
+ export const DEFAULT_MANIFEST_TTL_MS = 60_000
44
+ export const MIN_MANIFEST_TTL_MS = 1_000
45
+ export const MAX_MANIFEST_TTL_MS = 900_000
46
+
47
+ const OUTCOMES = new Set(['approved', 'auto-allow'])
48
+ const MANIFEST_ID = /^rst_[A-Za-z0-9_-]{1,80}$/
49
+ const NONCE = /^[0-9a-f]{64}$/
50
+
51
+ export const isManifestId = (id) => typeof id === 'string' && MANIFEST_ID.test(id)
52
+ export const isNonce = (nonce) => typeof nonce === 'string' && NONCE.test(nonce)
53
+ export const isRisk = (risk) => typeof risk === 'string' && Object.prototype.hasOwnProperty.call(RISK_RANK, risk)
54
+
55
+ /** TTL 夹到 [1s, 15min];非数字/负数回默认值。清单永远有有限有效期。 */
56
+ export function normalizeManifestTtl(ttlMs, fallback = DEFAULT_MANIFEST_TTL_MS) {
57
+ const n = Number(ttlMs)
58
+ if (!Number.isFinite(n) || n <= 0) return fallback
59
+ return Math.min(MAX_MANIFEST_TTL_MS, Math.max(MIN_MANIFEST_TTL_MS, Math.floor(n)))
60
+ }
61
+
62
+ /**
63
+ * 记录 digest = sha256(canonical(去掉 digest 的 body))。
64
+ * canonical() 递归排序键,跨平台/跨格式化稳定。
65
+ */
66
+ export function digestOf(record) {
67
+ const { digest: _ignored, ...body } = record ?? {}
68
+ return 'sha256:' + sha256(canonical(body))
69
+ }
70
+
71
+ /** 32 字节随机 nonce(64 hex)。作用是把回执绑定到这一次授权,防跨授权复用。 */
72
+ export function newNonce() {
73
+ return randomBytes(32).toString('hex')
74
+ }
75
+
76
+ /** 重新计算并比对记录自带 digest;返回 { ok, expected }。 */
77
+ export function checkDigest(record) {
78
+ const expected = digestOf(record)
79
+ return { ok: record?.digest === expected, expected }
80
+ }
81
+
82
+ // ───────────────────────── 清单签名(HMAC-SHA256) ─────────────────────────
83
+ // digest 只防手滑/截断;HMAC 才有「谁签的」真实性(前提:key 不落到写清单的人手里)。
84
+ // key 优先 env `FANG_RESTART_HMAC_SECRET`,否则 <stateDir>/restart-hmac.key(0600,自动生成)。
85
+ export const HMAC_KEY_FILENAME = 'restart-hmac.key'
86
+ const MIN_HMAC_SECRET_LENGTH = 32
87
+
88
+ const signatureBody = (record) => {
89
+ const { digest: _digest, signature: _signature, ...body } = record ?? {}
90
+ return body
91
+ }
92
+ export function hmacOf(record, secret) {
93
+ return createHmac('sha256', String(secret)).update(canonical(signatureBody(record))).digest('hex')
94
+ }
95
+ export function signRecord(record, secret) {
96
+ const body = signatureBody(record)
97
+ return { ...body, signature: { alg: 'hmac-sha256', value: hmacOf(record, secret) } }
98
+ }
99
+ export function verifyRecordSignature(record, secret) {
100
+ const signature = record?.signature
101
+ if (!signature || signature.alg !== 'hmac-sha256' || typeof signature.value !== 'string') {
102
+ return { ok: false, reason: 'missing-signature' }
103
+ }
104
+ const expected = hmacOf(record, secret)
105
+ const provided = signature.value
106
+ const a = Buffer.from(provided, 'utf8')
107
+ const b = Buffer.from(expected, 'utf8')
108
+ const ok = a.length === b.length && timingSafeEqual(a, b)
109
+ return { ok, reason: ok ? null : 'signature-mismatch', expected }
110
+ }
111
+
112
+ /** 只读已存在的 key(env 或 stateDir 文件),不生成;supervisor/watchdog 用。 */
113
+ export function readHmacSecret(stateDir, { env = process.env } = {}) {
114
+ const fromEnv = String(env?.FANG_RESTART_HMAC_SECRET ?? '').trim()
115
+ if (fromEnv.length >= MIN_HMAC_SECRET_LENGTH) return { ok: true, secret: fromEnv, source: 'env' }
116
+ const file = path.join(String(stateDir), HMAC_KEY_FILENAME)
117
+ try {
118
+ const stored = fs.readFileSync(file, 'utf8').trim()
119
+ if (stored.length >= MIN_HMAC_SECRET_LENGTH) return { ok: true, secret: stored, source: 'file', file }
120
+ } catch { /* absent */ }
121
+ return { ok: false, reason: 'hmac-key-absent' }
122
+ }
123
+
124
+ /** 守卫用:env 优先;否则读取/生成 stateDir key 文件(0600)。 */
125
+ export function resolveHmacSecret(stateDir, { env = process.env } = {}) {
126
+ const existing = readHmacSecret(stateDir, { env })
127
+ if (existing.ok) return existing
128
+ const file = path.join(String(stateDir), HMAC_KEY_FILENAME)
129
+ try {
130
+ fs.mkdirSync(String(stateDir), { recursive: true })
131
+ const secret = randomBytes(32).toString('hex')
132
+ fs.writeFileSync(file, secret, { encoding: 'utf8', mode: 0o600 })
133
+ try { fs.chmodSync(file, 0o600) } catch { /* windows */ }
134
+ return { ok: true, secret, source: 'generated', file }
135
+ } catch (error) {
136
+ return { ok: false, reason: 'hmac-key-unavailable', detail: String(error?.message ?? error) }
137
+ }
138
+ }
139
+
140
+ // ───────────────────────────── 阶段 1:授权清单 ─────────────────────────────
141
+
142
+ /**
143
+ * 构造授权清单(守卫在 outcome=approved|auto-allow 时写盘)。
144
+ * 只做形状/取值校验,不读盘、不写盘。
145
+ *
146
+ * @param {object} input
147
+ * @param {string} input.id restart request id (`rst_...`)
148
+ * @param {'blue'|'yellow'|'red'} input.risk
149
+ * @param {'approved'|'auto-allow'} input.outcome
150
+ * @param {string|null} [input.approvalId] auto-allow 可为 null
151
+ * @param {string|null} [input.approvedBy]
152
+ * @param {string} [input.reason]
153
+ * @param {string} [input.scope]
154
+ * @param {string} [input.origin]
155
+ * @param {string[]} [input.digests]
156
+ * @param {'caller'|'service'|null} [input.impactSource]
157
+ * @param {number} [input.requestedAt]
158
+ * @param {number} [input.approvedAt]
159
+ * @param {number} [input.ttlMs]
160
+ * @param {{mode?:string,authMode?:string,strictRedLine?:boolean,allowProcessRestart?:boolean,pid?:number,instanceId?:string}} [input.guard]
161
+ * @returns {object} manifest(含 digest 与 execution.executed=false)
162
+ */
163
+ export function buildApprovalManifest(input = {}) {
164
+ const id = String(input.id ?? '')
165
+ if (!isManifestId(id)) throw new TypeError(`invalid restart request id: ${id || '(empty)'}`)
166
+ if (!isRisk(input.risk)) throw new TypeError(`invalid risk: ${String(input.risk)}`)
167
+
168
+ const outcome = String(input.outcome ?? '')
169
+ if (!OUTCOMES.has(outcome)) throw new TypeError(`invalid outcome: ${outcome || '(empty)'}`)
170
+ const approvalId = input.approvalId === undefined || input.approvalId === null ? null : String(input.approvalId)
171
+ if (outcome === 'approved' && !approvalId) throw new TypeError('approved manifest requires approvalId')
172
+ const approvedBy = input.approvedBy === undefined || input.approvedBy === null ? null : String(input.approvedBy)
173
+
174
+ const requestedAt = Number.isFinite(input.requestedAt) ? Math.floor(input.requestedAt) : Date.now()
175
+ const approvedAt = Number.isFinite(input.approvedAt) ? Math.floor(input.approvedAt) : Date.now()
176
+ const ttlMs = normalizeManifestTtl(input.ttlMs)
177
+ const guard = input.guard && typeof input.guard === 'object' ? input.guard : {}
178
+ const impactSource = input.impactSource === 'caller' || input.impactSource === 'service' ? input.impactSource : null
179
+
180
+ const body = {
181
+ version: CONTRACT_VERSION,
182
+ kind: APPROVAL_KIND,
183
+ id,
184
+ nonce: isNonce(input.nonce) ? input.nonce : newNonce(),
185
+ risk: input.risk,
186
+ outcome,
187
+ approvalId,
188
+ approvedBy,
189
+ reason: String(input.reason ?? 'unspecified'),
190
+ scope: String(input.scope ?? 'mother'),
191
+ origin: String(input.origin ?? 'unknown'),
192
+ digests: Array.isArray(input.digests) ? input.digests.map(String) : [],
193
+ impactSource,
194
+ requestedAt,
195
+ approvedAt,
196
+ expiresAt: approvedAt + ttlMs,
197
+ guard: {
198
+ mode: String(guard.mode ?? 'observe'),
199
+ authMode: String(guard.authMode ?? 'manual'),
200
+ strictRedLine: guard.strictRedLine !== false,
201
+ allowProcessRestart: guard.allowProcessRestart === true,
202
+ decisionOnly: true,
203
+ pid: Number.isFinite(guard.pid) ? Math.floor(guard.pid) : null,
204
+ instanceId: guard.instanceId ? String(guard.instanceId) : null,
205
+ },
206
+ // 守卫是决策层:executed=false 是事实,不是占位符。
207
+ execution: {
208
+ executed: false,
209
+ decisionOnly: true,
210
+ plannedExitCode: PLANNED_EXIT_CODE,
211
+ executedBy: null,
212
+ },
213
+ }
214
+ let record = { ...body }
215
+ if (input.hmacSecret) record = signRecord(record, input.hmacSecret)
216
+ return { ...record, digest: digestOf(record) }
217
+ }
218
+
219
+ const fail = (reason, extra = {}) => ({ ok: false, reason, expired: false, digestValid: false, remainingMs: null, ...extra })
220
+
221
+ /**
222
+ * 只读校验授权清单。返回 { ok, reason, expired, digestValid, remainingMs }。
223
+ * reason 取值:invalid-shape / unsupported-version / wrong-kind / bad-id / id-mismatch /
224
+ * bad-nonce / nonce-mismatch / bad-risk / bad-outcome / bad-timestamps /
225
+ * missing-approval-id / guard-executed / not-decision-only / bad-exit-code /
226
+ * digest-mismatch / expired
227
+ */
228
+ export function verifyApprovalManifest(manifest, { now = Date.now(), expectedId = null, expectedNonce = null, hmacSecret = null, requireSignature = false } = {}) {
229
+ if (!manifest || typeof manifest !== 'object') return fail('invalid-shape')
230
+ if (manifest.version !== CONTRACT_VERSION) return fail('unsupported-version')
231
+ if (manifest.kind !== APPROVAL_KIND) return fail('wrong-kind')
232
+ if (!isManifestId(manifest.id)) return fail('bad-id')
233
+ if (expectedId !== null && manifest.id !== expectedId) return fail('id-mismatch')
234
+ if (!isNonce(manifest.nonce)) return fail('bad-nonce')
235
+ if (expectedNonce !== null && manifest.nonce !== expectedNonce) return fail('nonce-mismatch')
236
+ if (!isRisk(manifest.risk)) return fail('bad-risk')
237
+ if (!OUTCOMES.has(manifest.outcome)) return fail('bad-outcome')
238
+ if (manifest.outcome === 'approved' && !manifest.approvalId) return fail('missing-approval-id')
239
+
240
+ const { requestedAt, approvedAt, expiresAt } = manifest
241
+ if (!Number.isFinite(requestedAt) || !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt)) {
242
+ return fail('bad-timestamps')
243
+ }
244
+ if (approvedAt < requestedAt || expiresAt <= approvedAt) return fail('bad-timestamps')
245
+ if (expiresAt - approvedAt > MAX_MANIFEST_TTL_MS) return fail('ttl-too-long')
246
+
247
+ const execution = manifest.execution
248
+ if (!execution || typeof execution !== 'object') return fail('invalid-shape')
249
+ // 守卫自己执行过 => 这份清单不是给 supervisor 用的;supervisor 必须拒绝。
250
+ if (execution.executed !== false) return fail('guard-executed')
251
+ if (execution.decisionOnly !== true) return fail('not-decision-only')
252
+ if (execution.plannedExitCode !== PLANNED_EXIT_CODE) return fail('bad-exit-code')
253
+
254
+ const digest = checkDigest(manifest)
255
+ if (!digest.ok) return fail('digest-mismatch')
256
+
257
+ if (hmacSecret) {
258
+ const signature = verifyRecordSignature(manifest, hmacSecret)
259
+ if (!signature.ok) return fail(signature.reason)
260
+ } else if (requireSignature) {
261
+ return fail('missing-signature')
262
+ }
263
+
264
+ const remainingMs = Math.max(0, expiresAt - now)
265
+ const expired = now >= expiresAt
266
+ return { ok: !expired, reason: expired ? 'expired' : null, expired, digestValid: true, remainingMs }
267
+ }
268
+
269
+ // ───────────────────────────── 阶段 2:执行回执 ─────────────────────────────
270
+
271
+ /**
272
+ * 构造执行回执(只有真的完成了重启的 supervisor 才允许调用/写盘)。
273
+ * 回执的 execution.executed 恒为 true —— 这就是「已执行」的唯一证明。
274
+ *
275
+ * @param {object} input
276
+ * @param {object} input.manifest 已通过 verifyApprovalManifest 的授权清单
277
+ * @param {string} input.supervisorId 执行者标识(非空)
278
+ * @param {number|null} [input.pid] supervisor 进程 pid
279
+ * @param {number} [input.executedAt] 实际执行时间(默认 now)
280
+ * @param {number} [input.exitCode] 目标进程的退出码(默认 75 = 守卫放行码)
281
+ * @param {number|null} [input.previousPid] 被重启进程的 pid(supervisor 记录)
282
+ * @param {number|null} [input.replacementPid] 新进程的 pid(supervisor 记录)
283
+ */
284
+ export function buildExecutionReceipt({ manifest, supervisorId, pid = null, executedAt = Date.now(), exitCode = PLANNED_EXIT_CODE, previousPid = null, replacementPid = null } = {}) {
285
+ const at = Number.isFinite(executedAt) ? Math.floor(executedAt) : Date.now()
286
+ const verification = verifyApprovalManifest(manifest, { now: at })
287
+ if (!verification.ok) throw new Error(`cannot build receipt for an invalid manifest: ${verification.reason}`)
288
+ const by = String(supervisorId ?? '').trim()
289
+ if (!by || by.length > 128) throw new TypeError('supervisorId must be a non-empty string (<=128 chars)')
290
+
291
+ const body = {
292
+ version: CONTRACT_VERSION,
293
+ kind: EXECUTION_KIND,
294
+ id: manifest.id,
295
+ nonce: manifest.nonce,
296
+ manifestDigest: manifest.digest,
297
+ approvedAt: manifest.approvedAt,
298
+ execution: {
299
+ executed: true,
300
+ executedBy: by,
301
+ supervisorPid: Number.isFinite(pid) ? Math.floor(pid) : null,
302
+ at,
303
+ exitCode: Number.isFinite(exitCode) ? Math.floor(exitCode) : PLANNED_EXIT_CODE,
304
+ ...(Number.isFinite(previousPid) ? { previousPid: Math.floor(previousPid) } : {}),
305
+ ...(Number.isFinite(replacementPid) ? { replacementPid: Math.floor(replacementPid) } : {}),
306
+ },
307
+ }
308
+ return { ...body, digest: digestOf(body) }
309
+ }
310
+
311
+ /**
312
+ * 只读校验执行回执。硬规则:execution.executed 必须是 true,否则一律视为**未执行**。
313
+ * 传 manifest 时还会校验 nonce / manifestDigest,并把清单的有效期换算到执行时刻。
314
+ *
315
+ * reason 取值:invalid-shape / unsupported-version / wrong-kind / bad-id / bad-nonce /
316
+ * not-executed / bad-executor / bad-timestamp / digest-mismatch / manifest-mismatch /
317
+ * manifest-invalid / receipt-outside-window
318
+ */
319
+ export function verifyExecutionReceipt(receipt, { manifest = null } = {}) {
320
+ if (!receipt || typeof receipt !== 'object') return { ok: false, reason: 'invalid-shape', executed: false }
321
+ if (receipt.version !== CONTRACT_VERSION) return { ok: false, reason: 'unsupported-version', executed: false }
322
+ if (receipt.kind !== EXECUTION_KIND) return { ok: false, reason: 'wrong-kind', executed: false }
323
+ if (!isManifestId(receipt.id)) return { ok: false, reason: 'bad-id', executed: false }
324
+ if (!isNonce(receipt.nonce)) return { ok: false, reason: 'bad-nonce', executed: false }
325
+ const execution = receipt.execution
326
+ if (!execution || typeof execution !== 'object') return { ok: false, reason: 'invalid-shape', executed: false }
327
+ // 唯一证明:executed === true。false / 缺失 / 'true' 字符串都不算。
328
+ if (execution.executed !== true) return { ok: false, reason: 'not-executed', executed: false }
329
+ if (typeof execution.executedBy !== 'string' || !execution.executedBy.trim()) {
330
+ return { ok: false, reason: 'bad-executor', executed: false }
331
+ }
332
+ if (!Number.isFinite(execution.at)) return { ok: false, reason: 'bad-timestamp', executed: false }
333
+
334
+ const digest = checkDigest(receipt)
335
+ if (!digest.ok) return { ok: false, reason: 'digest-mismatch', executed: false }
336
+
337
+ if (manifest) {
338
+ if (manifest.id !== receipt.id || manifest.nonce !== receipt.nonce) {
339
+ return { ok: false, reason: 'manifest-mismatch', executed: false }
340
+ }
341
+ if (manifest.digest !== receipt.manifestDigest) return { ok: false, reason: 'manifest-mismatch', executed: false }
342
+ if (execution.at < manifest.approvedAt || execution.at >= manifest.expiresAt) {
343
+ return { ok: false, reason: 'receipt-outside-window', executed: false }
344
+ }
345
+ const atVerification = verifyApprovalManifest(manifest, { now: execution.at })
346
+ if (!atVerification.ok) return { ok: false, reason: `manifest-invalid:${atVerification.reason}`, executed: false }
347
+ }
348
+
349
+ return { ok: true, reason: null, executed: true, executedAt: execution.at, executedBy: execution.executedBy }
350
+ }
351
+
352
+ /**
353
+ * 清单是否「已武装」:supervisor 只能执行已武装的批准。
354
+ * 对应参考实现三重门禁里的两个**可由清单证明**的条件:
355
+ * mode === 'enforce' ∧ allowProcessRestart === true
356
+ * 第三重 supervisorPresent 不写进清单——校验者本身就是一个 supervisor。
357
+ */
358
+ export function isArmedManifest(manifest) {
359
+ return !!(manifest && manifest.guard && manifest.guard.mode === 'enforce' && manifest.guard.allowProcessRestart === true)
360
+ }
361
+
362
+ /**
363
+ * 未来 supervisor 入口在动进程前的唯一校验入口(只读)。
364
+ * 返回 { ok, allowed, reason, verification, receipt }。
365
+ * 注意:这里放行的是「可以执行」,不代表「已经执行」;执行完必须写回执。
366
+ * requireArmed=true(默认)时,observe 模式或 allowProcessRestart=false 的批准一律不放行。
367
+ */
368
+ export function verifyRestartAuthorization(manifest, { now = Date.now(), expectedId = null, receipt = null, requireArmed = true } = {}) {
369
+ const verification = verifyApprovalManifest(manifest, { now, expectedId })
370
+ if (!verification.ok) {
371
+ return { ok: false, allowed: false, reason: verification.reason, verification, receipt: null }
372
+ }
373
+ if (requireArmed && !isArmedManifest(manifest)) {
374
+ const reason = manifest.guard?.mode !== 'enforce' ? 'guard-not-enforce' : 'process-restart-disabled'
375
+ return { ok: false, allowed: false, reason, verification, receipt: null }
376
+ }
377
+ if (receipt !== null && receipt !== undefined) {
378
+ const receiptVerification = verifyExecutionReceipt(receipt, { manifest })
379
+ if (receiptVerification.ok) {
380
+ return { ok: false, allowed: false, reason: 'already-executed', verification, receipt: receiptVerification }
381
+ }
382
+ // 存在但不可信的回执 => fail-closed,不允许重试执行(可能是损坏或伪造)。
383
+ return { ok: false, allowed: false, reason: `invalid-receipt:${receiptVerification.reason}`, verification, receipt: receiptVerification }
384
+ }
385
+ return { ok: true, allowed: true, reason: null, verification, receipt: null }
386
+ }
387
+
388
+ // ───────────────────────────── 只读磁盘辅助 ─────────────────────────────
389
+
390
+ export function approvalDir(stateDir) {
391
+ return path.join(String(stateDir), APPROVAL_DIRNAME)
392
+ }
393
+
394
+ export function executionDir(stateDir) {
395
+ return path.join(String(stateDir), EXECUTION_DIRNAME)
396
+ }
397
+
398
+ /** id 非法时返回 null —— 绝不把未校验的 id 拼进路径。 */
399
+ export function approvalFile(stateDir, id) {
400
+ return isManifestId(id) ? path.join(approvalDir(stateDir), `${id}.json`) : null
401
+ }
402
+
403
+ export function receiptFile(stateDir, id) {
404
+ return isManifestId(id) ? path.join(executionDir(stateDir), `${id}.json`) : null
405
+ }
406
+
407
+ export function latestApprovalFile(stateDir) {
408
+ return path.join(approvalDir(stateDir), LATEST_FILENAME)
409
+ }
410
+
411
+ function readJson(file) {
412
+ if (!file) return null
413
+ try {
414
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
415
+ } catch {
416
+ return null
417
+ }
418
+ }
419
+
420
+ export function readApprovalManifest(stateDir, id) {
421
+ return readJson(approvalFile(stateDir, id))
422
+ }
423
+
424
+ export function readExecutionReceipt(stateDir, id) {
425
+ return readJson(receiptFile(stateDir, id))
426
+ }
427
+
428
+ /** 已落盘的清单 id(`latest.json` 与非 rst_ 文件不在此列),不保证内容有效。 */
429
+ export function listApprovalIds(stateDir) {
430
+ let names
431
+ try {
432
+ names = fs.readdirSync(approvalDir(stateDir))
433
+ } catch {
434
+ return []
435
+ }
436
+ return names
437
+ .filter(n => n.endsWith('.json'))
438
+ .map(n => n.slice(0, -'.json'.length))
439
+ .filter(isManifestId)
440
+ }
441
+
442
+ /**
443
+ * 只读检查一份授权(清单 + 回执)。不做任何写入/审计。
444
+ * 返回对象包含 valid/expired/executed/authorized/stale 五个布尔量与原始记录。
445
+ */
446
+ export function inspectApproval(stateDir, id, { now = Date.now(), hmacSecret = null, requireSignature = false } = {}) {
447
+ const base = {
448
+ id, found: false, valid: false, expired: false, executed: false, authorized: false, stale: false, armed: false,
449
+ receiptState: 'absent', blockedReason: null, manifest: null, receipt: null,
450
+ manifestVerification: null, receiptVerification: null,
451
+ }
452
+ if (!isManifestId(id)) return { ...base, reason: 'bad-id', blockedReason: 'bad-id' }
453
+ const manifest = readApprovalManifest(stateDir, id)
454
+ if (!manifest) return { ...base, reason: 'not-found', blockedReason: 'not-found' }
455
+ const manifestVerification = verifyApprovalManifest(manifest, { now, expectedId: id, hmacSecret, requireSignature })
456
+ const receipt = readExecutionReceipt(stateDir, id)
457
+ const receiptVerification = receipt ? verifyExecutionReceipt(receipt, { manifest }) : null
458
+ const executed = receiptVerification?.ok === true
459
+ const receiptPresent = receipt !== null
460
+ // 清单格式/摘要可信(过期也仍然可信,只是不能再授权执行)。
461
+ const valid = manifestVerification.ok === true || manifestVerification.expired === true
462
+ const expired = manifestVerification.expired === true
463
+ const armed = isArmedManifest(manifest)
464
+ // 有回执(哪怕损坏)就不允许再执行:损坏回执是 fail-closed,不是「没执行过」。
465
+ const authorized = manifestVerification.ok === true && !receiptPresent && armed
466
+ const stale = valid && expired && !executed
467
+ const blockedReason = !manifestVerification.ok
468
+ ? manifestVerification.reason
469
+ : executed ? 'already-executed'
470
+ : receiptPresent ? 'invalid-receipt'
471
+ : !armed ? (manifest?.guard?.mode !== 'enforce' ? 'guard-not-enforce' : 'process-restart-disabled')
472
+ : null
473
+ return {
474
+ ...base, found: true, valid, expired, executed, authorized, stale, armed,
475
+ receiptState: !receiptPresent ? 'absent' : executed ? 'executed' : 'invalid',
476
+ blockedReason, manifest, receipt, manifestVerification, receiptVerification,
477
+ }
478
+ }
479
+
480
+ /**
481
+ * 启动对账:扫描 approved/ + executed/,把每份清单归类为
482
+ * armed / not-armed / executed / invalid-receipt / stale / invalid。
483
+ * 只读,不删任何东西;HMAC secret 可选,提供时签名错误会判 invalid。
484
+ */
485
+ export function reconcileRestartState(stateDir, { now = Date.now(), hmacSecret = null } = {}) {
486
+ const rows = listApprovalIds(stateDir).map((id) => {
487
+ const manifest = readApprovalManifest(stateDir, id)
488
+ if (!manifest) return { id, state: 'invalid', reason: 'not-found', approvedAt: null, expiresAt: null, signed: false, armed: false }
489
+ const verification = verifyApprovalManifest(manifest, { now, expectedId: id, hmacSecret })
490
+ const receipt = readExecutionReceipt(stateDir, id)
491
+ const receiptVerification = receipt ? verifyExecutionReceipt(receipt, { manifest }) : null
492
+ const executed = receiptVerification?.ok === true
493
+ const armed = isArmedManifest(manifest)
494
+ const state = executed ? 'executed'
495
+ : receipt ? 'invalid-receipt'
496
+ : verification.ok ? (armed ? 'armed' : 'not-armed')
497
+ : verification.expired ? 'stale' : 'invalid'
498
+ return {
499
+ id,
500
+ state,
501
+ reason: verification.reason ?? null,
502
+ approvedAt: Number(manifest.approvedAt) || null,
503
+ expiresAt: Number(manifest.expiresAt) || null,
504
+ signed: Boolean(manifest.signature),
505
+ armed,
506
+ }
507
+ })
508
+ const count = (state) => rows.filter(row => row.state === state).length
509
+ return {
510
+ ok: true,
511
+ checked: rows.length,
512
+ counts: {
513
+ armed: count('armed'),
514
+ notArmed: count('not-armed'),
515
+ executed: count('executed'),
516
+ stale: count('stale'),
517
+ invalid: count('invalid') + count('invalid-receipt'),
518
+ },
519
+ rows,
520
+ }
521
+ }
522
+
523
+ /**
524
+ * 旧清单 prune:删除陈旧/已执行/无效的清单与回执文件,保护 newest keep 份 + latest 指针。
525
+ * 返回 { ok, removed:[id], kept, latestCleared }。
526
+ */
527
+ export function pruneRestartState(stateDir, { now = Date.now(), hmacSecret = null, retentionMs = 7 * 24 * 3600_000, keep = 20 } = {}) {
528
+ const reconciled = reconcileRestartState(stateDir, { now, hmacSecret })
529
+ const sorted = [...reconciled.rows].sort((a, b) => (b.approvedAt ?? 0) - (a.approvedAt ?? 0))
530
+ const protectedIds = new Set(sorted.slice(0, Math.max(0, Math.floor(Number(keep) || 0))).map(row => row.id))
531
+ const removable = reconciled.rows.filter((row) => {
532
+ if (!['stale', 'invalid', 'invalid-receipt', 'executed'].includes(row.state)) return false
533
+ if (protectedIds.has(row.id)) return false
534
+ const at = Number(row.approvedAt ?? row.expiresAt ?? 0)
535
+ return now - at >= retentionMs
536
+ })
537
+ const removed = []
538
+ for (const row of removable) {
539
+ let ok = true
540
+ for (const file of [approvalFile(stateDir, row.id), receiptFile(stateDir, row.id)]) {
541
+ if (!file) { ok = false; continue }
542
+ try {
543
+ if (fs.existsSync(file)) fs.unlinkSync(file)
544
+ } catch { ok = false }
545
+ }
546
+ if (ok) removed.push(row.id)
547
+ }
548
+ // latest.json 指向被删的 id 时,改指向剩余最新的一份;没有就删掉指针。
549
+ let latestCleared = false
550
+ try {
551
+ const latestFile = latestApprovalFile(stateDir)
552
+ const latest = readJson(latestFile)
553
+ if (latest && removed.includes(String(latest.id))) {
554
+ const next = sorted.find(row => !removed.includes(row.id))
555
+ if (next) {
556
+ fs.writeFileSync(latestFile, JSON.stringify({ ...latest, id: next.id, file: `${next.id}.json`, at: now }), 'utf8')
557
+ } else {
558
+ fs.unlinkSync(latestFile)
559
+ latestCleared = true
560
+ }
561
+ }
562
+ } catch { /* prune is best-effort */ }
563
+ return { ok: true, removed, kept: reconciled.checked - removed.length, latestCleared, retentionMs }
564
+ }
565
+
566
+ /** 从摘要行统计:CLI / HTTP / UI 共用的唯一计数口径。 */export function summarizeApprovalCounts(rows = []) {
567
+ const count = (state) => rows.filter(r => r.state === state).length
568
+ return {
569
+ total: rows.length,
570
+ authorized: count('authorized'),
571
+ executed: count('executed'),
572
+ stale: count('stale'),
573
+ notArmed: count('not-armed'),
574
+ invalid: count('invalid') + count('invalid-receipt') + count('missing'),
575
+ }
576
+ }
577
+
578
+ /**
579
+ * 只读汇总:`<dataDir>/restart` 的守卫状态 + 授权清单摘要。
580
+ * 不写盘、不审计、不执行;CLI(fangctl restart-status)与 HTTP/UI 共用同一投影。
581
+ * 状态文件缺失/损坏时 state='unknown',不伪造 idle。
582
+ */
583
+ export function readRestartStatus(dataDir, { now = Date.now(), limit = 50 } = {}) {
584
+ const dataDirText = String(dataDir ?? '')
585
+ const stateDirPath = path.join(dataDirText, 'restart')
586
+ const raw = readJson(path.join(stateDirPath, 'state.json'))
587
+ const max = Math.min(200, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 50))
588
+ const rows = listApprovalIds(stateDirPath)
589
+ .map(id => summarizeApproval(inspectApproval(stateDirPath, id, { now })))
590
+ .sort((a, b) => (b.approvedAt ?? 0) - (a.approvedAt ?? 0))
591
+ return {
592
+ ok: true,
593
+ dataDir: dataDirText,
594
+ stateDir: stateDirPath,
595
+ state: typeof raw?.state === 'string' ? raw.state : 'unknown',
596
+ pending: raw?.pending ?? null,
597
+ lastInterrupted: raw?.lastInterrupted ?? null,
598
+ disposed: raw?.disposed === true,
599
+ stateAt: Number.isFinite(raw?.at) ? raw.at : null,
600
+ approvals: { items: rows.slice(0, max), counts: summarizeApprovalCounts(rows) },
601
+ dirs: {
602
+ approved: approvalDir(stateDirPath),
603
+ executed: executionDir(stateDirPath),
604
+ latest: latestApprovalFile(stateDirPath),
605
+ },
606
+ contract: {
607
+ version: CONTRACT_VERSION,
608
+ decisionOnly: true,
609
+ guardExecuted: false,
610
+ plannedExitCode: PLANNED_EXIT_CODE,
611
+ armedWhen: ['guard.mode === "enforce"', 'guard.allowProcessRestart === true'],
612
+ rule: '「已执行」只认回执 execution.executed === true;授权清单里恒为 false。',
613
+ },
614
+ executed: false,
615
+ }
616
+ }
617
+
618
+ /** 给面板/列表用的紧凑摘要(不携带完整清单/回执)。 */
619
+ export function summarizeApproval(inspected) {
620
+ const m = inspected?.manifest ?? null
621
+ const executed = inspected?.executed === true
622
+ const expired = inspected?.expired === true
623
+ const authorized = inspected?.authorized === true
624
+ const armed = inspected?.armed === true
625
+ const state = !inspected?.found
626
+ ? 'missing'
627
+ : executed ? 'executed'
628
+ : !inspected?.valid ? 'invalid'
629
+ : expired ? 'stale'
630
+ : authorized ? 'authorized'
631
+ : inspected?.receiptState === 'invalid' ? 'invalid-receipt'
632
+ : !armed ? 'not-armed' : 'invalid'
633
+ return {
634
+ id: inspected?.id ?? null,
635
+ found: inspected?.found === true,
636
+ state,
637
+ risk: m?.risk ?? null,
638
+ outcome: m?.outcome ?? null,
639
+ approvalId: m?.approvalId ?? null,
640
+ approvedBy: m?.approvedBy ?? null,
641
+ approvedAt: m?.approvedAt ?? null,
642
+ expiresAt: m?.expiresAt ?? null,
643
+ digest: m?.digest ?? null,
644
+ expired, executed, authorized, armed,
645
+ stale: inspected?.stale === true,
646
+ receiptState: inspected?.receiptState ?? 'absent',
647
+ blockedReason: inspected?.blockedReason ?? null,
648
+ reason: inspected?.manifestVerification?.reason ?? inspected?.reason ?? null,
649
+ }
650
+ }
651
+
652
+
653
+ // ── v3.3.0 崩溃循环制动(纯函数,供 restart-guard / main-city gate / watchdog 共用)──
654
+ export const CRASH_LOOP_DEFAULTS = Object.freeze({
655
+ windowMs: 15 * 60_000,
656
+ threshold: 3,
657
+ })
658
+
659
+ /** 容错解析 crash ledger(JSONL);坏行跳过,供「只读判死」使用。 */
660
+ export function parseCrashLedgerText(text) {
661
+ const out = []
662
+ for (const line of String(text ?? '').split(/\r?\n/)) {
663
+ if (!line.trim()) continue
664
+ try {
665
+ const rec = JSON.parse(line)
666
+ if (rec && typeof rec === 'object') out.push(rec)
667
+ } catch { /* 坏行不是证据 */ }
668
+ }
669
+ return out
670
+ }
671
+
672
+ /**
673
+ * 崩溃循环判定:windowMs 内 >= threshold 次崩溃 = active。
674
+ * 只看数字 at;没有 at 的记录不参与,避免用无时间证据误杀。
675
+ */
676
+ export function detectCrashLoop(input, options = {}) {
677
+ const opts = {
678
+ now: Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now(),
679
+ windowMs: Number(options.windowMs) > 0 ? Number(options.windowMs) : CRASH_LOOP_DEFAULTS.windowMs,
680
+ threshold: Number(options.threshold) > 0 ? Math.floor(Number(options.threshold)) : CRASH_LOOP_DEFAULTS.threshold,
681
+ }
682
+ const records = Array.isArray(input) ? input : parseCrashLedgerText(input)
683
+ const recent = records
684
+ .map((rec) => Number(rec?.at))
685
+ .filter((at) => Number.isFinite(at) && at > 0 && opts.now - at <= opts.windowMs)
686
+ .sort((a, b) => a - b)
687
+ const active = recent.length >= opts.threshold
688
+ return {
689
+ active,
690
+ count: recent.length,
691
+ threshold: opts.threshold,
692
+ windowMs: opts.windowMs,
693
+ firstAt: recent[0] ?? null,
694
+ lastAt: recent[recent.length - 1] ?? null,
695
+ reason: active ? 'crash-loop' : 'below-threshold',
696
+ }
697
+ }
698
+
699
+ /** 读取 crash ledger 文件并判定;文件不存在/不可读 = 空证据(inactive)。 */
700
+ export function readCrashLoop(file, options = {}) {
701
+ try {
702
+ const raw = fs.readFileSync(String(file ?? ''), 'utf8')
703
+ return detectCrashLoop(raw, options)
704
+ } catch {
705
+ return detectCrashLoop([], options)
706
+ }
707
+ }
708
+
709
+ /**
710
+ * v3.3.0:离线人工解除 restart 崩溃循环安全模式。
711
+ * 只做三件事:归档 runtime-crash.jsonl、把 safe-mode.json 标成人工清除、写一条链式审计(若账本可读)。
712
+ * 不做重启、不改 profile、不删除崩溃证据。
713
+ */
714
+ export function clearCrashLoopSafeMode(dataDir, options = {}) {
715
+ const by = options && options.by !== undefined ? options.by : ''
716
+ const note = options && options.note !== undefined ? options.note : ''
717
+ const now = Number(options && options.now) > 0 ? Number(options.now) : Date.now()
718
+ const base = String(dataDir ?? '').trim()
719
+ if (!base) return { ok: false, reason: 'data-dir-required' }
720
+ const stateDir = base + '/restart'
721
+ const safeFile = stateDir + '/safe-mode.json'
722
+ const crashFile = stateDir + '/runtime-crash.jsonl'
723
+ let current = null
724
+ try { current = JSON.parse(fs.readFileSync(safeFile, "utf8")) } catch { current = null }
725
+ if (!current || current.active !== true) return { ok: true, cleared: false, reason: 'not-active', file: safeFile }
726
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
727
+ let archived = null
728
+ try {
729
+ if (fs.existsSync(crashFile)) {
730
+ archived = crashFile + '.cleared-' + stamp
731
+ fs.renameSync(crashFile, archived)
732
+ }
733
+ fs.mkdirSync(stateDir, { recursive: true })
734
+ fs.writeFileSync(safeFile, JSON.stringify({
735
+ version: 1, active: false, reason: 'cleared', at: now, clearedAt: now,
736
+ clearedBy: String(by ?? '').slice(0, 200) || null,
737
+ note: String(note ?? '').slice(0, 500) || null,
738
+ archived,
739
+ }, null, 2) + '\n', 'utf8')
740
+ } catch (error) {
741
+ return { ok: false, cleared: false, reason: 'clear-failed', error: String(error?.message ?? error) }
742
+ }
743
+ let audit = null
744
+ try {
745
+ const ledger = base + '/audit.jsonl'
746
+ const chain = verifyChain(ledger)
747
+ if (chain.ok) {
748
+ audit = appendChained(ledger, { kind: 'restart-safe-mode-cleared', by: String(by ?? '') || null, note: String(note ?? '') || null, archived, at: new Date(now).toISOString() })
749
+ }
750
+ } catch { /* 审计失败不阻断人工恢复 */ }
751
+ return { ok: true, cleared: true, file: safeFile, archived, audit }
752
+ }