@mzzsfy/dsh-session-manager 0.7.1 → 0.9.0

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.
@@ -1,1968 +1,1730 @@
1
- // 路由层 BDD(mock ctx 强制 cordis 注入语义):宿主上下文上任何未声明 inject 的
2
- // 服务属性访问一律抛 cannot get property ... without inject,与 cordis reflect 行为
3
- // 一致,防止 inject 声明缺失回归。peer 依赖未安装的仓库环境(禁止 install)下整文件跳过。
4
-
5
- import test, { mock } from 'node:test'
6
- import assert from 'node:assert/strict'
7
- import { EventEmitter } from 'node:events'
8
- import { mkdtemp, writeFile, utimes, mkdir, rm, readFile } from 'node:fs/promises'
9
- import { mkdtempSync, rmSync } from 'node:fs'
10
- import { tmpdir } from 'node:os'
11
- import path from 'node:path'
12
-
13
- // 仅 peer 依赖缺失允许整文件跳过;其余加载失败(src 自身损坏)直接失败,禁止静默 skip
14
- const indexModule = await import('../src/index.js').catch((error) => {
15
- const missingPeer = error && error.code === 'ERR_MODULE_NOT_FOUND'
16
- && (error.message.includes("'@deepseek-ai/") || error.message.includes("'zod"))
17
- if (missingPeer) return error
18
- throw error
19
- })
20
- const apply = indexModule.apply
21
- const declaredInject = Array.isArray(indexModule.inject) ? indexModule.inject : []
22
- const dependencyReady = typeof apply === 'function'
23
- const skipMissingDeps = { skip: dependencyReady ? false : 'peer 依赖未安装,路由层测试跳过' }
24
- const MESSAGES = indexModule.MESSAGES
25
- const { DELETE_MESSAGES, HISTORY_INPUT_LIMIT, HISTORY_STARTUP_SCAN_LIMIT, HISTORY_INPUT_MAX_CHARS, HISTORY_PROMPTS_MAX, HISTORY_SCOPES, DEFAULT_AUTO_ARCHIVE_DAYS, DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS } = await import('../src/core.mjs').catch(() => ({ DELETE_MESSAGES: {} }))
26
- const { ensureCacheDir, writeWorkspaceCache } = await import('../src/history-cache.mjs')
27
-
28
- // 插件激活即跑历史缓存启动对齐:所有测试统一隔离缓存目录,
29
- // 防止夹具工作区经默认路径(~/.dsh/historyPrompt)泄漏进真实用户目录
30
- const sharedCacheDir = mkdtempSync(path.join(tmpdir(), 'sm-hist-shared-'))
31
- process.env.DSH_HISTORY_CACHE_DIR = sharedCacheDir
32
- test.after(() => { rmSync(sharedCacheDir, { recursive: true, force: true }) })
33
- // 台账/重挂载夹具路径仅作数据,不落盘;形态与实现一致(会话目录)
34
- const LEDGER_FIXTURE_PATH = 'C:\\store\\s1'
35
- // 幽灵产物夹具:tmpdir 下未创建的路径,跨平台 stat 必 ENOENT
36
- // (POSIX 对纯反斜杠 Windows 字面量取 dirname 会落回 '.',stat 成功使幽灵分支不可达)
37
- const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
38
- const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
39
-
40
- function makeRegistry(initialIds) {
41
- // 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
42
- // archiveSession 归档即入集合(官方幂等判定依据),桩保持同语义
43
- const registry = {
44
- state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
45
- list: () => [],
46
- archiveCalls: [],
47
- archiveSession: async (id) => {
48
- const sid = String(id)
49
- registry.archiveCalls.push(sid)
50
- registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
51
- },
52
- }
53
- Object.defineProperty(registry, 'archivedSessionIds', {
54
- get: () => registry.state.archivedSessionIds,
55
- })
56
- return registry
57
- }
58
-
59
- // workspace 域句柄:null 表示域未打开(get 返回 undefined)
60
- function makeDomain(initialIds) {
61
- const domain = {
62
- state: { archivedSessionIds: initialIds },
63
- writes: 0,
64
- global: {
65
- get: () => domain.state,
66
- set: async (next) => {
67
- domain.state = { ...next }
68
- domain.writes += 1
69
- },
70
- },
71
- }
72
- return domain
73
- }
74
-
75
- // 台账域句柄;openRejected 模拟台账域打开失败
76
- function makeLedgerDomain(entries) {
77
- const domain = {
78
- state: { deleted: entries },
79
- writes: 0,
80
- global: {
81
- get: () => domain.state,
82
- set: async (next) => {
83
- domain.state = { ...next }
84
- domain.writes += 1
85
- },
86
- },
87
- }
88
- return domain
89
- }
90
-
91
- // workspace 注册表实体桩:detach/attach 均为官方实体契约,供删除与重挂载断言
92
- function makeWorkspace(path, sessionIds, options = {}) {
93
- const workspace = {
94
- path,
95
- sessionIds,
96
- attachCalls: [],
97
- attachSession: async (id) => {
98
- if (options.attachError) throw options.attachError
99
- workspace.attachCalls.push(String(id))
100
- workspace.sessionIds = [String(id), ...workspace.sessionIds.filter((existing) => existing !== id)]
101
- },
102
- detachSession: async (id) => {
103
- if (options.detachError) throw options.detachError
104
- workspace.sessionIds = workspace.sessionIds.filter((existing) => String(existing) !== String(id))
105
- },
106
- }
107
- return workspace
108
- }
109
-
110
- function makeCtx({
111
- archivedIds,
112
- headers,
113
- agents,
114
- domain,
115
- sessionPersistence,
116
- ledger,
117
- openRejected,
118
- workspaces,
119
- settingsValue,
120
- timerAvailable,
121
- readSessions,
122
- }) {
123
- const routes = []
124
- const eventHandlers = {}
125
- const pendingInjects = []
126
- const ledgerDomain = ledger === undefined ? makeLedgerDomain([]) : ledger
127
- const workspaceList = workspaces || []
128
- const settingsState = { value: settingsValue }
129
- const settingsService = {
130
- get: () => settingsState.value,
131
- register: () => ({ resolved: undefined }),
132
- // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
133
- update: (ns, patch) => new Promise((resolve) => queueMicrotask(() => {
134
- settingsState.value = { ...(settingsState.value || {}), ...patch }
135
- resolve()
136
- })),
137
- }
138
- // timer 服务桩:模拟宿主 timer 激活后的 interval;unref 保证测试进程可自然退出
139
- const intervalStub = (fn, ms) => {
140
- const timer = setInterval(fn, ms)
141
- timer.unref()
142
- return () => clearInterval(timer)
143
- }
144
- // readSession 桩:sessionId → 事件数组(工厂形态则调用后抛错);reads 记录实际读取次数供缓存断言
145
- const readCounts = new Map()
146
- const sessionQuery = {
147
- listSessions: async () => headers.map((header) => ({ header })),
148
- readSession: async (sessionId) => {
149
- readCounts.set(sessionId, (readCounts.get(sessionId) || 0) + 1)
150
- const events = readSessions ? readSessions[sessionId] : undefined
151
- if (typeof events === 'function') throw events()
152
- if (events === undefined) throw new Error('桩未配置该会话产物')
153
- return { session: headers.find((header) => header.id === sessionId), events }
154
- },
155
- }
156
- const services = {
157
- webServer: { register: (route) => routes.push(route) },
158
- workspaceRegistry: Object.assign(makeRegistry(archivedIds), { list: () => workspaceList }),
159
- sessionQuery,
160
- storageDomain: {
161
- get: (name) => name === 'workspace' ? (domain || undefined) : undefined,
162
- open: openRejected ? () => Promise.reject(openRejected) : async () => ledgerDomain,
163
- },
164
- }
165
- const base = {
166
- effect: (fn) => fn(),
167
- inject: (_deps, fn) => { pendingInjects.push(fn) },
168
- get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
169
- on: (event, handler) => { eventHandlers[event] = handler },
170
- // 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
171
- logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
172
- }
173
- const ctx = new Proxy(base, {
174
- get(target, prop) {
175
- if (typeof prop === 'symbol') return undefined
176
- if (prop in target) return target[prop]
177
- if (!declaredInject.includes(prop)) {
178
- throw new Error(`cannot get property "${String(prop)}" without inject`)
179
- }
180
- return services[prop]
181
- },
182
- })
183
- apply(ctx, undefined)
184
- return {
185
- handlers: new Map(routes.map((route) => [route.path, route.handler])),
186
- eventHandlers,
187
- domain,
188
- ledger: ledgerDomain,
189
- registry: services.workspaceRegistry,
190
- logger: base.logger,
191
- readCounts,
192
- // 模拟宿主 settings 与 timer 服务激活:触发 inject 回调(注册 + 启动补扫 + 周期武装)
193
- activateSettings: () => {
194
- const injected = { settings: settingsService, interval: timerAvailable === false ? undefined : intervalStub }
195
- while (pendingInjects.length > 0) pendingInjects.shift()(injected)
196
- },
197
- }
198
- }
199
-
200
- async function waitFor(predicate) {
201
- // performance.now 不受 mock.timers 的 Date 接管影响,周期评估测试中仍可真实计时
202
- const deadline = performance.now() + 2000
203
- while (performance.now() < deadline) {
204
- if (predicate()) return
205
- await new Promise((resolve) => setTimeout(resolve, 10))
206
- }
207
- assert.ok(predicate(), 'waitFor 超时')
208
- }
209
-
210
- function request(sessionId, method = 'POST') {
211
- const req = new EventEmitter()
212
- req.method = method
213
- if (method !== 'GET') {
214
- process.nextTick(() => {
215
- req.emit('data', Buffer.from(JSON.stringify({ sessionId })))
216
- req.emit('end')
217
- })
218
- }
219
- return req
220
- }
221
-
222
- function response() {
223
- const state = { status: undefined, body: undefined }
224
- state.writeHead = (status) => { state.status = status }
225
- state.end = (text) => { state.body = JSON.parse(text) }
226
- return state
227
- }
228
-
229
- const HEADER = { id: 's1', cwd: 'C:\\x', createdAt: 0 }
230
-
231
- test('inject 声明覆盖路由层触及的全部宿主服务(cordis 未声明即抛 cannot get property)', skipMissingDeps, () => {
232
- for (const service of ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']) {
233
- assert.ok(declaredInject.includes(service), `inject 缺少 "${service}"`)
234
- }
235
- })
236
-
237
- test('请求体非法 JSON:协议错误与业务错误分通道,不透出引擎 SyntaxError', skipMissingDeps, async () => {
238
- const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
239
- const req = new EventEmitter()
240
- req.method = 'POST'
241
- process.nextTick(() => {
242
- req.emit('data', Buffer.from('{not json'))
243
- req.emit('end')
244
- })
245
- const res = response()
246
- await handlers.get('/api/session-manager/unarchive')(req, res)
247
- assert.equal(res.status, 400)
248
- assert.equal(res.body.error, '请求体不是合法 JSON')
249
- })
250
-
251
- test('自动归档评估门闩:评估进行中的新触发被丢弃不排队', skipMissingDeps, async () => {
252
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-'))
253
- try {
254
- const alivePath = path.join(dir, 'alive.jsonl')
255
- await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
256
- const DAY_MS = 24 * 60 * 60 * 1000
257
- const stale = Date.now() - 30 * DAY_MS
258
- await utimes(alivePath, stale / 1000, stale / 1000)
259
- const cwd = 'C:\\x'
260
- const headers = [{ id: 's1', cwd, createdAt: stale }]
261
- const { eventHandlers, registry } = makeCtx({
262
- archivedIds: [],
263
- headers,
264
- agents: new Map(),
265
- sessionPersistence: { locate: (header) => ({ path: alivePath }) },
266
- })
267
- eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
268
- // 首轮启动后立即补新候选并连发两触发:首轮候选列表已捕获(s1),门闩正常时
269
- // trigger2/3 被丢弃,s2 永不评估;门闩失效则 s2 被归档,断言可见
270
- headers.push({ id: 's2', cwd, createdAt: stale })
271
- eventHandlers['session/created']({ header: { id: 'trigger2', cwd } })
272
- eventHandlers['session/created']({ header: { id: 'trigger3', cwd } })
273
- await waitFor(() => registry.archiveCalls.length > 0)
274
- // 给在途与迟到轮留完成窗口后断言:仅首轮产出,s2 未被任何一轮归档
275
- await new Promise((resolve) => setTimeout(resolve, 150))
276
- assert.deepEqual(registry.archiveCalls, ['s1'], '门闩应丢弃在途触发,新候选 s2 不被评估')
277
- } finally {
278
- await rm(dir, { recursive: true, force: true })
279
- }
280
- })
281
-
282
- test('并发删除:台账串行化不丢条目', skipMissingDeps, async () => {
283
- const artifactA = await makeLocatedArtifact('a')
284
- const artifactB = await makeLocatedArtifact('b')
285
- try {
286
- const headers = [
287
- { id: 'a', cwd: 'C:\\x', createdAt: 0 },
288
- { id: 'b', cwd: 'C:\\x', createdAt: 0 },
289
- ]
290
- const workspace = makeWorkspace('C:\\x', ['a', 'b'])
291
- const { handlers, ledger } = makeCtx({
292
- archivedIds: ['a', 'b'],
293
- headers,
294
- agents: new Map([['a', { status: 'idle' }], ['b', { status: 'idle' }]]),
295
- domain: makeDomain(['a', 'b']),
296
- sessionPersistence: { locate: (header) => header.id === 'a' ? { path: artifactA.locatedPath } : { path: artifactB.locatedPath } },
297
- workspaces: [workspace],
298
- })
299
- const resA = response()
300
- const resB = response()
301
- await Promise.all([
302
- withTrashStub(async () => {}, async () => {
303
- await handlers.get('/api/session-manager/delete')(request('a'), resA)
304
- }),
305
- withTrashStub(async () => {}, async () => {
306
- await handlers.get('/api/session-manager/delete')(request('b'), resB)
307
- }),
308
- ])
309
- assert.equal(resA.status, 200)
310
- assert.equal(resB.status, 200)
311
- // 串行化后两条台账都在;丢失更新会让后写者只剩自己的条目
312
- assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId).sort(), ['a', 'b'])
313
- } finally {
314
- await artifactA.cleanup()
315
- await artifactB.cleanup()
316
- }
317
- })
318
-
319
- test('同 id 并发删除:第二个请求收到 in-flight 提示', skipMissingDeps, async () => {
320
- const artifact = await makeLocatedArtifact()
321
- try {
322
- let releaseTrash
323
- const trashGate = new Promise((resolve) => { releaseTrash = resolve })
324
- const workspace = makeWorkspace('C:\\x', ['s1'])
325
- const { handlers } = makeCtx({
326
- archivedIds: ['s1'],
327
- headers: [HEADER],
328
- agents: IDLE_S1,
329
- domain: makeDomain(['s1']),
330
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
331
- workspaces: [workspace],
332
- })
333
- const first = response()
334
- const firstDone = withTrashStub(async () => { await trashGate }, async () => {
335
- await handlers.get('/api/session-manager/delete')(request('s1'), first)
336
- })
337
- // 等 trash 进入挂起后再发第二发
338
- await new Promise((resolve) => setTimeout(resolve, 20))
339
- const second = response()
340
- await handlers.get('/api/session-manager/delete')(request('s1'), second)
341
- assert.equal(second.status, 400)
342
- assert.equal(second.body.error, '该会话正在删除中,请稍后重试')
343
- releaseTrash()
344
- await firstDone
345
- } finally {
346
- await artifact.cleanup()
347
- }
348
- })
349
-
350
- test('多工作区 detach:首个失败不终止后续,全部失败进日志', skipMissingDeps, async () => {
351
- const artifact = await makeLocatedArtifact()
352
- try {
353
- const broken = makeWorkspace('C:\\one', ['s1'], { detachError: new Error('one boom') })
354
- const healthy = makeWorkspace('C:\\two', ['s1'])
355
- const { handlers } = makeCtx({
356
- archivedIds: ['s1'],
357
- headers: [HEADER],
358
- agents: IDLE_S1,
359
- domain: makeDomain(['s1']),
360
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
361
- workspaces: [broken, healthy],
362
- })
363
- const res = response()
364
- await withTrashStub(async () => {}, async () => {
365
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
366
- })
367
- assert.equal(res.status, 200)
368
- assert.equal(res.body.partial, true)
369
- assert.equal(res.body.message, '已移入回收站,但移除列表记录失败')
370
- // 健康工作区仍被解除关联
371
- assert.deepEqual(healthy.sessionIds, [])
372
- } finally {
373
- await artifact.cleanup()
374
- }
375
- })
376
-
377
- test('取消归档:域领先快照时反向合并清理', skipMissingDeps, async () => {
378
- const domain = makeDomain(['s1'])
379
- const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
380
- const res = response()
381
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
382
- assert.equal(res.status, 200)
383
- assert.deepEqual(res.body, { ok: true })
384
- // durable 有而快照无:合并后仍完成移除,防漏清理
385
- assert.deepEqual(domain.state.archivedSessionIds, [])
386
- assert.equal(domain.writes, 1)
387
- assert.deepEqual(registry.state.archivedSessionIds, [])
388
- })
389
-
390
- test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
391
- const artifact = await makeLocatedArtifact()
392
- try {
393
- const before = Date.now()
394
- const { handlers, ledger } = makeCtx({
395
- archivedIds: ['s1'],
396
- headers: [HEADER],
397
- agents: IDLE_S1,
398
- domain: makeDomain(['s1']),
399
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
400
- })
401
- const res = response()
402
- await withTrashStub(async () => {}, async () => {
403
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
404
- })
405
- assert.equal(res.status, 200)
406
- const deletedAt = ledger.state.deleted[0].deletedAt
407
- assert.ok(typeof deletedAt === 'number' && deletedAt >= before && deletedAt <= Date.now() + 1000)
408
- } finally {
409
- await artifact.cleanup()
410
- }
411
- })
412
-
413
- test('取消归档路由:从域集合移除目标 id 并写回', skipMissingDeps, async () => {
414
- const domain = makeDomain(['s1', 's2'])
415
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
416
- const res = response()
417
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
418
- assert.equal(res.status, 200)
419
- assert.deepEqual(res.body, { ok: true })
420
- assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
421
- assert.equal(domain.writes, 1)
422
- // 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
423
- assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
424
- assert.deepEqual(registry.archivedSessionIds, ['s2'])
425
- })
426
-
427
- test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
428
- const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
429
- const res = response()
430
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
431
- assert.equal(res.status, 400)
432
- assert.equal(res.body.error, 'workspace 域未打开')
433
- })
434
-
435
- test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
436
- const domain = makeDomain(['s1'])
437
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
438
- const res = response()
439
- await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
440
- assert.equal(res.status, 200)
441
- assert.deepEqual(res.body, { ok: true })
442
- assert.deepEqual(domain.state.archivedSessionIds, ['s1'])
443
- assert.equal(domain.writes, 0)
444
- assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
445
- })
446
-
447
- test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
448
- const domain = makeDomain([])
449
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
450
- const res = response()
451
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
452
- assert.equal(res.status, 200)
453
- assert.deepEqual(res.body, { ok: true })
454
- assert.deepEqual(domain.state.archivedSessionIds, [])
455
- assert.equal(domain.writes, 1)
456
- assert.deepEqual(registry.state.archivedSessionIds, [])
457
- })
458
-
459
- test('info 路由:会话不存在拒绝', skipMissingDeps, async () => {
460
- const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
461
- const res = response()
462
- await handlers.get('/api/session-manager/info')(request('s1'), res)
463
- assert.equal(res.status, 400)
464
- assert.equal(res.body.error, '会话不存在')
465
- })
466
-
467
- test('info 路由:locate 缺失按不支持返回', skipMissingDeps, async () => {
468
- const { handlers } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map() })
469
- const res = response()
470
- await handlers.get('/api/session-manager/info')(request('s1'), res)
471
- assert.equal(res.status, 200)
472
- assert.deepEqual(res.body, { supported: false })
473
- })
474
-
475
- test('info 路由:产物已缺失按 missing 返回,不裸抛', skipMissingDeps, async () => {
476
- const { handlers } = makeCtx({
477
- archivedIds: [],
478
- headers: [HEADER],
479
- agents: new Map(),
480
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
481
- })
482
- const res = response()
483
- await handlers.get('/api/session-manager/info')(request('s1'), res)
484
- assert.equal(res.status, 200)
485
- assert.deepEqual(res.body, { supported: true, sizeBytes: 0, missing: true })
486
- })
487
-
488
- test('删除路由:运行中会话被守卫拒绝,不触发 locate 与 trash', skipMissingDeps, async () => {
489
- const { handlers } = makeCtx({
490
- archivedIds: ['s1'],
491
- headers: [HEADER],
492
- agents: new Map([['s1', { status: 'running' }]]),
493
- })
494
- const res = response()
495
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
496
- assert.equal(res.status, 400)
497
- assert.equal(res.body.error, '运行中的会话不可删除')
498
- })
499
-
500
- test('删除路由:非运行中的已归档会话因 locate 缺失拒绝(守卫未误拦)', skipMissingDeps, async () => {
501
- const { handlers } = makeCtx({
502
- archivedIds: ['s1'],
503
- headers: [HEADER],
504
- agents: new Map([['s1', { status: 'idle' }]]),
505
- })
506
- const res = response()
507
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
508
- assert.equal(res.status, 400)
509
- assert.equal(res.body.error, '当前存储后端不支持按会话删除')
510
- })
511
-
512
- test('删除路由:未归档会话拒绝(运行守卫优先于资格)', skipMissingDeps, async () => {
513
- const { handlers } = makeCtx({
514
- archivedIds: [],
515
- headers: [HEADER],
516
- agents: new Map([['s1', { status: 'running' }]]),
517
- })
518
- const res = response()
519
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
520
- assert.equal(res.status, 400)
521
- assert.equal(res.body.error, '运行中的会话不可删除')
522
- })
523
-
524
- test('删除路由:未归档且空闲的会话拒绝', skipMissingDeps, async () => {
525
- const artifact = await makeLocatedArtifact()
526
- try {
527
- const { handlers } = makeCtx({
528
- archivedIds: [],
529
- headers: [HEADER],
530
- agents: new Map([['s1', { status: 'idle' }]]),
531
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
532
- })
533
- const res = response()
534
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
535
- assert.equal(res.status, 400)
536
- assert.equal(res.body.error, '仅已归档会话可删除')
537
- } finally {
538
- await artifact.cleanup()
539
- }
540
- })
541
-
542
- test('自动归档评估:产物不可读的会话不参与归档(防删除后复活)', skipMissingDeps, async () => {
543
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-'))
544
- const alivePath = path.join(dir, 'alive.jsonl')
545
- await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
546
- const DAY_MS = 24 * 60 * 60 * 1000
547
- const stale = Date.now() - 30 * DAY_MS
548
- await utimes(alivePath, stale / 1000, stale / 1000)
549
- const cwd = 'C:\\x'
550
- const headers = [
551
- { id: 's1', cwd, createdAt: stale },
552
- { id: 's2', cwd, createdAt: stale },
553
- ]
554
- const sessionPersistence = {
555
- locate: (header) => header.id === 's1' ? { path: alivePath } : { path: path.join(dir, 'gone.jsonl') },
556
- }
557
- const { eventHandlers, registry, logger } = makeCtx({
558
- archivedIds: [],
559
- headers,
560
- agents: new Map(),
561
- sessionPersistence,
562
- })
563
- eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
564
- await waitFor(() => registry.archiveCalls.length > 0)
565
- // s2 产物缺失必须被守卫跳过:否则已删除会话会被重新归档而在面板复活
566
- assert.deepEqual(registry.archiveCalls, ['s1'])
567
- // 归档成功须落可追溯日志(含会话 id)
568
- await waitFor(() => logger.infos.length > 0)
569
- assert.ok(logger.infos.some((message) => message.includes('s1')), '归档成功应落 info 日志')
570
- })
571
-
572
- test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档', skipMissingDeps, async () => {
573
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-nolocate-'))
574
- try {
575
- const alivePath = path.join(dir, 'alive.jsonl')
576
- await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
577
- const DAY_MS = 24 * 60 * 60 * 1000
578
- const stale = Date.now() - 30 * DAY_MS
579
- await utimes(alivePath, stale / 1000, stale / 1000)
580
- const cwd = 'C:\\x'
581
- const headers = [
582
- { id: 's1', cwd, createdAt: stale },
583
- { id: 's2', cwd, createdAt: stale },
584
- ]
585
- const { eventHandlers, registry } = makeCtx({
586
- archivedIds: [],
587
- headers,
588
- agents: new Map(),
589
- // s2 locate 返回 undefined(抽象后端契约允许):仅凭 createdAt 判活跃会绕过
590
- // mtime 保护,与 delete 的 unsupportedBackend 拒绝对齐
591
- sessionPersistence: {
592
- locate: (header) => header.id === 's1' ? { path: alivePath } : undefined,
593
- },
594
- })
595
- eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
596
- await waitFor(() => registry.archiveCalls.length > 0)
597
- assert.deepEqual(registry.archiveCalls, ['s1'])
598
- } finally {
599
- await rm(dir, { recursive: true, force: true })
600
- }
601
- })
602
-
603
- test('自动归档评估:subagent 内部会话不参与归档(官方主列表不收录 subagent)', skipMissingDeps, async () => {
604
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-subagent-'))
605
- try {
606
- const plainPath = path.join(dir, 'plain.jsonl')
607
- const childPath = path.join(dir, 'child.jsonl')
608
- await writeFile(plainPath, '{"header":1}\n{"event":0}\n')
609
- await writeFile(childPath, '{"header":1}\n{"event":0}\n')
610
- const DAY_MS = 24 * 60 * 60 * 1000
611
- const stale = Date.now() - 30 * DAY_MS
612
- await utimes(plainPath, stale / 1000, stale / 1000)
613
- await utimes(childPath, stale / 1000, stale / 1000)
614
- const cwd = 'C:\\x'
615
- const headers = [
616
- { id: 'plain', cwd, createdAt: stale },
617
- { id: 'child', cwd, createdAt: stale, origin: 'subagent', parentSession: 'parent' },
618
- ]
619
- const { eventHandlers, registry } = makeCtx({
620
- archivedIds: [],
621
- headers,
622
- agents: new Map(),
623
- sessionPersistence: {
624
- locate: (header) => ({ path: header.id === 'plain' ? plainPath : childPath }),
625
- },
626
- })
627
- eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
628
- await waitFor(() => registry.archiveCalls.length > 0)
629
- assert.deepEqual(registry.archiveCalls, ['plain'], 'subagent 会话永不入选归档')
630
- } finally {
631
- await rm(dir, { recursive: true, force: true })
632
- }
633
- })
634
-
635
- test('启动评估:settings 就绪后全量归档超期会话,不限于单工作区', skipMissingDeps, async () => {
636
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-startup-'))
637
- try {
638
- const artifactA = path.join(dir, 'a.jsonl')
639
- const artifactB = path.join(dir, 'b.jsonl')
640
- await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
641
- await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
642
- const DAY_MS = 24 * 60 * 60 * 1000
643
- const stale = Date.now() - 30 * DAY_MS
644
- await utimes(artifactA, stale / 1000, stale / 1000)
645
- await utimes(artifactB, stale / 1000, stale / 1000)
646
- const headers = [
647
- { id: 'a', cwd: 'C:\\x', createdAt: stale },
648
- { id: 'b', cwd: 'C:\\y', createdAt: stale },
649
- ]
650
- const { activateSettings, registry, handlers } = makeCtx({
651
- archivedIds: [],
652
- headers,
653
- agents: new Map(),
654
- sessionPersistence: {
655
- locate: (header) => ({ path: header.id === 'a' ? artifactA : artifactB }),
656
- },
657
- })
658
- activateSettings()
659
- await waitFor(() => registry.archiveCalls.length >= 2)
660
- assert.deepEqual([...registry.archiveCalls].sort(), ['a', 'b'])
661
- const status = response()
662
- await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
663
- assert.equal(status.status, 200)
664
- assert.equal(status.body.periodic.running, true)
665
- } finally {
666
- await rm(dir, { recursive: true, force: true })
667
- }
668
- })
669
-
670
- test('启动评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
671
- const { activateSettings, registry } = makeCtx({
672
- archivedIds: [],
673
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
674
- agents: new Map(),
675
- settingsValue: { autoArchiveDays: 0 },
676
- })
677
- activateSettings()
678
- await new Promise((resolve) => setTimeout(resolve, 50))
679
- assert.deepEqual(registry.archiveCalls, [])
680
- })
681
-
682
- test('周期评估:每日定时补扫首轮不可评估的会话', skipMissingDeps, async () => {
683
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-period-'))
684
- // 夹具 mtime 用真实时钟落盘,之后接管 Date 与 setInterval:到期判断(nextDueAt)
685
- // 与定时器触发共用同一虚拟时钟,tick 推进 24h 即触发周期轮
686
- const artifactA = path.join(dir, 'a.jsonl')
687
- const artifactB = path.join(dir, 'b.jsonl')
688
- await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
689
- await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
690
- const DAY_MS = 24 * 60 * 60 * 1000
691
- const realNow = Date.now()
692
- const stale = realNow - 30 * DAY_MS
693
- await utimes(artifactA, stale / 1000, stale / 1000)
694
- await utimes(artifactB, stale / 1000, stale / 1000)
695
- // 显式传 now:mock.timers 接管 Date 后从 epoch 0 起算,不传则夹具时间戳全成「未来」
696
- mock.timers.enable({ apis: ['setInterval', 'Date'], now: realNow })
697
- try {
698
- // 首轮 b 产物不可读(守卫跳过),周期轮起可读:b 只能经周期评估归档
699
- let bReadable = false
700
- const { activateSettings, registry } = makeCtx({
701
- archivedIds: [],
702
- headers: [
703
- { id: 'a', cwd: 'C:\\x', createdAt: stale },
704
- { id: 'b', cwd: 'C:\\x', createdAt: stale },
705
- ],
706
- agents: new Map(),
707
- sessionPersistence: {
708
- locate: (header) => (header.id === 'b' && !bReadable)
709
- ? { path: path.join(dir, 'gone.jsonl') }
710
- : { path: header.id === 'a' ? artifactA : artifactB },
711
- },
712
- })
713
- activateSettings()
714
- await waitFor(() => registry.archiveCalls.length > 0)
715
- assert.deepEqual(registry.archiveCalls, ['a'])
716
- bReadable = true
717
- mock.timers.tick(DAY_MS)
718
- await waitFor(() => registry.archiveCalls.includes('b'))
719
- // 桩回写 archivedSessionIds(真实注册表契约):已归档 a 不被周期轮重复选中
720
- assert.deepEqual(registry.archiveCalls, ['a', 'b'])
721
- } finally {
722
- mock.timers.reset()
723
- await rm(dir, { recursive: true, force: true })
724
- }
725
- })
726
-
727
- test('周期评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
728
- mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
729
- try {
730
- const { activateSettings, registry } = makeCtx({
731
- archivedIds: [],
732
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
733
- agents: new Map(),
734
- settingsValue: { autoArchiveDays: 0 },
735
- })
736
- activateSettings()
737
- mock.timers.tick(24 * 60 * 60 * 1000)
738
- await new Promise((resolve) => setTimeout(resolve, 50))
739
- assert.deepEqual(registry.archiveCalls, [])
740
- } finally {
741
- mock.timers.reset()
742
- }
743
- })
744
-
745
- test('周期评估:间隔设置运行时变更经 tick 对账(0 重启用 / 关闭即时暂停)', skipMissingDeps, async () => {
746
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-rearm-'))
747
- // settings 桩返回同一对象引用:测试中改字段即模拟用户运行时改设置
748
- const settingsValue = { autoArchiveIntervalHours: 0 }
749
- mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
750
- try {
751
- const artifacts = {}
752
- const DAY_MS = 24 * 60 * 60 * 1000
753
- const TICK_MS = 60 * 1000
754
- const stale = Date.now() - 30 * DAY_MS
755
- const readable = { s1: true, s2: false, s3: false }
756
- for (const id of ['s1', 's2', 's3']) {
757
- const artifact = path.join(dir, id + '.jsonl')
758
- await writeFile(artifact, '{"header":1}\n{"event":0}\n')
759
- await utimes(artifact, stale / 1000, stale / 1000)
760
- artifacts[id] = artifact
761
- }
762
- const headers = [
763
- { id: 's1', cwd: 'C:\\x', createdAt: stale },
764
- { id: 's2', cwd: 'C:\\x', createdAt: stale },
765
- { id: 's3', cwd: 'C:\\x', createdAt: stale },
766
- ]
767
- const { activateSettings, registry } = makeCtx({
768
- archivedIds: [],
769
- headers,
770
- agents: new Map(),
771
- settingsValue,
772
- sessionPersistence: {
773
- locate: (header) => ({ path: readable[header.id] ? artifacts[header.id] : path.join(dir, 'gone-' + header.id) }),
774
- },
775
- })
776
- activateSettings()
777
- // 启动补扫只看 autoArchiveDays(默认 7),intervalHours=0 不影响首轮
778
- await waitFor(() => registry.archiveCalls.length > 0)
779
- assert.deepEqual(registry.archiveCalls, ['s1'])
780
- // intervalHours=0 期间周期轮保持关闭:到期也不评估
781
- readable.s2 = true
782
- mock.timers.tick(DAY_MS)
783
- await new Promise((resolve) => setTimeout(resolve, 50))
784
- assert.deepEqual(registry.archiveCalls, ['s1'], '关闭状态下到期不得评估')
785
- // 0 重启用:缺失 due 经 tick 对账补排,最迟下个 tick 恢复周期轮
786
- settingsValue.autoArchiveIntervalHours = 24
787
- mock.timers.tick(TICK_MS)
788
- await waitFor(() => registry.archiveCalls.includes('s2'))
789
- assert.deepEqual(registry.archiveCalls, ['s1', 's2'])
790
- // 再次关闭:即时暂停,s3 不被评估
791
- readable.s3 = true
792
- settingsValue.autoArchiveIntervalHours = 0
793
- mock.timers.tick(DAY_MS)
794
- await new Promise((resolve) => setTimeout(resolve, 50))
795
- assert.deepEqual(registry.archiveCalls, ['s1', 's2'], '再次关闭后到期不得评估')
796
- // 第三次重启用:对账恢复,归档 s3
797
- settingsValue.autoArchiveIntervalHours = 24
798
- mock.timers.tick(TICK_MS)
799
- await waitFor(() => registry.archiveCalls.includes('s3'))
800
- assert.deepEqual(registry.archiveCalls, ['s1', 's2', 's3'])
801
- } finally {
802
- mock.timers.reset()
803
- await rm(dir, { recursive: true, force: true })
804
- }
805
- })
806
-
807
- test('周期评估:缩短间隔经 earliest 钳制前移,不等满旧周期', skipMissingDeps, async () => {
808
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-shorten-'))
809
- const TICK_MS = 60 * 1000
810
- const HOUR_MS = 60 * 60 * 1000
811
- mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
812
- try {
813
- const readable = { a: true, b: false }
814
- const artifacts = {}
815
- const DAY_MS = 24 * 60 * 60 * 1000
816
- const stale = Date.now() - 30 * DAY_MS
817
- for (const id of ['a', 'b']) {
818
- const artifact = path.join(dir, id + '.jsonl')
819
- await writeFile(artifact, '{"header":1}\n{"event":0}\n')
820
- await utimes(artifact, stale / 1000, stale / 1000)
821
- artifacts[id] = artifact
822
- }
823
- const settingsValue = { autoArchiveIntervalHours: 24 }
824
- const { activateSettings, registry } = makeCtx({
825
- archivedIds: [],
826
- headers: [
827
- { id: 'a', cwd: 'C:\\x', createdAt: stale },
828
- { id: 'b', cwd: 'C:\\x', createdAt: stale },
829
- ],
830
- agents: new Map(),
831
- settingsValue,
832
- sessionPersistence: {
833
- locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
834
- },
835
- })
836
- activateSettings()
837
- await waitFor(() => registry.archiveCalls.length > 0)
838
- assert.deepEqual(registry.archiveCalls, ['a'])
839
- const afterStartup = registry.archiveCalls.length
840
- // T+24h 到期轮(a 已归档无产出);紧接缩短为 1h:earliest 钳制应把 due 前移到
841
- // 缩短时刻 + 1h,而非等满旧周期 T+48h;b 在缩短后变可读,由前移后的轮归档
842
- mock.timers.tick(DAY_MS)
843
- settingsValue.autoArchiveIntervalHours = 1
844
- readable.b = true
845
- mock.timers.tick(DAY_MS + TICK_MS)
846
- await waitFor(() => registry.archiveCalls.length > afterStartup)
847
- assert.deepEqual(registry.archiveCalls, ['a', 'b'])
848
- } finally {
849
- mock.timers.reset()
850
- await rm(dir, { recursive: true, force: true })
851
- }
852
- })
853
-
854
- test('定时服务缺失:周期评估降级,状态接口提示且不影响启动补扫', skipMissingDeps, async () => {
855
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-degrade-'))
856
- try {
857
- const artifact = path.join(dir, 'a.jsonl')
858
- await writeFile(artifact, '{"header":1}\n{"event":0}\n')
859
- const DAY_MS = 24 * 60 * 60 * 1000
860
- const stale = Date.now() - 30 * DAY_MS
861
- await utimes(artifact, stale / 1000, stale / 1000)
862
- const { activateSettings, handlers, registry } = makeCtx({
863
- archivedIds: [],
864
- headers: [{ id: 'a', cwd: 'C:\\x', createdAt: stale }],
865
- agents: new Map(),
866
- sessionPersistence: { locate: () => ({ path: artifact }) },
867
- timerAvailable: false,
868
- })
869
- activateSettings()
870
- // 启动补扫照常工作:软依赖缺失只影响周期轮
871
- await waitFor(() => registry.archiveCalls.length > 0)
872
- assert.deepEqual(registry.archiveCalls, ['a'])
873
- const status = response()
874
- await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
875
- assert.equal(status.status, 200)
876
- assert.equal(status.body.periodic.running, false)
877
- assert.notEqual(status.body.periodic.reason, '')
878
- } finally {
879
- await rm(dir, { recursive: true, force: true })
880
- }
881
- })
882
-
883
- test('周期评估:到期 tick 恰逢门闩占用时保持到期态,下个 tick 重试而非推后一个周期', skipMissingDeps, async () => {
884
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-tick-'))
885
- const TICK_MS = 60 * 1000
886
- const DAY_MS = 24 * 60 * 60 * 1000
887
- mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
888
- try {
889
- const readable = { a: true, b: false, c: false }
890
- const artifacts = {}
891
- const stale = Date.now() - 30 * DAY_MS
892
- for (const id of ['a', 'b', 'c']) {
893
- const artifact = path.join(dir, id + '.jsonl')
894
- await writeFile(artifact, '{"header":1}\n{"event":0}\n')
895
- await utimes(artifact, stale / 1000, stale / 1000)
896
- artifacts[id] = artifact
897
- }
898
- const headers = [
899
- { id: 'a', cwd: 'C:\\x', createdAt: stale },
900
- { id: 'b', cwd: 'C:\\x', createdAt: stale },
901
- { id: 'c', cwd: 'C:\\x', createdAt: stale },
902
- ]
903
- const { activateSettings, eventHandlers, registry } = makeCtx({
904
- archivedIds: [],
905
- headers,
906
- agents: new Map(),
907
- sessionPersistence: {
908
- locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
909
- },
910
- })
911
- activateSettings()
912
- // 启动补扫:a 归档(桩回写 archived),b/c 不可读留候选
913
- await waitFor(() => registry.archiveCalls.length > 0)
914
- assert.deepEqual(registry.archiveCalls, ['a'])
915
- // 挂起归档执行器,再触发一轮评估:门闩被占
916
- let releaseArchive
917
- const held = new Promise((resolve) => { releaseArchive = resolve })
918
- const originalArchive = registry.archiveSession
919
- let archiveArrived = false
920
- registry.archiveSession = async (id) => {
921
- archiveArrived = true
922
- await held
923
- await originalArchive(id)
924
- }
925
- readable.b = true
926
- eventHandlers['session/created']({ header: headers[1] })
927
- await waitFor(() => archiveArrived)
928
- assert.deepEqual(registry.archiveCalls, ['a'], '挂起的 b 尚未计入归档调用')
929
- // 到期 tick 恰逢门闩占用:被丢弃,不得把周期推后 24h(保持到期态)
930
- mock.timers.tick(DAY_MS)
931
- // 释放挂起评估并让出事件循环,确保门闩复位(evaluating=false)落定
932
- releaseArchive()
933
- await new Promise((resolve) => setImmediate(resolve))
934
- await new Promise((resolve) => setTimeout(resolve, 0))
935
- // c 变可读,60s 后的下个 tick 即应重试并归档 c(若周期被推后 24h 此步超时)
936
- readable.c = true
937
- mock.timers.tick(TICK_MS)
938
- await waitFor(() => registry.archiveCalls.includes('c'))
939
- assert.deepEqual(registry.archiveCalls, ['a', 'b', 'c'])
940
- } finally {
941
- mock.timers.reset()
942
- await rm(dir, { recursive: true, force: true })
943
- }
944
- })
945
-
946
- // 执行器桩替:trash 为进程级唯一 OS 副作用出口,经 executor 注册表注入(README 已知测试缺口的基建扩展)
947
- async function withTrashStub(stub, run) {
948
- const original = indexModule.executor.trashPath
949
- indexModule.executor.trashPath = stub
950
- try {
951
- await run()
952
- } finally {
953
- indexModule.executor.trashPath = original
954
- }
955
- }
956
-
957
- const IDLE_S1 = new Map([['s1', { status: 'idle' }]])
958
-
959
- // locate 契约实证:返回会话目录下的日志文件;stat 为真实 fs,产物以临时文件承载
960
- async function makeLocatedArtifact(sessionId = 's1') {
961
- const root = await mkdtemp(path.join(tmpdir(), 'sm-del-'))
962
- const locatedDir = path.join(root, sessionId)
963
- const locatedPath = path.join(locatedDir, 'session.jsonl.zstd')
964
- await mkdir(locatedDir, { recursive: true })
965
- await writeFile(locatedPath, 'log-bytes')
966
- return {
967
- locatedDir,
968
- locatedPath,
969
- cleanup: () => rm(root, { recursive: true, force: true }),
970
- }
971
- }
972
-
973
- test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMissingDeps, async () => {
974
- const artifact = await makeLocatedArtifact()
975
- try {
976
- const domain = makeDomain(['s1'])
977
- const workspace = makeWorkspace('C:\\x', ['s1'])
978
- const { handlers, ledger } = makeCtx({
979
- archivedIds: ['s1'],
980
- headers: [HEADER],
981
- agents: IDLE_S1,
982
- domain,
983
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
984
- workspaces: [workspace],
985
- })
986
- const trashed = []
987
- const res = response()
988
- await withTrashStub(async (p) => { trashed.push(p) }, async () => {
989
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
990
- })
991
- assert.equal(res.status, 200)
992
- assert.deepEqual(res.body, { ok: true })
993
- // locate 给的是日志文件,trash 必须上移到会话目录,否则残留空目录
994
- assert.deepEqual(trashed, [artifact.locatedDir])
995
- assert.equal(ledger.state.deleted.length, 1)
996
- assert.equal(ledger.state.deleted[0].sessionId, 's1')
997
- assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
998
- assert.deepEqual(domain.state.archivedSessionIds, [])
999
- assert.deepEqual(workspace.sessionIds, [])
1000
- } finally {
1001
- await artifact.cleanup()
1002
- }
1003
- })
1004
-
1005
- test('删除成功:同 id 残留台账被替换,无重复条目', skipMissingDeps, async () => {
1006
- const artifact = await makeLocatedArtifact()
1007
- try {
1008
- const { handlers, ledger } = makeCtx({
1009
- archivedIds: ['s1'],
1010
- headers: [HEADER],
1011
- agents: IDLE_S1,
1012
- domain: makeDomain(['s1']),
1013
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1014
- ledger: makeLedgerDomain([{ sessionId: 's1', path: 'old', deletedAt: 1 }]),
1015
- })
1016
- const res = response()
1017
- await withTrashStub(async () => {}, async () => {
1018
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1019
- })
1020
- assert.equal(res.status, 200)
1021
- assert.deepEqual(res.body, { ok: true })
1022
- assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
1023
- assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
1024
- } finally {
1025
- await artifact.cleanup()
1026
- }
1027
- })
1028
-
1029
- test('删除:产物已缺失时跳过 trash 与台账,完成列表清理(同 id 重删场景)', skipMissingDeps, async () => {
1030
- const domain = makeDomain([])
1031
- const workspace = makeWorkspace('C:\\x', ['s1'])
1032
- const { handlers, ledger } = makeCtx({
1033
- // 幽灵会话已不在归档集合:残留清理不要求归档资格,但要求台账有记录
1034
- // (首删已记录,重删走幽灵收尾)
1035
- archivedIds: [],
1036
- headers: [HEADER],
1037
- agents: IDLE_S1,
1038
- domain,
1039
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1040
- ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1041
- workspaces: [workspace],
1042
- })
1043
- const trashed = []
1044
- const res = response()
1045
- await withTrashStub(async (p) => { trashed.push(p) }, async () => {
1046
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1047
- })
1048
- assert.equal(res.status, 200)
1049
- assert.deepEqual(res.body, { ok: true, message: MESSAGES.ghostCleanup })
1050
- assert.deepEqual(trashed, [])
1051
- assert.equal(ledger.writes, 0)
1052
- // 残留清理仍解除工作区关联与归档集合(幂等)
1053
- assert.deepEqual(workspace.sessionIds, [])
1054
- assert.deepEqual(domain.state.archivedSessionIds, [])
1055
- })
1056
-
1057
- test('删除:产物缺失且无归档记录无台账时拒绝(防新建会话未落盘被剥离)', skipMissingDeps, async () => {
1058
- const workspace = makeWorkspace('C:\\x', ['s1'])
1059
- const { handlers } = makeCtx({
1060
- archivedIds: [],
1061
- headers: [HEADER],
1062
- agents: IDLE_S1,
1063
- domain: makeDomain([]),
1064
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1065
- workspaces: [workspace],
1066
- })
1067
- const res = response()
1068
- await withTrashStub(async () => {}, async () => {
1069
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1070
- })
1071
- assert.equal(res.status, 400)
1072
- assert.equal(res.body.error, MESSAGES.notArchived)
1073
- // 拒绝路径不动工作区关联
1074
- assert.deepEqual(workspace.sessionIds, ['s1'])
1075
- })
1076
-
1077
- test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, async () => {
1078
- const { handlers } = makeCtx({
1079
- archivedIds: [],
1080
- headers: [HEADER],
1081
- agents: new Map([['s1', { status: 'running' }]]),
1082
- domain: makeDomain([]),
1083
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1084
- })
1085
- const res = response()
1086
- await withTrashStub(async () => {}, async () => {
1087
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1088
- })
1089
- assert.equal(res.status, 400)
1090
- assert.equal(res.body.error, MESSAGES.running)
1091
- })
1092
-
1093
- test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDeps, async () => {
1094
- const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1095
- const { handlers, logger } = makeCtx({
1096
- archivedIds: [],
1097
- headers: [HEADER],
1098
- agents: IDLE_S1,
1099
- domain: null,
1100
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1101
- ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1102
- workspaces: [workspace],
1103
- })
1104
- const res = response()
1105
- await withTrashStub(async () => {}, async () => {
1106
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1107
- })
1108
- assert.equal(res.status, 200)
1109
- assert.equal(res.body.partial, true)
1110
- assert.equal(res.body.message, '产物已不存在,但移除列表记录失败,且移除归档记录失败')
1111
- // 幽灵清理半失败:原始错误进服务端日志(排障契约),响应文案保持摘要
1112
- assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), '幽灵 detach 失败应落日志')
1113
- })
1114
-
1115
- test('删除:回收窗口内会话恢复运行,响应警告形态且无 partial 键', skipMissingDeps, async () => {
1116
- const artifact = await makeLocatedArtifact()
1117
- try {
1118
- const agents = new Map([['s1', { status: 'idle' }]])
1119
- const { handlers } = makeCtx({
1120
- archivedIds: ['s1'],
1121
- headers: [HEADER],
1122
- agents,
1123
- domain: makeDomain(['s1']),
1124
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1125
- })
1126
- const res = response()
1127
- // trash 桩内翻转运行态:模拟 OS 回收异步窗口内的执行期翻转
1128
- await withTrashStub(async () => { agents.set('s1', { status: 'running' }) }, async () => {
1129
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1130
- })
1131
- assert.equal(res.status, 200)
1132
- // 全成功警告态:{ok,message} 形态,无 partial 键
1133
- assert.deepEqual(res.body, { ok: true, message: DELETE_MESSAGES.runningDuringTrash })
1134
- } finally {
1135
- await artifact.cleanup()
1136
- }
1137
- })
1138
-
1139
- test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二道防线)', skipMissingDeps, async () => {
1140
- const stale = Date.now() - 30 * 24 * 60 * 60 * 1000
1141
- let locateCalls = 0
1142
- const { activateSettings, registry } = makeCtx({
1143
- archivedIds: [],
1144
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: stale }],
1145
- agents: new Map([['s1', { status: 'running' }]]),
1146
- sessionPersistence: {
1147
- locate: (header) => {
1148
- locateCalls += 1
1149
- return { path: 'C:\\x\\s1\\session.jsonl.zstd' }
1150
- },
1151
- },
1152
- })
1153
- // 历史启动对齐(运行中会话即时路径)也走 locate:等它跑完并清零计数,
1154
- // 此后断言只针对归档评估的产物 IO
1155
- await new Promise((resolve) => setTimeout(resolve, 30))
1156
- locateCalls = 0
1157
- activateSettings()
1158
- // 等评估轮跑完(正常会话路径必然触达 locate):running 会话不得产生 locate 调用
1159
- await new Promise((resolve) => setTimeout(resolve, 250))
1160
- assert.equal(locateCalls, 0, '运行中会话应在产物 IO 前早退')
1161
- assert.deepEqual(registry.archiveCalls, [])
1162
- })
1163
-
1164
- test('删除:workspace 域未打开时归档清理失败,响应 partial 且台账已记录', skipMissingDeps, async () => {
1165
- const artifact = await makeLocatedArtifact()
1166
- try {
1167
- const { handlers, ledger } = makeCtx({
1168
- archivedIds: ['s1'],
1169
- headers: [HEADER],
1170
- agents: IDLE_S1,
1171
- domain: null,
1172
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1173
- })
1174
- const res = response()
1175
- await withTrashStub(async () => {}, async () => {
1176
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1177
- })
1178
- assert.equal(res.status, 200)
1179
- assert.equal(res.body.partial, true)
1180
- assert.equal(res.body.message, '已移入回收站,但移除归档记录失败')
1181
- // detach 已完成、台账已记录:仅归档集合清理半失败
1182
- assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
1183
- } finally {
1184
- await artifact.cleanup()
1185
- }
1186
- })
1187
-
1188
- test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点', skipMissingDeps, async () => {
1189
- const artifact = await makeLocatedArtifact()
1190
- try {
1191
- const brokenLedger = makeLedgerDomain([])
1192
- brokenLedger.global.set = async () => { throw new Error('medium broken') }
1193
- const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1194
- const { handlers, logger } = makeCtx({
1195
- archivedIds: ['s1'],
1196
- headers: [HEADER],
1197
- agents: IDLE_S1,
1198
- domain: makeDomain(['s1']),
1199
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1200
- ledger: brokenLedger,
1201
- workspaces: [workspace],
1202
- })
1203
- const res = response()
1204
- await withTrashStub(async () => {}, async () => {
1205
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1206
- })
1207
- assert.equal(res.status, 200)
1208
- assert.equal(res.body.partial, true)
1209
- assert.equal(res.body.message, '已移入回收站,但移除列表记录失败,且重挂载记录失败')
1210
- // 台账与 detach 的原始错误均进服务端日志
1211
- assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('medium broken')), '台账失败应落日志')
1212
- assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), 'detach 失败应落日志')
1213
- } finally {
1214
- await artifact.cleanup()
1215
- }
1216
- })
1217
-
1218
- test('trash 失败:整体中止,台账不写', skipMissingDeps, async () => {
1219
- const artifact = await makeLocatedArtifact()
1220
- try {
1221
- const { handlers, ledger } = makeCtx({
1222
- archivedIds: ['s1'],
1223
- headers: [HEADER],
1224
- agents: IDLE_S1,
1225
- domain: makeDomain(['s1']),
1226
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1227
- })
1228
- const res = response()
1229
- await withTrashStub(async () => { throw new Error('no trash') }, async () => {
1230
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1231
- })
1232
- assert.equal(res.status, 400)
1233
- assert.deepEqual(ledger.state.deleted, [])
1234
- assert.equal(ledger.writes, 0)
1235
- } finally {
1236
- await artifact.cleanup()
1237
- }
1238
- })
1239
-
1240
- test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1241
- const artifact = await makeLocatedArtifact()
1242
- try {
1243
- const domain = makeDomain(['s1'])
1244
- const { handlers } = makeCtx({
1245
- archivedIds: ['s1'],
1246
- headers: [HEADER],
1247
- agents: IDLE_S1,
1248
- domain,
1249
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1250
- openRejected: new Error('ledger down'),
1251
- })
1252
- const res = response()
1253
- await withTrashStub(async () => {}, async () => {
1254
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1255
- })
1256
- assert.equal(res.status, 200)
1257
- assert.equal(res.body.partial, true)
1258
- assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1259
- // 台账失败不阻断删除主链路:归档清理照常完成
1260
- assert.deepEqual(domain.state.archivedSessionIds, [])
1261
- } finally {
1262
- await artifact.cleanup()
1263
- }
1264
- })
1265
-
1266
- test('台账写入失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1267
- const artifact = await makeLocatedArtifact()
1268
- try {
1269
- const brokenLedger = makeLedgerDomain([])
1270
- brokenLedger.global.set = async () => { throw new Error('medium broken') }
1271
- const { handlers } = makeCtx({
1272
- archivedIds: ['s1'],
1273
- headers: [HEADER],
1274
- agents: IDLE_S1,
1275
- domain: makeDomain(['s1']),
1276
- sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1277
- ledger: brokenLedger,
1278
- })
1279
- const res = response()
1280
- await withTrashStub(async () => {}, async () => {
1281
- await handlers.get('/api/session-manager/delete')(request('s1'), res)
1282
- })
1283
- assert.equal(res.status, 200)
1284
- assert.equal(res.body.partial, true)
1285
- assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1286
- } finally {
1287
- await artifact.cleanup()
1288
- }
1289
- })
1290
-
1291
- test('已删除列表:GET 按存储序全量返回', skipMissingDeps, async () => {
1292
- const entries = [
1293
- { sessionId: 's2', path: 'p2', deletedAt: 2 },
1294
- { sessionId: 's1', path: 'p1', deletedAt: 1 },
1295
- ]
1296
- const { handlers } = makeCtx({
1297
- archivedIds: [],
1298
- headers: [],
1299
- agents: new Map(),
1300
- ledger: makeLedgerDomain(entries),
1301
- })
1302
- const res = response()
1303
- await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
1304
- assert.equal(res.status, 200)
1305
- assert.deepEqual(res.body, { deleted: entries })
1306
- })
1307
-
1308
- test('已删除列表:非 GET 拒绝', skipMissingDeps, async () => {
1309
- const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
1310
- const res = response()
1311
- await handlers.get('/api/session-manager/deleted')(request('s1'), res)
1312
- assert.equal(res.status, 405)
1313
- })
1314
-
1315
- test('已删除列表:台账域打开失败拒绝', skipMissingDeps, async () => {
1316
- const { handlers } = makeCtx({
1317
- archivedIds: [],
1318
- headers: [],
1319
- agents: new Map(),
1320
- openRejected: new Error('ledger down'),
1321
- })
1322
- const res = response()
1323
- await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
1324
- assert.equal(res.status, 400)
1325
- })
1326
-
1327
- test('重挂载:产物已还原时经官方 attachSession 挂回并清除台账', skipMissingDeps, async () => {
1328
- const workspace = makeWorkspace('C:\\x', [])
1329
- const { handlers, ledger } = makeCtx({
1330
- archivedIds: [],
1331
- headers: [HEADER],
1332
- agents: new Map(),
1333
- workspaces: [workspace],
1334
- ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1335
- })
1336
- const res = response()
1337
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1338
- assert.equal(res.status, 200)
1339
- assert.deepEqual(res.body, { ok: true })
1340
- assert.deepEqual(workspace.attachCalls, ['s1'])
1341
- assert.deepEqual(workspace.sessionIds, ['s1'])
1342
- assert.deepEqual(ledger.state.deleted, [])
1343
- assert.equal(ledger.writes, 1)
1344
- })
1345
-
1346
- test('重挂载:产物未还原(持久层无 header)拒绝', skipMissingDeps, async () => {
1347
- const { handlers, ledger } = makeCtx({
1348
- archivedIds: [],
1349
- headers: [],
1350
- agents: new Map(),
1351
- ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1352
- })
1353
- const res = response()
1354
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1355
- assert.equal(res.status, 400)
1356
- assert.equal(res.body.error, '会话产物不在持久层,请先到系统回收站还原后重试')
1357
- // 拒绝路径不动台账
1358
- assert.equal(ledger.writes, 0)
1359
- })
1360
-
1361
- test('重挂载:找不到所属工作区拒绝', skipMissingDeps, async () => {
1362
- const { handlers } = makeCtx({
1363
- archivedIds: [],
1364
- headers: [HEADER],
1365
- agents: new Map(),
1366
- workspaces: [makeWorkspace('C:\\other', [])],
1367
- ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1368
- })
1369
- const res = response()
1370
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1371
- assert.equal(res.status, 400)
1372
- assert.equal(res.body.error, '未找到会话所属工作区,无法重新挂载')
1373
- })
1374
-
1375
- test('重挂载:attachSession 失败时拒绝且台账保留', skipMissingDeps, async () => {
1376
- const workspace = makeWorkspace('C:\\x', [], { attachError: new Error('cwd 消失') })
1377
- const entries = [{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]
1378
- const { handlers, ledger } = makeCtx({
1379
- archivedIds: [],
1380
- headers: [HEADER],
1381
- agents: new Map(),
1382
- workspaces: [workspace],
1383
- ledger: makeLedgerDomain(entries),
1384
- })
1385
- const res = response()
1386
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1387
- assert.equal(res.status, 400)
1388
- assert.match(res.body.error, /cwd 消失/)
1389
- assert.deepEqual(ledger.state.deleted, entries)
1390
- assert.equal(ledger.writes, 0)
1391
- })
1392
-
1393
- test('重挂载:会话已在工作区时幂等收尾(残留条目重试无副作用)', skipMissingDeps, async () => {
1394
- const workspace = makeWorkspace('C:\\x', ['s1'])
1395
- const { handlers, ledger } = makeCtx({
1396
- archivedIds: [],
1397
- headers: [HEADER],
1398
- agents: new Map(),
1399
- workspaces: [workspace],
1400
- ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1401
- })
1402
- const res = response()
1403
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1404
- assert.equal(res.status, 200)
1405
- assert.deepEqual(res.body, { ok: true })
1406
- // 官方契约:id 已在 sessionIds 中,挂载幂等无重复
1407
- assert.deepEqual(workspace.sessionIds, ['s1'])
1408
- assert.deepEqual(ledger.state.deleted, [])
1409
- })
1410
-
1411
- test('重挂载:挂载成功但台账清除失败时响应 partial', skipMissingDeps, async () => {
1412
- const workspace = makeWorkspace('C:\\x', [])
1413
- const brokenLedger = makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }])
1414
- brokenLedger.global.set = async () => { throw new Error('medium broken') }
1415
- const { handlers } = makeCtx({
1416
- archivedIds: [],
1417
- headers: [HEADER],
1418
- agents: new Map(),
1419
- workspaces: [workspace],
1420
- ledger: brokenLedger,
1421
- })
1422
- const res = response()
1423
- await handlers.get('/api/session-manager/remount')(request('s1'), res)
1424
- assert.equal(res.status, 200)
1425
- assert.equal(res.body.partial, true)
1426
- assert.equal(res.body.message, '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾')
1427
- assert.deepEqual(workspace.attachCalls, ['s1'])
1428
- })
1429
-
1430
- test('移除记录:命中删除,未命中幂等无写', skipMissingDeps, async () => {
1431
- const entries = [
1432
- { sessionId: 's1', path: 'p1', deletedAt: 1 },
1433
- { sessionId: 's2', path: 'p2', deletedAt: 2 },
1434
- ]
1435
- const { handlers, ledger } = makeCtx({
1436
- archivedIds: [],
1437
- headers: [],
1438
- agents: new Map(),
1439
- ledger: makeLedgerDomain(entries),
1440
- })
1441
- const hit = response()
1442
- await handlers.get('/api/session-manager/forget')(request('s1'), hit)
1443
- assert.equal(hit.status, 200)
1444
- assert.deepEqual(hit.body, { ok: true })
1445
- assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s2'])
1446
- assert.equal(ledger.writes, 1)
1447
- const miss = response()
1448
- await handlers.get('/api/session-manager/forget')(request('s9'), miss)
1449
- assert.equal(miss.status, 200)
1450
- assert.equal(ledger.writes, 1)
1451
- })
1452
-
1453
- // ── 历史输入路由(GET /api/session-manager/inputs;工作区持久缓存)──
1454
-
1455
- function getRequest(query) {
1456
- const req = new EventEmitter()
1457
- req.method = 'GET'
1458
- req.url = '/api/session-manager/inputs' + query
1459
- return req
1460
- }
1461
-
1462
- function userMessageEvent(text, at) {
1463
- return {
1464
- type: 'user/message',
1465
- seq: at,
1466
- time: at,
1467
- data: { id: 'm' + at, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text }] },
1468
- }
1469
- }
1470
-
1471
- function makeHistoryCtx() {
1472
- return makeCtx({
1473
- archivedIds: [],
1474
- headers: [
1475
- { id: 's1', cwd: 'C:\\x', createdAt: 0 },
1476
- { id: 's2', cwd: 'C:\\x', createdAt: 0 },
1477
- { id: 's3', cwd: 'C:\\other', createdAt: 0 },
1478
- ],
1479
- agents: new Map(),
1480
- readSessions: {
1481
- s1: [userMessageEvent('更早的输入', 100), userMessageEvent('最新输入', 900)],
1482
- s2: [userMessageEvent('第二条', 500)],
1483
- s3: [userMessageEvent('别的工作区', 999)],
1484
- },
1485
- })
1486
- }
1487
-
1488
- // 后台对齐是异步的:轮询等待响应满足条件(默认 aligned 翻真,可断言数据就绪)
1489
- async function requestUntil(handlers, query, predicate, maxMs = 3000) {
1490
- for (let waited = 0; waited <= maxMs; waited += 100) {
1491
- const res = response()
1492
- await handlers.get('/api/session-manager/inputs')(getRequest(query), res)
1493
- if (predicate(res.body)) return res
1494
- await new Promise((resolve) => setTimeout(resolve, 100))
1495
- }
1496
- throw new Error('条件未满足: ' + query)
1497
- }
1498
-
1499
- function requestUntilAligned(handlers, query) {
1500
- return requestUntil(handlers, query, (body) => body.aligned)
1501
- }
1502
-
1503
- // POST 夹具:请求体走 readBody 的 data/end 事件流,handler 按路径查找
1504
- async function postJson(handlers, routePath, body) {
1505
- const req = new EventEmitter()
1506
- req.method = 'POST'
1507
- req.url = routePath
1508
- const res = response()
1509
- const done = handlers.get(routePath)(req, res)
1510
- req.emit('data', Buffer.from(JSON.stringify(body), 'utf8'))
1511
- req.emit('end')
1512
- await done
1513
- return res
1514
- }
1515
-
1516
- // 每个测试独占临时缓存目录:插件 factory 在 makeCtx 时读取 env 解析缓存目录。
1517
- // 后台对齐 fire-and-forget,测试结束仍在写目录——清理重试躲开 Windows 文件占用
1518
- async function withHistoryCacheDir(run) {
1519
- const cacheDir = await mkdtemp(path.join(tmpdir(), 'sm-hist-'))
1520
- process.env.DSH_HISTORY_CACHE_DIR = cacheDir
1521
- try {
1522
- return await run(cacheDir)
1523
- } finally {
1524
- delete process.env.DSH_HISTORY_CACHE_DIR
1525
- for (let attempt = 0; attempt < 6; attempt += 1) {
1526
- try {
1527
- await rm(cacheDir, { recursive: true, force: true })
1528
- break
1529
- } catch {
1530
- await new Promise((resolve) => setTimeout(resolve, 150))
1531
- }
1532
- }
1533
- }
1534
- }
1535
-
1536
- test('历史输入路由:首次 aligned=false 触发后台对齐,对齐后三范围正确且 session 按 sid 过滤', skipMissingDeps, async () => {
1537
- await withHistoryCacheDir(async () => {
1538
- const { handlers } = makeHistoryCtx()
1539
- const first = response()
1540
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), first)
1541
- assert.equal(first.status, 200)
1542
- assert.deepEqual(first.body.inputs, [])
1543
- assert.equal(first.body.aligned, false)
1544
- const workspaceRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
1545
- assert.deepEqual(workspaceRes.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入'])
1546
- const sessionRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=session')
1547
- assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
1548
- const otherSessionRes = await requestUntilAligned(handlers, '?sessionId=s2&scope=session')
1549
- assert.deepEqual(otherSessionRes.body.inputs.map((item) => item.text), ['第二条'])
1550
- // global 合并全部工作区缓存:先触发 C:\other 对齐(模拟其已就绪),再等 global 汇聚
1551
- await requestUntilAligned(handlers, '?sessionId=s3&scope=workspace')
1552
- const globalRes = await requestUntil(handlers, '?sessionId=s1&scope=global',
1553
- (body) => body.aligned && body.inputs.some((item) => item.text === '别的工作区'))
1554
- assert.deepEqual(globalRes.body.inputs.map((item) => item.text), ['别的工作区', '最新输入', '第二条', '更早的输入'])
1555
- })
1556
- })
1557
-
1558
- test('历史输入路由:对齐与既有缓存合并——旧条目保留、新输入追加、sid 溯源不变', skipMissingDeps, async () => {
1559
- await withHistoryCacheDir(async () => {
1560
- await ensureCacheDir(process.env.DSH_HISTORY_CACHE_DIR)
1561
- await writeWorkspaceCache(process.env.DSH_HISTORY_CACHE_DIR, 'C:\\x', {
1562
- entries: [
1563
- { text: '更早的输入', at: 100, sid: 's1' },
1564
- { text: '已消失会话的输入', at: 50, sid: 'gone' },
1565
- ],
1566
- extracts: {},
1567
- })
1568
- const { handlers } = makeHistoryCtx()
1569
- const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
1570
- (body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
1571
- assert.deepEqual(res.body.inputs.map((item) => item.text), ['最新输入', '第二条', '更早的输入', '已消失会话的输入'])
1572
- const sessionRes = await requestUntil(handlers, '?sessionId=s1&scope=session',
1573
- (body) => body.aligned && body.inputs.some((item) => item.text === '最新输入'))
1574
- assert.deepEqual(sessionRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
1575
- })
1576
- })
1577
-
1578
- test('历史输入路由:scope 缺省回退 session;未知会话/非 GET/缺 sessionId 拒绝', skipMissingDeps, async () => {
1579
- await withHistoryCacheDir(async () => {
1580
- const { handlers } = makeHistoryCtx()
1581
- const defaultRes = await requestUntilAligned(handlers, '?sessionId=s1')
1582
- assert.deepEqual(defaultRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
1583
- const invalidRes = await requestUntilAligned(handlers, '?sessionId=s1&scope=bogus')
1584
- assert.deepEqual(invalidRes.body.inputs.map((item) => item.text), ['最新输入', '更早的输入'])
1585
- const unknown = response()
1586
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=ghost'), unknown)
1587
- assert.equal(unknown.status, 400)
1588
- assert.equal(unknown.body.error, MESSAGES.unknownSession)
1589
- const wrongMethod = response()
1590
- await handlers.get('/api/session-manager/inputs')(request('s1'), wrongMethod)
1591
- assert.equal(wrongMethod.status, 405)
1592
- const missing = response()
1593
- await handlers.get('/api/session-manager/inputs')(getRequest(''), missing)
1594
- assert.equal(missing.status, 400)
1595
- })
1596
- })
1597
-
1598
- test('历史输入路由:损坏会话对齐跳过不中断;无 cwd 会话 workspace/session 返回空且 aligned', skipMissingDeps, async () => {
1599
- await withHistoryCacheDir(async () => {
1600
- const { handlers, logger } = makeCtx({
1601
- archivedIds: [],
1602
- headers: [
1603
- { id: 's1', cwd: 'C:\\x', createdAt: 0 },
1604
- { id: 'broken', cwd: 'C:\\x', createdAt: 0 },
1605
- { id: 'nocwd', createdAt: 0 },
1606
- ],
1607
- agents: new Map(),
1608
- readSessions: {
1609
- s1: [userMessageEvent('存活输入', 100)],
1610
- broken: () => { throw new Error('产物损坏') },
1611
- },
1612
- })
1613
- const res = await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
1614
- assert.deepEqual(res.body.inputs.map((item) => item.text), ['存活输入'])
1615
- assert.ok(logger.warns.some((line) => line.includes('broken')), '对齐失败未进服务端日志')
1616
- const noCwdWorkspace = response()
1617
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=nocwd&scope=workspace'), noCwdWorkspace)
1618
- assert.deepEqual(noCwdWorkspace.body, { inputs: [], aligned: true })
1619
- const noCwdSession = response()
1620
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=nocwd&scope=session'), noCwdSession)
1621
- assert.deepEqual(noCwdSession.body, { inputs: [], aligned: true })
1622
- })
1623
- })
1624
-
1625
- test('历史输入路由:运行中会话参与对齐(实时追加),缓存上限裁剪保留最新', skipMissingDeps, async () => {
1626
- await withHistoryCacheDir(async () => {
1627
- const agents = new Map([['running1', { status: 'running' }]])
1628
- const manyEvents = Array.from({ length: HISTORY_INPUT_LIMIT + 50 }, (_, i) => userMessageEvent('运行输入' + i, 1000 + i))
1629
- const { handlers, readCounts } = makeCtx({
1630
- archivedIds: [],
1631
- headers: [
1632
- { id: 's1', cwd: 'C:\\x', createdAt: 0 },
1633
- { id: 'running1', cwd: 'C:\\x', createdAt: 0 },
1634
- ],
1635
- agents,
1636
- readSessions: {
1637
- s1: [userMessageEvent('历史输入', 1000 + manyEvents.length + 10)],
1638
- running1: manyEvents,
1639
- },
1640
- })
1641
- const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
1642
- (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT + 1)
1643
- const texts = res.body.inputs.map((item) => item.text)
1644
- // 运行中会话输入量远超上限:limit 之外仅追加该会话去重后最早一条(s1 首条已在 limit 内)
1645
- assert.equal(texts.length, HISTORY_INPUT_LIMIT + 1)
1646
- assert.ok(texts.includes('历史输入'), '最新既有输入保留')
1647
- assert.ok(texts.includes('运行输入' + (HISTORY_INPUT_LIMIT + 49)), '次新运行输入保留')
1648
- assert.ok(texts.includes('运行输入0'), '运行中会话最早输入受首条保护')
1649
- assert.ok(!texts.includes('运行输入1'), '首条之外的旧输入仍被上限裁剪')
1650
- assert.ok(readCounts.get('running1') >= 1, '运行中会话被对齐解压')
1651
- const sessionRes = await requestUntilAligned(handlers, '?sessionId=running1&scope=session')
1652
- assert.ok(sessionRes.body.inputs.length > 0, 'session 范围可取运行中会话自身输入')
1653
- })
1654
- })
1655
-
1656
- test('历史输入路由:再次请求读缓存不再解压产物(对齐节流内)', skipMissingDeps, async () => {
1657
- await withHistoryCacheDir(async () => {
1658
- const { handlers, readCounts } = makeCtx({
1659
- archivedIds: [],
1660
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1661
- agents: new Map(),
1662
- readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
1663
- })
1664
- await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
1665
- const readsAfterAlign = readCounts.get('s1')
1666
- const second = response()
1667
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
1668
- assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
1669
- assert.equal(second.body.aligned, true)
1670
- assert.equal(readCounts.get('s1'), readsAfterAlign, '读路径不得解压产物')
1671
- })
1672
- })
1673
-
1674
- test('历史输入路由:会话产物变化即席聚焦对齐,单次请求内拿到新输入(首条<3s)', skipMissingDeps, async () => {
1675
- await withHistoryCacheDir(async () => {
1676
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-focus-'))
1677
- const artifactPath = path.join(dir, 'session.jsonl.zstd')
1678
- await writeFile(artifactPath, 'log-v1')
1679
- try {
1680
- const shared = {
1681
- archivedIds: [],
1682
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1683
- agents: new Map(),
1684
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1685
- }
1686
- const first = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100)] } })
1687
- await requestUntilAligned(first.handlers, '?sessionId=s1&scope=session')
1688
- // 产物增长(会话有新输入):指纹失效——路由就地等待聚焦对齐,
1689
- // 测试桩的 readSession 立即完成,单次请求内应直接拿到新输入且 aligned
1690
- await writeFile(artifactPath, 'log-v1-much-longer-appended')
1691
- const second = makeCtx({ ...shared, readSessions: { s1: [userMessageEvent('旧输入', 100), userMessageEvent('新输入XYZ', 200)] } })
1692
- const res = response()
1693
- await second.handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=session'), res)
1694
- assert.equal(res.body.aligned, true, '焦点对齐快于等待时限,首请求即就绪')
1695
- assert.deepEqual(res.body.inputs.map((item) => item.text), ['新输入XYZ', '旧输入'])
1696
- } finally {
1697
- await rm(dir, { recursive: true, force: true })
1698
- }
1699
- })
1700
- })
1701
-
1702
- test('历史输入路由:聚焦对齐只更新目标会话,不抹其他会话的持久 extracts', skipMissingDeps, async () => {
1703
- await withHistoryCacheDir(async () => {
1704
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prune-'))
1705
- const artifactPath = path.join(dir, 'session.jsonl.zstd')
1706
- await writeFile(artifactPath, 'log-v1')
1707
- try {
1708
- await ensureCacheDir(process.env.DSH_HISTORY_CACHE_DIR)
1709
- await writeWorkspaceCache(process.env.DSH_HISTORY_CACHE_DIR, 'C:\\x', {
1710
- entries: [{ text: '更早的输入', at: 100, sid: 's1' }],
1711
- extracts: { s2: { mtimeMs: 1, size: 2, entries: [{ text: '邻居会话输入', at: 50 }] } },
1712
- })
1713
- const { handlers } = makeCtx({
1714
- archivedIds: [],
1715
- headers: [
1716
- { id: 's1', cwd: 'C:\\x', createdAt: 0 },
1717
- { id: 's2', cwd: 'C:\\x', createdAt: 0 },
1718
- ],
1719
- agents: new Map(),
1720
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1721
- readSessions: { s1: [userMessageEvent('更早的输入', 100), userMessageEvent('聚焦新输入', 200)] },
1722
- })
1723
- await requestUntil(handlers, '?sessionId=s1&scope=session',
1724
- (body) => body.aligned && body.inputs.some((item) => item.text === '聚焦新输入'))
1725
- const cached = JSON.parse(await readFile(path.join(process.env.DSH_HISTORY_CACHE_DIR, 'x-5358b306.json'), 'utf8'))
1726
- assert.ok(cached.extracts.s2, '邻居会话 extracts 不得被聚焦对齐抹掉')
1727
- assert.ok(cached.extracts.s1, '目标会话 extracts 已更新')
1728
- } finally {
1729
- await rm(dir, { recursive: true, force: true })
1730
- }
1731
- })
1732
- })
1733
-
1734
- test('历史输入路由:产物未变的会话跨对齐零解压(磁盘 extracts 指纹命中)', skipMissingDeps, async () => {
1735
- await withHistoryCacheDir(async () => {
1736
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-art-'))
1737
- const artifactPath = path.join(dir, 'session.jsonl.zstd')
1738
- await writeFile(artifactPath, 'log-bytes')
1739
- try {
1740
- const { handlers, readCounts } = makeCtx({
1741
- archivedIds: [],
1742
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1743
- agents: new Map(),
1744
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1745
- readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
1746
- })
1747
- await requestUntilAligned(handlers, '?sessionId=s1&scope=workspace')
1748
- const readsAfterFirstAlign = readCounts.get('s1')
1749
- assert.ok(readsAfterFirstAlign >= 1, '首次对齐必然解压')
1750
- // 节流壳 30s 挡住同工作区二次触发:直接以新 makeCtx(新节流 Map)模拟"重启后再次对齐"
1751
- const { handlers: handlers2, readCounts: readCounts2 } = makeCtx({
1752
- archivedIds: [],
1753
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1754
- agents: new Map(),
1755
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1756
- readSessions: { s1: [userMessageEvent('工作区输入', 100)] },
1757
- })
1758
- // 同一缓存目录(env 未变,withHistoryCacheDir 复用):指纹命中 → 零解压
1759
- const second = response()
1760
- await handlers2.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=workspace'), second)
1761
- assert.equal(second.body.aligned, true)
1762
- assert.deepEqual(second.body.inputs.map((item) => item.text), ['工作区输入'])
1763
- // 触发一次对齐(绕过节流:新 ctx 节流 Map 为空,请求即触发)
1764
- await new Promise((resolve) => setTimeout(resolve, 300))
1765
- assert.equal(readCounts2.get('s1') === undefined, true, '产物未变,重启后对齐零解压')
1766
- } finally {
1767
- await rm(dir, { recursive: true, force: true })
1768
- }
1769
- })
1770
- })
1771
-
1772
- test('历史输入路由:启动对齐只回溯最近 STARTUP_SCAN 个会话,老会话不解压', skipMissingDeps, async () => {
1773
- await withHistoryCacheDir(async () => {
1774
- const total = HISTORY_STARTUP_SCAN_LIMIT + 20
1775
- // 宿主 listSessions 为最新在前:数组首元素即最新会话
1776
- const headers = Array.from({ length: total }, (_, i) => ({ id: 's' + (total - 1 - i), cwd: 'C:\\x', createdAt: 0 }))
1777
- const readSessions = {}
1778
- for (let i = 0; i < total; i++) readSessions['s' + i] = [userMessageEvent('输入' + i, 1000 + i)]
1779
- const { handlers, readCounts } = makeCtx({ archivedIds: [], headers, agents: new Map(), readSessions })
1780
- // 触发对齐(请求工作区),等队列排空后窗口外老会话(s0..s19)不得被解压
1781
- await requestUntil(handlers, '?sessionId=s' + (total - 1) + '&scope=workspace',
1782
- (body) => body.aligned && body.inputs.length > 0)
1783
- assert.equal(readCounts.get('s0'), undefined, '窗口外最老会话不被启动对齐解压')
1784
- assert.ok(readCounts.get('s' + (total - 1)) >= 1, '窗口内最新会话被解压')
1785
- })
1786
- })
1787
-
1788
- test('常用提示词:toggle 收藏/取消,prompts 范围可见,与工作区缓存互不污染', skipMissingDeps, async () => {
1789
- await withHistoryCacheDir(async () => {
1790
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prompts-'))
1791
- const artifactPath = path.join(dir, 'session.jsonl.zstd')
1792
- try {
1793
- await writeFile(artifactPath, 'log-v1')
1794
- const { handlers } = makeCtx({
1795
- archivedIds: [],
1796
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1797
- agents: new Map(),
1798
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1799
- readSessions: { s1: [userMessageEvent('历史输入A', 100)] },
1800
- })
1801
- // 初始为空
1802
- const empty = response()
1803
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), empty)
1804
- assert.deepEqual(empty.body.inputs, [])
1805
- // 收藏 → prompts 可见;工作区范围不混入
1806
- await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '常用句子X' })
1807
- const listed = response()
1808
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
1809
- assert.deepEqual(listed.body.inputs.map((item) => item.text), ['常用句子X'])
1810
- const workspace = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
1811
- (body) => body.aligned && body.inputs.some((item) => item.text === '历史输入A'))
1812
- assert.equal(workspace.body.inputs.some((item) => item.text === '常用句子X'), false, '收藏不混入工作区历史')
1813
- // 再 toggle = 取消
1814
- await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '常用句子X' })
1815
- const cleared = response()
1816
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), cleared)
1817
- assert.deepEqual(cleared.body.inputs, [])
1818
- } finally {
1819
- await rm(dir, { recursive: true, force: true })
1820
- }
1821
- })
1822
- })
1823
-
1824
- test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissingDeps, async () => {
1825
- await withHistoryCacheDir(async () => {
1826
- const dir = await mkdtemp(path.join(tmpdir(), 'sm-hist-prompts-cap-'))
1827
- const artifactPath = path.join(dir, 'session.jsonl.zstd')
1828
- try {
1829
- await writeFile(artifactPath, 'log-v1')
1830
- const { handlers } = makeCtx({
1831
- archivedIds: [],
1832
- headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1833
- agents: new Map(),
1834
- sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
1835
- readSessions: { s1: [userMessageEvent('占位', 100)] },
1836
- })
1837
- await requestUntilAligned(handlers, '?sessionId=s1&scope=prompts')
1838
- // 空白文本拒绝
1839
- const blank = await postJson(handlers, '/api/session-manager/prompts/toggle', { text: ' ' })
1840
- assert.equal(blank.status, 400)
1841
- // 截断:HISTORY_INPUT_MAX_CHARS 以上裁到上限
1842
- const long = '长'.repeat(HISTORY_INPUT_MAX_CHARS + 10)
1843
- await postJson(handlers, '/api/session-manager/prompts/toggle', { text: long })
1844
- const listed = response()
1845
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), listed)
1846
- assert.equal(listed.body.inputs[0].text.length, HISTORY_INPUT_MAX_CHARS)
1847
- await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '长'.repeat(HISTORY_INPUT_MAX_CHARS) })
1848
- // 上限:超过 HISTORY_PROMPTS_MAX 后最旧被裁
1849
- for (let i = 0; i < HISTORY_PROMPTS_MAX + 1; i++) {
1850
- await postJson(handlers, '/api/session-manager/prompts/toggle', { text: '条目' + i })
1851
- }
1852
- const capped = response()
1853
- await handlers.get('/api/session-manager/inputs')(getRequest('?sessionId=s1&scope=prompts'), capped)
1854
- assert.equal(capped.body.inputs.length, HISTORY_PROMPTS_MAX)
1855
- assert.equal(capped.body.inputs.some((item) => item.text === '条目0'), false, '最旧条目被裁掉')
1856
- assert.equal(capped.body.inputs[0].text, '条目' + HISTORY_PROMPTS_MAX, '最新条目在顶部')
1857
- } finally {
1858
- await rm(dir, { recursive: true, force: true })
1859
- }
1860
- })
1861
- })
1862
-
1863
- test('常用提示词:client 与 core 的 HISTORY_SCOPES 镜像同序同值,默认落点为当前会话', { skip: process.env.TEST_SKIP_PARITY }, async () => {
1864
- const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
1865
- const declared = clientSource.match(/const HISTORY_SCOPES = (\[[^\]]*\])/)
1866
- assert.ok(declared, 'client.js 必须内联声明 HISTORY_SCOPES')
1867
- assert.deepEqual(JSON.parse(declared[1].replace(/'/g, '"')), HISTORY_SCOPES)
1868
- const labels = clientSource.match(/const HISTORY_SCOPE_LABELS = (\[[^\]]*\])/)
1869
- assert.ok(labels, 'client.js 必须内联声明 HISTORY_SCOPE_LABELS')
1870
- assert.equal(JSON.parse(labels[1].replace(/'/g, '"')).length, HISTORY_SCOPES.length, '标签与范围一一对应')
1871
- assert.equal(HISTORY_SCOPES.indexOf('session'), 1, '浮层默认落点为当前会话(索引 1)')
1872
- })
1873
-
1874
- test('历史浮层启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
1875
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1876
- const initial = response()
1877
- await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), initial)
1878
- assert.equal(initial.body.enabled, true, '默认启用')
1879
- const off = await postJson(handlers, '/api/session-manager/history-enabled', { enabled: false })
1880
- assert.equal(off.body.enabled, false)
1881
- // settings 持久:重读反映关闭态;POST 回 true 恢复
1882
- const reread = response()
1883
- await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), reread)
1884
- assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
1885
- await postJson(handlers, '/api/session-manager/history-enabled', { enabled: true })
1886
- const restored = response()
1887
- await handlers.get('/api/session-manager/history-enabled')(getRequest2('/api/session-manager/history-enabled', 'GET'), restored)
1888
- assert.equal(restored.body.enabled, true)
1889
- })
1890
-
1891
- test('自动归档配置:GET 回显生效值(未配置回默认),POST 持久,GET 反映新值', skipMissingDeps, async () => {
1892
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1893
- const initial = response()
1894
- await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), initial)
1895
- assert.deepEqual(initial.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '未配置时回显默认值')
1896
- const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3, intervalHours: 12 })
1897
- assert.deepEqual(changed.body, { days: 3, intervalHours: 12 })
1898
- const reread = response()
1899
- await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1900
- assert.deepEqual(reread.body, { days: 3, intervalHours: 12 }, 'settings 持久化新值')
1901
- })
1902
-
1903
- test('自动归档配置:单字段部分更新,未提交字段保持不变', skipMissingDeps, async () => {
1904
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1905
- const onlyDays = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1906
- assert.deepEqual(onlyDays.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '仅更新 days')
1907
- const onlyHours = await postJson(handlers, '/api/session-manager/auto-archive', { intervalHours: 12 })
1908
- assert.deepEqual(onlyHours.body, { days: 3, intervalHours: 12 }, '仅更新 intervalHours,days 保持')
1909
- })
1910
-
1911
- test('自动归档配置:0 值合法且持久(阈值天数关自动归档,间隔小时数关周期轮)', skipMissingDeps, async () => {
1912
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1913
- const off = await postJson(handlers, '/api/session-manager/auto-archive', { days: 0, intervalHours: 0 })
1914
- assert.deepEqual(off.body, { days: 0, intervalHours: 0 })
1915
- const reread = response()
1916
- await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1917
- assert.deepEqual(reread.body, { days: 0, intervalHours: 0 }, '关闭值持久化')
1918
- })
1919
-
1920
- test('自动归档配置:负数/非整数/非数值/空字段一律 400,配置不变', skipMissingDeps, async () => {
1921
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1922
- const invalidPayloads = [
1923
- { days: -1, intervalHours: 1 },
1924
- { days: 1.5, intervalHours: 1 },
1925
- { days: '3', intervalHours: 1 },
1926
- { days: true, intervalHours: 1 },
1927
- { days: null, intervalHours: 1 },
1928
- {},
1929
- ]
1930
- for (const payload of invalidPayloads) {
1931
- const rejected = await postJson(handlers, '/api/session-manager/auto-archive', payload)
1932
- assert.equal(rejected.status, 400, JSON.stringify(payload))
1933
- }
1934
- const after = response()
1935
- await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), after)
1936
- assert.deepEqual(after.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '被拒请求不改动配置')
1937
- })
1938
-
1939
- test('自动归档配置提交判定:noop/invalid/post 三态分类', async () => {
1940
- const { classifyAutoArchiveInput } = await import('../src/core.mjs')
1941
- assert.deepEqual(classifyAutoArchiveInput('7', '7'), { action: 'noop' }, '值未变不提交')
1942
- assert.deepEqual(classifyAutoArchiveInput('7', ' 7 '), { action: 'noop' }, '空白差异修剪后视为未变')
1943
- assert.deepEqual(classifyAutoArchiveInput('7', '-1'), { action: 'invalid' }, '负数拒绝')
1944
- assert.deepEqual(classifyAutoArchiveInput('7', '1.5'), { action: 'invalid' }, '非整数拒绝')
1945
- assert.deepEqual(classifyAutoArchiveInput('7', 'abc'), { action: 'invalid' }, '非数值拒绝')
1946
- assert.deepEqual(classifyAutoArchiveInput('7', ''), { action: 'invalid' }, '空文本拒绝')
1947
- assert.deepEqual(classifyAutoArchiveInput('7', '0'), { action: 'post', value: 0 }, '0 合法(关闭语义)')
1948
- assert.deepEqual(classifyAutoArchiveInput('7', '3'), { action: 'post', value: 3 }, '正整数可提交')
1949
- })
1950
-
1951
- test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫对照已提交值', { skip: process.env.TEST_SKIP_PARITY }, async () => {
1952
- const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
1953
- const declared = clientSource.match(/function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1954
- assert.ok(declared, 'client.js 必须内联声明 classifyAutoArchiveInput')
1955
- const coreSource = await readFile(new URL('../src/core.mjs', import.meta.url), 'utf8')
1956
- const coreBody = coreSource.match(/export function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1957
- assert.ok(coreBody, 'core.mjs 必须声明 classifyAutoArchiveInput')
1958
- assert.equal(declared[1].trim(), coreBody[1].trim(), 'client 内联实现与 core 逐行一致')
1959
- // 调用点契约:守卫实参必须是已提交基准 committed[field],对照显示值比较恒相等(受控输入击键已同步)
1960
- assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1961
- })
1962
-
1963
- function getRequest2(url, method) {
1964
- const req = new EventEmitter()
1965
- req.method = method
1966
- req.url = url
1967
- return req
1968
- }
1
+ // 路由层 BDD(mock ctx 强制 cordis 注入语义):宿主上下文上任何未声明 inject 的
2
+ // 服务属性访问一律抛 cannot get property ... without inject,与 cordis reflect 行为
3
+ // 一致,防止 inject 声明缺失回归。peer 依赖未安装的仓库环境(禁止 install)下整文件跳过。
4
+
5
+ import test, { mock } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { EventEmitter } from 'node:events'
8
+ import { mkdtemp, writeFile, utimes, mkdir, rm, readFile, access } from 'node:fs/promises'
9
+ import { mkdtempSync, rmSync } from 'node:fs'
10
+ import { createHash } from 'node:crypto'
11
+ import { tmpdir } from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ // 仅 peer 依赖缺失允许整文件跳过;其余加载失败(src 自身损坏)直接失败,禁止静默 skip
15
+ const indexModule = await import('../src/index.js').catch((error) => {
16
+ const missingPeer = error && error.code === 'ERR_MODULE_NOT_FOUND'
17
+ && (error.message.includes("'@deepseek-ai/") || error.message.includes("'zod"))
18
+ if (missingPeer) return error
19
+ throw error
20
+ })
21
+ const apply = indexModule.apply
22
+ const declaredInject = Array.isArray(indexModule.inject) ? indexModule.inject : []
23
+ const dependencyReady = typeof apply === 'function'
24
+ const skipMissingDeps = { skip: dependencyReady ? false : 'peer 依赖未安装,路由层测试跳过' }
25
+ const MESSAGES = indexModule.MESSAGES
26
+ const { DELETE_MESSAGES, DEFAULT_AUTO_ARCHIVE_DAYS, DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS } = await import('../src/core.mjs').catch(() => ({ DELETE_MESSAGES: {} }))
27
+
28
+ // 回收区目录统一隔离:删除降级路径把产物搬进该目录,防泄漏真实用户目录
29
+ const sharedQuarantineDir = mkdtempSync(path.join(tmpdir(), 'sm-quarantine-shared-'))
30
+ process.env.DSH_QUARANTINE_DIR = sharedQuarantineDir
31
+ test.after(() => {
32
+ rmSync(sharedQuarantineDir, { recursive: true, force: true })
33
+ })
34
+ // 台账/重挂载夹具路径仅作数据,不落盘;形态与实现一致(会话目录)
35
+ const LEDGER_FIXTURE_PATH = 'C:\\store\\s1'
36
+ // 幽灵产物夹具:tmpdir 下未创建的路径,跨平台 stat 必 ENOENT
37
+ // (POSIX 对纯反斜杠 Windows 字面量取 dirname 会落回 '.',stat 成功使幽灵分支不可达)
38
+ const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
39
+ const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
40
+
41
+ function makeRegistry(initialIds) {
42
+ // 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
43
+ // archiveSession 归档即入集合(官方幂等判定依据),桩保持同语义
44
+ const registry = {
45
+ state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
46
+ list: () => [],
47
+ archiveCalls: [],
48
+ archiveSession: async (id) => {
49
+ const sid = String(id)
50
+ registry.archiveCalls.push(sid)
51
+ registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
52
+ },
53
+ }
54
+ Object.defineProperty(registry, 'archivedSessionIds', {
55
+ get: () => registry.state.archivedSessionIds,
56
+ })
57
+ return registry
58
+ }
59
+
60
+ // workspace 域句柄:null 表示域未打开(get 返回 undefined)
61
+ function makeDomain(initialIds) {
62
+ const domain = {
63
+ state: { archivedSessionIds: initialIds },
64
+ writes: 0,
65
+ global: {
66
+ get: () => domain.state,
67
+ set: async (next) => {
68
+ domain.state = { ...next }
69
+ domain.writes += 1
70
+ },
71
+ },
72
+ }
73
+ return domain
74
+ }
75
+
76
+ // 台账域句柄;openRejected 模拟台账域打开失败
77
+ function makeLedgerDomain(entries) {
78
+ const domain = {
79
+ state: { deleted: entries },
80
+ writes: 0,
81
+ global: {
82
+ get: () => domain.state,
83
+ set: async (next) => {
84
+ domain.state = { ...next }
85
+ domain.writes += 1
86
+ },
87
+ },
88
+ }
89
+ return domain
90
+ }
91
+
92
+ // workspace 注册表实体桩:detach/attach 均为官方实体契约,供删除与重挂载断言
93
+ function makeWorkspace(path, sessionIds, options = {}) {
94
+ const workspace = {
95
+ path,
96
+ sessionIds,
97
+ attachCalls: [],
98
+ attachSession: async (id) => {
99
+ if (options.attachError) throw options.attachError
100
+ workspace.attachCalls.push(String(id))
101
+ workspace.sessionIds = [String(id), ...workspace.sessionIds.filter((existing) => existing !== id)]
102
+ },
103
+ detachSession: async (id) => {
104
+ if (options.detachError) throw options.detachError
105
+ workspace.sessionIds = workspace.sessionIds.filter((existing) => String(existing) !== String(id))
106
+ },
107
+ }
108
+ return workspace
109
+ }
110
+
111
+ function makeCtx({
112
+ archivedIds,
113
+ headers,
114
+ agents,
115
+ domain,
116
+ sessionPersistence,
117
+ ledger,
118
+ openRejected,
119
+ workspaces,
120
+ settingsValue,
121
+ timerAvailable,
122
+ readSessions,
123
+ }) {
124
+ const routes = []
125
+ const eventHandlers = {}
126
+ const pendingInjects = []
127
+ const ledgerDomain = ledger === undefined ? makeLedgerDomain([]) : ledger
128
+ const workspaceList = workspaces || []
129
+ const settingsState = { value: settingsValue }
130
+ const settingsService = {
131
+ get: () => settingsState.value,
132
+ register: () => ({ resolved: undefined }),
133
+ // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
134
+ update: (ns, patch) => new Promise((resolve) => queueMicrotask(() => {
135
+ settingsState.value = { ...(settingsState.value || {}), ...patch }
136
+ resolve()
137
+ })),
138
+ }
139
+ // timer 服务桩:模拟宿主 timer 激活后的 interval;unref 保证测试进程可自然退出
140
+ const intervalStub = (fn, ms) => {
141
+ const timer = setInterval(fn, ms)
142
+ timer.unref()
143
+ return () => clearInterval(timer)
144
+ }
145
+ // readSession 桩:sessionId → 事件数组(工厂形态则调用后抛错);reads 记录实际读取次数供缓存断言
146
+ // headers 支持同步或异步函数形态:每次列会话现求值,模拟宿主按产物现状动态列会话;
147
+ // 数组形态在调用点同步快照(门闩时序测试依赖该语义,禁入 await 链)
148
+ const readCounts = new Map()
149
+ const sessionQuery = {
150
+ listSessions: async () => {
151
+ if (typeof headers !== 'function') return headers.map((header) => ({ header }))
152
+ const list = await headers()
153
+ return list.map((header) => ({ header }))
154
+ },
155
+ readSession: async (sessionId) => {
156
+ readCounts.set(sessionId, (readCounts.get(sessionId) || 0) + 1)
157
+ const events = readSessions ? readSessions[sessionId] : undefined
158
+ if (typeof events === 'function') throw events()
159
+ if (events === undefined) throw new Error('桩未配置该会话产物')
160
+ const list = typeof headers !== 'function' ? headers : await headers()
161
+ return { session: list.find((header) => header.id === sessionId), events }
162
+ },
163
+ }
164
+ const services = {
165
+ webServer: { register: (route) => routes.push(route) },
166
+ workspaceRegistry: Object.assign(makeRegistry(archivedIds), { list: () => workspaceList }),
167
+ sessionQuery,
168
+ storageDomain: {
169
+ get: (name) => name === 'workspace' ? (domain || undefined) : undefined,
170
+ open: openRejected ? () => Promise.reject(openRejected) : async () => ledgerDomain,
171
+ },
172
+ }
173
+ const base = {
174
+ effect: (fn) => fn(),
175
+ inject: (_deps, fn) => { pendingInjects.push(fn) },
176
+ get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
177
+ on: (event, handler) => { eventHandlers[event] = handler },
178
+ // 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
179
+ logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
180
+ }
181
+ const ctx = new Proxy(base, {
182
+ get(target, prop) {
183
+ if (typeof prop === 'symbol') return undefined
184
+ if (prop in target) return target[prop]
185
+ if (!declaredInject.includes(prop)) {
186
+ throw new Error(`cannot get property "${String(prop)}" without inject`)
187
+ }
188
+ return services[prop]
189
+ },
190
+ })
191
+ apply(ctx, undefined)
192
+ return {
193
+ handlers: new Map(routes.map((route) => [route.path, route.handler])),
194
+ eventHandlers,
195
+ domain,
196
+ ledger: ledgerDomain,
197
+ registry: services.workspaceRegistry,
198
+ logger: base.logger,
199
+ readCounts,
200
+ // 模拟宿主 settings 与 timer 服务激活:触发 inject 回调(注册 + 启动补扫 + 周期武装)
201
+ activateSettings: () => {
202
+ const injected = { settings: settingsService, interval: timerAvailable === false ? undefined : intervalStub }
203
+ while (pendingInjects.length > 0) pendingInjects.shift()(injected)
204
+ },
205
+ }
206
+ }
207
+
208
+ async function waitFor(predicate) {
209
+ // performance.now 不受 mock.timers 的 Date 接管影响,周期评估测试中仍可真实计时
210
+ const deadline = performance.now() + 2000
211
+ while (performance.now() < deadline) {
212
+ if (predicate()) return
213
+ await new Promise((resolve) => setTimeout(resolve, 10))
214
+ }
215
+ assert.ok(predicate(), 'waitFor 超时')
216
+ }
217
+
218
+ function request(sessionId, method = 'POST') {
219
+ const req = new EventEmitter()
220
+ req.method = method
221
+ if (method !== 'GET') {
222
+ process.nextTick(() => {
223
+ req.emit('data', Buffer.from(JSON.stringify({ sessionId })))
224
+ req.emit('end')
225
+ })
226
+ }
227
+ return req
228
+ }
229
+
230
+ function response() {
231
+ const state = { status: undefined, body: undefined }
232
+ state.writeHead = (status) => { state.status = status }
233
+ state.end = (text) => { state.body = JSON.parse(text) }
234
+ return state
235
+ }
236
+
237
+ const HEADER = { id: 's1', cwd: 'C:\\x', createdAt: 0 }
238
+
239
+ test('inject 声明覆盖路由层触及的全部宿主服务(cordis 未声明即抛 cannot get property)', skipMissingDeps, () => {
240
+ for (const service of ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']) {
241
+ assert.ok(declaredInject.includes(service), `inject 缺少 "${service}"`)
242
+ }
243
+ })
244
+
245
+ test('请求体非法 JSON:协议错误与业务错误分通道,不透出引擎 SyntaxError', skipMissingDeps, async () => {
246
+ const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
247
+ const req = new EventEmitter()
248
+ req.method = 'POST'
249
+ process.nextTick(() => {
250
+ req.emit('data', Buffer.from('{not json'))
251
+ req.emit('end')
252
+ })
253
+ const res = response()
254
+ await handlers.get('/api/session-manager/unarchive')(req, res)
255
+ assert.equal(res.status, 400)
256
+ assert.equal(res.body.error, '请求体不是合法 JSON')
257
+ })
258
+
259
+ test('自动归档评估门闩:评估进行中的新触发被丢弃不排队', skipMissingDeps, async () => {
260
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-'))
261
+ try {
262
+ const alivePath = path.join(dir, 'alive.jsonl')
263
+ await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
264
+ const DAY_MS = 24 * 60 * 60 * 1000
265
+ const stale = Date.now() - 30 * DAY_MS
266
+ await utimes(alivePath, stale / 1000, stale / 1000)
267
+ const cwd = 'C:\\x'
268
+ const headers = [{ id: 's1', cwd, createdAt: stale }]
269
+ const { eventHandlers, registry } = makeCtx({
270
+ archivedIds: [],
271
+ headers,
272
+ agents: new Map(),
273
+ sessionPersistence: { locate: (header) => ({ path: alivePath }) },
274
+ })
275
+ eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
276
+ // 首轮启动后立即补新候选并连发两触发:首轮候选列表已捕获(s1),门闩正常时
277
+ // trigger2/3 被丢弃,s2 永不评估;门闩失效则 s2 被归档,断言可见
278
+ headers.push({ id: 's2', cwd, createdAt: stale })
279
+ eventHandlers['session/created']({ header: { id: 'trigger2', cwd } })
280
+ eventHandlers['session/created']({ header: { id: 'trigger3', cwd } })
281
+ await waitFor(() => registry.archiveCalls.length > 0)
282
+ // 给在途与迟到轮留完成窗口后断言:仅首轮产出,s2 未被任何一轮归档
283
+ await new Promise((resolve) => setTimeout(resolve, 150))
284
+ assert.deepEqual(registry.archiveCalls, ['s1'], '门闩应丢弃在途触发,新候选 s2 不被评估')
285
+ } finally {
286
+ await rm(dir, { recursive: true, force: true })
287
+ }
288
+ })
289
+
290
+ test('并发删除:台账串行化不丢条目', skipMissingDeps, async () => {
291
+ const artifactA = await makeLocatedArtifact('a')
292
+ const artifactB = await makeLocatedArtifact('b')
293
+ try {
294
+ const headers = [
295
+ { id: 'a', cwd: 'C:\\x', createdAt: 0 },
296
+ { id: 'b', cwd: 'C:\\x', createdAt: 0 },
297
+ ]
298
+ const workspace = makeWorkspace('C:\\x', ['a', 'b'])
299
+ const { handlers, ledger } = makeCtx({
300
+ archivedIds: ['a', 'b'],
301
+ headers,
302
+ agents: new Map([['a', { status: 'idle' }], ['b', { status: 'idle' }]]),
303
+ domain: makeDomain(['a', 'b']),
304
+ sessionPersistence: { locate: (header) => header.id === 'a' ? { path: artifactA.locatedPath } : { path: artifactB.locatedPath } },
305
+ workspaces: [workspace],
306
+ })
307
+ const resA = response()
308
+ const resB = response()
309
+ await Promise.all([
310
+ withTrashStub(async () => {}, async () => {
311
+ await handlers.get('/api/session-manager/delete')(request('a'), resA)
312
+ }),
313
+ withTrashStub(async () => {}, async () => {
314
+ await handlers.get('/api/session-manager/delete')(request('b'), resB)
315
+ }),
316
+ ])
317
+ assert.equal(resA.status, 200)
318
+ assert.equal(resB.status, 200)
319
+ // 串行化后两条台账都在;丢失更新会让后写者只剩自己的条目
320
+ assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId).sort(), ['a', 'b'])
321
+ } finally {
322
+ await artifactA.cleanup()
323
+ await artifactB.cleanup()
324
+ }
325
+ })
326
+
327
+ test('同 id 并发删除:第二个请求收到 in-flight 提示', skipMissingDeps, async () => {
328
+ const artifact = await makeLocatedArtifact()
329
+ try {
330
+ let releaseTrash
331
+ const trashGate = new Promise((resolve) => { releaseTrash = resolve })
332
+ const workspace = makeWorkspace('C:\\x', ['s1'])
333
+ const { handlers } = makeCtx({
334
+ archivedIds: ['s1'],
335
+ headers: [HEADER],
336
+ agents: IDLE_S1,
337
+ domain: makeDomain(['s1']),
338
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
339
+ workspaces: [workspace],
340
+ })
341
+ const first = response()
342
+ const firstDone = withTrashStub(async () => { await trashGate }, async () => {
343
+ await handlers.get('/api/session-manager/delete')(request('s1'), first)
344
+ })
345
+ // 等 trash 进入挂起后再发第二发
346
+ await new Promise((resolve) => setTimeout(resolve, 20))
347
+ const second = response()
348
+ await handlers.get('/api/session-manager/delete')(request('s1'), second)
349
+ assert.equal(second.status, 400)
350
+ assert.equal(second.body.error, '该会话正在删除中,请稍后重试')
351
+ releaseTrash()
352
+ await firstDone
353
+ } finally {
354
+ await artifact.cleanup()
355
+ }
356
+ })
357
+
358
+ test('多工作区 detach:首个失败不终止后续,全部失败进日志', skipMissingDeps, async () => {
359
+ const artifact = await makeLocatedArtifact()
360
+ try {
361
+ const broken = makeWorkspace('C:\\one', ['s1'], { detachError: new Error('one boom') })
362
+ const healthy = makeWorkspace('C:\\two', ['s1'])
363
+ const { handlers } = makeCtx({
364
+ archivedIds: ['s1'],
365
+ headers: [HEADER],
366
+ agents: IDLE_S1,
367
+ domain: makeDomain(['s1']),
368
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
369
+ workspaces: [broken, healthy],
370
+ })
371
+ const res = response()
372
+ await withTrashStub(async () => {}, async () => {
373
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
374
+ })
375
+ assert.equal(res.status, 200)
376
+ assert.equal(res.body.partial, true)
377
+ assert.equal(res.body.message, '已移入回收站,但移除列表记录失败')
378
+ // 健康工作区仍被解除关联
379
+ assert.deepEqual(healthy.sessionIds, [])
380
+ } finally {
381
+ await artifact.cleanup()
382
+ }
383
+ })
384
+
385
+ test('取消归档:域领先快照时反向合并清理', skipMissingDeps, async () => {
386
+ const domain = makeDomain(['s1'])
387
+ const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
388
+ const res = response()
389
+ await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
390
+ assert.equal(res.status, 200)
391
+ assert.deepEqual(res.body, { ok: true })
392
+ // durable 有而快照无:合并后仍完成移除,防漏清理
393
+ assert.deepEqual(domain.state.archivedSessionIds, [])
394
+ assert.equal(domain.writes, 1)
395
+ assert.deepEqual(registry.state.archivedSessionIds, [])
396
+ })
397
+
398
+ test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
399
+ const artifact = await makeLocatedArtifact()
400
+ try {
401
+ const before = Date.now()
402
+ const { handlers, ledger } = makeCtx({
403
+ archivedIds: ['s1'],
404
+ headers: [HEADER],
405
+ agents: IDLE_S1,
406
+ domain: makeDomain(['s1']),
407
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
408
+ })
409
+ const res = response()
410
+ await withTrashStub(async () => {}, async () => {
411
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
412
+ })
413
+ assert.equal(res.status, 200)
414
+ const deletedAt = ledger.state.deleted[0].deletedAt
415
+ assert.ok(typeof deletedAt === 'number' && deletedAt >= before && deletedAt <= Date.now() + 1000)
416
+ } finally {
417
+ await artifact.cleanup()
418
+ }
419
+ })
420
+
421
+ test('取消归档路由:从域集合移除目标 id 并写回', skipMissingDeps, async () => {
422
+ const domain = makeDomain(['s1', 's2'])
423
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
424
+ const res = response()
425
+ await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
426
+ assert.equal(res.status, 200)
427
+ assert.deepEqual(res.body, { ok: true })
428
+ assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
429
+ assert.equal(domain.writes, 1)
430
+ // 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
431
+ assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
432
+ assert.deepEqual(registry.archivedSessionIds, ['s2'])
433
+ })
434
+
435
+ test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
436
+ const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
437
+ const res = response()
438
+ await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
439
+ assert.equal(res.status, 400)
440
+ assert.equal(res.body.error, 'workspace 域未打开')
441
+ })
442
+
443
+ test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
444
+ const domain = makeDomain(['s1'])
445
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
446
+ const res = response()
447
+ await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
448
+ assert.equal(res.status, 200)
449
+ assert.deepEqual(res.body, { ok: true })
450
+ assert.deepEqual(domain.state.archivedSessionIds, ['s1'])
451
+ assert.equal(domain.writes, 0)
452
+ assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
453
+ })
454
+
455
+ test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
456
+ const domain = makeDomain([])
457
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
458
+ const res = response()
459
+ await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
460
+ assert.equal(res.status, 200)
461
+ assert.deepEqual(res.body, { ok: true })
462
+ assert.deepEqual(domain.state.archivedSessionIds, [])
463
+ assert.equal(domain.writes, 1)
464
+ assert.deepEqual(registry.state.archivedSessionIds, [])
465
+ })
466
+
467
+ test('info 路由:会话不存在拒绝', skipMissingDeps, async () => {
468
+ const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
469
+ const res = response()
470
+ await handlers.get('/api/session-manager/info')(request('s1'), res)
471
+ assert.equal(res.status, 400)
472
+ assert.equal(res.body.error, '会话不存在')
473
+ })
474
+
475
+ test('info 路由:locate 缺失按不支持返回', skipMissingDeps, async () => {
476
+ const { handlers } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map() })
477
+ const res = response()
478
+ await handlers.get('/api/session-manager/info')(request('s1'), res)
479
+ assert.equal(res.status, 200)
480
+ assert.deepEqual(res.body, { supported: false })
481
+ })
482
+
483
+ test('info 路由:产物已缺失按 missing 返回,不裸抛', skipMissingDeps, async () => {
484
+ const { handlers } = makeCtx({
485
+ archivedIds: [],
486
+ headers: [HEADER],
487
+ agents: new Map(),
488
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
489
+ })
490
+ const res = response()
491
+ await handlers.get('/api/session-manager/info')(request('s1'), res)
492
+ assert.equal(res.status, 200)
493
+ assert.deepEqual(res.body, { supported: true, sizeBytes: 0, missing: true })
494
+ })
495
+
496
+ test('删除路由:运行中会话被守卫拒绝,不触发 locate 与 trash', skipMissingDeps, async () => {
497
+ const { handlers } = makeCtx({
498
+ archivedIds: ['s1'],
499
+ headers: [HEADER],
500
+ agents: new Map([['s1', { status: 'running' }]]),
501
+ })
502
+ const res = response()
503
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
504
+ assert.equal(res.status, 400)
505
+ assert.equal(res.body.error, '运行中的会话不可删除')
506
+ })
507
+
508
+ test('删除路由:非运行中的已归档会话因 locate 缺失拒绝(守卫未误拦)', skipMissingDeps, async () => {
509
+ const { handlers } = makeCtx({
510
+ archivedIds: ['s1'],
511
+ headers: [HEADER],
512
+ agents: new Map([['s1', { status: 'idle' }]]),
513
+ })
514
+ const res = response()
515
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
516
+ assert.equal(res.status, 400)
517
+ assert.equal(res.body.error, '当前存储后端不支持按会话删除')
518
+ })
519
+
520
+ test('删除路由:未归档会话拒绝(运行守卫优先于资格)', skipMissingDeps, async () => {
521
+ const { handlers } = makeCtx({
522
+ archivedIds: [],
523
+ headers: [HEADER],
524
+ agents: new Map([['s1', { status: 'running' }]]),
525
+ })
526
+ const res = response()
527
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
528
+ assert.equal(res.status, 400)
529
+ assert.equal(res.body.error, '运行中的会话不可删除')
530
+ })
531
+
532
+ test('删除路由:未归档且空闲的会话拒绝', skipMissingDeps, async () => {
533
+ const artifact = await makeLocatedArtifact()
534
+ try {
535
+ const { handlers } = makeCtx({
536
+ archivedIds: [],
537
+ headers: [HEADER],
538
+ agents: new Map([['s1', { status: 'idle' }]]),
539
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
540
+ })
541
+ const res = response()
542
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
543
+ assert.equal(res.status, 400)
544
+ assert.equal(res.body.error, '仅已归档会话可删除')
545
+ } finally {
546
+ await artifact.cleanup()
547
+ }
548
+ })
549
+
550
+ test('自动归档评估:产物不可读的会话不参与归档(防删除后复活)', skipMissingDeps, async () => {
551
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-'))
552
+ const alivePath = path.join(dir, 'alive.jsonl')
553
+ await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
554
+ const DAY_MS = 24 * 60 * 60 * 1000
555
+ const stale = Date.now() - 30 * DAY_MS
556
+ await utimes(alivePath, stale / 1000, stale / 1000)
557
+ const cwd = 'C:\\x'
558
+ const headers = [
559
+ { id: 's1', cwd, createdAt: stale },
560
+ { id: 's2', cwd, createdAt: stale },
561
+ ]
562
+ const sessionPersistence = {
563
+ locate: (header) => header.id === 's1' ? { path: alivePath } : { path: path.join(dir, 'gone.jsonl') },
564
+ }
565
+ const { eventHandlers, registry, logger } = makeCtx({
566
+ archivedIds: [],
567
+ headers,
568
+ agents: new Map(),
569
+ sessionPersistence,
570
+ })
571
+ eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
572
+ await waitFor(() => registry.archiveCalls.length > 0)
573
+ // s2 产物缺失必须被守卫跳过:否则已删除会话会被重新归档而在面板复活
574
+ assert.deepEqual(registry.archiveCalls, ['s1'])
575
+ // 归档成功须落可追溯日志(含会话 id)
576
+ await waitFor(() => logger.infos.length > 0)
577
+ assert.ok(logger.infos.some((message) => message.includes('s1')), '归档成功应落 info 日志')
578
+ })
579
+
580
+ test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档', skipMissingDeps, async () => {
581
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-nolocate-'))
582
+ try {
583
+ const alivePath = path.join(dir, 'alive.jsonl')
584
+ await writeFile(alivePath, '{"header":1}\n{"event":0}\n')
585
+ const DAY_MS = 24 * 60 * 60 * 1000
586
+ const stale = Date.now() - 30 * DAY_MS
587
+ await utimes(alivePath, stale / 1000, stale / 1000)
588
+ const cwd = 'C:\\x'
589
+ const headers = [
590
+ { id: 's1', cwd, createdAt: stale },
591
+ { id: 's2', cwd, createdAt: stale },
592
+ ]
593
+ const { eventHandlers, registry } = makeCtx({
594
+ archivedIds: [],
595
+ headers,
596
+ agents: new Map(),
597
+ // s2 locate 返回 undefined(抽象后端契约允许):仅凭 createdAt 判活跃会绕过
598
+ // mtime 保护,与 delete 的 unsupportedBackend 拒绝对齐
599
+ sessionPersistence: {
600
+ locate: (header) => header.id === 's1' ? { path: alivePath } : undefined,
601
+ },
602
+ })
603
+ eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
604
+ await waitFor(() => registry.archiveCalls.length > 0)
605
+ assert.deepEqual(registry.archiveCalls, ['s1'])
606
+ } finally {
607
+ await rm(dir, { recursive: true, force: true })
608
+ }
609
+ })
610
+
611
+ test('自动归档评估:subagent 内部会话不参与归档(官方主列表不收录 subagent)', skipMissingDeps, async () => {
612
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-subagent-'))
613
+ try {
614
+ const plainPath = path.join(dir, 'plain.jsonl')
615
+ const childPath = path.join(dir, 'child.jsonl')
616
+ await writeFile(plainPath, '{"header":1}\n{"event":0}\n')
617
+ await writeFile(childPath, '{"header":1}\n{"event":0}\n')
618
+ const DAY_MS = 24 * 60 * 60 * 1000
619
+ const stale = Date.now() - 30 * DAY_MS
620
+ await utimes(plainPath, stale / 1000, stale / 1000)
621
+ await utimes(childPath, stale / 1000, stale / 1000)
622
+ const cwd = 'C:\\x'
623
+ const headers = [
624
+ { id: 'plain', cwd, createdAt: stale },
625
+ { id: 'child', cwd, createdAt: stale, origin: 'subagent', parentSession: 'parent' },
626
+ ]
627
+ const { eventHandlers, registry } = makeCtx({
628
+ archivedIds: [],
629
+ headers,
630
+ agents: new Map(),
631
+ sessionPersistence: {
632
+ locate: (header) => ({ path: header.id === 'plain' ? plainPath : childPath }),
633
+ },
634
+ })
635
+ eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
636
+ await waitFor(() => registry.archiveCalls.length > 0)
637
+ assert.deepEqual(registry.archiveCalls, ['plain'], 'subagent 会话永不入选归档')
638
+ } finally {
639
+ await rm(dir, { recursive: true, force: true })
640
+ }
641
+ })
642
+
643
+ test('启动评估:settings 就绪后全量归档超期会话,不限于单工作区', skipMissingDeps, async () => {
644
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-startup-'))
645
+ try {
646
+ const artifactA = path.join(dir, 'a.jsonl')
647
+ const artifactB = path.join(dir, 'b.jsonl')
648
+ await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
649
+ await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
650
+ const DAY_MS = 24 * 60 * 60 * 1000
651
+ const stale = Date.now() - 30 * DAY_MS
652
+ await utimes(artifactA, stale / 1000, stale / 1000)
653
+ await utimes(artifactB, stale / 1000, stale / 1000)
654
+ const headers = [
655
+ { id: 'a', cwd: 'C:\\x', createdAt: stale },
656
+ { id: 'b', cwd: 'C:\\y', createdAt: stale },
657
+ ]
658
+ const { activateSettings, registry, handlers } = makeCtx({
659
+ archivedIds: [],
660
+ headers,
661
+ agents: new Map(),
662
+ sessionPersistence: {
663
+ locate: (header) => ({ path: header.id === 'a' ? artifactA : artifactB }),
664
+ },
665
+ })
666
+ activateSettings()
667
+ await waitFor(() => registry.archiveCalls.length >= 2)
668
+ assert.deepEqual([...registry.archiveCalls].sort(), ['a', 'b'])
669
+ const status = response()
670
+ await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
671
+ assert.equal(status.status, 200)
672
+ assert.equal(status.body.periodic.running, true)
673
+ } finally {
674
+ await rm(dir, { recursive: true, force: true })
675
+ }
676
+ })
677
+
678
+ test('启动评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
679
+ const { activateSettings, registry } = makeCtx({
680
+ archivedIds: [],
681
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
682
+ agents: new Map(),
683
+ settingsValue: { autoArchiveDays: 0 },
684
+ })
685
+ activateSettings()
686
+ await new Promise((resolve) => setTimeout(resolve, 50))
687
+ assert.deepEqual(registry.archiveCalls, [])
688
+ })
689
+
690
+ test('周期评估:每日定时补扫首轮不可评估的会话', skipMissingDeps, async () => {
691
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-period-'))
692
+ // 夹具 mtime 用真实时钟落盘,之后接管 Date 与 setInterval:到期判断(nextDueAt)
693
+ // 与定时器触发共用同一虚拟时钟,tick 推进 24h 即触发周期轮
694
+ const artifactA = path.join(dir, 'a.jsonl')
695
+ const artifactB = path.join(dir, 'b.jsonl')
696
+ await writeFile(artifactA, '{"header":1}\n{"event":0}\n')
697
+ await writeFile(artifactB, '{"header":1}\n{"event":0}\n')
698
+ const DAY_MS = 24 * 60 * 60 * 1000
699
+ const realNow = Date.now()
700
+ const stale = realNow - 30 * DAY_MS
701
+ await utimes(artifactA, stale / 1000, stale / 1000)
702
+ await utimes(artifactB, stale / 1000, stale / 1000)
703
+ // 显式传 now:mock.timers 接管 Date 后从 epoch 0 起算,不传则夹具时间戳全成「未来」
704
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: realNow })
705
+ try {
706
+ // 首轮 b 产物不可读(守卫跳过),周期轮起可读:b 只能经周期评估归档
707
+ let bReadable = false
708
+ const { activateSettings, registry } = makeCtx({
709
+ archivedIds: [],
710
+ headers: [
711
+ { id: 'a', cwd: 'C:\\x', createdAt: stale },
712
+ { id: 'b', cwd: 'C:\\x', createdAt: stale },
713
+ ],
714
+ agents: new Map(),
715
+ sessionPersistence: {
716
+ locate: (header) => (header.id === 'b' && !bReadable)
717
+ ? { path: path.join(dir, 'gone.jsonl') }
718
+ : { path: header.id === 'a' ? artifactA : artifactB },
719
+ },
720
+ })
721
+ activateSettings()
722
+ await waitFor(() => registry.archiveCalls.length > 0)
723
+ assert.deepEqual(registry.archiveCalls, ['a'])
724
+ bReadable = true
725
+ mock.timers.tick(DAY_MS)
726
+ await waitFor(() => registry.archiveCalls.includes('b'))
727
+ // 桩回写 archivedSessionIds(真实注册表契约):已归档 a 不被周期轮重复选中
728
+ assert.deepEqual(registry.archiveCalls, ['a', 'b'])
729
+ } finally {
730
+ mock.timers.reset()
731
+ await rm(dir, { recursive: true, force: true })
732
+ }
733
+ })
734
+
735
+ test('周期评估:autoArchiveDays=0 时不评估', skipMissingDeps, async () => {
736
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
737
+ try {
738
+ const { activateSettings, registry } = makeCtx({
739
+ archivedIds: [],
740
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
741
+ agents: new Map(),
742
+ settingsValue: { autoArchiveDays: 0 },
743
+ })
744
+ activateSettings()
745
+ mock.timers.tick(24 * 60 * 60 * 1000)
746
+ await new Promise((resolve) => setTimeout(resolve, 50))
747
+ assert.deepEqual(registry.archiveCalls, [])
748
+ } finally {
749
+ mock.timers.reset()
750
+ }
751
+ })
752
+
753
+ test('周期评估:间隔设置运行时变更经 tick 对账(0 重启用 / 关闭即时暂停)', skipMissingDeps, async () => {
754
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-rearm-'))
755
+ // settings 桩返回同一对象引用:测试中改字段即模拟用户运行时改设置
756
+ const settingsValue = { autoArchiveIntervalHours: 0 }
757
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
758
+ try {
759
+ const artifacts = {}
760
+ const DAY_MS = 24 * 60 * 60 * 1000
761
+ const TICK_MS = 60 * 1000
762
+ const stale = Date.now() - 30 * DAY_MS
763
+ const readable = { s1: true, s2: false, s3: false }
764
+ for (const id of ['s1', 's2', 's3']) {
765
+ const artifact = path.join(dir, id + '.jsonl')
766
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
767
+ await utimes(artifact, stale / 1000, stale / 1000)
768
+ artifacts[id] = artifact
769
+ }
770
+ const headers = [
771
+ { id: 's1', cwd: 'C:\\x', createdAt: stale },
772
+ { id: 's2', cwd: 'C:\\x', createdAt: stale },
773
+ { id: 's3', cwd: 'C:\\x', createdAt: stale },
774
+ ]
775
+ const { activateSettings, registry } = makeCtx({
776
+ archivedIds: [],
777
+ headers,
778
+ agents: new Map(),
779
+ settingsValue,
780
+ sessionPersistence: {
781
+ locate: (header) => ({ path: readable[header.id] ? artifacts[header.id] : path.join(dir, 'gone-' + header.id) }),
782
+ },
783
+ })
784
+ activateSettings()
785
+ // 启动补扫只看 autoArchiveDays(默认 7),intervalHours=0 不影响首轮
786
+ await waitFor(() => registry.archiveCalls.length > 0)
787
+ assert.deepEqual(registry.archiveCalls, ['s1'])
788
+ // intervalHours=0 期间周期轮保持关闭:到期也不评估
789
+ readable.s2 = true
790
+ mock.timers.tick(DAY_MS)
791
+ await new Promise((resolve) => setTimeout(resolve, 50))
792
+ assert.deepEqual(registry.archiveCalls, ['s1'], '关闭状态下到期不得评估')
793
+ // 0 重启用:缺失 due 经 tick 对账补排,最迟下个 tick 恢复周期轮
794
+ settingsValue.autoArchiveIntervalHours = 24
795
+ mock.timers.tick(TICK_MS)
796
+ await waitFor(() => registry.archiveCalls.includes('s2'))
797
+ assert.deepEqual(registry.archiveCalls, ['s1', 's2'])
798
+ // 再次关闭:即时暂停,s3 不被评估
799
+ readable.s3 = true
800
+ settingsValue.autoArchiveIntervalHours = 0
801
+ mock.timers.tick(DAY_MS)
802
+ await new Promise((resolve) => setTimeout(resolve, 50))
803
+ assert.deepEqual(registry.archiveCalls, ['s1', 's2'], '再次关闭后到期不得评估')
804
+ // 第三次重启用:对账恢复,归档 s3
805
+ settingsValue.autoArchiveIntervalHours = 24
806
+ mock.timers.tick(TICK_MS)
807
+ await waitFor(() => registry.archiveCalls.includes('s3'))
808
+ assert.deepEqual(registry.archiveCalls, ['s1', 's2', 's3'])
809
+ } finally {
810
+ mock.timers.reset()
811
+ await rm(dir, { recursive: true, force: true })
812
+ }
813
+ })
814
+
815
+ test('周期评估:缩短间隔经 earliest 钳制前移,不等满旧周期', skipMissingDeps, async () => {
816
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-shorten-'))
817
+ const TICK_MS = 60 * 1000
818
+ const HOUR_MS = 60 * 60 * 1000
819
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
820
+ try {
821
+ const readable = { a: true, b: false }
822
+ const artifacts = {}
823
+ const DAY_MS = 24 * 60 * 60 * 1000
824
+ const stale = Date.now() - 30 * DAY_MS
825
+ for (const id of ['a', 'b']) {
826
+ const artifact = path.join(dir, id + '.jsonl')
827
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
828
+ await utimes(artifact, stale / 1000, stale / 1000)
829
+ artifacts[id] = artifact
830
+ }
831
+ const settingsValue = { autoArchiveIntervalHours: 24 }
832
+ const { activateSettings, registry } = makeCtx({
833
+ archivedIds: [],
834
+ headers: [
835
+ { id: 'a', cwd: 'C:\\x', createdAt: stale },
836
+ { id: 'b', cwd: 'C:\\x', createdAt: stale },
837
+ ],
838
+ agents: new Map(),
839
+ settingsValue,
840
+ sessionPersistence: {
841
+ locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
842
+ },
843
+ })
844
+ activateSettings()
845
+ await waitFor(() => registry.archiveCalls.length > 0)
846
+ assert.deepEqual(registry.archiveCalls, ['a'])
847
+ const afterStartup = registry.archiveCalls.length
848
+ // T+24h 到期轮(a 已归档无产出);紧接缩短为 1h:earliest 钳制应把 due 前移到
849
+ // 缩短时刻 + 1h,而非等满旧周期 T+48h;b 在缩短后变可读,由前移后的轮归档
850
+ mock.timers.tick(DAY_MS)
851
+ settingsValue.autoArchiveIntervalHours = 1
852
+ readable.b = true
853
+ mock.timers.tick(DAY_MS + TICK_MS)
854
+ await waitFor(() => registry.archiveCalls.length > afterStartup)
855
+ assert.deepEqual(registry.archiveCalls, ['a', 'b'])
856
+ } finally {
857
+ mock.timers.reset()
858
+ await rm(dir, { recursive: true, force: true })
859
+ }
860
+ })
861
+
862
+ test('定时服务缺失:周期评估降级,状态接口提示且不影响启动补扫', skipMissingDeps, async () => {
863
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-degrade-'))
864
+ try {
865
+ const artifact = path.join(dir, 'a.jsonl')
866
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
867
+ const DAY_MS = 24 * 60 * 60 * 1000
868
+ const stale = Date.now() - 30 * DAY_MS
869
+ await utimes(artifact, stale / 1000, stale / 1000)
870
+ const { activateSettings, handlers, registry } = makeCtx({
871
+ archivedIds: [],
872
+ headers: [{ id: 'a', cwd: 'C:\\x', createdAt: stale }],
873
+ agents: new Map(),
874
+ sessionPersistence: { locate: () => ({ path: artifact }) },
875
+ timerAvailable: false,
876
+ })
877
+ activateSettings()
878
+ // 启动补扫照常工作:软依赖缺失只影响周期轮
879
+ await waitFor(() => registry.archiveCalls.length > 0)
880
+ assert.deepEqual(registry.archiveCalls, ['a'])
881
+ const status = response()
882
+ await handlers.get('/api/session-manager/status')(request('x', 'GET'), status)
883
+ assert.equal(status.status, 200)
884
+ assert.equal(status.body.periodic.running, false)
885
+ assert.notEqual(status.body.periodic.reason, '')
886
+ } finally {
887
+ await rm(dir, { recursive: true, force: true })
888
+ }
889
+ })
890
+
891
+ test('周期评估:到期 tick 恰逢门闩占用时保持到期态,下个 tick 重试而非推后一个周期', skipMissingDeps, async () => {
892
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-gate-tick-'))
893
+ const TICK_MS = 60 * 1000
894
+ const DAY_MS = 24 * 60 * 60 * 1000
895
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
896
+ try {
897
+ const readable = { a: true, b: false, c: false }
898
+ const artifacts = {}
899
+ const stale = Date.now() - 30 * DAY_MS
900
+ for (const id of ['a', 'b', 'c']) {
901
+ const artifact = path.join(dir, id + '.jsonl')
902
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
903
+ await utimes(artifact, stale / 1000, stale / 1000)
904
+ artifacts[id] = artifact
905
+ }
906
+ const headers = [
907
+ { id: 'a', cwd: 'C:\\x', createdAt: stale },
908
+ { id: 'b', cwd: 'C:\\x', createdAt: stale },
909
+ { id: 'c', cwd: 'C:\\x', createdAt: stale },
910
+ ]
911
+ const { activateSettings, eventHandlers, registry } = makeCtx({
912
+ archivedIds: [],
913
+ headers,
914
+ agents: new Map(),
915
+ sessionPersistence: {
916
+ locate: (header) => readable[header.id] ? { path: artifacts[header.id] } : { path: path.join(dir, 'gone-' + header.id) },
917
+ },
918
+ })
919
+ activateSettings()
920
+ // 启动补扫:a 归档(桩回写 archived),b/c 不可读留候选
921
+ await waitFor(() => registry.archiveCalls.length > 0)
922
+ assert.deepEqual(registry.archiveCalls, ['a'])
923
+ // 挂起归档执行器,再触发一轮评估:门闩被占
924
+ let releaseArchive
925
+ const held = new Promise((resolve) => { releaseArchive = resolve })
926
+ const originalArchive = registry.archiveSession
927
+ let archiveArrived = false
928
+ registry.archiveSession = async (id) => {
929
+ archiveArrived = true
930
+ await held
931
+ await originalArchive(id)
932
+ }
933
+ readable.b = true
934
+ eventHandlers['session/created']({ header: headers[1] })
935
+ await waitFor(() => archiveArrived)
936
+ assert.deepEqual(registry.archiveCalls, ['a'], '挂起的 b 尚未计入归档调用')
937
+ // 到期 tick 恰逢门闩占用:被丢弃,不得把周期推后 24h(保持到期态)
938
+ mock.timers.tick(DAY_MS)
939
+ // 释放挂起评估并让出事件循环,确保门闩复位(evaluating=false)落定
940
+ releaseArchive()
941
+ await new Promise((resolve) => setImmediate(resolve))
942
+ await new Promise((resolve) => setTimeout(resolve, 0))
943
+ // c 变可读,60s 后的下个 tick 即应重试并归档 c(若周期被推后 24h 此步超时)
944
+ readable.c = true
945
+ mock.timers.tick(TICK_MS)
946
+ await waitFor(() => registry.archiveCalls.includes('c'))
947
+ assert.deepEqual(registry.archiveCalls, ['a', 'b', 'c'])
948
+ } finally {
949
+ mock.timers.reset()
950
+ await rm(dir, { recursive: true, force: true })
951
+ }
952
+ })
953
+
954
+ // 执行器桩替:trash 与回收区搬移为进程级 OS 副作用出口,经 executor 注册表注入
955
+ async function withExecutorStub(stubs, run) {
956
+ const originals = {}
957
+ for (const [name, stub] of Object.entries(stubs)) {
958
+ originals[name] = indexModule.executor[name]
959
+ indexModule.executor[name] = stub
960
+ }
961
+ try {
962
+ await run()
963
+ } finally {
964
+ for (const [name, original] of Object.entries(originals)) indexModule.executor[name] = original
965
+ }
966
+ }
967
+
968
+ async function withTrashStub(stub, run) {
969
+ await withExecutorStub({ trashPath: stub }, run)
970
+ }
971
+
972
+ const IDLE_S1 = new Map([['s1', { status: 'idle' }]])
973
+
974
+ // locate 契约实证:返回会话目录下的日志文件;stat 为真实 fs,产物以临时文件承载
975
+ async function makeLocatedArtifact(sessionId = 's1') {
976
+ const root = await mkdtemp(path.join(tmpdir(), 'sm-del-'))
977
+ const locatedDir = path.join(root, sessionId)
978
+ const locatedPath = path.join(locatedDir, 'session.jsonl.zstd')
979
+ await mkdir(locatedDir, { recursive: true })
980
+ await writeFile(locatedPath, 'log-bytes')
981
+ return {
982
+ locatedDir,
983
+ locatedPath,
984
+ cleanup: () => rm(root, { recursive: true, force: true }),
985
+ }
986
+ }
987
+
988
+ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMissingDeps, async () => {
989
+ const artifact = await makeLocatedArtifact()
990
+ try {
991
+ const domain = makeDomain(['s1'])
992
+ const workspace = makeWorkspace('C:\\x', ['s1'])
993
+ const { handlers, ledger } = makeCtx({
994
+ archivedIds: ['s1'],
995
+ headers: [HEADER],
996
+ agents: IDLE_S1,
997
+ domain,
998
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
999
+ workspaces: [workspace],
1000
+ })
1001
+ const trashed = []
1002
+ const res = response()
1003
+ await withTrashStub(async (p) => { trashed.push(p) }, async () => {
1004
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1005
+ })
1006
+ assert.equal(res.status, 200)
1007
+ assert.deepEqual(res.body, { ok: true, mode: 'os' })
1008
+ // locate 给的是日志文件,trash 必须上移到会话目录,否则残留空目录
1009
+ assert.deepEqual(trashed, [artifact.locatedDir])
1010
+ assert.equal(ledger.state.deleted.length, 1)
1011
+ assert.equal(ledger.state.deleted[0].sessionId, 's1')
1012
+ assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
1013
+ assert.deepEqual(domain.state.archivedSessionIds, [])
1014
+ assert.deepEqual(workspace.sessionIds, [])
1015
+ } finally {
1016
+ await artifact.cleanup()
1017
+ }
1018
+ })
1019
+
1020
+ test('删除成功:同 id 残留台账被替换,无重复条目', skipMissingDeps, async () => {
1021
+ const artifact = await makeLocatedArtifact()
1022
+ try {
1023
+ const { handlers, ledger } = makeCtx({
1024
+ archivedIds: ['s1'],
1025
+ headers: [HEADER],
1026
+ agents: IDLE_S1,
1027
+ domain: makeDomain(['s1']),
1028
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1029
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: 'old', deletedAt: 1 }]),
1030
+ })
1031
+ const res = response()
1032
+ await withTrashStub(async () => {}, async () => {
1033
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1034
+ })
1035
+ assert.equal(res.status, 200)
1036
+ assert.deepEqual(res.body, { ok: true, mode: 'os' })
1037
+ assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
1038
+ assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
1039
+ } finally {
1040
+ await artifact.cleanup()
1041
+ }
1042
+ })
1043
+
1044
+ // Given 系统回收站不可用(trash 抛错), When 删除, Then 产物降级搬入插件回收区,
1045
+ // 台账记录暂存路径 heldPath,响应 mode=quarantine
1046
+ test('删除:系统回收站不可用时降级搬入插件回收区,台账记录暂存路径', skipMissingDeps, async () => {
1047
+ const artifact = await makeLocatedArtifact()
1048
+ try {
1049
+ const { handlers, ledger } = makeCtx({
1050
+ archivedIds: ['s1'],
1051
+ headers: [HEADER],
1052
+ agents: IDLE_S1,
1053
+ domain: makeDomain(['s1']),
1054
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1055
+ })
1056
+ const res = response()
1057
+ await withExecutorStub({ trashPath: async () => { throw new Error('gio: not found') } }, async () => {
1058
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1059
+ })
1060
+ assert.equal(res.status, 200)
1061
+ assert.equal(res.body.ok, true)
1062
+ assert.equal(res.body.mode, 'quarantine')
1063
+ const entry = ledger.state.deleted[0]
1064
+ assert.equal(entry.sessionId, 's1')
1065
+ assert.equal(entry.path, artifact.locatedDir, '台账 path 仍为原位置,移回时使用')
1066
+ assert.ok(entry.heldPath && entry.heldPath.startsWith(process.env.DSH_QUARANTINE_DIR), '台账记录回收区暂存路径')
1067
+ await assert.rejects(access(artifact.locatedDir), '原位置产物应已搬走')
1068
+ const held = await readFile(path.join(entry.heldPath, 'session.jsonl.zstd'), 'utf8')
1069
+ assert.equal(held, 'log-bytes')
1070
+ } finally {
1071
+ await artifact.cleanup()
1072
+ }
1073
+ })
1074
+
1075
+ // Given 回收站与回收区均不可用, When 删除, Then 400 且台账不写(会话未被删除)
1076
+ test('删除:回收站与回收区均失败时拒绝且台账不写', skipMissingDeps, async () => {
1077
+ const artifact = await makeLocatedArtifact()
1078
+ try {
1079
+ const { handlers, ledger } = makeCtx({
1080
+ archivedIds: ['s1'],
1081
+ headers: [HEADER],
1082
+ agents: IDLE_S1,
1083
+ domain: makeDomain(['s1']),
1084
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1085
+ })
1086
+ const res = response()
1087
+ await withExecutorStub({
1088
+ trashPath: async () => { throw new Error('gio: not found') },
1089
+ moveToQuarantine: async () => { throw new Error('read-only volume') },
1090
+ }, async () => {
1091
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1092
+ })
1093
+ assert.equal(res.status, 400)
1094
+ assert.ok(res.body.error.startsWith(MESSAGES.trashFailed) && res.body.error.includes('均不可用'), '双失败文案区分两级')
1095
+ assert.deepEqual(ledger.state.deleted, [])
1096
+ // 双失败产物原地保留
1097
+ await access(artifact.locatedDir)
1098
+ } finally {
1099
+ await artifact.cleanup()
1100
+ }
1101
+ })
1102
+
1103
+ test('删除:产物已缺失时跳过 trash 与台账,完成列表清理(同 id 重删场景)', skipMissingDeps, async () => {
1104
+ const domain = makeDomain([])
1105
+ const workspace = makeWorkspace('C:\\x', ['s1'])
1106
+ const { handlers, ledger } = makeCtx({
1107
+ // 幽灵会话已不在归档集合:残留清理不要求归档资格,但要求台账有记录
1108
+ // (首删已记录,重删走幽灵收尾)
1109
+ archivedIds: [],
1110
+ headers: [HEADER],
1111
+ agents: IDLE_S1,
1112
+ domain,
1113
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1114
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1115
+ workspaces: [workspace],
1116
+ })
1117
+ const trashed = []
1118
+ const res = response()
1119
+ await withTrashStub(async (p) => { trashed.push(p) }, async () => {
1120
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1121
+ })
1122
+ assert.equal(res.status, 200)
1123
+ assert.deepEqual(res.body, { ok: true, message: MESSAGES.ghostCleanup })
1124
+ assert.deepEqual(trashed, [])
1125
+ assert.equal(ledger.writes, 0)
1126
+ // 残留清理仍解除工作区关联与归档集合(幂等)
1127
+ assert.deepEqual(workspace.sessionIds, [])
1128
+ assert.deepEqual(domain.state.archivedSessionIds, [])
1129
+ })
1130
+
1131
+ test('删除:产物缺失且无归档记录无台账时拒绝(防新建会话未落盘被剥离)', skipMissingDeps, async () => {
1132
+ const workspace = makeWorkspace('C:\\x', ['s1'])
1133
+ const { handlers } = makeCtx({
1134
+ archivedIds: [],
1135
+ headers: [HEADER],
1136
+ agents: IDLE_S1,
1137
+ domain: makeDomain([]),
1138
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1139
+ workspaces: [workspace],
1140
+ })
1141
+ const res = response()
1142
+ await withTrashStub(async () => {}, async () => {
1143
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1144
+ })
1145
+ assert.equal(res.status, 400)
1146
+ assert.equal(res.body.error, MESSAGES.notArchived)
1147
+ // 拒绝路径不动工作区关联
1148
+ assert.deepEqual(workspace.sessionIds, ['s1'])
1149
+ })
1150
+
1151
+ test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, async () => {
1152
+ const { handlers } = makeCtx({
1153
+ archivedIds: [],
1154
+ headers: [HEADER],
1155
+ agents: new Map([['s1', { status: 'running' }]]),
1156
+ domain: makeDomain([]),
1157
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1158
+ })
1159
+ const res = response()
1160
+ await withTrashStub(async () => {}, async () => {
1161
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1162
+ })
1163
+ assert.equal(res.status, 400)
1164
+ assert.equal(res.body.error, MESSAGES.running)
1165
+ })
1166
+
1167
+ test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDeps, async () => {
1168
+ const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1169
+ const { handlers, logger } = makeCtx({
1170
+ archivedIds: [],
1171
+ headers: [HEADER],
1172
+ agents: IDLE_S1,
1173
+ domain: null,
1174
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1175
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1176
+ workspaces: [workspace],
1177
+ })
1178
+ const res = response()
1179
+ await withTrashStub(async () => {}, async () => {
1180
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1181
+ })
1182
+ assert.equal(res.status, 200)
1183
+ assert.equal(res.body.partial, true)
1184
+ assert.equal(res.body.message, '产物已不存在,但移除列表记录失败,且移除归档记录失败')
1185
+ // 幽灵清理半失败:原始错误进服务端日志(排障契约),响应文案保持摘要
1186
+ assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), '幽灵 detach 失败应落日志')
1187
+ })
1188
+
1189
+ // Given 回收窗口内会话恢复运行(runningDuringTrash), When 聚合, Then 警告形态且无 partial 键
1190
+ test('删除:回收窗口内会话恢复运行,响应警告形态且无 partial 键', skipMissingDeps, async () => {
1191
+ const artifact = await makeLocatedArtifact()
1192
+ try {
1193
+ const agents = new Map([['s1', { status: 'idle' }]])
1194
+ const { handlers } = makeCtx({
1195
+ archivedIds: ['s1'],
1196
+ headers: [HEADER],
1197
+ agents,
1198
+ domain: makeDomain(['s1']),
1199
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1200
+ })
1201
+ const res = response()
1202
+ // trash 桩内翻转运行态:模拟 OS 回收异步窗口内的执行期翻转
1203
+ await withTrashStub(async () => { agents.set('s1', { status: 'running' }) }, async () => {
1204
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1205
+ })
1206
+ assert.equal(res.status, 200)
1207
+ // 全成功警告态:{ok,message,mode} 形态,无 partial 键
1208
+ assert.deepEqual(res.body, { ok: true, mode: 'os', message: DELETE_MESSAGES.runningDuringTrash })
1209
+ } finally {
1210
+ await artifact.cleanup()
1211
+ }
1212
+ })
1213
+
1214
+ test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二道防线)', skipMissingDeps, async () => {
1215
+ const stale = Date.now() - 30 * 24 * 60 * 60 * 1000
1216
+ let locateCalls = 0
1217
+ const { activateSettings, registry } = makeCtx({
1218
+ archivedIds: [],
1219
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: stale }],
1220
+ agents: new Map([['s1', { status: 'running' }]]),
1221
+ sessionPersistence: {
1222
+ locate: (header) => {
1223
+ locateCalls += 1
1224
+ return { path: 'C:\\x\\s1\\session.jsonl.zstd' }
1225
+ },
1226
+ },
1227
+ })
1228
+ // 历史启动对齐(运行中会话即时路径)也走 locate:等它跑完并清零计数,
1229
+ // 此后断言只针对归档评估的产物 IO
1230
+ await new Promise((resolve) => setTimeout(resolve, 30))
1231
+ locateCalls = 0
1232
+ activateSettings()
1233
+ // 等评估轮跑完(正常会话路径必然触达 locate):running 会话不得产生 locate 调用
1234
+ await new Promise((resolve) => setTimeout(resolve, 250))
1235
+ assert.equal(locateCalls, 0, '运行中会话应在产物 IO 前早退')
1236
+ assert.deepEqual(registry.archiveCalls, [])
1237
+ })
1238
+
1239
+ test('删除:workspace 域未打开时归档清理失败,响应 partial 且台账已记录', skipMissingDeps, async () => {
1240
+ const artifact = await makeLocatedArtifact()
1241
+ try {
1242
+ const { handlers, ledger } = makeCtx({
1243
+ archivedIds: ['s1'],
1244
+ headers: [HEADER],
1245
+ agents: IDLE_S1,
1246
+ domain: null,
1247
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1248
+ })
1249
+ const res = response()
1250
+ await withTrashStub(async () => {}, async () => {
1251
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1252
+ })
1253
+ assert.equal(res.status, 200)
1254
+ assert.equal(res.body.partial, true)
1255
+ assert.equal(res.body.message, '已移入回收站,但移除归档记录失败')
1256
+ // detach 已完成、台账已记录:仅归档集合清理半失败
1257
+ assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s1'])
1258
+ } finally {
1259
+ await artifact.cleanup()
1260
+ }
1261
+ })
1262
+
1263
+ test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点', skipMissingDeps, async () => {
1264
+ const artifact = await makeLocatedArtifact()
1265
+ try {
1266
+ const brokenLedger = makeLedgerDomain([])
1267
+ brokenLedger.global.set = async () => { throw new Error('medium broken') }
1268
+ const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1269
+ const { handlers, logger } = makeCtx({
1270
+ archivedIds: ['s1'],
1271
+ headers: [HEADER],
1272
+ agents: IDLE_S1,
1273
+ domain: makeDomain(['s1']),
1274
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1275
+ ledger: brokenLedger,
1276
+ workspaces: [workspace],
1277
+ })
1278
+ const res = response()
1279
+ await withTrashStub(async () => {}, async () => {
1280
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1281
+ })
1282
+ assert.equal(res.status, 200)
1283
+ assert.equal(res.body.partial, true)
1284
+ assert.equal(res.body.message, '已移入回收站,但移除列表记录失败,且重挂载记录失败')
1285
+ // 台账与 detach 的原始错误均进服务端日志
1286
+ assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('medium broken')), '台账失败应落日志')
1287
+ assert.ok(logger.warns.some((message) => message.includes('s1') && message.includes('detach boom')), 'detach 失败应落日志')
1288
+ } finally {
1289
+ await artifact.cleanup()
1290
+ }
1291
+ })
1292
+
1293
+ // (旧「trash 失败整体中止」语义已被回收区降级取代:单 trash 失败见降级用例,
1294
+ // 双失败中止见「回收站与回收区均失败时拒绝且台账不写」)
1295
+
1296
+ test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1297
+ const artifact = await makeLocatedArtifact()
1298
+ try {
1299
+ const domain = makeDomain(['s1'])
1300
+ const { handlers } = makeCtx({
1301
+ archivedIds: ['s1'],
1302
+ headers: [HEADER],
1303
+ agents: IDLE_S1,
1304
+ domain,
1305
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1306
+ openRejected: new Error('ledger down'),
1307
+ })
1308
+ const res = response()
1309
+ await withTrashStub(async () => {}, async () => {
1310
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1311
+ })
1312
+ assert.equal(res.status, 200)
1313
+ assert.equal(res.body.partial, true)
1314
+ assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1315
+ // 台账失败不阻断删除主链路:归档清理照常完成
1316
+ assert.deepEqual(domain.state.archivedSessionIds, [])
1317
+ } finally {
1318
+ await artifact.cleanup()
1319
+ }
1320
+ })
1321
+
1322
+ test('台账写入失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1323
+ const artifact = await makeLocatedArtifact()
1324
+ try {
1325
+ const brokenLedger = makeLedgerDomain([])
1326
+ brokenLedger.global.set = async () => { throw new Error('medium broken') }
1327
+ const { handlers } = makeCtx({
1328
+ archivedIds: ['s1'],
1329
+ headers: [HEADER],
1330
+ agents: IDLE_S1,
1331
+ domain: makeDomain(['s1']),
1332
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1333
+ ledger: brokenLedger,
1334
+ })
1335
+ const res = response()
1336
+ await withTrashStub(async () => {}, async () => {
1337
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1338
+ })
1339
+ assert.equal(res.status, 200)
1340
+ assert.equal(res.body.partial, true)
1341
+ assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1342
+ } finally {
1343
+ await artifact.cleanup()
1344
+ }
1345
+ })
1346
+
1347
+ test('已删除列表:GET 按存储序全量返回', skipMissingDeps, async () => {
1348
+ const entries = [
1349
+ { sessionId: 's2', path: 'p2', deletedAt: 2 },
1350
+ { sessionId: 's1', path: 'p1', deletedAt: 1 },
1351
+ ]
1352
+ const { handlers } = makeCtx({
1353
+ archivedIds: [],
1354
+ headers: [],
1355
+ agents: new Map(),
1356
+ ledger: makeLedgerDomain(entries),
1357
+ })
1358
+ const res = response()
1359
+ await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
1360
+ assert.equal(res.status, 200)
1361
+ assert.deepEqual(res.body, { deleted: entries })
1362
+ })
1363
+
1364
+ test('已删除列表:非 GET 拒绝', skipMissingDeps, async () => {
1365
+ const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
1366
+ const res = response()
1367
+ await handlers.get('/api/session-manager/deleted')(request('s1'), res)
1368
+ assert.equal(res.status, 405)
1369
+ })
1370
+
1371
+ test('已删除列表:台账域打开失败拒绝', skipMissingDeps, async () => {
1372
+ const { handlers } = makeCtx({
1373
+ archivedIds: [],
1374
+ headers: [],
1375
+ agents: new Map(),
1376
+ openRejected: new Error('ledger down'),
1377
+ })
1378
+ const res = response()
1379
+ await handlers.get('/api/session-manager/deleted')(request('x', 'GET'), res)
1380
+ assert.equal(res.status, 400)
1381
+ })
1382
+
1383
+ test('重挂载:产物已还原时经官方 attachSession 挂回并清除台账', skipMissingDeps, async () => {
1384
+ const workspace = makeWorkspace('C:\\x', [])
1385
+ const { handlers, ledger } = makeCtx({
1386
+ archivedIds: [],
1387
+ headers: [HEADER],
1388
+ agents: new Map(),
1389
+ workspaces: [workspace],
1390
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1391
+ })
1392
+ const res = response()
1393
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1394
+ assert.equal(res.status, 200)
1395
+ assert.deepEqual(res.body, { ok: true })
1396
+ assert.deepEqual(workspace.attachCalls, ['s1'])
1397
+ assert.deepEqual(workspace.sessionIds, ['s1'])
1398
+ assert.deepEqual(ledger.state.deleted, [])
1399
+ assert.equal(ledger.writes, 1)
1400
+ })
1401
+
1402
+ test('重挂载:产物未还原(持久层无 header)拒绝', skipMissingDeps, async () => {
1403
+ const { handlers, ledger } = makeCtx({
1404
+ archivedIds: [],
1405
+ headers: [],
1406
+ agents: new Map(),
1407
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1408
+ })
1409
+ const res = response()
1410
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1411
+ assert.equal(res.status, 400)
1412
+ assert.equal(res.body.error, '会话产物不在持久层,请先到系统回收站还原后重试')
1413
+ // 拒绝路径不动台账
1414
+ assert.equal(ledger.writes, 0)
1415
+ })
1416
+
1417
+ // Given 回收区暂存条目(heldPath)且产物在暂存处, When 重挂载, Then 先移回原位置再挂载,
1418
+ // 会话列表随产物回位而可见(动态 headers 模拟宿主按产物现状列会话),台账清除
1419
+ test('重挂载:回收区暂存条目自动移回原位置后挂载成功', skipMissingDeps, async () => {
1420
+ const base = await mkdtemp(path.join(tmpdir(), 'sm-held-'))
1421
+ const originalDir = path.join(base, 's1')
1422
+ const locatedPath = path.join(originalDir, 'session.jsonl.zstd')
1423
+ const workspace = makeWorkspace('C:\\x', [])
1424
+ try {
1425
+ await mkdir(originalDir, { recursive: true })
1426
+ await writeFile(locatedPath, 'log-bytes')
1427
+ const { moveToQuarantine } = await import('../src/trash.mjs')
1428
+ const heldPath = await moveToQuarantine(originalDir, path.join(base, 'held'))
1429
+ await access(originalDir).then(() => { throw new Error('夹具前置失败:原位置应已空') }, () => {})
1430
+ const { handlers, ledger } = makeCtx({
1431
+ archivedIds: [],
1432
+ // 宿主按产物现状列会话:移回前不可见,移回后可见
1433
+ headers: async () => ((await access(locatedPath).then(() => true, () => false)) ? [HEADER] : []),
1434
+ agents: new Map(),
1435
+ workspaces: [workspace],
1436
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: locatedPath } : undefined },
1437
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: originalDir, heldPath, deletedAt: 1 }]),
1438
+ })
1439
+ const res = response()
1440
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1441
+ assert.equal(res.status, 200)
1442
+ assert.deepEqual(res.body, { ok: true })
1443
+ assert.deepEqual(workspace.attachCalls, ['s1'])
1444
+ assert.deepEqual(ledger.state.deleted, [])
1445
+ // 产物已回到原位置,暂存消失
1446
+ const restored = await readFile(locatedPath, 'utf8')
1447
+ assert.equal(restored, 'log-bytes')
1448
+ await assert.rejects(access(heldPath))
1449
+ } finally {
1450
+ await rm(base, { recursive: true, force: true })
1451
+ }
1452
+ })
1453
+
1454
+ // Given 回收区暂存条目但暂存已缺失(原位置同样缺失), When 重挂载, Then 专属文案拒绝
1455
+ test('重挂载:回收区暂存缺失时拒绝且不动台账', skipMissingDeps, async () => {
1456
+ const { handlers, ledger } = makeCtx({
1457
+ archivedIds: [],
1458
+ headers: [],
1459
+ agents: new Map(),
1460
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: path.join(tmpdir(), 'sm-gone-original', 's1'), heldPath: path.join(tmpdir(), 'sm-gone-held', 's1-1'), deletedAt: 1 }]),
1461
+ })
1462
+ const res = response()
1463
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1464
+ assert.equal(res.status, 400)
1465
+ assert.equal(res.body.error, MESSAGES.heldMissing)
1466
+ assert.equal(ledger.writes, 0)
1467
+ })
1468
+
1469
+ // Given 回收区暂存条目且原位置产物已被手工还原, When 重挂载, Then 跳过移回直接挂载,暂存保留
1470
+ test('重挂载:原位置已有产物时跳过移回(手工还原场景)', skipMissingDeps, async () => {
1471
+ const base = await mkdtemp(path.join(tmpdir(), 'sm-skip-held-'))
1472
+ const originalDir = path.join(base, 's1')
1473
+ const locatedPath = path.join(originalDir, 'session.jsonl.zstd')
1474
+ try {
1475
+ await mkdir(originalDir, { recursive: true })
1476
+ await writeFile(locatedPath, 'restored-by-hand')
1477
+ const heldPath = path.join(base, 'held', 's1-123')
1478
+ await mkdir(heldPath, { recursive: true })
1479
+ await writeFile(path.join(heldPath, 'session.jsonl.zstd'), 'held-copy')
1480
+ const workspace = makeWorkspace('C:\\x', [])
1481
+ const { handlers, ledger } = makeCtx({
1482
+ archivedIds: [],
1483
+ headers: [HEADER],
1484
+ agents: new Map(),
1485
+ workspaces: [workspace],
1486
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: locatedPath } : undefined },
1487
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: originalDir, heldPath, deletedAt: 1 }]),
1488
+ })
1489
+ const res = response()
1490
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1491
+ assert.equal(res.status, 200)
1492
+ assert.deepEqual(res.body, { ok: true })
1493
+ assert.deepEqual(workspace.attachCalls, ['s1'])
1494
+ assert.deepEqual(ledger.state.deleted, [])
1495
+ // 原位置内容未被覆盖,暂存目录按已知取舍保留(孤儿清理不做)
1496
+ assert.equal(await readFile(locatedPath, 'utf8'), 'restored-by-hand')
1497
+ await access(heldPath)
1498
+ } finally {
1499
+ await rm(base, { recursive: true, force: true })
1500
+ }
1501
+ })
1502
+
1503
+ // Given quarantine 降级成功但 detach 失败, When 删除, Then partial 主文案为回收区前缀且 mode=quarantine
1504
+ test('删除:回收区降级加 detach 失败聚合为回收区前缀 partial', skipMissingDeps, async () => {
1505
+ const artifact = await makeLocatedArtifact()
1506
+ try {
1507
+ const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach 失败') })
1508
+ const { handlers } = makeCtx({
1509
+ archivedIds: ['s1'],
1510
+ headers: [HEADER],
1511
+ agents: IDLE_S1,
1512
+ domain: makeDomain(['s1']),
1513
+ workspaces: [workspace],
1514
+ sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1515
+ })
1516
+ const res = response()
1517
+ await withExecutorStub({ trashPath: async () => { throw new Error('gio: not found') } }, async () => {
1518
+ await handlers.get('/api/session-manager/delete')(request('s1'), res)
1519
+ })
1520
+ assert.equal(res.status, 200)
1521
+ assert.equal(res.body.partial, true)
1522
+ assert.equal(res.body.mode, 'quarantine')
1523
+ assert.ok(res.body.message.startsWith('已移入插件回收区'), 'partial 主文案为回收区前缀')
1524
+ } finally {
1525
+ await artifact.cleanup()
1526
+ }
1527
+ })
1528
+
1529
+ // Given 台账域打开失败, When 重挂载, Then 入口即拒绝(移回依据不可得,不做静默跳过)
1530
+ test('重挂载:台账域打开失败时入口拒绝', skipMissingDeps, async () => {
1531
+ const { handlers, ledger } = makeCtx({
1532
+ archivedIds: [],
1533
+ headers: [HEADER],
1534
+ agents: new Map(),
1535
+ openRejected: new Error('ledger down'),
1536
+ })
1537
+ const res = response()
1538
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1539
+ assert.equal(res.status, 400)
1540
+ assert.equal(res.body.error, MESSAGES.systemError)
1541
+ assert.equal(ledger.writes, 0)
1542
+ })
1543
+
1544
+ test('重挂载:找不到所属工作区拒绝', skipMissingDeps, async () => {
1545
+ const { handlers } = makeCtx({
1546
+ archivedIds: [],
1547
+ headers: [HEADER],
1548
+ agents: new Map(),
1549
+ workspaces: [makeWorkspace('C:\\other', [])],
1550
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1551
+ })
1552
+ const res = response()
1553
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1554
+ assert.equal(res.status, 400)
1555
+ assert.equal(res.body.error, '未找到会话所属工作区,无法重新挂载')
1556
+ })
1557
+
1558
+ test('重挂载:attachSession 失败时拒绝且台账保留', skipMissingDeps, async () => {
1559
+ const workspace = makeWorkspace('C:\\x', [], { attachError: new Error('cwd 消失') })
1560
+ const entries = [{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]
1561
+ const { handlers, ledger } = makeCtx({
1562
+ archivedIds: [],
1563
+ headers: [HEADER],
1564
+ agents: new Map(),
1565
+ workspaces: [workspace],
1566
+ ledger: makeLedgerDomain(entries),
1567
+ })
1568
+ const res = response()
1569
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1570
+ assert.equal(res.status, 400)
1571
+ assert.match(res.body.error, /cwd 消失/)
1572
+ assert.deepEqual(ledger.state.deleted, entries)
1573
+ assert.equal(ledger.writes, 0)
1574
+ })
1575
+
1576
+ test('重挂载:会话已在工作区时幂等收尾(残留条目重试无副作用)', skipMissingDeps, async () => {
1577
+ const workspace = makeWorkspace('C:\\x', ['s1'])
1578
+ const { handlers, ledger } = makeCtx({
1579
+ archivedIds: [],
1580
+ headers: [HEADER],
1581
+ agents: new Map(),
1582
+ workspaces: [workspace],
1583
+ ledger: makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }]),
1584
+ })
1585
+ const res = response()
1586
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1587
+ assert.equal(res.status, 200)
1588
+ assert.deepEqual(res.body, { ok: true })
1589
+ // 官方契约:id 已在 sessionIds 中,挂载幂等无重复
1590
+ assert.deepEqual(workspace.sessionIds, ['s1'])
1591
+ assert.deepEqual(ledger.state.deleted, [])
1592
+ })
1593
+
1594
+ test('重挂载:挂载成功但台账清除失败时响应 partial', skipMissingDeps, async () => {
1595
+ const workspace = makeWorkspace('C:\\x', [])
1596
+ const brokenLedger = makeLedgerDomain([{ sessionId: 's1', path: LEDGER_FIXTURE_PATH, deletedAt: 1 }])
1597
+ brokenLedger.global.set = async () => { throw new Error('medium broken') }
1598
+ const { handlers } = makeCtx({
1599
+ archivedIds: [],
1600
+ headers: [HEADER],
1601
+ agents: new Map(),
1602
+ workspaces: [workspace],
1603
+ ledger: brokenLedger,
1604
+ })
1605
+ const res = response()
1606
+ await handlers.get('/api/session-manager/remount')(request('s1'), res)
1607
+ assert.equal(res.status, 200)
1608
+ assert.equal(res.body.partial, true)
1609
+ assert.equal(res.body.message, '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾')
1610
+ assert.deepEqual(workspace.attachCalls, ['s1'])
1611
+ })
1612
+
1613
+ test('移除记录:命中删除,未命中幂等无写', skipMissingDeps, async () => {
1614
+ const entries = [
1615
+ { sessionId: 's1', path: 'p1', deletedAt: 1 },
1616
+ { sessionId: 's2', path: 'p2', deletedAt: 2 },
1617
+ ]
1618
+ const { handlers, ledger } = makeCtx({
1619
+ archivedIds: [],
1620
+ headers: [],
1621
+ agents: new Map(),
1622
+ ledger: makeLedgerDomain(entries),
1623
+ })
1624
+ const hit = response()
1625
+ await handlers.get('/api/session-manager/forget')(request('s1'), hit)
1626
+ assert.equal(hit.status, 200)
1627
+ assert.deepEqual(hit.body, { ok: true })
1628
+ assert.deepEqual(ledger.state.deleted.map((item) => item.sessionId), ['s2'])
1629
+ assert.equal(ledger.writes, 1)
1630
+ const miss = response()
1631
+ await handlers.get('/api/session-manager/forget')(request('s9'), miss)
1632
+ assert.equal(miss.status, 200)
1633
+ assert.equal(ledger.writes, 1)
1634
+ })
1635
+
1636
+ // ── 自动归档配置 ──
1637
+
1638
+ // GET 夹具:任意 URL 的 GET 请求
1639
+ function getRequest2(url, method) {
1640
+ const req = new EventEmitter()
1641
+ req.method = method
1642
+ req.url = url
1643
+ return req
1644
+ }
1645
+
1646
+ // POST 夹具:请求体走 readBody 的 data/end 事件流,handler 按路径查找
1647
+ async function postJson(handlers, routePath, body) {
1648
+ const req = new EventEmitter()
1649
+ req.method = 'POST'
1650
+ req.url = routePath
1651
+ const res = response()
1652
+ const done = handlers.get(routePath)(req, res)
1653
+ req.emit('data', Buffer.from(JSON.stringify(body), 'utf8'))
1654
+ req.emit('end')
1655
+ await done
1656
+ return res
1657
+ }
1658
+
1659
+ test('自动归档配置:GET 回显生效值(未配置回默认),POST 持久,GET 反映新值', skipMissingDeps, async () => {
1660
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1661
+ const initial = response()
1662
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), initial)
1663
+ assert.deepEqual(initial.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '未配置时回显默认值')
1664
+ const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3, intervalHours: 12 })
1665
+ assert.deepEqual(changed.body, { days: 3, intervalHours: 12 })
1666
+ const reread = response()
1667
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1668
+ assert.deepEqual(reread.body, { days: 3, intervalHours: 12 }, 'settings 持久化新值')
1669
+ })
1670
+
1671
+ test('自动归档配置:单字段部分更新,未提交字段保持不变', skipMissingDeps, async () => {
1672
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1673
+ const onlyDays = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1674
+ assert.deepEqual(onlyDays.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '仅更新 days')
1675
+ const onlyHours = await postJson(handlers, '/api/session-manager/auto-archive', { intervalHours: 12 })
1676
+ assert.deepEqual(onlyHours.body, { days: 3, intervalHours: 12 }, '仅更新 intervalHours,days 保持')
1677
+ })
1678
+
1679
+ test('自动归档配置:0 值合法且持久(阈值天数关自动归档,间隔小时数关周期轮)', skipMissingDeps, async () => {
1680
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1681
+ const off = await postJson(handlers, '/api/session-manager/auto-archive', { days: 0, intervalHours: 0 })
1682
+ assert.deepEqual(off.body, { days: 0, intervalHours: 0 })
1683
+ const reread = response()
1684
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1685
+ assert.deepEqual(reread.body, { days: 0, intervalHours: 0 }, '关闭值持久化')
1686
+ })
1687
+
1688
+ test('自动归档配置:负数/非整数/非数值/空字段一律 400,配置不变', skipMissingDeps, async () => {
1689
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1690
+ const invalidPayloads = [
1691
+ { days: -1, intervalHours: 1 },
1692
+ { days: 1.5, intervalHours: 1 },
1693
+ { days: '3', intervalHours: 1 },
1694
+ { days: true, intervalHours: 1 },
1695
+ { days: null, intervalHours: 1 },
1696
+ {},
1697
+ ]
1698
+ for (const payload of invalidPayloads) {
1699
+ const rejected = await postJson(handlers, '/api/session-manager/auto-archive', payload)
1700
+ assert.equal(rejected.status, 400, JSON.stringify(payload))
1701
+ }
1702
+ const after = response()
1703
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), after)
1704
+ assert.deepEqual(after.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '被拒请求不改动配置')
1705
+ })
1706
+
1707
+ test('自动归档配置提交判定:noop/invalid/post 三态分类', async () => {
1708
+ const { classifyAutoArchiveInput } = await import('../src/core.mjs')
1709
+ assert.deepEqual(classifyAutoArchiveInput('7', '7'), { action: 'noop' }, '值未变不提交')
1710
+ assert.deepEqual(classifyAutoArchiveInput('7', ' 7 '), { action: 'noop' }, '空白差异修剪后视为未变')
1711
+ assert.deepEqual(classifyAutoArchiveInput('7', '-1'), { action: 'invalid' }, '负数拒绝')
1712
+ assert.deepEqual(classifyAutoArchiveInput('7', '1.5'), { action: 'invalid' }, '非整数拒绝')
1713
+ assert.deepEqual(classifyAutoArchiveInput('7', 'abc'), { action: 'invalid' }, '非数值拒绝')
1714
+ assert.deepEqual(classifyAutoArchiveInput('7', ''), { action: 'invalid' }, '空文本拒绝')
1715
+ assert.deepEqual(classifyAutoArchiveInput('7', '0'), { action: 'post', value: 0 }, '0 合法(关闭语义)')
1716
+ assert.deepEqual(classifyAutoArchiveInput('7', '3'), { action: 'post', value: 3 }, '正整数可提交')
1717
+ })
1718
+
1719
+ test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫对照已提交值', { skip: process.env.TEST_SKIP_PARITY }, async () => {
1720
+ const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
1721
+ const declared = clientSource.match(/function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1722
+ assert.ok(declared, 'client.js 必须内联声明 classifyAutoArchiveInput')
1723
+ const coreSource = await readFile(new URL('../src/core.mjs', import.meta.url), 'utf8')
1724
+ const coreBody = coreSource.match(/export function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1725
+ assert.ok(coreBody, 'core.mjs 必须声明 classifyAutoArchiveInput')
1726
+ assert.equal(declared[1].trim(), coreBody[1].trim(), 'client 内联实现与 core 逐行一致')
1727
+ // 调用点契约:守卫实参必须是已提交基准 committed[field],对照显示值比较恒相等(受控输入击键已同步)
1728
+ assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1729
+ })
1730
+