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

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 (144) 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/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1210 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.0.0 — 2.4 审批子插件(T3/T9)
4
+ //
5
+ // 职责(四件能力,契约见 docs/CONTRACTS.md §5.7 与 §6;v2 增量见 CONTRACTS-V2.md §5.7):
6
+ // 1) 待批队列:request() 返回 Promise,只有 decide() 或 TTL 到期(fail-closed → 'timeout')才 resolve;
7
+ // 2) 多代快照:snapshot() 把目录内容复制到 <dataDir>/snapshots/<tag>/A(新)与 /B、/C…(历史代),
8
+ // 对复制出的内容做内容寻址 sha256 digest;rollback() 用快照覆盖回目标目录并校验 digest;
9
+ // 1a) 崩溃 journal:dsh-approval-journal.jsonl 只增记录 request/settled/orphaned;
10
+ // 重启后未 settled 的 request 只标记 orphaned,绝不复活已死的 Promise;
11
+ // 3) 监控:monitor({label,value,threshold}) 采样,越阈值返回 alert:true 并留痕;
12
+ // 4) 回滚 + 保留文件桥:dataDir 下的 command/answer/pending/status json 供 CLI 驱动(轮询式)。
13
+ //
14
+ // v2.0.0(双侧语义,CONTRACTS-V2.md §1/§5.7):
15
+ // * apply 首行 roleGate('approver');approver 在 mother/fork 两侧都激活,service.scope 给出实际角色;
16
+ // * mother 侧只处理晋升/重启:requestPromotion() / requestRestart() 是固定红灯的语义化包装;
17
+ // * summary({remotes}) 并发汇总本地待批 + 各分身 fetchApprovals(),单个 remote 失败不阻塞;
18
+ // * fork 侧仍用通用 request()/decide() 处理任务内黄/红灯动作(v1 行为不变)。
19
+ //
20
+ // 硬约束:
21
+ // * strictRedLine=true 时 risk==='red' 必须显式 decide(),authMode='auto' 不能绕过(fail-closed);
22
+ // * 所有落盘走 kernel fsx(writeJsonAtomic / appendJsonl / copyTree / removeTree / ensureDir);
23
+ // * 定时器与轮询监听全部登记在 ctx.effect 上,dispose 时清理,未决请求按 timeout 结算;
24
+ // * 决策后 emit kernel bus 'fang/approval-decided' 并 append 审计(auditor 可能未装载 → 静默容错)。
25
+ //
26
+ // 明确降级(NOT ESTABLISHED):
27
+ // * authMode='timed' 的 grants(限时授权/临时授权)未实现,按 manual 处理(fail-closed);
28
+ // * 快照保留 snapshotGenerations 代(默认 3,A 最新);不跟随 junction(copyTree 只重建链接);
29
+ // * monitor 为单点采样(不做内部定时 getter 轮询),轮询由调用方/CLI 决定;
30
+ // * requestPromotion/requestRestart 只在 mother scope 接受;fork 调用立即 denied/by:'scope'。
31
+ import fs from 'node:fs'
32
+ import path from 'node:path'
33
+ import crypto from 'node:crypto'
34
+ import { execFileSync } from 'node:child_process'
35
+ import {
36
+ AUDIT,
37
+ SERVICE,
38
+ appendJsonl,
39
+ bus,
40
+ copyTree,
41
+ digestTree,
42
+ ensureDir,
43
+ exists,
44
+ iso,
45
+ newId,
46
+ nowMs,
47
+ readJsonl,
48
+ readTextSafe,
49
+ removeTree,
50
+ resolveFangLayout,
51
+ roleGate,
52
+ sha256,
53
+ writeJsonAtomic,
54
+ z,
55
+ } from '../kernel/index.mjs'
56
+
57
+ export const name = 'fang-approver'
58
+ /** 审批对 auditor 是软依赖(审计器可能晚于/未装载):不写 inject,读的时候 safeGet + 可选链。 */
59
+ export const inject = []
60
+
61
+ export const Config = z.object({
62
+ dataRoot: z.string().default('')
63
+ .description('Fang 数据根目录;留空 = resolveFangLayout() 的默认 $DSH_HOME/fang/<profile>'),
64
+ defaultTtlMs: z.number().min(100).default(300000)
65
+ .description('待批项默认 TTL(毫秒);到期 fail-closed 记为 timeout'),
66
+ remindAt: z.number().min(0).max(1).default(0.5)
67
+ .description('在 TTL 的这个比例处标记提醒(0–1;只落状态,不自动放行)'),
68
+ authMode: z.union([z.const('manual'), z.const('auto'), z.const('timed')]).default('manual')
69
+ .description('manual 全人工 / auto 非红自动放行(stricted 红仍人工)/ timed 未实现,按 manual'),
70
+ strictRedLine: z.boolean().default(true)
71
+ .description('红灯必须人工显式 decide;即使 authMode=auto'),
72
+ backupRoot: z.string().default('')
73
+ .description('快照根目录;留空 = <dataDir>/snapshots(默认布局)'),
74
+ snapshotGenerations: z.number().min(2).max(10).default(3)
75
+ .description('每个 tag 保留的快照代数(A 最新,依次 B/C…;默认 3)'),
76
+ fileBridgePollMs: z.number().min(10).default(300)
77
+ .description('文件桥(command/answer json)轮询间隔(毫秒)'),
78
+ bridgeAclCheck: z.boolean().default(true)
79
+ .description('启动时校验 dataDir ACL;不通过则停用文件桥'),
80
+ bridgeSecret: z.string().default('')
81
+ .description('可选文件桥 HMAC 密钥;设置后 command/answer 必须带 signature'),
82
+ role: z.union([z.const('mother'), z.const('fork')]).default('mother')
83
+ .description('部署角色(v2 §1);DSH_FANG_ROLE 环境变量优先级更高(forker 启动分身时注入)'),
84
+ }).description('dsh-Fang 审批子插件配置(契约 §5.7)')
85
+
86
+ const DEFAULTS = Object.freeze({
87
+ dataRoot: '',
88
+ defaultTtlMs: 300000,
89
+ remindAt: 0.5,
90
+ authMode: 'manual',
91
+ strictRedLine: true,
92
+ backupRoot: '',
93
+ snapshotGenerations: 3,
94
+ fileBridgePollMs: 300,
95
+ bridgeAclCheck: true,
96
+ bridgeSecret: '',
97
+ role: 'mother',
98
+ })
99
+
100
+ /** 契约 §5.7 只允许这两种人工结论;'timeout' 由 TTL/卸载路径给出。 */
101
+ const DECIDABLE_OUTCOMES = Object.freeze(['approved', 'denied'])
102
+ const RISK_VALUES = Object.freeze(['blue', 'yellow', 'red'])
103
+ const AUTH_MODES = Object.freeze(['manual', 'auto', 'timed'])
104
+
105
+ const EVENT_APPROVAL_DECIDED = 'fang/approval-decided'
106
+ /** kernel types.mjs 里的键名是 `SNapshot_CREATED`(历史拼写),用字符串访问避免抄错。 */
107
+ const AUDIT_SNAPSHOT_CREATED = AUDIT['SNapshot_CREATED']
108
+
109
+ /** 文件桥文件名(保留 dsh-hot-update/packages/approval 的命名,便于 CLI 平滑迁移)。 */
110
+ const BRIDGE = Object.freeze({
111
+ command: 'dsh-approval-command.json',
112
+ answer: 'dsh-approval-answer.json',
113
+ pending: 'dsh-approval-pending.json',
114
+ status: 'dsh-approval-status.json',
115
+ })
116
+ /** 决策流水(appendJsonl,只增不改;status() 的 decisions 计数从内存历史来)。 */
117
+ const DECISIONS_FILE = 'dsh-approval-decisions.jsonl'
118
+ /** 崩溃 journal:request/settled/orphaned 生命周期只增日志,供重启恢复未结请求。 */
119
+ const JOURNAL_FILE = 'dsh-approval-journal.jsonl'
120
+
121
+ function clamp01(n) {
122
+ const v = Number(n)
123
+ if (!Number.isFinite(v)) return DEFAULTS.remindAt
124
+ return Math.min(1, Math.max(0, v))
125
+ }
126
+
127
+ function positiveNumber(n, fallback) {
128
+ const v = Number(n)
129
+ return Number.isFinite(v) && v > 0 ? v : fallback
130
+ }
131
+
132
+ /** 归一化 config:测试/宿主都可能绕过 schemastery 直接传裸对象,这里不抛错、全量兜底。 */
133
+ function normalizeConfig(config) {
134
+ const input = config && typeof config === 'object' ? config : {}
135
+ return {
136
+ dataRoot: typeof input.dataRoot === 'string' ? input.dataRoot : DEFAULTS.dataRoot,
137
+ defaultTtlMs: positiveNumber(input.defaultTtlMs, DEFAULTS.defaultTtlMs),
138
+ remindAt: clamp01(input.remindAt ?? DEFAULTS.remindAt),
139
+ authMode: AUTH_MODES.includes(String(input.authMode)) ? String(input.authMode) : DEFAULTS.authMode,
140
+ strictRedLine: input.strictRedLine === undefined ? DEFAULTS.strictRedLine : input.strictRedLine !== false,
141
+ backupRoot: typeof input.backupRoot === 'string' ? input.backupRoot : DEFAULTS.backupRoot,
142
+ snapshotGenerations: Math.max(2, Math.min(10, Math.floor(Number(input.snapshotGenerations) || DEFAULTS.snapshotGenerations))),
143
+ fileBridgePollMs: positiveNumber(input.fileBridgePollMs, DEFAULTS.fileBridgePollMs),
144
+ bridgeAclCheck: input.bridgeAclCheck === undefined ? DEFAULTS.bridgeAclCheck : input.bridgeAclCheck !== false,
145
+ bridgeSecret: typeof input.bridgeSecret === 'string' ? input.bridgeSecret : DEFAULTS.bridgeSecret,
146
+ role: input.role === 'fork' ? 'fork' : 'mother',
147
+ }
148
+ }
149
+
150
+ /** tag 会被用作目录名(可能来自 CLI),必须挡掉路径穿越字符。 */
151
+ function safeTag(raw) {
152
+ const s = String(raw ?? '').trim()
153
+ const cleaned = s.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '').slice(0, 64)
154
+ const base = cleaned || 'snap'
155
+ // Distinct raw tags must not collapse onto one directory (a/b vs a_b).
156
+ if (base === s) return base
157
+ return `${base.slice(0, 55)}-${sha256(s).slice(0, 8)}`
158
+ }
159
+
160
+ /** 内容寻址 digest:按相对路径排序后对 (rel\0contentHash) 串做 sha256,跨平台稳定。 */
161
+ function digestOf(dir) {
162
+ const tree = digestTree(dir)
163
+ const keys = Object.keys(tree).sort()
164
+ const lines = keys.map(rel => `${rel}\u0000${tree[rel]}`)
165
+ return { digest: sha256(lines.join('\n')), files: keys.length }
166
+ }
167
+
168
+ /** Restore a snapshot without deleting the live target first. */
169
+ function atomicRestoreDir(sourceDir, targetDir, expectedDigest) {
170
+ const suffix = newId('rollback')
171
+ const tmp = `${targetDir}.restore-${suffix}`
172
+ const old = `${targetDir}.rollback-${suffix}`
173
+ try {
174
+ removeTree(tmp)
175
+ removeTree(old)
176
+ copyTree(sourceDir, tmp)
177
+ const copied = digestOf(tmp)
178
+ if (copied.digest !== expectedDigest) {
179
+ removeTree(tmp)
180
+ return { ok: false, reason: 'digest-mismatch', digest: copied.digest, files: copied.files }
181
+ }
182
+ const hadOld = exists(targetDir)
183
+ if (hadOld) fs.renameSync(targetDir, old)
184
+ try {
185
+ fs.renameSync(tmp, targetDir)
186
+ } catch (error) {
187
+ if (hadOld && !exists(targetDir)) {
188
+ try { fs.renameSync(old, targetDir) } catch { /* best effort restore */ }
189
+ }
190
+ throw error
191
+ }
192
+ if (hadOld) removeTree(old)
193
+ return { ok: true, digest: copied.digest, files: copied.files }
194
+ } catch (error) {
195
+ removeTree(tmp)
196
+ if (exists(old) && !exists(targetDir)) {
197
+ try { fs.renameSync(old, targetDir) } catch { /* best effort restore */ }
198
+ }
199
+ throw error
200
+ }
201
+ }
202
+
203
+ function normalizeThreshold(threshold) {
204
+ if (typeof threshold === 'number') {
205
+ return Number.isFinite(threshold) ? { op: '>', value: threshold } : null
206
+ }
207
+ if (threshold && typeof threshold === 'object') {
208
+ const value = Number(threshold.value)
209
+ if (!Number.isFinite(value)) return null
210
+ const op = ['>', '>=', '<', '<='].includes(String(threshold.op)) ? String(threshold.op) : '>'
211
+ return { op, value }
212
+ }
213
+ return null
214
+ }
215
+
216
+ function thresholdHit(value, threshold) {
217
+ if (!threshold) return false
218
+ if (threshold.op === '>') return value > threshold.value
219
+ if (threshold.op === '>=') return value >= threshold.value
220
+ if (threshold.op === '<') return value < threshold.value
221
+ return value <= threshold.value
222
+ }
223
+
224
+ /**
225
+ * Best-effort platform ACL check for the file-bridge directory. The bridge is a
226
+ * local-only compatibility path; if the directory is writable by broad principals
227
+ * we disable it rather than expose command execution to any local process.
228
+ */
229
+ function checkBridgeAcl(dir) {
230
+ try {
231
+ const st = fs.statSync(dir)
232
+ if (process.platform !== 'win32') {
233
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
234
+ return { ok: false, reason: 'bridge-dir-not-owned' }
235
+ }
236
+ if ((st.mode & 0o022) !== 0) return { ok: false, reason: 'bridge-dir-group-or-world-writable' }
237
+ return { ok: true, platform: 'posix', mode: st.mode & 0o777 }
238
+ }
239
+ const output = execFileSync('icacls', [dir], { encoding: 'utf8', timeout: 5000, windowsHide: true })
240
+ const dangerous = /(everyone|builtin\\users|authenticated users|interactive|iusr|iis_iusrs)/i
241
+ const resolved = path.resolve(dir).toLowerCase()
242
+ let checked = 0
243
+ for (const rawLine of String(output).split(/\r?\n/)) {
244
+ let line = rawLine.trim()
245
+ if (!line) continue
246
+ if (line.toLowerCase().startsWith(resolved)) line = line.slice(path.resolve(dir).length).trim()
247
+ if (!line.includes(':')) continue
248
+ checked += 1
249
+ if (dangerous.test(line)) return { ok: false, reason: 'bridge-dir-world-accessible', detail: line }
250
+ }
251
+ if (checked === 0) return { ok: false, reason: 'bridge-acl-unreadable' }
252
+ return { ok: true, platform: 'win32' }
253
+ } catch (error) {
254
+ return { ok: false, reason: 'bridge-acl-check-failed', error: String(error?.message ?? error) }
255
+ }
256
+ }
257
+
258
+ function stableStringify(value) {
259
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
260
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
261
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
262
+ }
263
+
264
+ function verifyBridgeSignature(payload, secret) {
265
+ if (!secret) return true
266
+ if (!payload || typeof payload !== 'object') return false
267
+ const signature = String(payload.signature ?? '')
268
+ if (!/^[0-9a-f]{64}$/i.test(signature)) return false
269
+ const copy = { ...payload }
270
+ delete copy.signature
271
+ const expected = crypto.createHmac('sha256', secret).update(stableStringify(copy)).digest('hex')
272
+ try {
273
+ return crypto.timingSafeEqual(Buffer.from(signature.toLowerCase(), 'hex'), Buffer.from(expected, 'hex'))
274
+ } catch {
275
+ return false
276
+ }
277
+ }
278
+
279
+
280
+ // v2 §1:角色门禁必须是 apply 的第一件事;未部署到该角色时不注册服务/定时器/文件桥。
281
+ export function apply(ctx, config) {
282
+ // v2 role gate: must be the first thing apply does.
283
+ const gate = roleGate('approver', {
284
+ cfgRole: config && typeof config === 'object' ? config.role : '',
285
+ logger: ctx.logger,
286
+ })
287
+ if (!gate.active) return null
288
+ const scope = gate.role
289
+ const cfg = normalizeConfig(config)
290
+ const layout = resolveFangLayout({ dataRoot: cfg.dataRoot })
291
+ const dataDir = layout.dataDir
292
+ const snapshotsRoot = cfg.backupRoot ? path.resolve(cfg.backupRoot) : layout.snapshotsDir
293
+ const bridgePaths = {
294
+ command: path.join(dataDir, BRIDGE.command),
295
+ answer: path.join(dataDir, BRIDGE.answer),
296
+ pending: path.join(dataDir, BRIDGE.pending),
297
+ status: path.join(dataDir, BRIDGE.status),
298
+ }
299
+ const decisionsFile = path.join(dataDir, DECISIONS_FILE)
300
+ const journalFile = path.join(dataDir, JOURNAL_FILE)
301
+ const snapshotsIndexFile = path.join(snapshotsRoot, 'index.json')
302
+ const bridgeSecret = String(process.env.FANG_BRIDGE_SECRET ?? cfg.bridgeSecret ?? '').trim()
303
+ let bridgeAcl = null
304
+ let bridgeSecurityChecked = false
305
+ const ensureBridgeSecurity = () => {
306
+ if (bridgeSecurityChecked) return bridgeAcl?.ok === true
307
+ bridgeSecurityChecked = true
308
+ if (!cfg.bridgeAclCheck) {
309
+ bridgeAcl = { ok: true, skipped: true }
310
+ return true
311
+ }
312
+ try { ensureDir(dataDir) } catch { /* check will fail closed below */ }
313
+ bridgeAcl = checkBridgeAcl(dataDir)
314
+ if (!bridgeAcl.ok) logger('warn', `[fang-approver] file bridge disabled: ${bridgeAcl.reason}`)
315
+ return bridgeAcl.ok === true
316
+ }
317
+
318
+ /** id → item(结案后保留一小段用于幂等判定,超过上限再修剪)。 */
319
+ const queue = new Map()
320
+ /** 决策历史(后 10 条同时写进 status 文件;全量追加 decisions.jsonl)。 */
321
+ const decisions = []
322
+ /** tag → {tag, A:{dir,digest,at,files}, B:{...}|null} */
323
+ const snapshots = new Map()
324
+ /** label → {label, samples:[{at,value}], min, max, last, alerts:[], threshold} */
325
+ const monitors = new Map()
326
+
327
+ // Snapshot generation directories outlive the process, but digest metadata used to
328
+ // live only in this Map. Persist it so rollback still works after a restart.
329
+ const persistSnapshotIndex = () => {
330
+ try {
331
+ ensureDir(snapshotsRoot)
332
+ writeJsonAtomic(snapshotsIndexFile, {
333
+ version: 2,
334
+ snapshots: [...snapshots.values()].map((record) => ({
335
+ tag: record.tag,
336
+ A: record.A ? { digest: record.A.digest, at: record.A.at, files: record.A.files } : null,
337
+ B: record.B ? { digest: record.B.digest, at: record.B.at, files: record.B.files } : null,
338
+ generations: (record.generations ?? []).map((generation) => ({
339
+ slot: generation.slot, digest: generation.digest, at: generation.at, files: generation.files,
340
+ })),
341
+ })),
342
+ })
343
+ } catch (error) {
344
+ logger('warn', `[fang-approver] snapshot index write failed: ${String(error?.message ?? error)}`)
345
+ }
346
+ }
347
+
348
+ const loadSnapshotIndex = () => {
349
+ const raw = readTextSafe(snapshotsIndexFile, '')
350
+ if (!raw) return
351
+ let parsed = null
352
+ try { parsed = JSON.parse(raw) } catch { return }
353
+ for (const entry of Array.isArray(parsed?.snapshots) ? parsed.snapshots : []) {
354
+ const tag = safeTag(entry?.tag)
355
+ const base = path.join(snapshotsRoot, tag)
356
+ const slot = (name, explicitSource) => {
357
+ const source = explicitSource ?? entry?.[name]
358
+ if (!source || typeof source.digest !== 'string') return null
359
+ const dir = path.join(base, name)
360
+ if (!exists(dir)) return null
361
+ return { slot: name, dir, digest: source.digest, at: Number(source.at) || 0, files: Number(source.files) || 0 }
362
+ }
363
+ let generations = []
364
+ if (Array.isArray(entry?.generations)) {
365
+ for (const rawGen of entry.generations) {
366
+ const name = String(rawGen?.slot ?? '').toUpperCase()
367
+ if (!/^[A-J]$/.test(name)) continue
368
+ const loaded = slot(name, rawGen)
369
+ if (loaded) generations.push(loaded)
370
+ }
371
+ }
372
+ if (!generations.length) generations = [slot('A'), slot('B')].filter(Boolean)
373
+ generations.sort((a, b) => a.slot.localeCompare(b.slot))
374
+ const A = generations[0] ?? null
375
+ if (!A) continue
376
+ snapshots.set(tag, { tag, A, B: generations[1] ?? null, generations })
377
+ }
378
+ }
379
+ loadSnapshotIndex()
380
+ /** 所有 TTL/提醒定时器,dispose 时统一清理。 */
381
+ const timers = new Set()
382
+ let disposed = false
383
+ /** 发生过任何业务动作才允许落盘 —— `apply(makeCtx())` 本身不产生任何 profile 写入。 */
384
+ let touched = false
385
+
386
+ // ───────── 基础设施:日志 / 审计 / 状态落盘 ─────────
387
+
388
+ const logger = (level, message) => {
389
+ try { ctx.logger?.[level]?.(message) } catch { /* logger 不可用不打断审批 */ }
390
+ }
391
+ const safeGet = (key) => {
392
+ try { return ctx.get(key) } catch { return undefined }
393
+ }
394
+ /** 审计器可能未装载:不 throw、不阻塞决策。 */
395
+ const audit = (kind, data) => {
396
+ try {
397
+ const auditor = safeGet(SERVICE.auditor)
398
+ if (auditor && typeof auditor.append === 'function') auditor.append(kind, data)
399
+ } catch (e) {
400
+ logger('debug', `[fang-approver] audit skipped: ${String(e?.message ?? e)}`)
401
+ }
402
+ }
403
+
404
+ const publicItem = (item) => ({
405
+ id: item.id,
406
+ kind: item.kind,
407
+ risk: item.risk,
408
+ title: item.title,
409
+ detail: item.detail,
410
+ impact: item.impact,
411
+ taskId: item.taskId ?? null,
412
+ pluginId: item.pluginId ?? null,
413
+ createdAt: item.createdAt,
414
+ deadlineAt: item.deadlineAt,
415
+ state: item.state,
416
+ by: item.by ?? null,
417
+ note: item.note ?? null,
418
+ resolvedAt: item.resolvedAt ?? null,
419
+ orphanedAt: item.orphanedAt ?? null,
420
+ reminded: item.reminded === true,
421
+ })
422
+
423
+ const monitorView = (rec, { sampleLimit = 20 } = {}) => ({
424
+ label: rec.label,
425
+ samples: rec.samples.length,
426
+ last: rec.last,
427
+ min: rec.min,
428
+ max: rec.max,
429
+ threshold: rec.threshold ? { ...rec.threshold } : null,
430
+ alerts: rec.alerts.slice(-5),
431
+ recent: rec.samples.slice(-sampleLimit),
432
+ })
433
+
434
+ const snapshotView = (rec, { short = false } = {}) => {
435
+ const fmt = (slot) => slot
436
+ ? { digest: short ? slot.digest.slice(0, 16) : slot.digest, at: slot.at, files: slot.files }
437
+ : null
438
+ return { tag: rec.tag, A: fmt(rec.A), B: fmt(rec.B) }
439
+ }
440
+
441
+ const pendingItems = () => [...queue.values()].filter(item => item.state === 'pending').map(publicItem)
442
+ const orphanedItems = () => [...queue.values()].filter(item => item.state === 'orphaned').map(publicItem)
443
+
444
+ /**
445
+ * A previous process may have left pending.json behind. Those Promises died with
446
+ * that process, so restoring them as "pending" would be a lie. Mark them orphaned,
447
+ * keep them visible for the UI/CLI, and never resolve a Promise that no longer exists.
448
+ */
449
+ const recoverOrphaned = () => {
450
+ const raw = readTextSafe(bridgePaths.pending, '')
451
+ if (!raw) return
452
+ let list = null
453
+ try { list = JSON.parse(raw) } catch { return }
454
+ let recovered = 0
455
+ for (const entry of Array.isArray(list) ? list : []) {
456
+ const id = String(entry?.id ?? '').trim()
457
+ if (!id || queue.has(id)) continue
458
+ const at = nowMs()
459
+ const item = {
460
+ id,
461
+ kind: String(entry?.kind ?? 'unknown'),
462
+ risk: RISK_VALUES.includes(String(entry?.risk)) ? String(entry.risk) : 'red',
463
+ title: String(entry?.title ?? ''),
464
+ detail: String(entry?.detail ?? ''),
465
+ impact: entry?.impact ?? null,
466
+ taskId: entry?.taskId === undefined || entry?.taskId === null ? null : String(entry.taskId),
467
+ pluginId: entry?.pluginId === undefined || entry?.pluginId === null ? null : String(entry.pluginId),
468
+ createdAt: Number(entry?.createdAt) || at,
469
+ deadlineAt: Number(entry?.deadlineAt) || at,
470
+ ttlMs: Number(entry?.ttlMs) || cfg.defaultTtlMs,
471
+ state: 'orphaned',
472
+ by: 'restart',
473
+ note: 'process-restarted',
474
+ reminded: entry?.reminded === true,
475
+ resolvedAt: at,
476
+ orphanedAt: at,
477
+ resolve: null,
478
+ timers: new Set(),
479
+ }
480
+ queue.set(id, item)
481
+ touched = true
482
+ recovered += 1
483
+ audit('approval-interrupted', {
484
+ id, kind: item.kind, risk: item.risk, title: item.title,
485
+ taskId: item.taskId, pluginId: item.pluginId,
486
+ previousState: 'pending', state: 'orphaned', by: 'restart',
487
+ })
488
+ }
489
+ if (recovered > 0) persist()
490
+ }
491
+
492
+ // ───────── 1a) 崩溃 journal ─────────
493
+ let journalWrites = 0
494
+ const appendJournal = (entry) => {
495
+ try {
496
+ appendJsonl(journalFile, { at: iso(), ...entry })
497
+ journalWrites += 1
498
+ } catch (error) {
499
+ logger('warn', `[fang-approver] journal append failed: ${String(error?.message ?? error)}`)
500
+ }
501
+ }
502
+
503
+ /** Journal 里 request 未出现 settled/timeout → 进程在 pending 期间崩溃;恢复为 orphaned,绝不复活 Promise。 */
504
+ const recoverJournal = () => {
505
+ const entries = readJsonl(journalFile)
506
+ if (!entries.length) return
507
+ const requests = new Map()
508
+ const terminal = new Set()
509
+ for (const entry of entries) {
510
+ const id = String(entry?.id ?? '').trim()
511
+ if (!id) continue
512
+ const type = String(entry?.type ?? '')
513
+ if (type === 'request') requests.set(id, entry)
514
+ else if (type === 'settled' || type === 'timeout' || type === 'orphaned') terminal.add(id)
515
+ }
516
+ let recovered = 0
517
+ const at = nowMs()
518
+ for (const [id, entry] of requests) {
519
+ if (terminal.has(id) || queue.has(id)) continue
520
+ const item = {
521
+ id,
522
+ kind: String(entry.kind ?? 'unknown'),
523
+ risk: RISK_VALUES.includes(String(entry.risk)) ? String(entry.risk) : 'red',
524
+ title: String(entry.title ?? ''),
525
+ detail: String(entry.detail ?? ''),
526
+ impact: entry.impact ?? null,
527
+ taskId: entry.taskId === undefined || entry.taskId === null ? null : String(entry.taskId),
528
+ pluginId: entry.pluginId === undefined || entry.pluginId === null ? null : String(entry.pluginId),
529
+ createdAt: Number(entry.createdAt) || at,
530
+ deadlineAt: Number(entry.deadlineAt) || at,
531
+ ttlMs: Number(entry.ttlMs) || cfg.defaultTtlMs,
532
+ state: 'orphaned',
533
+ by: 'restart',
534
+ note: 'process-restarted(journal)',
535
+ reminded: entry.reminded === true,
536
+ resolvedAt: at,
537
+ orphanedAt: at,
538
+ resolve: null,
539
+ timers: new Set(),
540
+ }
541
+ queue.set(id, item)
542
+ touched = true
543
+ recovered += 1
544
+ appendJournal({ type: 'orphaned', id, by: 'restart', source: 'journal' })
545
+ audit('approval-interrupted', {
546
+ id, kind: item.kind, risk: item.risk, title: item.title,
547
+ taskId: item.taskId, pluginId: item.pluginId,
548
+ previousState: 'pending', state: 'orphaned', by: 'restart', source: 'journal',
549
+ })
550
+ }
551
+ if (recovered > 0) persist()
552
+ }
553
+
554
+ /** 把状态同步到 dataDir 的 pending/status 文件(文件桥)。任何异常都只记日志。 */
555
+ const persist = () => {
556
+ if (!touched) return // apply 本身不落盘,避免污染 profile
557
+ const at = iso()
558
+ const pending = pendingItems()
559
+ const decided = decisions.slice(-10)
560
+ const statusDoc = {
561
+ at,
562
+ dataDir,
563
+ snapshotsRoot,
564
+ counts: {
565
+ pending: pending.length,
566
+ orphaned: orphanedItems().length,
567
+ snapshots: snapshots.size,
568
+ monitors: monitors.size,
569
+ decisions: decisions.length,
570
+ },
571
+ pending,
572
+ orphaned: orphanedItems(),
573
+ decided,
574
+ snapshots: [...snapshots.values()].map(rec => snapshotView(rec, { short: true })),
575
+ monitors: [...monitors.values()].map(rec => monitorView(rec, { sampleLimit: 5 })),
576
+ policy: { authMode: cfg.authMode, strictRedLine: cfg.strictRedLine, defaultTtlMs: cfg.defaultTtlMs },
577
+ }
578
+ try { writeJsonAtomic(bridgePaths.status, statusDoc) } catch (e) {
579
+ logger('warn', `[fang-approver] status write failed: ${String(e?.message ?? e)}`)
580
+ }
581
+ try { writeJsonAtomic(bridgePaths.pending, pending) } catch (e) {
582
+ logger('warn', `[fang-approver] pending write failed: ${String(e?.message ?? e)}`)
583
+ }
584
+ }
585
+
586
+ const recordDecision = (item, outcome, by, note) => {
587
+ const record = {
588
+ id: item.id,
589
+ kind: item.kind,
590
+ risk: item.risk,
591
+ title: item.title,
592
+ taskId: item.taskId ?? null,
593
+ pluginId: item.pluginId ?? null,
594
+ outcome,
595
+ by,
596
+ note,
597
+ at: iso(),
598
+ }
599
+ decisions.push(record)
600
+ if (decisions.length > 500) decisions.splice(0, decisions.length - 500)
601
+ try { appendJsonl(decisionsFile, record) } catch (e) {
602
+ logger('debug', `[fang-approver] decisions append failed: ${String(e?.message ?? e)}`)
603
+ }
604
+ return record
605
+ }
606
+
607
+ const clearItemTimers = (item) => {
608
+ for (const timer of item.timers) {
609
+ clearTimeout(timer)
610
+ timers.delete(timer)
611
+ }
612
+ item.timers.clear()
613
+ }
614
+
615
+ const pruneQueue = () => {
616
+ if (queue.size <= 256) return
617
+ for (const [id, item] of queue) {
618
+ if (item.state !== 'pending') queue.delete(id)
619
+ if (queue.size <= 128) break
620
+ }
621
+ }
622
+
623
+ /**
624
+ * 唯一结案路径:改状态 → 记录 → resolve Promise → emit bus → 审计 → 落盘。
625
+ * outcome ∈ {'approved','denied','timeout'}。
626
+ */
627
+ const settle = (item, outcome, by, note = null) => {
628
+ if (!item || item.state !== 'pending') return null
629
+ item.state = outcome
630
+ item.by = by ?? null
631
+ item.note = note
632
+ item.resolvedAt = nowMs()
633
+ clearItemTimers(item)
634
+ const result = { id: item.id, outcome, by: item.by, at: iso() }
635
+ recordDecision(item, outcome, item.by, note)
636
+ appendJournal({ type: 'settled', id: item.id, outcome, by: item.by, note })
637
+ try { item.resolve(result) } catch (e) {
638
+ logger('debug', `[fang-approver] resolve failed: ${String(e?.message ?? e)}`)
639
+ }
640
+ try { bus.emit(EVENT_APPROVAL_DECIDED, { id: item.id, outcome }) } catch { /* listener 容错 */ }
641
+ audit(AUDIT.APPROVAL_DECIDED, {
642
+ id: item.id,
643
+ kind: item.kind,
644
+ risk: item.risk,
645
+ title: item.title,
646
+ taskId: item.taskId ?? null,
647
+ pluginId: item.pluginId ?? null,
648
+ outcome,
649
+ by: item.by,
650
+ note,
651
+ })
652
+ pruneQueue()
653
+ persist()
654
+ return result
655
+ }
656
+
657
+ // ───────── 1) 待批队列 ─────────
658
+
659
+ /**
660
+ * 契约 §5.7:request({kind, risk, title, detail, impact?, ttlMs?})
661
+ * -> Promise<{id, outcome:'approved'|'denied'|'timeout', by, at}>
662
+ * authMode='auto' 时非红(或 strictRedLine=false)立即以 by:'auto' 放行;红灯在 strictRedLine
663
+ * 下永远进队列,等待显式 decide() 或 TTL 到期(timeout,fail-closed)。
664
+ */
665
+ const request = (input = {}) => {
666
+ const raw = input && typeof input === 'object' ? input : {}
667
+ const id = newId('apr')
668
+ if (disposed) {
669
+ return Promise.resolve({ id, outcome: 'timeout', by: 'unload', at: iso() })
670
+ }
671
+ // 未知风险按红处理(fail-closed);未知 kind 记 unknown。
672
+ const risk = RISK_VALUES.includes(String(raw.risk)) ? String(raw.risk) : 'red'
673
+ const kind = String(raw.kind ?? 'unknown')
674
+ const kindKey = kind.trim().toLowerCase()
675
+ // Mother-only semantic kinds must be blocked at the generic request() layer too:
676
+ // requestPromotion/requestRestart already gate themselves, but a caller can call
677
+ // request({kind:'promotion'}) directly.
678
+ if ((kindKey === 'promotion' || kindKey === 'restart') && scope !== 'mother') {
679
+ const denied = { id, outcome: 'denied', by: 'scope', reason: 'mother-only', at: iso() }
680
+ audit(AUDIT.APPROVAL_DECIDED, { id, kind, risk, outcome: 'denied', by: 'scope', reason: 'mother-only' })
681
+ return Promise.resolve(denied)
682
+ }
683
+ const ttl = positiveNumber(raw.ttlMs, cfg.defaultTtlMs)
684
+ const createdAt = nowMs()
685
+
686
+ let resolveDeferred
687
+ const promise = new Promise((resolve) => { resolveDeferred = resolve })
688
+ const item = {
689
+ id,
690
+ kind: String(raw.kind ?? 'unknown'),
691
+ risk,
692
+ title: String(raw.title ?? ''),
693
+ detail: String(raw.detail ?? ''),
694
+ impact: raw.impact ?? null,
695
+ // v2:母体侧把审批对回任务/插件(可选;v1 调用方不传则为 null)。
696
+ taskId: raw.taskId === undefined || raw.taskId === null ? null : String(raw.taskId),
697
+ pluginId: raw.pluginId === undefined || raw.pluginId === null ? null : String(raw.pluginId),
698
+ createdAt,
699
+ deadlineAt: createdAt + ttl,
700
+ ttlMs: ttl,
701
+ state: 'pending',
702
+ by: null,
703
+ note: null,
704
+ reminded: false,
705
+ resolve: resolveDeferred,
706
+ timers: new Set(),
707
+ }
708
+ touched = true
709
+ queue.set(id, item)
710
+ audit(AUDIT.APPROVAL_REQUESTED, {
711
+ id,
712
+ kind: item.kind,
713
+ risk,
714
+ title: item.title,
715
+ detail: item.detail,
716
+ impact: item.impact,
717
+ taskId: item.taskId,
718
+ pluginId: item.pluginId,
719
+ ttlMs: ttl,
720
+ deadlineAt: item.deadlineAt,
721
+ authMode: cfg.authMode,
722
+ })
723
+ appendJournal({
724
+ type: 'request', id, kind: item.kind, risk, title: item.title,
725
+ detail: item.detail, impact: item.impact, taskId: item.taskId, pluginId: item.pluginId,
726
+ createdAt: item.createdAt, deadlineAt: item.deadlineAt, ttlMs: ttl,
727
+ })
728
+
729
+ const autoBypass = cfg.authMode === 'auto' && (risk !== 'red' || cfg.strictRedLine === false)
730
+ if (autoBypass) {
731
+ settle(item, 'approved', 'auto', 'auto-mode')
732
+ return promise
733
+ }
734
+
735
+ // TTL 到期:fail-closed 记为 timeout。
736
+ const expiryTimer = setTimeout(() => {
737
+ timers.delete(expiryTimer)
738
+ const current = queue.get(id)
739
+ if (current && current.state === 'pending') settle(current, 'timeout', 'ttl', 'ttl-expired')
740
+ }, ttl)
741
+ item.timers.add(expiryTimer)
742
+ timers.add(expiryTimer)
743
+
744
+ // 过半 TTL 标记提醒(只落状态,绝不自动放行)。
745
+ const remindMs = ttl * clamp01(cfg.remindAt)
746
+ if (remindMs > 0 && remindMs < ttl) {
747
+ const remindTimer = setTimeout(() => {
748
+ timers.delete(remindTimer)
749
+ const current = queue.get(id)
750
+ if (current && current.state === 'pending' && !current.reminded) {
751
+ current.reminded = true
752
+ current.remindedAt = nowMs()
753
+ persist()
754
+ }
755
+ }, remindMs)
756
+ item.timers.add(remindTimer)
757
+ timers.add(remindTimer)
758
+ }
759
+
760
+ persist()
761
+ return promise
762
+ }
763
+
764
+ /** 契约:pending() -> item[] */
765
+ const pending = () => pendingItems()
766
+
767
+ /** 契约:decide(id, 'approved'|'denied', {by, note}) -> {ok} */
768
+ const decide = (id, outcome, options = {}) => {
769
+ const key = String(id ?? '')
770
+ if (!DECIDABLE_OUTCOMES.includes(outcome)) {
771
+ return { ok: false, reason: 'bad-outcome', id: key, allowed: [...DECIDABLE_OUTCOMES] }
772
+ }
773
+ const item = queue.get(key)
774
+ if (!item) return { ok: false, reason: 'not-found', id: key }
775
+ if (item.state !== 'pending') return { ok: false, reason: 'not-pending', id: key, state: item.state }
776
+ const by = String(options?.by ?? 'api')
777
+ const note = options?.note === undefined || options?.note === null ? null : String(options.note)
778
+ const result = settle(item, outcome, by, note)
779
+ return { ok: true, id: key, outcome: result.outcome, by: result.by, at: result.at }
780
+ }
781
+
782
+ // ───────── 1b) v2 双侧语义:母体侧的语义化入口(promotion / restart) ─────────
783
+
784
+ /**
785
+ * v2 §5.7 母体语义:晋升审批 == request({kind:'promotion', risk:'red'})。
786
+ * risk 固定红灯(晋升母体不可降级),pluginId/taskId 透传到 pending()/summary()。
787
+ */
788
+ const requestPromotion = (input = {}) => {
789
+ if (scope !== 'mother') {
790
+ const denied = { id: newId('apr'), outcome: 'denied', by: 'scope', at: iso(), reason: 'mother-only' }
791
+ audit(AUDIT.APPROVAL_DECIDED, { kind: 'promotion', outcome: 'denied', by: 'scope', reason: 'mother-only' })
792
+ return Promise.resolve(denied)
793
+ }
794
+ const raw = input && typeof input === 'object' ? input : {}
795
+ const pluginId = raw.pluginId === undefined || raw.pluginId === null ? '' : String(raw.pluginId).trim()
796
+ const detail = raw.detail === undefined || raw.detail === null
797
+ ? (pluginId ? `promote ${pluginId}` : 'promotion')
798
+ : String(raw.detail)
799
+ return request({
800
+ kind: 'promotion',
801
+ risk: 'red',
802
+ title: raw.title === undefined || raw.title === null
803
+ ? (pluginId ? `晋升 ${pluginId}` : '晋升审批')
804
+ : String(raw.title),
805
+ detail,
806
+ impact: raw.impact ?? null,
807
+ ttlMs: raw.ttlMs,
808
+ pluginId: pluginId || null,
809
+ taskId: raw.taskId,
810
+ })
811
+ }
812
+
813
+ /** v2 §5.7 母体语义:母体重启审批 == request({kind:'restart', risk:'red'});risk 由服务强制红灯。 */
814
+ const requestRestart = (input = {}) => {
815
+ if (scope !== 'mother') {
816
+ const denied = { id: newId('apr'), outcome: 'denied', by: 'scope', at: iso(), reason: 'mother-only' }
817
+ audit(AUDIT.APPROVAL_DECIDED, { kind: 'restart', outcome: 'denied', by: 'scope', reason: 'mother-only' })
818
+ return Promise.resolve(denied)
819
+ }
820
+ const raw = input && typeof input === 'object' ? input : {}
821
+ const reason = raw.reason === undefined || raw.reason === null ? '' : String(raw.reason)
822
+ const detail = raw.detail === undefined || raw.detail === null ? reason : String(raw.detail)
823
+ return request({
824
+ kind: 'restart',
825
+ risk: 'red',
826
+ title: raw.title === undefined || raw.title === null
827
+ ? (reason ? `母体重启:${reason}` : '母体重启')
828
+ : String(raw.title),
829
+ detail,
830
+ impact: raw.impact ?? null,
831
+ ttlMs: raw.ttlMs,
832
+ pluginId: raw.pluginId,
833
+ taskId: raw.taskId,
834
+ })
835
+ }
836
+
837
+ // ───────── 1c) v2 汇总:本地待批 + 各分身待批(并发、单点失败不阻塞) ─────────
838
+
839
+ /** 兼容 remote 的多种返回形状:{items} / {data:{items}} / {data:[...]} / 裸数组。 */
840
+ const remoteItemsOf = (result) => {
841
+ if (Array.isArray(result)) return result
842
+ if (!result || typeof result !== 'object') return []
843
+ if (Array.isArray(result.items)) return result.items
844
+ if (result.data && Array.isArray(result.data.items)) return result.data.items
845
+ if (Array.isArray(result.data)) return result.data
846
+ return []
847
+ }
848
+
849
+ const fetchRemoteApprovals = async (remote) => {
850
+ const taskId = remote && remote.taskId !== undefined && remote.taskId !== null ? String(remote.taskId) : null
851
+ if (!remote || typeof remote.fetchApprovals !== 'function') {
852
+ return { taskId, ok: false, items: [], error: 'fetchApprovals-unavailable' }
853
+ }
854
+ try {
855
+ const result = await remote.fetchApprovals()
856
+ if (result && result.ok === false) {
857
+ return { taskId, ok: false, items: [], error: String(result.error ?? result.reason ?? 'remote-failed') }
858
+ }
859
+ return { taskId, ok: true, items: remoteItemsOf(result) }
860
+ } catch (e) {
861
+ return { taskId, ok: false, items: [], error: String(e?.message ?? e) }
862
+ }
863
+ }
864
+
865
+ /**
866
+ * v2 §5.7:汇总本地待批 + 所有分身的待批。
867
+ * remotes = [{taskId, fetchApprovals: async () => ({ok, items})}]
868
+ * 单个 remote 抛错 / 返回 ok:false 只记在该条目上(ok:false + error),不阻塞其他 remote。
869
+ * 返回 { ok:true, scope, local, remote, total, failed }:
870
+ * ok=true 表示"汇总完成";failed 是降级的 remote 数;total 只统计成功 remote 的 items。
871
+ */
872
+ const summary = async (options = {}) => {
873
+ const remotes = Array.isArray(options?.remotes) ? options.remotes : []
874
+ const local = pending()
875
+ const remote = await Promise.all(remotes.map(fetchRemoteApprovals))
876
+ const failed = remote.filter(entry => entry.ok === false).length
877
+ const total = local.length + remote.reduce((sum, entry) => sum + (entry.ok ? entry.items.length : 0), 0)
878
+ return { ok: true, scope, local, remote, total, failed, degraded: failed > 0, complete: failed === 0 }
879
+ }
880
+
881
+ // ───────── 2) 多代快照(A 最新,B/C… 历史;默认 3 代)─────────
882
+
883
+ const GENERATION_SLOTS = Object.freeze(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'])
884
+ const generationSlots = () => GENERATION_SLOTS.slice(0, cfg.snapshotGenerations)
885
+
886
+ const publicGeneration = (generation) => generation
887
+ ? { slot: generation.slot, digest: generation.digest, at: generation.at, files: generation.files }
888
+ : null
889
+
890
+ /**
891
+ * 契约扩展:snapshot({tag, dir}) -> {tag, A, B, generations}
892
+ * A = 本次快照(当前内容),B/C… = 历史代;保留代数由 snapshotGenerations 控制。
893
+ * 落盘位置:<snapshotsRoot>/<tag>/A、/B、/C…
894
+ */
895
+ const snapshot = (options = {}) => {
896
+ const sourceDir = options?.dir ? path.resolve(String(options.dir)) : ''
897
+ if (!sourceDir || !exists(sourceDir)) {
898
+ return { ok: false, reason: 'dir-not-found', dir: sourceDir || null }
899
+ }
900
+ const tag = safeTag(options?.tag ?? path.basename(sourceDir))
901
+ const baseDir = path.join(snapshotsRoot, tag)
902
+ const previous = snapshots.get(tag) ?? null
903
+ const slots = generationSlots()
904
+ const slotDir = (name) => path.join(baseDir, name)
905
+ touched = true
906
+ try {
907
+ ensureDir(baseDir)
908
+ // 先把新内容复制到 staging,成功后再轮换,避免 copy 失败破坏旧代。
909
+ const staging = path.join(baseDir, `.staging-${newId('snap')}`)
910
+ removeTree(staging)
911
+ copyTree(sourceDir, staging)
912
+ const { digest, files } = digestOf(staging)
913
+ // 从最老一代往前轮换:删除最老,B→C…,A→B。
914
+ for (let index = slots.length - 1; index >= 1; index -= 1) {
915
+ removeTree(slotDir(slots[index]))
916
+ if (exists(slotDir(slots[index - 1]))) fs.renameSync(slotDir(slots[index - 1]), slotDir(slots[index]))
917
+ }
918
+ removeTree(slotDir('A'))
919
+ fs.renameSync(staging, slotDir('A'))
920
+ const previousGenerations = Array.isArray(previous?.generations)
921
+ ? previous.generations
922
+ : [previous?.A, previous?.B].filter(Boolean)
923
+ const generations = [
924
+ { slot: 'A', dir: slotDir('A'), digest, at: nowMs(), files },
925
+ ...previousGenerations.slice(0, slots.length - 1).map((generation, index) => ({
926
+ slot: slots[index + 1], dir: slotDir(slots[index + 1]),
927
+ digest: generation.digest, at: generation.at, files: generation.files,
928
+ })),
929
+ ]
930
+ const record = {
931
+ tag,
932
+ A: generations[0],
933
+ B: generations[1] ?? null,
934
+ generations,
935
+ }
936
+ for (const generation of generations) record[generation.slot] = generation
937
+ snapshots.set(tag, record)
938
+ persistSnapshotIndex()
939
+ audit(AUDIT_SNAPSHOT_CREATED, { tag, dir: sourceDir, digest, files, generations: generations.length })
940
+ persist()
941
+ return {
942
+ tag,
943
+ A: { digest: record.A.digest, at: record.A.at },
944
+ B: record.B ? { digest: record.B.digest, at: record.B.at } : null,
945
+ C: record.C ? { digest: record.C.digest, at: record.C.at } : null,
946
+ generations: generations.map(publicGeneration),
947
+ }
948
+ } catch (e) {
949
+ logger('warn', `[fang-approver] snapshot failed: ${String(e?.message ?? e)}`)
950
+ return { ok: false, reason: 'snapshot-failed', tag, error: String(e?.message ?? e) }
951
+ }
952
+ }
953
+
954
+ /**
955
+ * 契约扩展:rollback({tag, dir, to?}) -> {ok, restored}
956
+ * to 支持 A…J(默认 A);恢复后重算 digest 并校验。
957
+ */
958
+ const rollback = (options = {}) => {
959
+ const rawTag = String(options?.tag ?? '').trim()
960
+ if (!rawTag) return { ok: false, reason: 'no-tag' }
961
+ const tag = safeTag(rawTag)
962
+ const record = snapshots.get(tag)
963
+ if (!record) return { ok: false, reason: 'no-snapshot', tag }
964
+ const targetDir = options?.dir ? path.resolve(String(options.dir)) : ''
965
+ if (!targetDir) return { ok: false, reason: 'no-target-dir', tag }
966
+ const requested = String(options?.to ?? 'A').toUpperCase()
967
+ const slotName = /^[A-J]$/.test(requested) ? requested : 'A'
968
+ const slot = record[slotName] ?? (record.generations ?? []).find((generation) => generation.slot === slotName) ?? null
969
+ if (!slot || !exists(slot.dir)) return { ok: false, reason: 'slot-empty', tag, slot: slotName }
970
+ touched = true
971
+ try {
972
+ const copy = atomicRestoreDir(slot.dir, targetDir, slot.digest)
973
+ const restored = {
974
+ tag, slot: slotName, dir: targetDir,
975
+ digest: copy.digest, files: copy.files ?? null,
976
+ expected: slot.digest, matches: copy.ok === true, at: iso(),
977
+ }
978
+ if (!copy.ok) {
979
+ audit(AUDIT.ROLLBACK, { tag, slot: slotName, dir: targetDir, digest: copy.digest, expected: slot.digest, ok: false, reason: copy.reason })
980
+ persist()
981
+ return { ok: false, reason: copy.reason, tag, slot: slotName, restored }
982
+ }
983
+ audit(AUDIT.ROLLBACK, { tag, slot: slotName, dir: targetDir, digest: copy.digest, files: copy.files, ok: true })
984
+ persist()
985
+ return { ok: true, restored }
986
+ } catch (e) {
987
+ const error = String(e?.message ?? e)
988
+ logger('warn', `[fang-approver] rollback failed: ${error}`)
989
+ audit(AUDIT.ROLLBACK, { tag, slot: slotName, dir: targetDir, ok: false, error })
990
+ return { ok: false, reason: 'restore-failed', tag, slot: slotName, error }
991
+ }
992
+ }
993
+
994
+ // ───────── 3) 监控 ─────────
995
+
996
+ /** 契约:monitor({label, value, threshold}) -> {ok, alert:boolean}。threshold 支持数字或 {op,value}。 */
997
+ const monitor = (input = {}) => {
998
+ const label = String(input?.label ?? '').trim() || 'monitor'
999
+ const rawValue = input?.value
1000
+ const value = Number(rawValue)
1001
+ if (rawValue === null || rawValue === undefined || !Number.isFinite(value)) {
1002
+ return { ok: false, reason: 'bad-value', label }
1003
+ }
1004
+ touched = true
1005
+ const threshold = normalizeThreshold(input?.threshold)
1006
+ const record = monitors.get(label) ?? {
1007
+ label,
1008
+ samples: [],
1009
+ min: null,
1010
+ max: null,
1011
+ last: null,
1012
+ alerts: [],
1013
+ threshold: null,
1014
+ }
1015
+ if (threshold) record.threshold = threshold
1016
+ record.last = value
1017
+ record.samples.push({ at: nowMs(), value })
1018
+ while (record.samples.length > 240) record.samples.shift()
1019
+ record.min = record.min === null ? value : Math.min(record.min, value)
1020
+ record.max = record.max === null ? value : Math.max(record.max, value)
1021
+ const alert = thresholdHit(value, record.threshold)
1022
+ if (alert) {
1023
+ record.alerts.push({ at: nowMs(), value, threshold: { ...record.threshold } })
1024
+ if (record.alerts.length > 100) record.alerts.shift()
1025
+ logger('warn', `[fang-approver] monitor alert: ${label}=${value} ${record.threshold.op} ${record.threshold.value}`)
1026
+ }
1027
+ monitors.set(label, record)
1028
+ persist()
1029
+ return {
1030
+ ok: true,
1031
+ alert,
1032
+ label,
1033
+ value,
1034
+ threshold: record.threshold ? { ...record.threshold } : null,
1035
+ samples: record.samples.length,
1036
+ min: record.min,
1037
+ max: record.max,
1038
+ alerts: record.alerts.length,
1039
+ }
1040
+ }
1041
+
1042
+ // ───────── 4) 汇总状态 + 文件桥 ─────────
1043
+
1044
+ /** 契约:status() -> {pending, snapshots, monitors, decisions}(计数 + 明细兄弟字段)。 */
1045
+ const status = () => ({
1046
+ scope,
1047
+ pending: pendingItems().length,
1048
+ orphaned: orphanedItems().length,
1049
+ snapshots: snapshots.size,
1050
+ monitors: monitors.size,
1051
+ decisions: decisions.length,
1052
+ pendingItems: pendingItems(),
1053
+ snapshotsList: [...snapshots.values()].map(rec => snapshotView(rec)),
1054
+ orphanedList: orphanedItems(),
1055
+ monitorsList: [...monitors.values()].map(rec => monitorView(rec)),
1056
+ lastDecisions: decisions.slice(-10),
1057
+ policy: { authMode: cfg.authMode, strictRedLine: cfg.strictRedLine, defaultTtlMs: cfg.defaultTtlMs },
1058
+ bridge: { enabled: bridgeAcl ? bridgeAcl.ok === true : null, checked: bridgeSecurityChecked, acl: bridgeAcl, signed: bridgeSecret.length > 0 },
1059
+ dataDir,
1060
+ snapshotsRoot,
1061
+ })
1062
+
1063
+ const bridgeOutcome = (incoming) => {
1064
+ if (incoming?.outcome === 'approved' || incoming?.outcome === 'denied') return incoming.outcome
1065
+ return incoming?.decision === 'approve' ? 'approved' : 'denied'
1066
+ }
1067
+
1068
+ const runBridgeCommand = (command) => {
1069
+ if (!command || typeof command !== 'object') return
1070
+ const action = String(command.action ?? '')
1071
+ if (action === 'request') { void request(command); return }
1072
+ if (action === 'promotion') { void requestPromotion(command); return }
1073
+ if (action === 'restart') { void requestRestart(command); return }
1074
+ if (action === 'decide' || action === 'answer') {
1075
+ decide(command.id, bridgeOutcome(command), { by: String(command.by ?? 'file'), note: command.note })
1076
+ return
1077
+ }
1078
+ if (action === 'snapshot') { snapshot(command); return }
1079
+ if (action === 'rollback') { rollback(command); return }
1080
+ if (action === 'monitor' || action === 'sample') { monitor(command); return }
1081
+ if (action === 'status') { persist(); return }
1082
+ }
1083
+
1084
+ /** command 文件:读取后立即消费(先删再执行,避免重复执行)。 */
1085
+ const pollCommandFile = () => {
1086
+ if (!ensureBridgeSecurity()) return
1087
+ const raw = readTextSafe(bridgePaths.command, '')
1088
+ if (!raw) return
1089
+ removeTree(bridgePaths.command)
1090
+ let command = null
1091
+ try { command = JSON.parse(String(raw).replace(/^\uFEFF/, '')) } catch {
1092
+ logger('warn', '[fang-approver] command file is not valid JSON; ignored')
1093
+ return
1094
+ }
1095
+ if (!verifyBridgeSignature(command, bridgeSecret)) {
1096
+ logger('warn', '[fang-approver] command rejected: missing or invalid bridge signature')
1097
+ audit('approval-bridge-rejected', { bridge: 'command', reason: 'bad-signature' })
1098
+ return
1099
+ }
1100
+ try { runBridgeCommand(command) } catch (e) {
1101
+ logger('warn', `[fang-approver] bridge command failed: ${String(e?.message ?? e)}`)
1102
+ }
1103
+ }
1104
+
1105
+ /** answer 文件:兼容老 CLI 的 {id, decision:'approve'|'deny'} 写法。 */
1106
+ const pollAnswerFile = () => {
1107
+ if (!ensureBridgeSecurity()) return
1108
+ const raw = readTextSafe(bridgePaths.answer, '')
1109
+ if (!raw) return
1110
+ let answer = null
1111
+ try { answer = JSON.parse(String(raw).replace(/^\uFEFF/, '')) } catch {
1112
+ logger('warn', '[fang-approver] answer file is not valid JSON; consumed')
1113
+ removeTree(bridgePaths.answer)
1114
+ return
1115
+ }
1116
+ if (!verifyBridgeSignature(answer, bridgeSecret)) {
1117
+ logger('warn', '[fang-approver] answer rejected: missing or invalid bridge signature')
1118
+ audit('approval-bridge-rejected', { bridge: 'answer', reason: 'bad-signature' })
1119
+ removeTree(bridgePaths.answer)
1120
+ return
1121
+ }
1122
+ const id = String(answer?.id ?? '')
1123
+ const item = queue.get(id)
1124
+ if (!item || item.state !== 'pending') {
1125
+ // Do not leave an unusable answer on disk for every 300ms poll.
1126
+ logger('warn', `[fang-approver] answer for unknown/non-pending item ${id || '(empty)'}; consumed`)
1127
+ removeTree(bridgePaths.answer)
1128
+ return
1129
+ }
1130
+ decide(id, bridgeOutcome(answer), { by: String(answer?.by ?? 'file'), note: answer?.note })
1131
+ removeTree(bridgePaths.answer)
1132
+ }
1133
+
1134
+ recoverOrphaned()
1135
+ recoverJournal()
1136
+
1137
+ const service = {
1138
+ scope,
1139
+ request,
1140
+ pending,
1141
+ orphaned: orphanedItems,
1142
+ decide,
1143
+ requestPromotion,
1144
+ requestRestart,
1145
+ summary,
1146
+ snapshot,
1147
+ rollback,
1148
+ monitor,
1149
+ status,
1150
+ /** 只读辅助:单条查询(结算后仍可查到最后状态)。 */
1151
+ get: (id) => {
1152
+ const item = queue.get(String(id ?? ''))
1153
+ return item ? publicItem(item) : null
1154
+ },
1155
+ /** 文件桥路径,供 tools/fangctl.mjs 之类的 CLI 对齐(而不是自己拼路径)。 */
1156
+ paths: Object.freeze({ ...bridgePaths, decisions: decisionsFile, dataDir, snapshotsRoot }),
1157
+ config: Object.freeze({ ...cfg }),
1158
+ }
1159
+
1160
+ // ───────── 生命周期:服务注册 / 文件桥轮询 / 清理 ─────────
1161
+
1162
+ ctx.effect(() => ctx.provide(SERVICE.approver, service))
1163
+
1164
+ ctx.effect(() => {
1165
+ const pollMs = Math.max(10, Math.round(cfg.fileBridgePollMs))
1166
+ const timer = setInterval(() => {
1167
+ try { pollCommandFile() } catch (e) {
1168
+ logger('debug', `[fang-approver] command poll failed: ${String(e?.message ?? e)}`)
1169
+ }
1170
+ try { pollAnswerFile() } catch (e) {
1171
+ logger('debug', `[fang-approver] answer poll failed: ${String(e?.message ?? e)}`)
1172
+ }
1173
+ }, pollMs)
1174
+ return () => clearInterval(timer)
1175
+ })
1176
+
1177
+ // 卸载:清定时器,未决请求全部按 timeout / by:'unload' 结算(fail-closed,避免悬挂 Promise)。
1178
+ ctx.effect(() => () => {
1179
+ disposed = true
1180
+ for (const timer of timers) clearTimeout(timer)
1181
+ timers.clear()
1182
+ for (const item of queue.values()) {
1183
+ if (item.state !== 'pending') continue
1184
+ item.state = 'timeout'
1185
+ item.by = 'unload'
1186
+ item.note = 'plugin-unloaded'
1187
+ item.resolvedAt = nowMs()
1188
+ clearItemTimers(item)
1189
+ const result = { id: item.id, outcome: 'timeout', by: 'unload', at: iso() }
1190
+ recordDecision(item, 'timeout', 'unload', 'plugin-unloaded')
1191
+ try { item.resolve(result) } catch { /* ignore */ }
1192
+ try { bus.emit(EVENT_APPROVAL_DECIDED, { id: item.id, outcome: 'timeout' }) } catch { /* ignore */ }
1193
+ audit(AUDIT.APPROVAL_DECIDED, {
1194
+ id: item.id,
1195
+ kind: item.kind,
1196
+ risk: item.risk,
1197
+ title: item.title,
1198
+ taskId: item.taskId ?? null,
1199
+ pluginId: item.pluginId ?? null,
1200
+ outcome: 'timeout',
1201
+ by: 'unload',
1202
+ note: 'plugin-unloaded',
1203
+ })
1204
+ }
1205
+ persist()
1206
+ })
1207
+ }
1208
+
1209
+ /** 供测试/诊断使用的纯函数导出(不影响插件契约)。 */
1210
+ export const _internals = { digestOf, safeTag, normalizeThreshold, thresholdHit, normalizeConfig }