@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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 }
|