@twinsearth/fang-dsh-plugin 3.3.4 → 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/docs/APPROVAL-DRILL.md +42 -0
- package/package.json +1 -1
- package/src/kernel/types.mjs +1 -1
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/main-gate.mjs +3 -0
- package/src/plugins/orchestrator.mjs +44 -3
- package/tools/approval-drill.mjs +21 -0
- package/tools/fangctl.mjs +19 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 审批 / 提升 / 回滚 drill
|
|
2
|
+
|
|
3
|
+
生效版本:`v3.3.5`
|
|
4
|
+
|
|
5
|
+
## 目的
|
|
6
|
+
|
|
7
|
+
证明 Approver 不只是“已加载”,而是能真实执行:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
requestPromotion(red) → pending → decide(approved) → snapshot → 修改文件 → rollback
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 两种执行方式
|
|
14
|
+
|
|
15
|
+
### 1. 本机隔离 drill(不碰生产)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node tools/approval-drill.mjs
|
|
19
|
+
node tools/approval-drill.mjs --json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- 使用临时 `dataRoot`;不写生产 fang 数据目录;
|
|
23
|
+
- 真实创建红票、真实 `decide`、真实 A 代快照与 digest 校验回滚;
|
|
24
|
+
- 结束清理临时目录,`residue=clean`。
|
|
25
|
+
|
|
26
|
+
### 2. 运行宿主上的隔离 drill(需 v3.3.5)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node tools/fangctl.mjs approvals drill --url http://127.0.0.1:<port>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
调用 `POST /fang/api/approvals/drill`;同样在临时 `dataRoot` 执行,不影响生产审批队列。
|
|
33
|
+
|
|
34
|
+
## /approvals 修复
|
|
35
|
+
|
|
36
|
+
- 只聚合 `status=ready|running` 的分身;
|
|
37
|
+
- 失败/停止/回收的分身归入 `skipped`,不再把整个收件箱标成 degraded;
|
|
38
|
+
- 真正活跃分身 adapter 失败才记 `failed`。
|
|
39
|
+
|
|
40
|
+
## 边界
|
|
41
|
+
|
|
42
|
+
drill 证明的是 Approver 的审批/快照/回滚链路;生产“提升插件”仍需真实合法分身插件并走 Scorer/Router。drill 不会写入 `promoted/`,也不会改 profile。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/fang-dsh-plugin",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "dsh-Fang (瓮城) — mother/fork plugin development environment for DeepSeek Harness: one readonly mother, a disposable child dsh-Web per task.",
|
package/src/kernel/types.mjs
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// approval-drill — 在隔离的临时 dataRoot 里真实执行一次“提升审批”闭环:
|
|
5
|
+
// requestPromotion(red) → pending → decide(approved) → snapshot → 改动文件 → rollback
|
|
6
|
+
// 不触碰生产 dataDir、不加载真实插件、不写 profile;用于证明审批/快照/回滚链路可执行。
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { apply as applyApprover } from '../plugins/approver.mjs'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @returns {{
|
|
15
|
+
* ok:boolean, request:{id:string,kind:string,risk:string,outcome:string},
|
|
16
|
+
* pendingBefore:number, pendingAfter:number, snapshot:object, rollback:object,
|
|
17
|
+
* restored:boolean, target:string, dataRoot:string, cleanup:boolean
|
|
18
|
+
* }}
|
|
19
|
+
*/
|
|
20
|
+
export async function runApprovalDrill() {
|
|
21
|
+
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fang-approval-drill-'))
|
|
22
|
+
const target = path.join(dataRoot, 'target')
|
|
23
|
+
fs.mkdirSync(target, { recursive: true })
|
|
24
|
+
fs.writeFileSync(path.join(target, 'plugin.mjs'), 'export const version = 1\n', 'utf8')
|
|
25
|
+
|
|
26
|
+
const services = new Map()
|
|
27
|
+
const disposers = []
|
|
28
|
+
const ctx = {
|
|
29
|
+
effect(fn) {
|
|
30
|
+
const dispose = fn()
|
|
31
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
32
|
+
return () => { try { dispose?.() } catch { /* ignore */ } }
|
|
33
|
+
},
|
|
34
|
+
provide(key, value) { services.set(key, value); return () => services.delete(key) },
|
|
35
|
+
get(key) { return services.get(key) },
|
|
36
|
+
logger: { info() {}, warn() {}, debug() {}, error() {} },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let approver = null
|
|
40
|
+
let requestPromise = null
|
|
41
|
+
try {
|
|
42
|
+
applyApprover(ctx, {
|
|
43
|
+
dataRoot,
|
|
44
|
+
defaultTtlMs: 10000,
|
|
45
|
+
authMode: 'manual',
|
|
46
|
+
strictRedLine: true,
|
|
47
|
+
snapshotGenerations: 3,
|
|
48
|
+
})
|
|
49
|
+
approver = services.get('fangApprover')
|
|
50
|
+
if (!approver || typeof approver.requestPromotion !== 'function') {
|
|
51
|
+
return { ok: false, error: 'approver-service-missing', dataRoot, cleanup: false }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
requestPromise = approver.requestPromotion({
|
|
55
|
+
pluginId: 'approval-drill',
|
|
56
|
+
taskId: 'approval-drill',
|
|
57
|
+
detail: '隔离审批 drill:验证 request→decide→snapshot→rollback',
|
|
58
|
+
ttlMs: 10000,
|
|
59
|
+
})
|
|
60
|
+
// request() 同步入队;让出一次事件循环以确保 pending 可见。
|
|
61
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
62
|
+
const pendingBefore = approver.pending()
|
|
63
|
+
if (pendingBefore.length !== 1) {
|
|
64
|
+
return { ok: false, error: 'request-not-pending', pendingBefore: pendingBefore.length, dataRoot, cleanup: false }
|
|
65
|
+
}
|
|
66
|
+
const id = String(pendingBefore[0].id)
|
|
67
|
+
const decided = approver.decide(id, 'approved', { by: 'approval-drill', note: 'isolated drill' })
|
|
68
|
+
const outcome = await requestPromise
|
|
69
|
+
const pendingAfter = approver.pending().length
|
|
70
|
+
requestPromise = null
|
|
71
|
+
|
|
72
|
+
const snapshot = await approver.snapshot({ tag: 'approval-drill', dir: target })
|
|
73
|
+
fs.writeFileSync(path.join(target, 'plugin.mjs'), 'export const version = 2\n', 'utf8')
|
|
74
|
+
const rollback = await approver.rollback({ tag: 'approval-drill', dir: target })
|
|
75
|
+
const restored = fs.readFileSync(path.join(target, 'plugin.mjs'), 'utf8') === 'export const version = 1\n'
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ok: outcome?.outcome === 'approved' && decided?.ok === true && snapshot?.ok !== false && rollback?.ok === true && restored && pendingAfter === 0,
|
|
79
|
+
request: { id, kind: 'promotion', risk: 'red', outcome: String(outcome?.outcome ?? '') },
|
|
80
|
+
decided,
|
|
81
|
+
pendingBefore: pendingBefore.length,
|
|
82
|
+
pendingAfter,
|
|
83
|
+
snapshot,
|
|
84
|
+
rollback,
|
|
85
|
+
restored,
|
|
86
|
+
target,
|
|
87
|
+
dataRoot,
|
|
88
|
+
cleanup: false,
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
// 未决请求不能被遗留在计时器里:显式拒绝/超时结算。
|
|
92
|
+
if (requestPromise) {
|
|
93
|
+
const pending = approver?.pending?.() ?? []
|
|
94
|
+
for (const item of pending) {
|
|
95
|
+
try { approver.decide(item.id, 'denied', { by: 'approval-drill-cleanup' }) } catch { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
await requestPromise.catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
for (const dispose of disposers.reverse()) { try { dispose() } catch { /* ignore */ } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 删除 drill 的临时目录;返回是否已不存在。 */
|
|
104
|
+
export function cleanupApprovalDrill(result) {
|
|
105
|
+
const dir = String(result?.dataRoot ?? '')
|
|
106
|
+
if (!dir) return true
|
|
107
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
108
|
+
return !fs.existsSync(dir)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const _internals = { runApprovalDrill, cleanupApprovalDrill }
|
package/src/lib/main-gate.mjs
CHANGED
|
@@ -36,6 +36,8 @@ const REQUIRED_SOURCE_FILES = Object.freeze([
|
|
|
36
36
|
'src/lib/fork-supervisor.mjs',
|
|
37
37
|
'src/lib/restart-contract.mjs',
|
|
38
38
|
'src/lib/main-gate.mjs',
|
|
39
|
+
'src/lib/approval-drill.mjs',
|
|
40
|
+
'tools/approval-drill.mjs',
|
|
39
41
|
'src/plugins/restart-guard.mjs',
|
|
40
42
|
'src/plugins/scorer.mjs',
|
|
41
43
|
'src/plugins/approver.mjs',
|
|
@@ -49,6 +51,7 @@ const REQUIRED_SOURCE_FILES = Object.freeze([
|
|
|
49
51
|
'docs/DSH-ARCHITECTURE-MAP.md',
|
|
50
52
|
'docs/OVERLAY-MASTER-SWITCH.md',
|
|
51
53
|
'docs/PACKAGE-RENAME-v3.3.3.md',
|
|
54
|
+
'docs/APPROVAL-DRILL.md',
|
|
52
55
|
])
|
|
53
56
|
|
|
54
57
|
const REQUIRED_ARTIFACT_FILES = Object.freeze([
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { parseWorkspaceIndex, scanSessionDirs, readBoundedEvents, extractSessionTitle, encodeWorkspaceKey } from '../lib/session-format.mjs'
|
|
23
23
|
import { ensureEssenceRuntimePnpmConfig, readNodeLinker } from '../lib/pnpm-store.mjs'
|
|
24
24
|
import { captureRuntimeProfileIntoEssence } from '../lib/runtime-capture.mjs'
|
|
25
|
+
import { cleanupApprovalDrill, runApprovalDrill } from '../lib/approval-drill.mjs'
|
|
25
26
|
import { negotiateProtocol } from '../lib/adapter-protocol.mjs'
|
|
26
27
|
import { describeMotherLink, probeLink, readSharedProfileSummary } from '../lib/mother-link.mjs'
|
|
27
28
|
import { MirrorManager } from '../lib/mirror.mjs'
|
|
@@ -106,6 +107,15 @@ function forkBundlePath(profileDir) {
|
|
|
106
107
|
export const name = 'fang-orchestrator'
|
|
107
108
|
export const inject = []
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* v3.3.5: a fork participates in the mother's approval aggregation only while it
|
|
112
|
+
* is actually alive. Failed/stopped/reaped forks are offline state, not approval
|
|
113
|
+
* failures; they must not turn the inbox `degraded`.
|
|
114
|
+
*/
|
|
115
|
+
export function isLiveForkForApprovals(_task, status) {
|
|
116
|
+
return status === TASK_STATUS.READY || status === TASK_STATUS.RUNNING
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
export const Config = z.object({
|
|
110
120
|
role: z.union([z.const('mother'), z.const('fork')]).default('mother')
|
|
111
121
|
.description('配置层角色;环境 DSH_FANG_ROLE 优先(forker 派生的分身自动为 fork)'),
|
|
@@ -3173,8 +3183,15 @@ export function apply(ctx, config) {
|
|
|
3173
3183
|
failed.push({ taskId: null, error: String(error?.message ?? error) })
|
|
3174
3184
|
}
|
|
3175
3185
|
|
|
3176
|
-
|
|
3177
|
-
|
|
3186
|
+
// v3.3.5: only live forks are aggregated. A dead/stopped fork is an
|
|
3187
|
+
// expected offline state, not an approval failure; keep it in `skipped`
|
|
3188
|
+
// so the panel can show it without marking the whole inbox degraded.
|
|
3189
|
+
const forkTasks = [...tasks.values()].filter((task) => task?.url)
|
|
3190
|
+
const liveForkTasks = forkTasks.filter((task) => isLiveForkForApprovals(task, taskView(task).status))
|
|
3191
|
+
const skipped = forkTasks
|
|
3192
|
+
.filter((task) => !liveForkTasks.includes(task))
|
|
3193
|
+
.map((task) => ({ taskId: task.taskId, status: taskView(task).status, reason: 'fork-offline' }))
|
|
3194
|
+
const forkResults = await Promise.all(liveForkTasks
|
|
3178
3195
|
.map(async (task) => {
|
|
3179
3196
|
if (task.adapter && task.adapter.ok === false) {
|
|
3180
3197
|
return { task, ok: false, error: task.adapter.error ?? 'adapter-unavailable' }
|
|
@@ -3212,7 +3229,31 @@ export function apply(ctx, config) {
|
|
|
3212
3229
|
}
|
|
3213
3230
|
}
|
|
3214
3231
|
items.sort((a, b) => (Number(a.deadlineAt) || Number.MAX_SAFE_INTEGER) - (Number(b.deadlineAt) || Number.MAX_SAFE_INTEGER))
|
|
3215
|
-
return json(res, 200, { ok: true, degraded: failed.length > 0, items, failed })
|
|
3232
|
+
return json(res, 200, { ok: true, degraded: failed.length > 0, items, failed, skipped })
|
|
3233
|
+
})
|
|
3234
|
+
|
|
3235
|
+
// v3.3.5: isolated approval drill. Runs the real approver request → decide →
|
|
3236
|
+
// snapshot → rollback path in a temp dataRoot; never touches production data.
|
|
3237
|
+
// This gives a live host a way to prove the approval chain executes.
|
|
3238
|
+
route('POST', `${prefix}/api/approvals/drill`, async (req, res) => {
|
|
3239
|
+
try {
|
|
3240
|
+
const out = await runApprovalDrill()
|
|
3241
|
+
const cleanup = cleanupApprovalDrill(out)
|
|
3242
|
+
return json(res, out.ok && cleanup ? 200 : 500, {
|
|
3243
|
+
ok: out.ok && cleanup,
|
|
3244
|
+
drill: {
|
|
3245
|
+
request: out.request ?? null,
|
|
3246
|
+
pendingBefore: out.pendingBefore ?? null,
|
|
3247
|
+
pendingAfter: out.pendingAfter ?? null,
|
|
3248
|
+
snapshot: out.snapshot ? { ok: out.snapshot.ok !== false, tag: out.snapshot.tag ?? null } : null,
|
|
3249
|
+
rollback: out.rollback ? { ok: out.rollback.ok === true, restored: out.restored === true } : null,
|
|
3250
|
+
cleanup,
|
|
3251
|
+
},
|
|
3252
|
+
error: out.error ?? null,
|
|
3253
|
+
})
|
|
3254
|
+
} catch (error) {
|
|
3255
|
+
return json(res, 500, { ok: false, error: 'approval-drill-failed', detail: String(error?.message ?? error) })
|
|
3256
|
+
}
|
|
3216
3257
|
})
|
|
3217
3258
|
|
|
3218
3259
|
// Decide one approval by source. The response is the real adapter/approver result;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// tools/approval-drill.mjs — 隔离审批 drill:真实 request→decide→snapshot→rollback。
|
|
4
|
+
// 用法:node tools/approval-drill.mjs [--json]
|
|
5
|
+
import { cleanupApprovalDrill, runApprovalDrill } from '../src/lib/approval-drill.mjs'
|
|
6
|
+
|
|
7
|
+
const json = process.argv.slice(2).includes('--json')
|
|
8
|
+
const result = await runApprovalDrill()
|
|
9
|
+
const cleanup = cleanupApprovalDrill(result)
|
|
10
|
+
result.cleanup = cleanup
|
|
11
|
+
if (json) console.log(JSON.stringify(result, null, 2))
|
|
12
|
+
else {
|
|
13
|
+
console.log(`${result.ok ? 'GREEN' : 'RED'} approval drill`)
|
|
14
|
+
console.log(` request : ${result.request?.id ?? '-'} kind=${result.request?.kind ?? '-'} risk=${result.request?.risk ?? '-'} outcome=${result.request?.outcome ?? '-'}`)
|
|
15
|
+
console.log(` pending : before=${result.pendingBefore ?? '-'} after=${result.pendingAfter ?? '-'}`)
|
|
16
|
+
console.log(` snapshot : ${result.snapshot?.ok === false ? 'FAIL' : 'ok'}`)
|
|
17
|
+
console.log(` rollback : ${result.rollback?.ok === true ? 'ok' : 'FAIL'} restored=${result.restored === true}`)
|
|
18
|
+
console.log(` residue : ${cleanup ? 'clean' : 'kept ' + (result.dataRoot ?? '')}`)
|
|
19
|
+
if (result.error) console.log(` error : ${result.error}`)
|
|
20
|
+
}
|
|
21
|
+
process.exitCode = result.ok && cleanup ? 0 : 1
|
package/tools/fangctl.mjs
CHANGED
|
@@ -387,7 +387,24 @@ switch (cmd) {
|
|
|
387
387
|
case 'tail': code = cmdTail(layout, Number(args._[1] ?? 20)); break
|
|
388
388
|
case 'doctor': code = cmdDoctor(layout); break
|
|
389
389
|
case 'approvals': {
|
|
390
|
-
try {
|
|
390
|
+
try {
|
|
391
|
+
if (args._[1] === 'drill') {
|
|
392
|
+
const out = await approvalRequest(args, '/fang/api/approvals/drill', { method: 'POST', body: {} })
|
|
393
|
+
if (args.json) console.log(JSON.stringify(out.body, null, 2))
|
|
394
|
+
else if (out.status === 200 && out.body?.ok) {
|
|
395
|
+
console.log('approval drill: GREEN')
|
|
396
|
+
console.log(` request : ${out.body.drill?.request?.id ?? '-'} ${out.body.drill?.request?.kind ?? '-'}/${out.body.drill?.request?.risk ?? '-'} → ${out.body.drill?.request?.outcome ?? '-'}`)
|
|
397
|
+
console.log(` pending : ${out.body.drill?.pendingBefore ?? '-'} → ${out.body.drill?.pendingAfter ?? '-'}`)
|
|
398
|
+
console.log(` rollback : ${out.body.drill?.rollback?.ok === true ? 'ok' : 'FAIL'} restored=${out.body.drill?.rollback?.restored === true}`)
|
|
399
|
+
console.log(` residue : ${out.body.drill?.cleanup === true ? 'clean' : 'kept'}`)
|
|
400
|
+
} else {
|
|
401
|
+
console.error(`FAIL approval drill: HTTP ${out.status} ${out.text.slice(0, 300)}`)
|
|
402
|
+
}
|
|
403
|
+
code = out.status === 200 && out.body?.ok === true ? 0 : 1
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
code = await cmdApprovals(args)
|
|
407
|
+
} catch (error) { console.error(String(error?.message ?? error)); code = 1 }
|
|
391
408
|
break
|
|
392
409
|
}
|
|
393
410
|
case 'approve':
|
|
@@ -402,7 +419,7 @@ switch (cmd) {
|
|
|
402
419
|
break
|
|
403
420
|
}
|
|
404
421
|
default:
|
|
405
|
-
console.log('usage: fangctl.mjs <status|forks|verify|tail [n]|doctor|restart-status [limit]|restart-safe [status|clear]|parse-url <line>|approvals|approve <source:id>|deny <source:id>> [--profile name] [--data-root dir] [--url url] [--token token] [--note text] [--by who] [--json]')
|
|
422
|
+
console.log('usage: fangctl.mjs <status|forks|verify|tail [n]|doctor|restart-status [limit]|restart-safe [status|clear]|parse-url <line>|approvals [drill]|approve <source:id>|deny <source:id>> [--profile name] [--data-root dir] [--url url] [--token token] [--note text] [--by who] [--json]')
|
|
406
423
|
code = 64
|
|
407
424
|
}
|
|
408
425
|
process.exitCode = code
|