dsh-agent-board 1.7.7 → 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 +14 -5
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/board-list.js +12 -8
- package/lib/client/dashboard.js +133 -7
- package/lib/client/kernel.js +62 -10
- package/lib/client/task-detail.js +13 -5
- package/lib/client.js +220 -30
- package/lib/core.mjs +30 -2
- package/lib/dispatch.mjs +82 -5
- package/lib/rpc.mjs +154 -25
- package/lib/store.mjs +86 -62
- 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 +294 -18
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/scoreboard-trends-client.test.mjs +21 -0
- package/test/store-data-safety.test.mjs +105 -0
- package/test/ui-zoom-client.test.mjs +76 -0
- package/test/verify-timing.test.mjs +26 -0
package/lib/store.mjs
CHANGED
|
@@ -9,6 +9,11 @@ import path from 'node:path'
|
|
|
9
9
|
import fsNode from 'node:fs'
|
|
10
10
|
const { seed, normalizeBoard, vt, boardHome } = core
|
|
11
11
|
|
|
12
|
+
// 进程内 tmp 序号(配合 pid + 时间戳拼出唯一 tmp 名):并发 wt() 各自写各自的 tmp,
|
|
13
|
+
// 消灭「两个 chunked 写在同一 p+'.tmp' 上交错 → rename 把拼接怪装上正位」的撕裂根因。
|
|
14
|
+
// 模块级(而非 per-store)是为了同一进程内多个 store 实例(测试/热重载)也不会撞名。
|
|
15
|
+
var tmpSeq = 0
|
|
16
|
+
|
|
12
17
|
// 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
|
|
13
18
|
// 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
|
|
14
19
|
// V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
|
|
@@ -108,6 +113,11 @@ export function createStore(ctx, state, deps) {
|
|
|
108
113
|
}
|
|
109
114
|
return false
|
|
110
115
|
}
|
|
116
|
+
// 锁感知写回(P1 根修 task-mv1e17x9):locked=true(调用方已在 mutateLocked 锁内)→ 直接执行;
|
|
117
|
+
// locked=false(rt 被锁外直读触发,如 poolCycle/rpc/notify)→ 经 withLock 串行。
|
|
118
|
+
// 为什么不能一律 withLock:rt 在 mutateLocked 锁内被调用时,再套 withLock 会链到当前**未落定**的锁上
|
|
119
|
+
// (withLock 是简单 promise 链,不可重入)→ 死锁。故用 locked 标志位区分,锁内直跑、锁外补锁。
|
|
120
|
+
function lockAware(sid, locked, fn) { return locked ? fn() : withLock(sid, fn) }
|
|
111
121
|
// 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
|
|
112
122
|
// 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
|
|
113
123
|
// ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
|
|
@@ -116,32 +126,37 @@ export function createStore(ctx, state, deps) {
|
|
|
116
126
|
// 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
|
|
117
127
|
// ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
|
|
118
128
|
// 任何时刻磁盘上至少有一份完整看板。
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
// 整段经 lockAware 串行(继承写回归一进锁):locked=false 时并发继承/写盘不会互相交错。
|
|
130
|
+
async function adoptBoard(sid, cand, cwd, locked) {
|
|
131
|
+
return lockAware(sid, locked, async function () {
|
|
132
|
+
var p = boardPath(cand.sid), np = boardPath(sid)
|
|
133
|
+
// 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
|
|
134
|
+
if (cand.sid === sid || fsNode.existsSync(np)) return false
|
|
135
|
+
var c = null
|
|
136
|
+
try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
|
|
137
|
+
if (!vt(c)) return false
|
|
138
|
+
c.ownerSession = sid
|
|
139
|
+
if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
|
|
140
|
+
if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
|
|
141
|
+
try {
|
|
142
|
+
await wt(sid, c)
|
|
143
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
|
|
144
|
+
return true
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
|
|
147
|
+
await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
137
150
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
|
|
152
|
+
await unlinkRetry(p)
|
|
153
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
143
156
|
}
|
|
144
|
-
|
|
157
|
+
// locked(内部参数):true=调用方已在 mutateLocked 锁内(继承/抢救写回不得再套 withLock,防死锁);
|
|
158
|
+
// 缺省 false=锁外直读(写回经 lockAware 补锁串行)。对外只暴露 rt(sid),锁内调用由 mutateLocked 传 true。
|
|
159
|
+
async function rt(sid, locked) {
|
|
145
160
|
var r = null
|
|
146
161
|
try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
|
|
147
162
|
// 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
|
|
@@ -151,7 +166,7 @@ export function createStore(ctx, state, deps) {
|
|
|
151
166
|
try {
|
|
152
167
|
var cwd0 = sessionCwd(sid)
|
|
153
168
|
var cand = findAdoptableBoardFile(sid, cwd0)
|
|
154
|
-
if (cand && await adoptBoard(sid, cand, cwd0)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
|
|
169
|
+
if (cand && await adoptBoard(sid, cand, cwd0, locked)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
|
|
155
170
|
} catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
|
|
156
171
|
if (r === null) return seed(sid, sessionCwd(sid))
|
|
157
172
|
}
|
|
@@ -167,45 +182,49 @@ export function createStore(ctx, state, deps) {
|
|
|
167
182
|
return seed(sid, sessionCwd(sid))
|
|
168
183
|
} catch (_) {
|
|
169
184
|
// JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
|
|
170
|
-
return recoverCorrupt(sid, r)
|
|
185
|
+
return recoverCorrupt(sid, r, locked)
|
|
171
186
|
}
|
|
172
187
|
}
|
|
173
188
|
// ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
|
|
174
189
|
// 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
|
|
175
190
|
// ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
|
|
176
191
|
// ③ 追加 healthHints err 级 hint(写 poolHealth[sid].corruptNote)+ notify 通知 owner——不再静默。
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
salvaged
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
192
|
+
// locked 语义同 adoptBoard:锁外直读触发 → 经 withLock 串行(双生 .corrupt 对已实证读路径并发触发写真实存在);
|
|
193
|
+
// mutateLocked 锁内触发 → 直跑。整段(含 copyFile 留档 + wt 写回 / rename 隔离)归一进锁,写回不再 bypass withLock。
|
|
194
|
+
async function recoverCorrupt(sid, raw, locked) {
|
|
195
|
+
return lockAware(sid, locked, async function () {
|
|
196
|
+
var p = boardPath(sid)
|
|
197
|
+
var ts = Date.now()
|
|
198
|
+
var corruptPath = p + '.corrupt-' + ts
|
|
199
|
+
var salv = trySalvage(raw)
|
|
200
|
+
var salvaged = null, kept = 0
|
|
201
|
+
if (salv.ok && vt(salv.board)) {
|
|
202
|
+
salvaged = normalizeBoard(salv.board)
|
|
203
|
+
salvaged.ownerSession = sid // 抢救出的板归属本 sid(与 adoptBoard 同口径)
|
|
204
|
+
var cw = sessionCwd(sid)
|
|
205
|
+
if (!salvaged.ownerCwd && cw) salvaged.ownerCwd = cw
|
|
206
|
+
kept = (salvaged.tasks || []).length
|
|
207
|
+
}
|
|
208
|
+
if (salvaged) {
|
|
209
|
+
// 抢救成功:原始坏内容**复制**留档 .corrupt-<ts>(不动主板,撕裂尾部不丢),
|
|
210
|
+
// 再以截断后的完整 JSON 为底原子写回主板继续用;写回失败则下次读盘再抢救(幂等,不留空板空档)。
|
|
211
|
+
try { await fsNode.promises.copyFile(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板复制留档失败:', String(e)) }
|
|
212
|
+
try {
|
|
213
|
+
await wt(sid, salvaged)
|
|
214
|
+
teamModeCache[sid] = !!salvaged.teamMode
|
|
215
|
+
feedbackCache[sid] = salvaged.feedbackEnabled !== false
|
|
216
|
+
epicSplitCache[sid] = salvaged.epicSplit !== false
|
|
217
|
+
} catch (e) { console.error('[task-board] 抢救写回失败(原始文件已留档 ' + corruptPath + '):', String(e)) }
|
|
218
|
+
recordCorruptNote(sid, { at: ts, salvage: true, kept: kept, file: path.basename(corruptPath) })
|
|
219
|
+
console.error('[task-board] board file corrupt, salvaged (kept ' + kept + ' tasks), original quarantined: ' + corruptPath)
|
|
220
|
+
return salvaged
|
|
221
|
+
}
|
|
222
|
+
// 抢救失败:坏文件改名 .corrupt-<ts> 隔离(防反复 poison)+ 空板重启(禁止回写)+ 可见 hint + notify
|
|
223
|
+
try { await fsNode.promises.rename(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板隔离改名失败(下次读盘重试):', String(e)) }
|
|
224
|
+
recordCorruptNote(sid, { at: ts, salvage: false, kept: 0, file: path.basename(corruptPath) })
|
|
225
|
+
console.error('[task-board] board file corrupt, quarantined: ' + corruptPath)
|
|
226
|
+
return seedNoPersist(sid)
|
|
227
|
+
})
|
|
209
228
|
}
|
|
210
229
|
// 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
|
|
211
230
|
function recordCorruptNote(sid, note) {
|
|
@@ -237,7 +256,11 @@ export function createStore(ctx, state, deps) {
|
|
|
237
256
|
finally { await fh.close().catch(function () {}) }
|
|
238
257
|
}
|
|
239
258
|
async function wt(sid, d) {
|
|
240
|
-
var c = JSON.stringify(d); var p = boardPath(sid)
|
|
259
|
+
var c = JSON.stringify(d); var p = boardPath(sid)
|
|
260
|
+
// tmp 唯一名(P1 根修 task-mv1e17x9):pid + 时间戳 + 进程内自增序号——并发 wt() 各自写各自的 tmp,
|
|
261
|
+
// rename 装上的永远是**某一份**完整 JSON(旧实现共享 p+'.tmp':两个并发 chunked 写在同一 tmp 交错,
|
|
262
|
+
// rename 把拼接怪原子装上正位——原子 rename 防写一半,防不住 tmp 本身是拼的)。
|
|
263
|
+
var tmp = p + '.' + process.pid + '.' + Date.now() + '-' + (++tmpSeq) + '.tmp'
|
|
241
264
|
for (var attempt = 0; attempt < 6; attempt++) {
|
|
242
265
|
try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
|
|
243
266
|
catch (e) {
|
|
@@ -246,14 +269,15 @@ export function createStore(ctx, state, deps) {
|
|
|
246
269
|
await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
|
|
247
270
|
continue
|
|
248
271
|
}
|
|
272
|
+
await unlinkRetry(tmp, 2) // 非瞬时错误:清掉自己的 tmp 残件再抛(不堆积)
|
|
249
273
|
console.error('[task-board] write:', String(e)); throw e
|
|
250
274
|
}
|
|
251
275
|
}
|
|
252
276
|
// 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
|
|
253
277
|
console.error('[task-board] write: rename 直覆失败(重试耗尽),降级 unlink+rename: ' + p)
|
|
254
|
-
try { await writeTmpFsync(tmp, c) } catch (e) { console.error('[task-board] write:', String(e)); throw e }
|
|
278
|
+
try { await writeTmpFsync(tmp, c) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
|
|
255
279
|
await unlinkRetry(p, 6)
|
|
256
|
-
await fsNode.promises.rename(tmp, p)
|
|
280
|
+
try { await fsNode.promises.rename(tmp, p) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
|
|
257
281
|
}
|
|
258
282
|
// 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
|
|
259
283
|
function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
|
|
@@ -285,7 +309,7 @@ export function createStore(ctx, state, deps) {
|
|
|
285
309
|
}
|
|
286
310
|
if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
|
|
287
311
|
}
|
|
288
|
-
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
312
|
+
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid, true); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
289
313
|
|
|
290
314
|
return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
|
|
291
315
|
}
|
package/package.json
CHANGED
|
@@ -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
|
+
})
|