dsh-agent-board 1.7.5 → 1.7.7

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.
@@ -9,7 +9,8 @@
9
9
  // · subagents.start(provider, req) → 一次性 run(result promise 由测试驱动落定/拒绝)
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
- // · agents.currentInitiator()/roots()/list()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root)
12
+ // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
14
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
14
15
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
15
16
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -48,6 +49,8 @@ import { Readable } from 'node:stream'
48
49
  * @property {Object} spSections systemPrompt 注册段记录器
49
50
  * @property {Object} llm llm 记录器(listProviders/listModels 调用留痕)
50
51
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
+ * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
+ * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
51
54
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
52
55
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
53
56
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -188,10 +191,14 @@ export function createMockCtx(opts) {
188
191
  // whenIdle 刻意不实现:flushReceipts 走「无空闲门控 → 立即投递」分支(45s 窗口到点即投,可断言)
189
192
  }
190
193
  var env // 前向引用(currentInitiator 读 env.actor)
194
+ var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
+ var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
+ persistedHeaders[sid] = { id: sid, parentSession: undefined }
191
197
  var agents = {
192
198
  currentInitiator: function () { return { id: env.actor } },
193
- roots: function () { return [root] },
194
- list: function () { return [root] },
199
+ roots: function () { return rootsList },
200
+ list: function () { return rootsList.slice() },
201
+ get: function (id) { for (var i = 0; i < rootsList.length; i++) { if (String(rootsList[i].id) === String(id)) return rootsList[i] } return undefined },
195
202
  isOwnedBy: function () { return false },
196
203
  }
197
204
 
@@ -228,6 +235,11 @@ export function createMockCtx(opts) {
228
235
  listModels: async function (p) { llmCalls.listModels.push(p); return [] },
229
236
  }
230
237
  }
238
+ if (name === 'sessionPersistence') {
239
+ return {
240
+ stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
241
+ }
242
+ }
231
243
  return undefined
232
244
  },
233
245
  effect: function (fn) {
@@ -260,6 +272,8 @@ export function createMockCtx(opts) {
260
272
  spSections: spSections,
261
273
  llm: llmCalls,
262
274
  actor: sid,
275
+ setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
+ persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
263
277
 
264
278
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
265
279
  rpc: async function (method, args) {
@@ -504,3 +504,40 @@ test('集成i·孤儿回收:in-progress 无活跃 rec 的卡推进时钟后回
504
504
  assert.equal(env.sent.length, 0, '孤儿回收的系统通知攒在 sysNotesBuf 等回执冲刷,不单独投递')
505
505
  } finally { await env.cleanup() }
506
506
  })
507
+
508
+ // ===== 场景 j:归档数据源全量——老归档(>7 天)与仪表盘同口径,get-tasks 全量返回 =====
509
+ // 归档 tab 空 vs 仪表盘 68 的根因锁定(反馈 n-muyg3e0m9z7i):归档 tab 数据源必须与仪表盘「已归档」
510
+ // 计数同源(state.tasks 全量,无 7 日窗口、无字段缺失)。host 侧 get-tasks 本就返回全量含归档,
511
+ // 本用例锁死这条数据源契约:建卡→resolve→archive(含 archivedAt>7 天前构造)→断言归档数据源含该卡。
512
+ test('集成j·归档数据源全量:老归档(>7天)与仪表盘同口径,get-tasks 返回全部归档', async () => {
513
+ var env = createMockCtx()
514
+ try {
515
+ plugin.apply(env.ctx)
516
+ // 新归档 + 老归档各一张:先建卡再 resolve 再 archive(省去派发链路,直接走到可归档态)
517
+ await createTask(env, 'task-j1', { pipeline: 'work' })
518
+ await createTask(env, 'task-j2', { pipeline: 'work' })
519
+ env.patchBoard(function (d) {
520
+ d.tasks.forEach(function (t) { if (t.id === 'task-j1' || t.id === 'task-j2') { t.status = 'resolved'; t.resolvedAt = new Date(Date.now() - 86400000).toISOString() } })
521
+ })
522
+ var a1 = await env.rpc('archive-task', { sessionId: env.sid, taskId: 'task-j1' })
523
+ var a2 = await env.rpc('archive-task', { sessionId: env.sid, taskId: 'task-j2' })
524
+ assert.equal(a1.ok, true, 'task-j1 可归档')
525
+ assert.equal(a2.ok, true, 'task-j2 可归档')
526
+ // 构造老归档:task-j1 归档于 20 天前(>7 天),task-j2 归档于现在
527
+ env.patchBoard(function (d) {
528
+ var j1 = d.tasks.find(function (x) { return x.id === 'task-j1' })
529
+ j1.archivedAt = new Date(Date.now() - 20 * 86400000).toISOString()
530
+ })
531
+ // 归档数据源(get-tasks 全量):两张归档都在,老归档(>7 天)不被窗口滤掉
532
+ var d = await env.rpc('get-tasks', { sessionId: env.sid, includeArchived: true })
533
+ var arch = d.tasks.filter(function (t) { return t.status === 'archived' })
534
+ assert.equal(arch.length, 2, '归档数据源含全部归档(全量,无 7 日窗口)')
535
+ assert.ok(arch.some(function (t) { return t.id === 'task-j1' && new Date(Date.now()) - new Date(t.archivedAt) > 7 * 86400000 }), '老归档(>7 天)仍在归档数据源')
536
+ assert.ok(arch.some(function (t) { return t.id === 'task-j2' }), '新归档也在归档数据源')
537
+ // 归档字段完整:archivedAt 已落卡(客户端按归档时间倒序的数据源字段不缺)
538
+ assert.ok(arch.every(function (t) { return !!t.archivedAt }), '每张归档卡都带 archivedAt')
539
+ // 与仪表盘口径一致:仪表盘「已归档」= state.tasks 里 status==='archived' 的计数,二者同源同值
540
+ var d2 = await env.rpc('get-tasks', { sessionId: env.sid })
541
+ assert.equal(d2.tasks.filter(function (t) { return t.status === 'archived' }).length, 2, '无 includeArchived 时同样全量(与仪表盘计数同源)')
542
+ } finally { await env.cleanup() }
543
+ })
@@ -0,0 +1,128 @@
1
+ // dsh-agent-board — 异常驱动审视②③:质量异动告警 + 报告导出批次 digest 单测(test/quality-alert-digest.test.mjs,task-muysyad1)
2
+ // 覆盖(对应任务断言①~④ + 接线):
3
+ // ② 质量异动告警 qualityChangeHints(scoreboard.trends 环比)
4
+ // ① 阈值边界——一次通过率恰好 10pp 跌不亮 / 10.1pp 跌亮
5
+ // ② 样本不足——任一侧 <5 不亮(即便跌幅巨大);恰好 5/5 判
6
+ // ③ 超时率/驳回率突变同构各一条(涨 >10pp 亮);三条同命全出;empty/null/脏输入全形态不炸
7
+ // ③ 报告导出 buildReport「## 批次摘要(近 24 小时)」段(client 无渲染 harness,源码级断言):
8
+ // 字段齐全 + 无数据优雅空态 + 产物重组装
9
+ // ④ 接线 + README 双份——usage/rpc/index 挂 qualityChangeHints + rejectByDay + README 各补一句
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { readFileSync } from 'node:fs'
13
+ import { qualityChangeHints, QUALITY_CHANGE_PP, QUALITY_MIN_SAMPLE, QUALITY_WINDOW_DAYS } from '../index.mjs'
14
+ import { localDayKey } from '../lib/usage.mjs'
15
+
16
+ // 固定「今天」= 2026-10-30 12:00(本地):近 7 天 = 10-24..10-30,再前 7 天 = 10-17..10-23
17
+ const NOW = new Date(2026, 9, 30, 12, 0, 0).getTime()
18
+ function dayOf(y, m, d) { return localDayKey(new Date(y, m - 1, d, 12)) }
19
+ const NEAR = dayOf(2026, 10, 30) // 近窗口某日
20
+ const PRIOR = dayOf(2026, 10, 20) // 前窗口某日
21
+
22
+ // ===== ②① 阈值边界 =====
23
+ test('②① 阈值边界:一次通过率恰好 10pp 跌不亮 / 10.1pp 跌亮(常量置顶核对)', () => {
24
+ assert.equal(QUALITY_CHANGE_PP, 10)
25
+ assert.equal(QUALITY_MIN_SAMPLE, 5)
26
+ assert.equal(QUALITY_WINDOW_DAYS, 7)
27
+ // 恰好 10pp:近 90%(900/1000)vs 前 100%(1000/1000)→ drop 10.0pp → 不亮
28
+ const exact = { trends: { firstPassByDay: { [NEAR]: { resolved: 1000, firstPass: 900 }, [PRIOR]: { resolved: 1000, firstPass: 1000 } }, timeoutByDay: {}, rejectByDay: {} } }
29
+ assert.deepEqual(qualityChangeHints(exact, NOW), [])
30
+ // 10.1pp:近 89.9%(899/1000)vs 前 100% → drop 10.1pp → 亮,文案逐字核对
31
+ const over = { trends: { firstPassByDay: { [NEAR]: { resolved: 1000, firstPass: 899 }, [PRIOR]: { resolved: 1000, firstPass: 1000 } }, timeoutByDay: {}, rejectByDay: {} } }
32
+ const h = qualityChangeHints(over, NOW)
33
+ assert.equal(h.length, 1)
34
+ assert.equal(h[0].level, 'warn')
35
+ assert.match(h[0].text, /质量异动:一次通过率本周 90%(上周 100%),建议抽查近期验收/)
36
+ })
37
+
38
+ // ===== ②② 样本不足 =====
39
+ test('②② 样本不足:任一侧 <5 不亮(即便 0% vs 100%);恰好 5/5 判', () => {
40
+ // 双侧皆 4(<5)→ 不亮
41
+ const s = { trends: { firstPassByDay: { [NEAR]: { resolved: 4, firstPass: 0 }, [PRIOR]: { resolved: 4, firstPass: 4 } }, timeoutByDay: {}, rejectByDay: {} } }
42
+ assert.deepEqual(qualityChangeHints(s, NOW), [])
43
+ // 仅一侧不足(近 5 / 前 4)→ 不亮
44
+ const s2 = { trends: { firstPassByDay: { [NEAR]: { resolved: 5, firstPass: 0 }, [PRIOR]: { resolved: 4, firstPass: 4 } }, timeoutByDay: {}, rejectByDay: {} } }
45
+ assert.deepEqual(qualityChangeHints(s2, NOW), [])
46
+ // 恰好 5/5 且大幅跌 → 亮(护栏边界:恰好 5 判,非 5 不判)
47
+ const s3 = { trends: { firstPassByDay: { [NEAR]: { resolved: 5, firstPass: 0 }, [PRIOR]: { resolved: 5, firstPass: 5 } }, timeoutByDay: {}, rejectByDay: {} } }
48
+ assert.equal(qualityChangeHints(s3, NOW).length, 1)
49
+ })
50
+
51
+ // ===== ②③ 超时率/驳回率同构 + 空形态 =====
52
+ test('②③ 超时率/驳回率突变同构各一条 + 三规则同命全出 + empty/null/脏输入不炸', () => {
53
+ // 超时率涨:近 20%(20/100)vs 前 5%(5/100)→ 涨 15pp → 亮
54
+ const to = { trends: { firstPassByDay: {}, timeoutByDay: { [NEAR]: { runs: 100, timeout: 20 }, [PRIOR]: { runs: 100, timeout: 5 } }, rejectByDay: {} } }
55
+ const ht = qualityChangeHints(to, NOW)
56
+ assert.equal(ht.length, 1)
57
+ assert.match(ht[0].text, /质量异动:超时率本周 20%(上周 5%),建议排查派发\/超时链路/)
58
+ // 驳回率涨:近 20 次驳回 / 前 5 次(分母 runs 各 100)→ 涨 15pp → 亮
59
+ const rj = { trends: { firstPassByDay: {}, timeoutByDay: { [NEAR]: { runs: 100, timeout: 0 }, [PRIOR]: { runs: 100, timeout: 0 } }, rejectByDay: { [NEAR]: { rejects: 20 }, [PRIOR]: { rejects: 5 } } } }
60
+ const hr = qualityChangeHints(rj, NOW)
61
+ assert.equal(hr.length, 1)
62
+ assert.match(hr[0].text, /质量异动:驳回率本周 20%(上周 5%),建议审查近期驳回原因/)
63
+ // 三条规则同时命中 → 三条全出(一次通过率 + 超时率 + 驳回率)
64
+ const all3 = { trends: { firstPassByDay: { [NEAR]: { resolved: 100, firstPass: 0 }, [PRIOR]: { resolved: 100, firstPass: 100 } }, timeoutByDay: { [NEAR]: { runs: 100, timeout: 50 }, [PRIOR]: { runs: 100, timeout: 0 } }, rejectByDay: { [NEAR]: { rejects: 50 }, [PRIOR]: { rejects: 0 } } } }
65
+ assert.equal(qualityChangeHints(all3, NOW).length, 3)
66
+ // empty/null/脏输入全形态不炸(老 host 缺 trends / rejectByDay 等)
67
+ assert.deepEqual(qualityChangeHints(null, NOW), [])
68
+ assert.deepEqual(qualityChangeHints({}, NOW), [])
69
+ assert.deepEqual(qualityChangeHints({ trends: {} }, NOW), [])
70
+ assert.deepEqual(qualityChangeHints({ trends: 'garbage' }, NOW), [])
71
+ assert.deepEqual(qualityChangeHints({ buckets: [] }, NOW), [])
72
+ // 缺 rejectByDay(老 host)只有超时率规则可判,驳回率规则静默跳过不炸
73
+ const noRj = { trends: { firstPassByDay: {}, timeoutByDay: { [NEAR]: { runs: 100, timeout: 30 }, [PRIOR]: { runs: 100, timeout: 0 } } } }
74
+ const hnr = qualityChangeHints(noRj, NOW)
75
+ assert.equal(hnr.length, 1)
76
+ assert.match(hnr[0].text, /质量异动:超时率本周/)
77
+ })
78
+
79
+ // ===== ③ 报告导出「批次摘要」段(源码级)=====
80
+ test('③ 报告段:buildReport「## 批次摘要(近 24 小时)」字段齐全 + 无数据优雅空态 + 产物重组装', () => {
81
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
82
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
83
+ // 段标题 + 五字段(完成/驳回/Token/异常事件/质量趋势)+ 无数据优雅空态
84
+ assert.ok(dash.indexOf('## 批次摘要(近 24 小时)') >= 0, '报告缺批次摘要段标题')
85
+ assert.ok(dash.indexOf('- 完成:') >= 0, '缺「完成」字段')
86
+ assert.ok(dash.indexOf('- 驳回:') >= 0, '缺「驳回」字段')
87
+ assert.ok(dash.indexOf('- Token:约 ') >= 0, '缺「Token」字段')
88
+ assert.ok(dash.indexOf('- 异常事件:超时 ') >= 0, '缺「异常事件」字段')
89
+ assert.ok(dash.indexOf('· 幽灵回收 ') >= 0, '缺「幽灵回收」字段')
90
+ assert.ok(dash.indexOf('- 质量趋势:') >= 0, '缺「质量趋势」字段')
91
+ assert.ok(dash.indexOf('近 24 小时无活动记录(无完成 / 驳回 / 消耗 / 异常事件)') >= 0, '缺无数据优雅空态')
92
+ assert.ok(dash.indexOf('function batchDigest()') >= 0, '缺 batchDigest 数据源')
93
+ // 纯聚合现有字段(不新建 host 状态、不重拉):run 级 token 现算 + healthHints 读幽灵回收
94
+ assert.match(dash, /r\.usage && typeof r\.usage === 'object'/)
95
+ assert.ok(dash.indexOf('本轮回收') >= 0, '缺幽灵回收 healthHints 读取')
96
+ // 产物重组装:关键标记逐一出现在 lib/client.js
97
+ for (const m of ['## 批次摘要(近 24 小时)', '近 24 小时无活动记录', 'function batchDigest()']) {
98
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
99
+ }
100
+ })
101
+
102
+ // ===== ④ 接线 + README 双份 =====
103
+ test('④ 接线:usage/rpc/index 挂 qualityChangeHints + rejectByDay;README 双份逐字一致 + 各补一句', () => {
104
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
105
+ const usage = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
106
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
107
+ // usage.mjs:质量异动常量置顶 + 函数 + rejectByDay 数据源
108
+ assert.match(usage, /export var QUALITY_CHANGE_PP = 10/)
109
+ assert.match(usage, /export var QUALITY_MIN_SAMPLE = 5/)
110
+ assert.match(usage, /export var QUALITY_WINDOW_DAYS = 7/)
111
+ assert.match(usage, /export function qualityChangeHints\(sb, now\)/)
112
+ assert.match(usage, /rejectByDay: \{\}/)
113
+ assert.match(usage, /tr\.rejectByDay\[rcj\.mDay\]/)
114
+ // rpc:import + healthHints 拼 qualityChangeHints(scoreboard 就位后现算,在 modelPerfHint 之后)
115
+ assert.match(rpc, /modelPerfHint, qualityChangeHints \} from '\.\/usage\.mjs'/)
116
+ const iQch = rpc.indexOf('var __qch = qualityChangeHints(d.usageSummary.scoreboard)')
117
+ const iMph = rpc.indexOf('var __mph = modelPerfHint(d.usageSummary.scoreboard)')
118
+ assert.ok(iQch >= 0 && iMph >= 0 && iQch > iMph, 'qualityChangeHints 必须在 modelPerfHint 之后现算')
119
+ assert.match(rpc, /d\.healthHints = __rh\.hints\.concat\(computeHealthHints\(d\.tasks\)\)\.concat\(__qch\)\.concat\(__mph \? \[__mph\] : \[\]\)/)
120
+ // index 兼容 re-export
121
+ assert.match(idx, /qualityChangeHints, QUALITY_CHANGE_PP, QUALITY_MIN_SAMPLE, QUALITY_WINDOW_DAYS/)
122
+ // README 双份:逐字一致 + 各补一句(质量异动 / 批次摘要)
123
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
124
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
125
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
126
+ assert.ok(r1.indexOf('质量异动') >= 0, 'README 缺「质量异动」说明')
127
+ assert.ok(r1.indexOf('批次摘要') >= 0, 'README 缺「批次摘要」说明')
128
+ })
@@ -0,0 +1,170 @@
1
+ // dsh-agent-board — 风险队列「待你过目」+ 审视摘要区单测(test/review-hints.test.mjs,task-muzikj1y)
2
+ // 覆盖(对应任务断言①~⑥ + 接线):
3
+ // ① 四硬信号各命中/不命中(computeReviewHints:驳回/歧义/核心 touches/full 无 acceptance)
4
+ // ② 软信号平时不计 / 质量异动告警激活期间计入(diff>300 行 + 续跑≥2 次,联动加严)
5
+ // ③ reviewedAt 幂等落账(mark-reviewed RPC 只写一次)+ get-tasks 现算透出 reviewHint 零落库
6
+ // ④⑤ 客户端接线(徽章/chip/审视摘要区四块+来源徽标/digest 未过目)源码级 + 产物重组装
7
+ // ⑥ README 双份逐字一致 + 各补「待你过目」说明
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import * as core from '../lib/core.mjs'
12
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
13
+ import * as plugin from '../index.mjs'
14
+
15
+ // 纯函数测试的任务形态:默认「干净卡」(无驳回/无歧义/无核心 touches/有 acceptance)→ score 0
16
+ function mkTask(over) {
17
+ return Object.assign({ id: 't1', title: 'T', status: 'resolved', pipeline: 'full', acceptance: 'npm test', touches: [], messages: [], runs: [], rejectCount: 0 }, over || {})
18
+ }
19
+
20
+ // ===== ① 四硬信号各命中/不命中 =====
21
+ test('① 四硬信号各命中/不命中(computeReviewHints)', () => {
22
+ // 干净卡 → score 0
23
+ assert.deepEqual(core.computeReviewHints([mkTask()]), { t1: { score: 0, reasons: [] } })
24
+ // 硬① 被驳回过:rejectCount>0(canonical 口径)
25
+ var h1 = core.computeReviewHints([mkTask({ id: 'a', rejectCount: 1 })])
26
+ assert.equal(h1.a.score, 1); assert.match(h1.a.reasons[0], /驳回/)
27
+ // 硬① 备选:verification.verdict='rejected'
28
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', verification: { verdict: 'rejected' } })]).a.score, 1)
29
+ // 硬① 备选:messages kind='rejection'
30
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'rejection', text: 'x' }] })]).a.score, 1)
31
+ // 硬① 备选:runs 含 rejected 结局(老留档兼容,字面「runs 含 rejected」口径)
32
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ outcome: 'rejected' }] })]).a.score, 1)
33
+ // 硬① 不命中:零驳回
34
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', rejectCount: 0, verification: { verdict: 'approved' } })]).a.score, 0)
35
+ // 硬② 上报过歧义:messages kind='arbitration'
36
+ var h2 = core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'arbitration', text: 'x' }] })])
37
+ assert.equal(h2.a.score, 1); assert.match(h2.a.reasons[0], /歧义/)
38
+ // 硬② 不命中:无 arbitration 包
39
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'progress', text: 'x' }] })]).a.score, 0)
40
+ // 硬③ touches 碰核心文件:三种核心文件 basename 各命中
41
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/dispatch.mjs'] })]).a.score, 1)
42
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/rpc.mjs'] })]).a.score, 1)
43
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['core.mjs'] })]).a.score, 1)
44
+ assert.match(core.computeReviewHints([mkTask({ id: 'a', touches: ['./lib/core.mjs'] })]).a.reasons[0], /核心文件/)
45
+ // 硬③ 不命中:非核心文件 / glob
46
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['src/a.mjs'] })]).a.score, 0)
47
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['*.mjs'] })]).a.score, 0)
48
+ // 硬④ full 无 acceptance
49
+ var h4 = core.computeReviewHints([mkTask({ id: 'a', acceptance: '' })])
50
+ assert.equal(h4.a.score, 1); assert.match(h4.a.reasons[0], /验收脚本/)
51
+ // 硬④ 不命中:有 acceptance / work 档 / 空白 acceptance 但有内容(trim 后非空不算空)
52
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: 'node --test' })]).a.score, 0)
53
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: '', pipeline: 'work' })]).a.score, 0)
54
+ })
55
+
56
+ // ===== ② 软信号:平时不计 / 告警态计入(联动加严)=====
57
+ test('② 软信号:质量异动告警未激活不计入 / 激活期间计入(diff>300 行 + 续跑≥2 次)', () => {
58
+ var diff = ' src/a.mjs | 200 +++++++\n 1 file changed, 350 insertions(+), 50 deletions(-)'
59
+ var t = mkTask({ id: 'a', deliverable: { diff: diff }, runs: [{ role: 'worker', resume: true }, { role: 'worker', resume: true }] })
60
+ // 平时(告警未激活):软信号不计入 → score 0
61
+ assert.equal(core.computeReviewHints([t], {}).a.score, 0)
62
+ assert.equal(core.computeReviewHints([t], { qualityAlertActive: false }).a.score, 0)
63
+ // 告警激活:diff(400 行)>300 + 续跑 2 次≥2 → 两条软信号全计入
64
+ var h = core.computeReviewHints([t], { qualityAlertActive: true })
65
+ assert.equal(h.a.score, 2)
66
+ assert.match(h.a.reasons[0], /diff 超 300 行/)
67
+ assert.match(h.a.reasons[1], /续跑 ≥2 次/)
68
+ // 阈值边界:恰好 300 行不亮(>300 才亮);续跑 1 次不亮
69
+ var diffEdge = ' 1 file changed, 250 insertions(+), 50 deletions(-)' // 300 行
70
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', deliverable: { diff: diffEdge } })], { qualityAlertActive: true }).a.score, 0)
71
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ resume: true }] })], { qualityAlertActive: true }).a.score, 0)
72
+ // 硬软叠加:硬④ + 软⑤ + 软⑥ → score 3
73
+ var tMix = mkTask({ id: 'a', acceptance: '', deliverable: { diff: diff }, runs: [{ resume: true }, { resume: true }] })
74
+ var hMix = core.computeReviewHints([tMix], { qualityAlertActive: true })
75
+ assert.equal(hMix.a.score, 3)
76
+ })
77
+
78
+ // ===== ③ reviewedAt 幂等落账 + get-tasks 现算透出 reviewHint(真 harness)=====
79
+ test('③ mark-reviewed 幂等落账(只写一次)+ get-tasks 现算透出 reviewHint 零落库', async () => {
80
+ var env = createMockCtx()
81
+ try {
82
+ plugin.apply(env.ctx)
83
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-rv', title: 'R', description: 'd', pipeline: 'full', acceptance: 'npm test' })
84
+ assert.equal(env.task('task-rv').reviewedAt, undefined, '初始无 reviewedAt')
85
+ // 第一次落账
86
+ var r1 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
87
+ assert.equal(r1.ok, true)
88
+ var at1 = env.task('task-rv').reviewedAt
89
+ assert.ok(at1, '第一次落账 reviewedAt')
90
+ // 幂等:第二次不重写
91
+ var r2 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
92
+ assert.equal(r2.ok, true)
93
+ assert.equal(env.task('task-rv').reviewedAt, at1, '第二次调用不重写 reviewedAt')
94
+ // 未找到任务
95
+ var r3 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'nope' })
96
+ assert.equal(r3.ok, false)
97
+ // get-tasks 透出 reviewHint(现算,不落盘)
98
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-gh', title: 'G', description: 'd', pipeline: 'full', acceptance: 'npm test' })
99
+ env.patchBoard(function (d) {
100
+ var t = d.tasks.find(function (x) { return x.id === 'task-gh' })
101
+ t.rejectCount = 1
102
+ t.touches = ['lib/core.mjs']
103
+ t.acceptance = ''
104
+ })
105
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
106
+ var gh = d.tasks.find(function (x) { return x.id === 'task-gh' })
107
+ assert.ok(gh.reviewHint, 'get-tasks 透出 reviewHint')
108
+ assert.equal(gh.reviewHint.score, 3, '驳回 + 核心文件 + 无 acceptance = 3 条硬信号')
109
+ assert.equal(gh.reviewHint.reasons.length, 3)
110
+ // 零落库:board 文件不存 reviewHint(现算字段不进盘)
111
+ var raw = env.board().tasks.find(function (x) { return x.id === 'task-gh' })
112
+ assert.equal(raw.reviewHint, undefined, 'reviewHint 现算零落库(board 文件不写 reviewHint)')
113
+ } finally { await env.cleanup() }
114
+ })
115
+
116
+ // ===== ④⑤⑥ 客户端接线 + 产物重组装 + README 双份(源码级)=====
117
+ test('④⑤ 客户端接线:徽章/chip/审视摘要区四块+来源徽标/digest(源码级 + 产物重组装)', () => {
118
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
119
+ const bl = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
120
+ const td = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
121
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
122
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
123
+ // kernel:单一事实源谓词 + 过滤接线 + state 默认字段
124
+ assert.match(ksrc, /function reviewPending\(t\)/)
125
+ assert.match(ksrc, /state\.filterReview && !reviewPending\(t\)/)
126
+ assert.match(ksrc, /filterReview: false/)
127
+ // board-list:chip「待你过目」+ 「建议过目」徽章
128
+ assert.ok(bl.indexOf('待你过目') >= 0, 'FilterBar 缺「待你过目」chip')
129
+ assert.ok(bl.indexOf('👁 建议过目') >= 0, '缺「建议过目」徽章')
130
+ assert.match(bl, /reviewPending\(t\) \?/)
131
+ // task-detail:审视摘要区四块 + 来源徽标 + mark-reviewed 触发
132
+ assert.ok(td.indexOf('tskb-review-summary') >= 0, '缺审视摘要区容器 id')
133
+ assert.ok(td.indexOf('① 为什么在这') >= 0, '缺四问①')
134
+ assert.ok(td.indexOf('② 改了什么') >= 0, '缺四问②')
135
+ assert.ok(td.indexOf('③ 机器怎么验的') >= 0, '缺四问③')
136
+ assert.ok(td.indexOf('④ 怎么亲自确认') >= 0, '缺四问④')
137
+ assert.ok(td.indexOf('Worker 汇报') >= 0, '缺「Worker 汇报」来源徽标')
138
+ assert.ok(td.indexOf('Verifier 实证') >= 0, '缺「Verifier 实证」来源徽标')
139
+ assert.ok(td.indexOf('系统记录') >= 0, '缺「系统记录」来源徽标')
140
+ assert.match(td, /rpc\('mark-reviewed'/)
141
+ // dashboard:digest 未过目计数 + buildReport 行
142
+ assert.match(dash, /reviewPending\(t\)/)
143
+ assert.ok(dash.indexOf('- 未过目:') >= 0, '报告缺「未过目」行')
144
+ // 产物重组装:关键标记逐一出现在 lib/client.js
145
+ for (const m of ['待你过目', '👁 建议过目', 'tskb-review-summary', '- 未过目:']) {
146
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
147
+ }
148
+ })
149
+
150
+ test('接线:core 纯函数 + rpc get-tasks 现算 + mark-reviewed(源码级)', () => {
151
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
152
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
153
+ assert.match(coreSrc, /export function computeReviewHints\(tasks, opts\)/)
154
+ assert.match(coreSrc, /export var REVIEW_CORE_FILES = \['dispatch\.mjs', 'rpc\.mjs', 'core\.mjs'\]/)
155
+ assert.match(rpc, /core\.computeReviewHints\(d\.tasks, \{ qualityAlertActive: __qch\.length > 0 \}\)/)
156
+ assert.match(rpc, /handle\('mark-reviewed'/)
157
+ assert.match(rpc, /if \(!t\.reviewedAt\) t\.reviewedAt = new Date\(\)\.toISOString\(\)/)
158
+ // tasksHash 序列化口径纳入 reviewedAt + reviewHint(已阅/软信号联动才能重渲染)
159
+ assert.match(coreSrc, /t\.reviewedAt,/)
160
+ assert.match(coreSrc, /t\.reviewHint \? String\(t\.reviewHint\.score\)/)
161
+ })
162
+
163
+ test('⑥ README 双份:逐字一致 + 各补「待你过目」说明', () => {
164
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
165
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
166
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
167
+ assert.ok(r1.indexOf('待你过目') >= 0, 'README 缺「待你过目」说明')
168
+ assert.ok(r1.indexOf('审视摘要') >= 0, 'README 缺「审视摘要」说明')
169
+ assert.ok(r1.indexOf('reviewHint') >= 0, 'README 缺 reviewHint 说明')
170
+ })
@@ -192,11 +192,11 @@ test('卡5⑤b:接线(源码级)——rpc healthHints 拼 modelPerfHint /
192
192
  const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
193
193
  const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
194
194
  // host:modelPerfHint 从 usage.mjs import;scoreboard 就位后现算;拼在 computeHealthHints 结果尾部
195
- assert.match(rpc, /import \{ findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint \} from '\.\/usage\.mjs'/)
195
+ assert.match(rpc, /import \{ findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint, qualityChangeHints \} from '\.\/usage\.mjs'/)
196
196
  const iSb = rpc.indexOf('d.usageSummary.scoreboard = buildScoreboard(d.tasks, args && args.range')
197
197
  const iMph = rpc.indexOf('var __mph = modelPerfHint(d.usageSummary.scoreboard)')
198
198
  assert.ok(iSb >= 0 && iMph > iSb, 'modelPerfHint 必须在 scoreboard 挂载之后现算')
199
- assert.match(rpc, /d\.healthHints = __rh\.hints\.concat\(computeHealthHints\(d\.tasks\)\)\.concat\(__mph \? \[__mph\] : \[\]\)/)
199
+ assert.match(rpc, /d\.healthHints = __rh\.hints\.concat\(computeHealthHints\(d\.tasks\)\)\.concat\(__qch\)\.concat\(__mph \? \[__mph\] : \[\]\)/)
200
200
  // usage.mjs:聚类规则表 / 聚类函数 / hint 阈值常量导出
201
201
  assert.match(usage, /export var REJECTION_CLUSTER_RULES = \[/)
202
202
  assert.match(usage, /export function clusterRejections\(texts\)/)
@@ -0,0 +1,112 @@
1
+ // dsh-agent-board — 看板数据安全 P0 根修测试(task-mv0bl9vg)
2
+ // 三件套:① 原子写盘(tmp+fsync+rename+unlink 兜底)② 腐坏隔离可见化(err hint + notify)
3
+ // ③ 自动 salvage 截断抢救(JSON.parse 报错 position 截断法)。
4
+ // 全部跑在真实 index.mjs apply(mockCtx) 上;每个用例独立 mockCtx + 独立临时 HOME(HOME 是进程级,严禁并行)。
5
+ // 建卡后不推进虚拟时钟 → 不触发派发,无 worker spawn 噪声。
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
11
+ import * as plugin from '../index.mjs'
12
+
13
+ function boardFile(env) { return path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json') }
14
+
15
+ // 建卡封装:显式 description 避开空描述软提示
16
+ async function createTask(env, id, extra) {
17
+ var args = { sessionId: env.sid, id: id, title: '数据安全卡 ' + id, description: '看板数据安全根修测试' }
18
+ if (extra) { for (var k in extra) args[k] = extra[k] }
19
+ var r = await env.rpc('create-task', args)
20
+ assert.equal(r.ok, true, 'create-task 成功: ' + id)
21
+ return r.task
22
+ }
23
+
24
+ // ===== ① 原子写:源码断言 + 行为级(rename 瞬断后旧文件不被撕裂)=====
25
+ test('① 原子写(源码级):store.mjs 写盘含 tmp+fsync+rename+unlink 兜底', () => {
26
+ var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
27
+ assert.match(sto, /async function writeTmpFsync/) // fsync 专用写 tmp helper
28
+ assert.match(sto, /fh\.sync\(\)/) // fsync 落盘(先于 rename)
29
+ assert.match(sto, /fsNode\.promises\.rename\(tmp, p\)/) // tmp → rename 覆盖目标
30
+ assert.match(sto, /unlinkRetry\(p, 6\)/) // 直覆失败降级 unlink 兜底
31
+ assert.match(sto, /降级 unlink\+rename/) // 降级记录(不静默)
32
+ })
33
+
34
+ test('① 原子写(行为级):rename 瞬断(EBUSY 一次)→ 旧文件全程完整,退避重试后写回', async () => {
35
+ var env = createMockCtx()
36
+ plugin.apply(env.ctx)
37
+ var origRename = fs.promises.rename
38
+ try {
39
+ await createTask(env, 'a1')
40
+ var p = boardFile(env)
41
+ assert.equal(JSON.parse(fs.readFileSync(p, 'utf8')).tasks.length, 1, '初始一张卡,文件是完整 JSON')
42
+ // 注入一次瞬断:首次 rename 到主板路径时同步读盘取证 → 抛 EBUSY;之后恢复原样
43
+ var injected = false
44
+ var duringRename = null
45
+ fs.promises.rename = async function (from, to) {
46
+ if (!injected && String(to) === p) {
47
+ injected = true
48
+ duringRename = fs.readFileSync(p, 'utf8') // 瞬断时刻主板文件内容取证
49
+ var e = new Error('EBUSY injected'); e.code = 'EBUSY'; throw e
50
+ }
51
+ return origRename.call(fs.promises, from, to)
52
+ }
53
+ await createTask(env, 'a2')
54
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
55
+ assert.equal(after.tasks.length, 2, '退避重试后写盘成功,最终内容完整(未撕裂)')
56
+ assert.ok(injected, '瞬断确实被注入')
57
+ assert.equal(JSON.parse(duringRename).tasks.length, 1, '瞬断时刻旧文件仍是完整旧 JSON(rename 原子,绝不半写)')
58
+ } finally {
59
+ fs.promises.rename = origRename
60
+ await env.cleanup()
61
+ }
62
+ })
63
+
64
+ // ===== ② 腐坏隔离:坏文件 → 隔离 .corrupt + err hint + notify(不再静默)=====
65
+ test('② 腐坏隔离:无法抢救的坏文件 → 隔离留档 + err hint + notify owner', async () => {
66
+ var env = createMockCtx()
67
+ plugin.apply(env.ctx)
68
+ try {
69
+ await createTask(env, 'b1')
70
+ var p = boardFile(env)
71
+ // 构造「无法抢救」的坏文件:单对象中部截断(无 position 可截 → salvage 失败 → 隔离)
72
+ fs.writeFileSync(p, '{"ownerSession":"' + env.sid + '","tasks":[{"id":"x"}', 'utf8')
73
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
74
+ assert.ok(Array.isArray(d.tasks) && d.tasks.length === 0, '隔离后空板重启')
75
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
76
+ assert.ok(errHint, '隔离产出 err 级 hint')
77
+ assert.match(errHint.text, /腐坏已隔离/)
78
+ assert.match(errHint.text, /\.corrupt-/)
79
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
80
+ assert.equal(corrupts.length, 1, '坏文件已改名 .corrupt-<ts> 留档')
81
+ var msg = env.sent.find(function (m) { return m && m.content && /腐坏已隔离/.test(m.content[0].text) })
82
+ assert.ok(msg, 'notify 通知 owner 会话(不再静默)')
83
+ } finally { await env.cleanup() }
84
+ })
85
+
86
+ // ===== ③ 自动 salvage:完整 JSON + 撕裂残片 → 截断抢救出第一段完整板 =====
87
+ test('③ 自动 salvage:完整 JSON 尾部夹杂撕裂残片 → 截断抢救出第一段完整板并写回', async () => {
88
+ var env = createMockCtx()
89
+ plugin.apply(env.ctx)
90
+ try {
91
+ await createTask(env, 'c1')
92
+ await createTask(env, 'c2')
93
+ await createTask(env, 'c3')
94
+ var p = boardFile(env)
95
+ var good = fs.readFileSync(p, 'utf8')
96
+ assert.equal(JSON.parse(good).tasks.length, 3, '前置:三张卡落盘')
97
+ // 构造真实腐坏样本形态:完整 JSON 尾部夹杂「另一版本残片」(未闭合的第二个 JSON 值)
98
+ var torn = good + '{"ownerSession":"' + env.sid + '","tasks":[{"id":"c4","title":"撕裂残片","status":"pend'
99
+ fs.writeFileSync(p, torn, 'utf8')
100
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
101
+ assert.equal(d.tasks.length, 3, '抢救出完整第一段(3 张卡),丢弃撕裂残片')
102
+ var after = JSON.parse(fs.readFileSync(p, 'utf8'))
103
+ assert.equal(after.tasks.length, 3, '抢救结果已原子写回主板文件(不再反复 poison)')
104
+ var errHint = (d.healthHints || []).find(function (h) { return h && h.level === 'err' })
105
+ assert.ok(errHint, '抢救产出 err 级 hint')
106
+ assert.match(errHint.text, /抢救保留 3 张卡/)
107
+ var corrupts = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return f.indexOf('.corrupt-') >= 0 })
108
+ assert.equal(corrupts.length, 1, '原始撕裂文件已留档 .corrupt-<ts>')
109
+ var msg = env.sent.find(function (m) { return m && m.content && /抢救/.test(m.content[0].text) })
110
+ assert.ok(msg, 'notify 通知 owner(抢救型)')
111
+ } finally { await env.cleanup() }
112
+ })