@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.
@@ -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.4",
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.",
@@ -131,7 +131,7 @@ export const AUDIT = {
131
131
  RUNTIME_ERROR: 'runtime-error',
132
132
  }
133
133
 
134
- export const FANG_VERSION = '3.3.4'
134
+ export const FANG_VERSION = '3.3.5'
135
135
 
136
136
  /**
137
137
  * @typedef {object} ForkInfo
@@ -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 }
@@ -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
- const forkResults = await Promise.all([...tasks.values()]
3177
- .filter((task) => task?.url)
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 { code = await cmdApprovals(args) } catch (error) { console.error(String(error?.message ?? error)); code = 1 }
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