@mzzsfy/dsh-session-manager 0.9.5 → 0.9.7

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