dsh-agent-board 1.7.8 → 1.7.9
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/README.md +12 -3
- package/lib/cleanup.mjs +256 -0
- package/lib/client/dashboard.js +128 -3
- package/lib/client/kernel.js +32 -7
- package/lib/client/task-detail.js +13 -7
- package/lib/client.js +173 -17
- package/lib/core.mjs +9 -2
- package/lib/dispatch.mjs +65 -0
- package/lib/rpc.mjs +103 -18
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +152 -6
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/ui-zoom-client.test.mjs +76 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// dsh-agent-board — 已结算会话清理套件(task-mv2131cz → 卡龄主闸重构 task-mv22cd5m):
|
|
2
|
+
// ① 可删判定:卡龄主闸(archived + 满保留天数)+ 三底闸(落定 / 不活跃 / 路径安全)
|
|
3
|
+
// ② preview 不动盘(dry-run 零副作用)
|
|
4
|
+
// ③ run 真删会话目录 + projcache json(临时 HOME 构造,与真实布局一致)
|
|
5
|
+
// ④ 路径逃逸防护(id 含 ../ 之类拒 + rm 前 isWithin 再验)
|
|
6
|
+
// ⑤ cleanupRetentionDays 设置项读写(缺省 7 / 钳 1-90 / get-tasks 透出 / 客户端文案)
|
|
7
|
+
// ⑥ 体积估算双轨(task-mv39ag1e):stat().sizeBytes 快照主源优先(不走目录遍历)+ stat 缺失/异常回退目录遍历
|
|
8
|
+
// 纯函数断言直接 import lib/cleanup.mjs;RPC 端到端跑真实 index.mjs apply(mockCtx)。
|
|
9
|
+
// 口径重构(task-mv22cd5m,2026-10-10 用户指令「回收与归档状态一致,归档后 X 天可完全回收」):
|
|
10
|
+
// 旧四闸(落定+已记账+非 continuable+不活跃)→ 卡龄主闸(卡归档满 X 天)+ 三底闸(落定/不活跃/路径安全)。
|
|
11
|
+
// continuable/usageRecorded 两闸撤除:归档满 X 天 = 账本封存 + 续跑意愿过期,恢复待办回落新 spawn(零新逻辑)。
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { createMockCtx } from './helpers/mock-ctx.mjs'
|
|
18
|
+
import * as plugin from '../index.mjs'
|
|
19
|
+
import { isSafeSessionId, classifyRun, scanDeletable, previewCleanup, runCleanup, isWithin, buildSessionDirIndex } from '../lib/cleanup.mjs'
|
|
20
|
+
|
|
21
|
+
// ===== ① 会话 id 白名单(路径逃逸第一道闸)=====
|
|
22
|
+
test('isSafeSessionId:合法 id 放行、路径字符/空值拒', function () {
|
|
23
|
+
assert.equal(isSafeSessionId('5c912460-ddd0-45f2-8298-717b2cfd6a25'), true, '裸 UUID 放行')
|
|
24
|
+
assert.equal(isSafeSessionId('session-55b0879a-b266-434e-98e2-5d2ba6ee85f6'), true, 'session-<uuid> 放行')
|
|
25
|
+
assert.equal(isSafeSessionId('import-sess_df0837fa-9e89-465b-9962-dd61e481c87e'), true, 'import-sess_ 放行')
|
|
26
|
+
assert.equal(isSafeSessionId('child-abc123'), true, 'child- 放行')
|
|
27
|
+
assert.equal(isSafeSessionId('../evil'), false, '父路径段拒')
|
|
28
|
+
assert.equal(isSafeSessionId('a/b'), false, '正斜杠拒')
|
|
29
|
+
assert.equal(isSafeSessionId('a\\b'), false, '反斜杠拒')
|
|
30
|
+
assert.equal(isSafeSessionId('.'), false, '单点拒')
|
|
31
|
+
assert.equal(isSafeSessionId('..'), false, '双点拒')
|
|
32
|
+
assert.equal(isSafeSessionId(''), false, '空串拒')
|
|
33
|
+
assert.equal(isSafeSessionId(' padded '), false, '首尾空白拒')
|
|
34
|
+
assert.equal(isSafeSessionId('a.b'), false, '含点拒(会话 id 不应含点)')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ===== ② 可删判定:卡龄主闸 + 三底闸 =====
|
|
38
|
+
test('classifyRun:卡龄主闸(非归档拒/未满天数拒/满天数放)+ 三底闸(落定/不活跃/路径安全)', function () {
|
|
39
|
+
var none = new Set()
|
|
40
|
+
var now = Date.now()
|
|
41
|
+
var DAY = 86400000
|
|
42
|
+
function aged(days) { return new Date(now - days * DAY).toISOString() }
|
|
43
|
+
var oldCard = { id: 'c1', status: 'archived', archivedAt: aged(8) }
|
|
44
|
+
var youngCard = { id: 'c2', status: 'archived', archivedAt: aged(3) }
|
|
45
|
+
var liveCard = { id: 'c3', status: 'resolved', archivedAt: aged(8) }
|
|
46
|
+
var opts = { now: now }
|
|
47
|
+
// 卡龄主闸:非 archived 拒 / 缺卡拒
|
|
48
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, liveCard, none, opts).reason, 'not-archived', '主闸:非 archived 卡拒(resolved 不放行)')
|
|
49
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, null, none, opts).reason, 'not-archived', '主闸:缺卡拒')
|
|
50
|
+
// 卡龄主闸:归档未满 7 天拒 / archivedAt 缺失拒 / 满 7 天放 / 恰好 7 天放(闭区间)
|
|
51
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, youngCard, none, opts).reason, 'too-young', '主闸:归档 3 天(<7)拒')
|
|
52
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, { id: 'c4', status: 'archived', archivedAt: '' }, none, opts).reason, 'too-young', '主闸:archivedAt 缺失拒(宁漏勿错删)')
|
|
53
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, { id: 'c5', status: 'archived', archivedAt: 'not-a-date' }, none, opts).reason, 'too-young', '主闸:archivedAt 非法拒')
|
|
54
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, oldCard, none, opts).deletable, true, '主闸:归档 8 天(≥7)放')
|
|
55
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, { id: 'c6', status: 'archived', archivedAt: aged(7) }, none, opts).deletable, true, '主闸:恰好 7 天放(≥ 闭区间)')
|
|
56
|
+
// continuable 死会话归档超期后可删(旧 continuable 闸已撤——Verifier 钓出的 151 个死持久会话回归)
|
|
57
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'timeout/error', continuable: true, noResume: true }, oldCard, none, opts).deletable, true, 'continuable 死会话归档超期可删')
|
|
58
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed', usageRecorded: false }, oldCard, none, opts).deletable, true, 'usageRecorded 闸已撤:未记账也放')
|
|
59
|
+
// 三底闸不回归:outcome 未落定 / 活跃血统 / 路径逃逸
|
|
60
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'running' }, oldCard, none, opts).reason, 'not-settled', '底闸:running 拒')
|
|
61
|
+
assert.equal(classifyRun({ id: 'a' }, oldCard, none, opts).reason, 'not-settled', '底闸:缺 outcome 拒')
|
|
62
|
+
assert.equal(classifyRun({ id: 'a', outcome: 'completed' }, oldCard, new Set(['a']), opts).reason, 'active', '底闸:活跃/血统拒')
|
|
63
|
+
assert.equal(classifyRun({ id: '../x', outcome: 'completed' }, oldCard, none, opts).reason, 'id-invalid', '底闸:路径逃逸优先拒')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ===== ③ scanDeletable:同 id 任一条不可删则整组不可删 =====
|
|
67
|
+
test('scanDeletable:按 id 聚合、任一条不可删整组拒(卡龄口径)', function () {
|
|
68
|
+
var now = Date.now()
|
|
69
|
+
var DAY = 86400000
|
|
70
|
+
function aged(days) { return new Date(now - days * DAY).toISOString() }
|
|
71
|
+
var tasks = [
|
|
72
|
+
{ id: 't1', title: 't1', status: 'archived', archivedAt: aged(8), runs: [{ id: 'ok', role: 'worker', outcome: 'completed' }, { id: 'dup', role: 'worker', outcome: 'completed' }] },
|
|
73
|
+
{ id: 't2', title: 't2', status: 'resolved', archivedAt: aged(8), runs: [{ id: 'dup', role: 'worker', outcome: 'completed' }] }
|
|
74
|
+
]
|
|
75
|
+
var r = scanDeletable(tasks, new Set(), { now: now })
|
|
76
|
+
assert.deepEqual(r.items.map(function (x) { return x.id }), ['ok'], '只 ok 可删')
|
|
77
|
+
assert.equal(r.skipped.length, 1)
|
|
78
|
+
assert.equal(r.skipped[0].id, 'dup')
|
|
79
|
+
assert.equal(r.skipped[0].reason, 'not-archived', 'dup 任一条所属卡非 archived 即整组拒')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ===== ④ preview 无副作用 + run 真删(临时 HOME 构造)=====
|
|
83
|
+
test('previewCleanup 只读不删、runCleanup 真删目录+projcache', async function () {
|
|
84
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cleanup-'))
|
|
85
|
+
try {
|
|
86
|
+
var sessionsRoot = path.join(tmp, 'sessions')
|
|
87
|
+
var bucket = path.join(sessionsRoot, 'b1')
|
|
88
|
+
var sessionDir = path.join(bucket, 'child-ok')
|
|
89
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
90
|
+
fs.writeFileSync(path.join(sessionDir, 'session.v4.jsonl.zstd'), 'x'.repeat(1000))
|
|
91
|
+
var projcacheDir = path.join(tmp, 'projcache')
|
|
92
|
+
fs.mkdirSync(projcacheDir, { recursive: true })
|
|
93
|
+
var pcPath = path.join(projcacheDir, 'child-ok.json')
|
|
94
|
+
fs.writeFileSync(pcPath, 'y'.repeat(500))
|
|
95
|
+
var roots = { sessions: sessionsRoot, projcacheDir: projcacheDir }
|
|
96
|
+
var agedAt = new Date(Date.now() - 8 * 86400000).toISOString()
|
|
97
|
+
var tasks = [{ id: 't1', title: 't1', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-ok', role: 'worker', outcome: 'completed' }] }]
|
|
98
|
+
// preview:dry-run,估体积 + 不动盘
|
|
99
|
+
var pv = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots })
|
|
100
|
+
assert.equal(pv.total, 1)
|
|
101
|
+
assert.ok(pv.totalBytes >= 1500, '体积估算含目录+projcache')
|
|
102
|
+
assert.ok(fs.existsSync(sessionDir), 'preview 后会话目录仍在(零副作用)')
|
|
103
|
+
assert.ok(fs.existsSync(pcPath), 'preview 后 projcache 仍在(零副作用)')
|
|
104
|
+
// run:真删
|
|
105
|
+
var rep = await runCleanup(pv.items, { roots: roots })
|
|
106
|
+
assert.equal(rep.deleted, 1)
|
|
107
|
+
assert.ok(!fs.existsSync(sessionDir), 'run 后会话目录已删')
|
|
108
|
+
assert.ok(!fs.existsSync(pcPath), 'run 后 projcache 已删')
|
|
109
|
+
assert.equal(rep.freedBytes, pv.totalBytes, '释放字节与预览一致')
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ===== ④b 体积估算主源(task-mv39ag1e):snapshot sizeBytes 优先,不走目录遍历 =====
|
|
116
|
+
test('previewCleanup:stat 返回 sizeBytes 时体积取自快照(不走目录遍历)', async function () {
|
|
117
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cleanup-snap-'))
|
|
118
|
+
try {
|
|
119
|
+
var sessionsRoot = path.join(tmp, 'sessions')
|
|
120
|
+
var sessionDir = path.join(sessionsRoot, 'b1', 'child-snap')
|
|
121
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
122
|
+
fs.writeFileSync(path.join(sessionDir, 'session.v4.jsonl.zstd'), 'x'.repeat(1000)) // 盘上真实 1000 字节
|
|
123
|
+
var projcacheDir = path.join(tmp, 'projcache')
|
|
124
|
+
fs.mkdirSync(projcacheDir, { recursive: true })
|
|
125
|
+
fs.writeFileSync(path.join(projcacheDir, 'child-snap.json'), 'y'.repeat(500))
|
|
126
|
+
var roots = { sessions: sessionsRoot, projcacheDir: projcacheDir }
|
|
127
|
+
var agedAt = new Date(Date.now() - 8 * 86400000).toISOString()
|
|
128
|
+
var tasks = [{ id: 't1', title: 't1', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-snap', role: 'worker', outcome: 'completed' }] }]
|
|
129
|
+
// mock 官方快照面:sizeBytes=77777(与盘上 1000 不同——体积等于 77777+500 即证未走目录遍历)
|
|
130
|
+
var statCalls = []
|
|
131
|
+
var statSession = async function (id) { statCalls.push(id); return { header: { id: id }, revision: 'r1', sizeBytes: 77777 } }
|
|
132
|
+
var pv = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots, statSession: statSession })
|
|
133
|
+
assert.equal(pv.total, 1)
|
|
134
|
+
assert.equal(pv.items[0].bytes, 77777 + 500, '体积=快照 sizeBytes + projcache(目录遍历口径应为 1000+500)')
|
|
135
|
+
assert.equal(pv.totalBytes, 77777 + 500)
|
|
136
|
+
assert.deepEqual(statCalls, ['child-snap'], 'stat 只查可删项(跳过项不查)')
|
|
137
|
+
assert.ok(fs.existsSync(sessionDir), 'preview 后会话目录仍在(零副作用)')
|
|
138
|
+
// run 同走快照主源:释放字节 = 快照口径
|
|
139
|
+
var rep = await runCleanup(pv.items, { roots: roots, statSession: statSession })
|
|
140
|
+
assert.equal(rep.deleted, 1)
|
|
141
|
+
assert.equal(rep.freedBytes, 77777 + 500, 'run 释放字节与快照预览一致')
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ===== ④c 体积估算回退(task-mv39ag1e):stat 缺失/异常/无 sizeBytes → 目录遍历兜底 =====
|
|
148
|
+
test('previewCleanup:stat 回退路径(undefined/异常/无 sizeBytes)一律目录遍历兜底', async function () {
|
|
149
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cleanup-fb-'))
|
|
150
|
+
try {
|
|
151
|
+
var sessionsRoot = path.join(tmp, 'sessions')
|
|
152
|
+
var sessionDir = path.join(sessionsRoot, 'b1', 'child-fb')
|
|
153
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
154
|
+
fs.writeFileSync(path.join(sessionDir, 'session.v4.jsonl.zstd'), 'x'.repeat(1000))
|
|
155
|
+
var projcacheDir = path.join(tmp, 'projcache')
|
|
156
|
+
fs.mkdirSync(projcacheDir, { recursive: true })
|
|
157
|
+
fs.writeFileSync(path.join(projcacheDir, 'child-fb.json'), 'y'.repeat(500))
|
|
158
|
+
var roots = { sessions: sessionsRoot, projcacheDir: projcacheDir }
|
|
159
|
+
var agedAt = new Date(Date.now() - 8 * 86400000).toISOString()
|
|
160
|
+
var tasks = [{ id: 't1', title: 't1', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-fb', role: 'worker', outcome: 'completed' }] }]
|
|
161
|
+
// A:stat 返回 undefined(会话不在持久层)→ 目录遍历
|
|
162
|
+
var pvA = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots, statSession: async function () { return undefined } })
|
|
163
|
+
assert.equal(pvA.items[0].bytes, 1500, 'stat undefined → 目录遍历兜底(1000+500)')
|
|
164
|
+
// B:stat 抛异常 → 目录遍历
|
|
165
|
+
var pvB = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots, statSession: async function () { throw new Error('backend down') } })
|
|
166
|
+
assert.equal(pvB.items[0].bytes, 1500, 'stat 异常 → 目录遍历兜底')
|
|
167
|
+
// C:snapshot 无 sizeBytes(未物化会话/非 JSONL 后端缺省字段)→ 目录遍历
|
|
168
|
+
var pvC = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots, statSession: async function (id) { return { header: { id: id }, revision: 'r1' } } })
|
|
169
|
+
assert.equal(pvC.items[0].bytes, 1500, 'sizeBytes 缺省 → 目录遍历兜底')
|
|
170
|
+
// D:非法 sizeBytes(负值/NaN 脏值)→ 目录遍历
|
|
171
|
+
var pvD = await previewCleanup(tasks, { blockedIds: new Set(), roots: roots, statSession: async function (id) { return { header: { id: id }, sizeBytes: -3 } } })
|
|
172
|
+
assert.equal(pvD.items[0].bytes, 1500, 'sizeBytes 脏值 → 目录遍历兜底')
|
|
173
|
+
assert.ok(fs.existsSync(sessionDir), '回退路径 preview 同样零副作用')
|
|
174
|
+
} finally {
|
|
175
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ===== ⑤ 路径逃逸防护:rm 前 isWithin 再验 + id 白名单 =====
|
|
180
|
+
test('路径逃逸:isWithin 拒绝桶外路径、runCleanup 拒删逃逸目标', async function () {
|
|
181
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cleanup-esc-'))
|
|
182
|
+
try {
|
|
183
|
+
var sessionsRoot = path.join(tmp, 'sessions')
|
|
184
|
+
var projcacheDir = path.join(tmp, 'projcache')
|
|
185
|
+
fs.mkdirSync(sessionsRoot, { recursive: true })
|
|
186
|
+
fs.mkdirSync(projcacheDir, { recursive: true })
|
|
187
|
+
// isWithin 纯函数:根内放行、根外/根本身拒绝
|
|
188
|
+
assert.equal(isWithin(sessionsRoot, path.join(sessionsRoot, 'b1', 'x')), true, '桶内放行')
|
|
189
|
+
assert.equal(isWithin(sessionsRoot, path.join(tmp, 'outside')), false, '桶外拒绝')
|
|
190
|
+
assert.equal(isWithin(sessionsRoot, sessionsRoot), false, '根本身拒绝')
|
|
191
|
+
// runCleanup:sessionDir 指向桶外 → 跳过并拒绝删除
|
|
192
|
+
var outside = path.join(tmp, 'outside-dir')
|
|
193
|
+
fs.mkdirSync(outside, { recursive: true })
|
|
194
|
+
fs.writeFileSync(path.join(outside, 'keep.txt'), 'do-not-delete')
|
|
195
|
+
var rep = await runCleanup([{ id: 'evil', sessionDir: outside, projcachePath: null }], { roots: { sessions: sessionsRoot, projcacheDir: projcacheDir } })
|
|
196
|
+
assert.equal(rep.deleted, 0)
|
|
197
|
+
assert.equal(rep.skipped.length, 1)
|
|
198
|
+
assert.equal(rep.skipped[0].reason, 'path-escape')
|
|
199
|
+
assert.ok(fs.existsSync(path.join(outside, 'keep.txt')), '桶外目录未被删')
|
|
200
|
+
// 含 ../ 的 id 在 runCleanup 层也被 id-invalid 拦下(即使给了合法 sessionDir)
|
|
201
|
+
var rep2 = await runCleanup([{ id: '../evil', sessionDir: path.join(sessionsRoot, 'b1', 'x'), projcachePath: null }], { roots: { sessions: sessionsRoot, projcacheDir: projcacheDir } })
|
|
202
|
+
assert.equal(rep2.deleted, 0)
|
|
203
|
+
assert.equal(rep2.skipped[0].reason, 'id-invalid')
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ===== ⑥ RPC 端到端:cleanup-preview / cleanup-run(真实 apply + 临时 HOME,卡龄主闸口径)=====
|
|
210
|
+
test('RPC cleanup-preview/run 端到端 + 留痕 + 活跃/逃逸跳过', async function () {
|
|
211
|
+
var env = createMockCtx()
|
|
212
|
+
plugin.apply(env.ctx)
|
|
213
|
+
try {
|
|
214
|
+
// 可删会话:on-disk 会话目录 + projcache
|
|
215
|
+
var sessionDir = path.join(env.home, '.dsh', 'sessions', 'b1', 'child-abc')
|
|
216
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
217
|
+
fs.writeFileSync(path.join(sessionDir, 'session.v4.jsonl.zstd'), 'z'.repeat(2000))
|
|
218
|
+
var projDir = path.join(env.home, '.dsh', 'storages', 'session_projcache', 'sessions')
|
|
219
|
+
fs.mkdirSync(projDir, { recursive: true })
|
|
220
|
+
var pcPath = path.join(projDir, 'child-abc.json')
|
|
221
|
+
fs.writeFileSync(pcPath, 'w'.repeat(600))
|
|
222
|
+
// 先建一张卡落出看板文件(patchBoard 需文件已存在),再整体覆盖 runs 场景
|
|
223
|
+
await env.rpc('create-task', { sessionId: env.sid, id: 'seed', title: 'seed', description: 'x' })
|
|
224
|
+
var agedAt = new Date(Date.now() - 8 * 86400000).toISOString()
|
|
225
|
+
env.patchBoard(function (d) {
|
|
226
|
+
d.tasks = [
|
|
227
|
+
{ id: 't1', title: 't1', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-abc', role: 'worker', outcome: 'completed' }] },
|
|
228
|
+
{ id: 't2', title: 't2', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-active', role: 'worker', outcome: 'completed' }] },
|
|
229
|
+
{ id: 't3', title: 't3', status: 'archived', archivedAt: agedAt, runs: [{ id: '../escape', role: 'worker', outcome: 'completed' }] }
|
|
230
|
+
]
|
|
231
|
+
})
|
|
232
|
+
// 活跃会话:child-active 塞进活跃树(agents.list 会返回它)→ 应被「不活跃」底闸跳过
|
|
233
|
+
env.setRoots([env.root, { id: 'child-active' }])
|
|
234
|
+
var pv = await env.rpc('cleanup-preview', { sessionId: env.sid })
|
|
235
|
+
assert.equal(pv.ok, true)
|
|
236
|
+
assert.equal(pv.total, 1, '只 child-abc 可删(卡龄主闸 + 三底闸全过)')
|
|
237
|
+
assert.ok(pv.totalBytes >= 2600)
|
|
238
|
+
var skipIds = (pv.skipped || []).map(function (s) { return s.id })
|
|
239
|
+
assert.ok(skipIds.indexOf('child-active') >= 0, '活跃会话进跳过清单')
|
|
240
|
+
assert.ok(skipIds.indexOf('../escape') >= 0, '路径逃逸 id 进跳过清单')
|
|
241
|
+
assert.ok(fs.existsSync(sessionDir), 'preview 后会话目录仍在(零副作用)')
|
|
242
|
+
assert.ok(fs.existsSync(pcPath), 'preview 后 projcache 仍在(零副作用)')
|
|
243
|
+
// run:confirm:false 拒、confirm:true 真删
|
|
244
|
+
var noConfirm = await env.rpc('cleanup-run', { sessionId: env.sid })
|
|
245
|
+
assert.equal(noConfirm.ok, false, '无 confirm 拒绝')
|
|
246
|
+
var rep = await env.rpc('cleanup-run', { sessionId: env.sid, confirm: true })
|
|
247
|
+
assert.equal(rep.ok, true)
|
|
248
|
+
assert.equal(rep.deleted, 1)
|
|
249
|
+
assert.ok(!fs.existsSync(sessionDir), 'run 后会话目录已删')
|
|
250
|
+
assert.ok(!fs.existsSync(pcPath), 'run 后 projcache 已删')
|
|
251
|
+
var board = env.board()
|
|
252
|
+
assert.ok(Array.isArray(board.cleanupLog) && board.cleanupLog.length === 1, 'cleanupLog 留痕')
|
|
253
|
+
assert.equal(board.cleanupLog[0].deleted, 1)
|
|
254
|
+
} finally {
|
|
255
|
+
await env.cleanup()
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ===== ⑥b RPC 端到端(task-mv39ag1e):snapshot sizeBytes 主源经真实接线生效 =====
|
|
260
|
+
test('RPC cleanup-preview/run:sessionPersistence.stat sizeBytes 主源经真实接线生效', async function () {
|
|
261
|
+
var env = createMockCtx()
|
|
262
|
+
plugin.apply(env.ctx)
|
|
263
|
+
try {
|
|
264
|
+
// 盘上真实会话目录 2000 字节 + projcache 600
|
|
265
|
+
var sessionDir = path.join(env.home, '.dsh', 'sessions', 'b1', 'child-snap')
|
|
266
|
+
fs.mkdirSync(sessionDir, { recursive: true })
|
|
267
|
+
fs.writeFileSync(path.join(sessionDir, 'session.v4.jsonl.zstd'), 'z'.repeat(2000))
|
|
268
|
+
var projDir = path.join(env.home, '.dsh', 'storages', 'session_projcache', 'sessions')
|
|
269
|
+
fs.mkdirSync(projDir, { recursive: true })
|
|
270
|
+
fs.writeFileSync(path.join(projDir, 'child-snap.json'), 'w'.repeat(600))
|
|
271
|
+
await env.rpc('create-task', { sessionId: env.sid, id: 'seed', title: 'seed', description: 'x' })
|
|
272
|
+
var agedAt = new Date(Date.now() - 8 * 86400000).toISOString()
|
|
273
|
+
env.patchBoard(function (d) {
|
|
274
|
+
d.tasks = [{ id: 't1', title: 't1', status: 'archived', archivedAt: agedAt, runs: [{ id: 'child-snap', role: 'worker', outcome: 'completed' }] }]
|
|
275
|
+
})
|
|
276
|
+
// 官方快照面登记:header 入持久表 + sizeBytes=88888(≠盘上 2000——体积取自快照即证主源生效)
|
|
277
|
+
env.persistedHeaders['child-snap'] = { id: 'child-snap', parentSession: undefined }
|
|
278
|
+
env.persistedSizes['child-snap'] = 88888
|
|
279
|
+
var pv = await env.rpc('cleanup-preview', { sessionId: env.sid })
|
|
280
|
+
assert.equal(pv.ok, true)
|
|
281
|
+
assert.equal(pv.total, 1)
|
|
282
|
+
assert.equal(pv.totalBytes, 88888 + 600, 'preview 体积=快照 sizeBytes+projcache(目录遍历口径为 2600,不等即证主源)')
|
|
283
|
+
assert.ok(fs.existsSync(sessionDir), 'preview 零副作用不动盘')
|
|
284
|
+
var rep = await env.rpc('cleanup-run', { sessionId: env.sid, confirm: true })
|
|
285
|
+
assert.equal(rep.ok, true)
|
|
286
|
+
assert.equal(rep.deleted, 1)
|
|
287
|
+
assert.equal(rep.freedBytes, 88888 + 600, 'run 释放字节同走快照主源')
|
|
288
|
+
assert.ok(!fs.existsSync(sessionDir), 'run 后目录真删')
|
|
289
|
+
var board = env.board()
|
|
290
|
+
assert.ok(Array.isArray(board.cleanupLog) && board.cleanupLog.length === 1, 'cleanupLog 留痕')
|
|
291
|
+
} finally {
|
|
292
|
+
await env.cleanup()
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// ===== ⑦ buildSessionDirIndex:一次性索引可定位目录(preview 用)=====
|
|
297
|
+
test('buildSessionDirIndex:id → 目录路径 索引', function () {
|
|
298
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-cleanup-idx-'))
|
|
299
|
+
try {
|
|
300
|
+
var sessionsRoot = path.join(tmp, 'sessions')
|
|
301
|
+
var b1 = path.join(sessionsRoot, 'b1')
|
|
302
|
+
var b2 = path.join(sessionsRoot, 'b2')
|
|
303
|
+
fs.mkdirSync(path.join(b1, 'aaa'), { recursive: true })
|
|
304
|
+
fs.mkdirSync(path.join(b2, 'bbb'), { recursive: true })
|
|
305
|
+
fs.writeFileSync(path.join(b1, 'aaa', 'session.jsonl.zstd'), 'x') // 目录里放个文件确保是目录
|
|
306
|
+
fs.writeFileSync(path.join(b2, 'bbb', 'session.jsonl.zstd'), 'y')
|
|
307
|
+
var idx = buildSessionDirIndex(sessionsRoot)
|
|
308
|
+
assert.equal(idx['aaa'], path.join(b1, 'aaa'))
|
|
309
|
+
assert.equal(idx['bbb'], path.join(b2, 'bbb'))
|
|
310
|
+
} finally {
|
|
311
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ===== ⑧ cleanupRetentionDays:设置项读写(缺省/钳制/get-tasks 透出)+ 客户端文案 =====
|
|
316
|
+
test('cleanupRetentionDays:设置项读写 + 缺省/钳制 + 客户端文案', async function () {
|
|
317
|
+
var env = createMockCtx()
|
|
318
|
+
plugin.apply(env.ctx)
|
|
319
|
+
try {
|
|
320
|
+
await env.rpc('create-task', { sessionId: env.sid, id: 'seed', title: 'seed', description: 'x' })
|
|
321
|
+
// 缺省 7
|
|
322
|
+
var g0 = await env.rpc('get-tasks', { sessionId: env.sid })
|
|
323
|
+
assert.equal(g0.cleanupRetentionDays, 7, '缺省 7(get-tasks 透出)')
|
|
324
|
+
// 写入 3 → 透出 3
|
|
325
|
+
await env.rpc('set-board-config', { sessionId: env.sid, key: 'cleanupRetentionDays', value: 3 })
|
|
326
|
+
var g1 = await env.rpc('get-tasks', { sessionId: env.sid })
|
|
327
|
+
assert.equal(g1.cleanupRetentionDays, 3, '写入 3 透出 3')
|
|
328
|
+
// 钳制:999 → 90(上限);0 → 7(脏值回默认)
|
|
329
|
+
await env.rpc('set-board-config', { sessionId: env.sid, key: 'cleanupRetentionDays', value: 999 })
|
|
330
|
+
assert.equal((await env.rpc('get-tasks', { sessionId: env.sid })).cleanupRetentionDays, 90, '上限钳 90')
|
|
331
|
+
await env.rpc('set-board-config', { sessionId: env.sid, key: 'cleanupRetentionDays', value: 0 })
|
|
332
|
+
assert.equal((await env.rpc('get-tasks', { sessionId: env.sid })).cleanupRetentionDays, 7, '0 回默认 7')
|
|
333
|
+
// 客户端文案(产物 lib/client.js):设置区「存储清理」小节 + 卡龄口径注释 + 动态天数
|
|
334
|
+
var cli = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
335
|
+
assert.match(cli, /'归档保留'/)
|
|
336
|
+
assert.match(cli, /cleanupRetentionDays/)
|
|
337
|
+
assert.match(cli, /归档超过 ' \+ \(state\.cleanupRetentionDays \|\| 7\) \+ ' 天/)
|
|
338
|
+
} finally {
|
|
339
|
+
await env.cleanup()
|
|
340
|
+
}
|
|
341
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// dsh-agent-board — get-tasks 条件请求瘦身(ifHash 一致不返全量)单测(test/conditional-get-tasks.test.mjs)
|
|
2
|
+
// 背景(task-mv492for,巡检实证 n-mv2turd1o3iv):面板打开期间每 3s 一次 get-tasks 全量响应恒定
|
|
3
|
+
// 1.34MB(tasks 数组含全部卡的 description/context/runs/deliverable 全文),一分钟 ≈26MB 传输。
|
|
4
|
+
// tasksHash 此前只做「收到全量后的客户端渲染短路」,载荷本身从没瘦过。修法=条件请求:
|
|
5
|
+
// 客户端 fetchTasks 带 ifHash=state.tasksHash;host 比对一致 → 返回 {unchanged:true}+轻量字段(不含 tasks)。
|
|
6
|
+
// 覆盖(任务断言①~④):
|
|
7
|
+
// ① hash 一致 → 响应 unchanged:true 且不含 tasks(字节数级:轻量 <10KB vs 全量 >100KB,harness 构造大板)
|
|
8
|
+
// ② 不带 ifHash / ifHash 不一致 / includeArchived / 卡已变化 → 全量返回不回归
|
|
9
|
+
// ③ unchanged 时客户端 state.tasks 保留(源码级:kernel ifHash 接线 + unchanged 分支优先于 hash 比对)
|
|
10
|
+
// ④ 产物重组装(lib/client.js 含 ifHash/unchanged 接线标记)
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { createMockCtx } from './helpers/mock-ctx.mjs'
|
|
15
|
+
import * as plugin from '../index.mjs'
|
|
16
|
+
|
|
17
|
+
// 多字节字符填充(贴近真实中文看板的字节体量;1 字 = 3 字节 UTF-8)
|
|
18
|
+
function big(n) { return '描'.repeat(n) }
|
|
19
|
+
function sizeOf(x) { return Buffer.byteLength(JSON.stringify(x)) }
|
|
20
|
+
|
|
21
|
+
// ===== ①② 字节数级断言:大板全量 >100KB;hash 一致轻量 <10KB 且不含 tasks =====
|
|
22
|
+
test('①② get-tasks 条件请求:ifHash 一致返轻量(<10KB 无 tasks);不一致/缺 ifHash/includeArchived 全量不回归(>100KB)', async () => {
|
|
23
|
+
var env = createMockCtx()
|
|
24
|
+
try {
|
|
25
|
+
plugin.apply(env.ctx)
|
|
26
|
+
// 构造大板:40 张卡 ×(6KB description + 3KB context.notes)≈ 360KB 级全量
|
|
27
|
+
for (var i = 0; i < 40; i++) {
|
|
28
|
+
await env.rpc('create-task', { sessionId: env.sid, id: 'task-big-' + i, title: '大板卡' + i, description: big(2000), contextNotes: big(1000), pipeline: 'work' })
|
|
29
|
+
}
|
|
30
|
+
// 全量基线:不带 ifHash(老客户端/首轮形态)
|
|
31
|
+
var full = await env.rpc('get-tasks', { sessionId: env.sid })
|
|
32
|
+
assert.ok(Array.isArray(full.tasks) && full.tasks.length === 40, '全量返回全部 40 卡')
|
|
33
|
+
assert.ok(!full.unchanged, '全量响应不带 unchanged 标记')
|
|
34
|
+
assert.ok(full.tasksHash, '全量响应附 tasksHash')
|
|
35
|
+
var fullBytes = sizeOf(full)
|
|
36
|
+
assert.ok(fullBytes > 100 * 1024, '大板全量响应 >100KB(实测 ' + fullBytes + 'B)——大板构造达标')
|
|
37
|
+
// ① hash 一致 → 轻量响应
|
|
38
|
+
var lite = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash })
|
|
39
|
+
assert.equal(lite.unchanged, true, 'hash 一致 → unchanged:true')
|
|
40
|
+
assert.equal(lite.tasks, undefined, '轻量响应不含 tasks 数组')
|
|
41
|
+
assert.equal(lite.tasksHash, full.tasksHash, '轻量响应透出同一 hash')
|
|
42
|
+
var liteBytes = sizeOf(lite)
|
|
43
|
+
assert.ok(liteBytes < 10 * 1024, '轻量响应 <10KB(实测 ' + liteBytes + 'B,全量 ' + fullBytes + 'B)')
|
|
44
|
+
// 轻量字段照常:usageSummary/healthHints/childStats/poolStatus/workMode/isRoot/各开关一个不少
|
|
45
|
+
assert.ok(lite.usageSummary, 'usageSummary 照常返回(KB 级聚合)')
|
|
46
|
+
assert.ok(Array.isArray(lite.healthHints), 'healthHints 照常返回')
|
|
47
|
+
assert.ok(lite.childStats && typeof lite.childStats === 'object', 'childStats 照常返回')
|
|
48
|
+
assert.ok(lite.poolStatus && typeof lite.poolStatus === 'object', 'poolStatus 照常返回')
|
|
49
|
+
assert.ok(lite.workMode, 'workMode 照常返回')
|
|
50
|
+
assert.equal(lite.isRoot, true, 'isRoot 照常返回')
|
|
51
|
+
assert.equal(lite.feedbackEnabled, true)
|
|
52
|
+
assert.equal(lite.notifyDispatch, true)
|
|
53
|
+
assert.equal(lite.cleanupRetentionDays, 7)
|
|
54
|
+
// ② 不带 ifHash → 全量(上面基线已证);ifHash 不一致 → 全量不回归
|
|
55
|
+
var stale = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: 'stalehash' })
|
|
56
|
+
assert.ok(Array.isArray(stale.tasks) && stale.tasks.length === 40, 'ifHash 不一致 → 全量返回')
|
|
57
|
+
assert.ok(!stale.unchanged)
|
|
58
|
+
// includeArchived 即使 ifHash 一致也强制全量(归档 tab 数据源 fetchArchived 不带 ifHash,host 侧双保险)
|
|
59
|
+
var arch = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash, includeArchived: true })
|
|
60
|
+
assert.ok(Array.isArray(arch.tasks) && arch.tasks.length === 40, 'includeArchived 强制全量')
|
|
61
|
+
assert.ok(!arch.unchanged)
|
|
62
|
+
// 卡变化 → hash 变 → 旧 ifHash 失效返全量(含新卡)
|
|
63
|
+
await env.rpc('create-task', { sessionId: env.sid, id: 'task-big-new', title: '新卡', description: 'd', pipeline: 'work' })
|
|
64
|
+
var grown = await env.rpc('get-tasks', { sessionId: env.sid, ifHash: full.tasksHash })
|
|
65
|
+
assert.ok(Array.isArray(grown.tasks) && grown.tasks.length === 41, '卡变化后旧 ifHash 失效 → 全量返回含新卡')
|
|
66
|
+
assert.notEqual(grown.tasksHash, full.tasksHash, 'hash 随卡变化')
|
|
67
|
+
assert.ok(!grown.unchanged)
|
|
68
|
+
} finally { await env.cleanup() }
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ===== ③④ 客户端/host 接线(源码级)+ 产物重组装 =====
|
|
72
|
+
test('③④ 接线:fetchTasks 带 ifHash + unchanged 分支保留 state.tasks(源码级)+ client.js 产物重组装', () => {
|
|
73
|
+
var ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
74
|
+
var rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
75
|
+
var built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
76
|
+
// 请求侧:state.tasksHash 非空才带 ifHash(首轮/会话切换后为空 → 全量拉取同老路径)
|
|
77
|
+
assert.match(ksrc, /if \(state\.tasksHash\) rpcArgs\.ifHash = state\.tasksHash/)
|
|
78
|
+
// 响应侧③:unchanged 优先于 hash 比对——缺 tasks 的轻量响应绝不清空 state.tasks
|
|
79
|
+
assert.match(ksrc, /var unchanged = !!\(d && d\.unchanged\)/)
|
|
80
|
+
assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
|
|
81
|
+
// state.tasks 赋值只在 tasksChanged 分支内(unchanged 时保留不动)
|
|
82
|
+
var iBranch = ksrc.indexOf('if (tasksChanged) {')
|
|
83
|
+
var iAssign = ksrc.indexOf('state.tasks = (d && d.tasks) || []')
|
|
84
|
+
assert.ok(iBranch >= 0 && iAssign > iBranch, 'state.tasks 赋值必须在 tasksChanged 分支内(unchanged 保留旧列表)')
|
|
85
|
+
// host 侧:ifHash 一致且非 includeArchived → 剔 tasks + unchanged 标记
|
|
86
|
+
assert.match(rpcSrc, /args\.ifHash && args\.ifHash === d\.tasksHash && !args\.includeArchived/)
|
|
87
|
+
assert.match(rpcSrc, /delete __light\.tasks/)
|
|
88
|
+
assert.match(rpcSrc, /__light\.unchanged = true/)
|
|
89
|
+
// 归档 tab 数据源不带 ifHash(fetchArchived 只传 includeArchived)——双保险的客户端一半
|
|
90
|
+
var fa = ksrc.match(/function fetchArchived\(\)[\s\S]*?\n \}/)
|
|
91
|
+
assert.ok(fa && fa[0].indexOf('ifHash') < 0, 'fetchArchived 不得带 ifHash(归档全量拉取)')
|
|
92
|
+
// ④ 产物重组装:关键接线标记逐一出现在 lib/client.js
|
|
93
|
+
for (const m of ['rpcArgs.ifHash = state.tasksHash', 'd && d.unchanged']) {
|
|
94
|
+
assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
|
|
95
|
+
}
|
|
96
|
+
})
|