@mzzsfy/dsh-session-manager 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,901 +1,901 @@
1
- // dsh-session-manager Host 半区:自动归档评估 + 取消归档 + 删除(回收站) + 已删除台账与重挂载。
2
- // 评估三触发源共用同一门闩:新会话创建(session/created,按工作区限定)、settings 就绪后的
3
- // 启动补扫(全量)、每日周期轮(全量,经宿主 timer 软依赖);取消归档与删除后的归档清理经
4
- // workspace 域 global 直写 archivedSessionIds 并同步注册表进程内快照(官方无 unarchive 表面,
5
- // 域写入经 domain/changed 触发 workspace.follow 的 archived 帧);删除按失败矩阵
6
- // 执行 locate → trash → detach → 归档清理,台账记录删除产物路径供回收站还原后
7
- // 一键重挂载(官方 attachSession)。面板数据不在此处:client 侧以 session.list
8
- // 行与归档集合做交集。
9
-
10
- import { open, realpath, stat } from 'node:fs/promises'
11
- import { homedir } from 'node:os'
12
- import { dirname, join } from 'node:path'
13
-
14
- import schemastery from '@deepseek-ai/schemastery'
15
- import { z } from 'zod'
16
- import { defineDomain } from '@deepseek-ai/dsh-storage-domain'
17
-
18
- import {
19
- DAY_MS,
20
- DEFAULT_AUTO_ARCHIVE_DAYS,
21
- DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
22
- aggregateDeleteOutcome,
23
- artifactLooksBlank,
24
- deleteEligibility,
25
- isSessionRunning,
26
- mergeDeletedEntry,
27
- removeDeletedEntry,
28
- selectArchiveCandidates,
29
- UNARCHIVE_GRACE_MS,
30
- unarchiveGraceActive,
31
- updatedAtOf,
32
- } from './core.mjs'
33
- import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
34
-
35
- export const name = 'dsh-session-manager'
36
-
37
- export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
38
-
39
- const NAMESPACE = 'session-manager'
40
- const WORKSPACE_DOMAIN_NAME = 'workspace'
41
-
42
- // 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息;
43
- // heldPath 仅回收区降级条目携带(暂存路径,重挂载时移回 path 原位置)
44
- const LEDGER_SPEC = defineDomain({
45
- name: 'session_manager',
46
- version: 1,
47
- tables: {},
48
- global: {
49
- schema: z.object({
50
- deleted: z.array(z.object({
51
- sessionId: z.string(),
52
- path: z.string(),
53
- heldPath: z.string().optional(),
54
- deletedAt: z.number(),
55
- })),
56
- }),
57
- initial: { deleted: [] },
58
- },
59
- })
60
-
61
- const BLANK_PROBE_CHUNK_BYTES = 64 * 1024
62
- // 台账条目上限:超过即裁掉最旧条目(数组头部为新,尾部最旧),防无界增长写放大
63
- const LEDGER_MAX_ENTRIES = 200
64
- // 自动归档评估整体超时:宿主服务挂起时不因门闩未复位而永久停摆
65
- const EVALUATION_TIMEOUT_MS = 30 * 1000
66
- const HOUR_MS = 60 * 60 * 1000
67
- // 周期评估固定短 tick:tick 内做到期判断,间隔设置变更自下一周期生效
68
- const PERIODIC_TICK_MS = 60 * 1000
69
- // 台账单次读改写超时:宿主存储域挂起时锁内 job 超时放行,防互斥链死锁与 inFlight 泄漏
70
- const LEDGER_OP_TIMEOUT_MS = 10 * 1000
71
-
72
- // 路由响应文案;导出供测试断言与实现同步。
73
- // trash 半失败聚合 5 条在 core.DELETE_MESSAGES(经 aggregateDeleteOutcome 组装),此处不重复
74
- export const MESSAGES = {
75
- unsupportedBackend: '当前存储后端不支持按会话删除',
76
- notArchived: '仅已归档会话可删除',
77
- unknownSession: '会话不存在',
78
- running: '运行中的会话不可删除',
79
- trashFailed: '移入回收站失败',
80
- notRestored: '会话产物不在持久层,请先到系统回收站还原后重试',
81
- ghostCleanup: '产物已不存在,已完成列表清理',
82
- ghostDetach: '产物已不存在,但移除列表记录失败',
83
- ghostArchiveSuffix: ',且移除归档记录失败',
84
- noWorkspace: '未找到会话所属工作区,无法重新挂载',
85
- ledgerCleanup: '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾',
86
- inFlight: '该会话正在删除中,请稍后重试',
87
- badJsonBody: '请求体不是合法 JSON',
88
- systemError: '操作失败(系统级错误,详见服务端日志)',
89
- heldMissing: '回收区暂存已缺失,原位置亦无产物,无法找回',
90
- restoreFailed: '回收区还原失败',
91
- }
92
-
93
- // trash 执行器出口:进程级唯一 OS 副作用注入点(回收站/回收区搬移/移回),测试经此桩替
94
- export const executor = { trashPath, moveToQuarantine, restoreFromQuarantine }
95
-
96
- const SETTINGS_SCHEMA = schemastery.object({
97
- autoArchiveDays: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_DAYS)
98
- .description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
99
- autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
100
- .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
101
- })
102
-
103
- // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
104
- // settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
105
- // 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
106
- export const Config = volatileWrap(SETTINGS_SCHEMA)
107
-
108
- function volatileWrap(schema) {
109
- return typeof schema.volatile === 'function' ? schema.volatile() : schema
110
- }
111
-
112
- // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
113
- // legacy 宿主 config 为普通对象原样透传
114
- function unwrapVolatile(value) {
115
- return typeof value?.get === 'function' ? value.get() : value
116
- }
117
-
118
- function sendJson(res, status, payload) {
119
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
120
- res.end(JSON.stringify(payload))
121
- }
122
-
123
- // 错误响应出口:业务错误(Error 无错误码)原样透传消息;系统级错误(带 fs 错误码,
124
- // message 内嵌绝对路径)收敛为固定文案,原始错误仅进服务端日志
125
- function respondError(ctx, res, error) {
126
- const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
127
- if (isSystem && ctx.logger) ctx.logger.warn('session-manager 系统级错误: ' + String(error && error.stack || error))
128
- const message = isSystem
129
- ? MESSAGES.systemError
130
- : (error && error.message ? error.message : String(error))
131
- sendJson(res, 400, { error: message })
132
- }
133
-
134
- function readSettings(ctx, config) {
135
- const settings = ctx.get('settings')
136
- // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
137
- // config 整节解包。方法面守卫兼防:服务在但缺 get(宿主升级变更面)时回落默认值,
138
- // 防 timer 回调抛错崩进程
139
- const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
140
- const days = Number(value && value.autoArchiveDays)
141
- const hours = Number(value && value.autoArchiveIntervalHours)
142
- return {
143
- days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
144
- intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
145
- }
146
- }
147
-
148
- // auto-archive 路由请求体 → settings patch:仅接受出现且为非负整数的字段(部分更新),
149
- // 一个合法字段都没有即拒绝;返回形态错误以业务错误抛出,由路由统一转 400
150
- const AUTO_ARCHIVE_FIELDS = [['days', 'autoArchiveDays'], ['intervalHours', 'autoArchiveIntervalHours']]
151
-
152
- function readAutoArchivePatch(body) {
153
- const patch = {}
154
- for (const [input, setting] of AUTO_ARCHIVE_FIELDS) {
155
- const raw = body ? body[input] : undefined
156
- if (raw === undefined) continue
157
- if (!Number.isInteger(raw) || raw < 0) throw new Error('自动归档配置须为非负整数: ' + input)
158
- patch[setting] = raw
159
- }
160
- if (Object.keys(patch).length === 0) throw new Error('缺少可更新的配置字段')
161
- return patch
162
- }
163
-
164
- function readBody(req) {
165
- const BODY_MAX_BYTES = 64 * 1024
166
- return new Promise((resolve, reject) => {
167
- let size = 0
168
- const chunks = []
169
- req.on('data', (chunk) => {
170
- size += chunk.length
171
- if (size > BODY_MAX_BYTES) {
172
- reject(new Error('请求体超过上限'))
173
- req.destroy()
174
- return
175
- }
176
- chunks.push(chunk)
177
- })
178
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
179
- req.on('error', reject)
180
- })
181
- }
182
-
183
- async function requireSessionId(req) {
184
- // 协议错误与业务错误分通道:JSON 解析失败给固定中文文案,不透出引擎 SyntaxError
185
- let body
186
- try {
187
- body = JSON.parse(await readBody(req))
188
- } catch {
189
- throw new Error(MESSAGES.badJsonBody)
190
- }
191
- const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
192
- if (sessionId.length === 0) throw new Error('sessionId 不能为空')
193
- return sessionId
194
- }
195
-
196
- /** JSONL 产物空白探测:读文件头部,不足两行即视为空白会话。 */
197
- async function artifactIsBlank(path) {
198
- let handle
199
- try {
200
- handle = await open(path, 'r')
201
- const { buffer, bytesRead } = await handle.read(
202
- Buffer.allocUnsafe(BLANK_PROBE_CHUNK_BYTES), 0, BLANK_PROBE_CHUNK_BYTES, 0)
203
- const headText = buffer.toString('utf8', 0, bytesRead)
204
- return artifactLooksBlank(headText, bytesRead === BLANK_PROBE_CHUNK_BYTES)
205
- } catch {
206
- // 产物不可读(已被移走等)时按非空白处理,宁可漏归档不可误归档
207
- return false
208
- } finally {
209
- if (handle !== undefined) await handle.close().catch(() => {})
210
- }
211
- }
212
-
213
- function rejectMethod(req, res, method) {
214
- if (req.method === method) return true
215
- sendJson(res, 405, { error: 'method not allowed' })
216
- return false
217
- }
218
-
219
- /** @param {import('@deepseek-ai/cordis').Context} ctx */
220
- export function apply(ctx, config) {
221
- let evaluating = false
222
- // 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
223
- // 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
224
- let unarchivedAtMs = 0
225
- // 删除路由同 id 并发去重(进程内)
226
- const inFlightDeletes = new Set()
227
-
228
- // 插件回收区:系统回收站不可用环境的删除降级暂存地,重挂载时移回原位置。
229
- // 测试经 env DSH_QUARANTINE_DIR 注入临时目录
230
- const quarantineDir = process.env.DSH_QUARANTINE_DIR
231
- || join(homedir(), '.dsh', 'trash', 'session-manager')
232
-
233
- // header 查找缓存:归档/删除/重挂载路由共用(inputs 浮层已迁 dsh-context-manager)
234
- const headerCache = new Map()
235
- ctx.effect(() => () => headerCache.clear(), 'session-manager header cache')
236
-
237
- // 台账域:应用装载即打开,路由内 await;打开失败由路由响应,此处仅防未处理拒绝
238
- const ledgerReady = ctx.storageDomain.open(LEDGER_SPEC)
239
- ledgerReady.catch(() => {})
240
- ctx.effect(() => () => {
241
- void ledgerReady.then((domain) => domain.close()).catch(() => {})
242
- }, 'session-manager ledger domain')
243
-
244
- // 周期评估状态:running 供设置页降级提示;timer 为软依赖,服务缺失不阻塞插件装载
245
- const periodic = { running: false, reason: '' }
246
-
247
- // 自动归档评估:筛出待归档会话并逐个走官方 archiveSession。cwd 缺省时全量评估
248
- // (启动补扫 / 周期轮),传入 cwd 时仅评估该工作区(新建会话触发)。
249
- // 门闩与超时守卫对全部触发源生效。两级筛选:先用 createdAt 纯内存预筛(mtime 只会
250
- // 增大 updatedAt,createdAt 未超期必不超期),仅对预筛存活者做产物 stat/blank 探测,
251
- // 活跃会话零 IO。返回是否真正启动:被门闩/关闭挡下的调用方(周期 tick)保持到期态
252
- // 由下个 tick 重试,不得据此重排周期
253
- function evaluateArchives(cwd) {
254
- const { days } = readSettings(ctx, config)
255
- if (days === 0 || evaluating) return false
256
- evaluating = true
257
- let timeoutGuard
258
- const work = (async () => {
259
- const registry = ctx.workspaceRegistry
260
- const archived = new Set(registry.archivedSessionIds.map(String))
261
- const sessionsService = ctx.get('sessions')
262
- const agents = ctx.get('agents')
263
- const persistence = ctx.get('sessionPersistence')
264
- const records = await ctx.sessionQuery.listSessions()
265
- const nowMs = Date.now()
266
- // 全局宽限短路:恢复动作后 graceMs 内暂停归档,保护「恢复 → 首条消息」窗口
267
- if (unarchiveGraceActive(unarchivedAtMs, nowMs)) return
268
- const cutoff = nowMs - days * DAY_MS
269
- const candidates = []
270
- for (const recordItem of records) {
271
- const header = recordItem.header
272
- if (cwd !== undefined && header.cwd !== cwd) continue
273
- // subagent 内部会话不属主列表(官方 sessionVisible 以 origin 排除),归档集合
274
- // 仅服务用户会话:误归档只产出不可辨认的面板行与无意义通知
275
- if (header.origin === 'subagent') continue
276
- // 预筛:createdAt 未超期必不超期(见上),跳过产物 IO
277
- if (header.createdAt >= cutoff) continue
278
- // 已归档记录永不是候选,集合随历史增长,提前跳过省产物 IO
279
- if (archived.has(String(header.id))) continue
280
- const live = sessionsService && sessionsService.get(header.id)
281
- const running = isSessionRunning({ agents, sessionId: header.id })
282
- // 运行中永不入选归档,超期 running 在此早退免付产物 IO(64KB 读);
283
- // core.selectArchiveCandidates 的 !running 过滤保留作第二道防线
284
- if (running) continue
285
- const located = persistence && persistence.locate(header)
286
- // locate 缺失(第三方后端不支持定位)与产物不可读同规跳过:仅凭 createdAt
287
- // 判活跃会绕过 mtime 保护,与 delete 的 unsupportedBackend 拒绝口径对齐
288
- if (!located) continue
289
- const activityAt = await safeMtime(located.path)
290
- // 产物不可读视为已删除,不参与归档:否则删除后的会话(重连前仍在持久层
291
- // 列表中)会因超期被重新归档,面板行复活且无法再删
292
- if (activityAt === null) continue
293
- // stat 后即可判活跃度:未超期不必做空白探测
294
- const updatedAt = updatedAtOf(header, activityAt)
295
- if (updatedAt >= cutoff) continue
296
- const blank = live ? live.seq === 0 : Boolean(await artifactIsBlank(located.path))
297
- candidates.push({
298
- id: String(header.id),
299
- archived: false,
300
- running,
301
- blank,
302
- updatedAt,
303
- })
304
- }
305
- for (const id of selectArchiveCandidates({ records: candidates, nowMs, thresholdDays: days })) {
306
- try {
307
- await registry.archiveSession(id)
308
- ctx.logger && ctx.logger.info && ctx.logger.info('session-manager 已归档会话: ' + id)
309
- } catch (error) {
310
- // 单个归档失败不中断整轮,剩余候选继续;失败者由下一次触发重试
311
- ctx.logger && ctx.logger.warn('session-manager 归档 ' + id + ' 失败: ' + String(error))
312
- }
313
- }
314
- })()
315
- // 门闩挂起兜底:宿主服务永不返回时超时复位,保证后续评估不被永久跳过
316
- const timeout = new Promise((_, reject) => {
317
- timeoutGuard = setTimeout(() => reject(new Error('自动归档评估超时')), EVALUATION_TIMEOUT_MS)
318
- })
319
- void Promise.race([work, timeout]).catch((error) => {
320
- ctx.logger && ctx.logger.warn('session-manager 自动归档评估失败: ' + String(error))
321
- }).finally(() => {
322
- clearTimeout(timeoutGuard)
323
- evaluating = false
324
- void work.catch(() => {})
325
- })
326
- return true
327
- }
328
-
329
- ctx.on('session/created', (session) => {
330
- const cwd = session.header && session.header.cwd
331
- if (cwd === undefined) return
332
- evaluateArchives(cwd)
333
- })
334
-
335
- // 取消归档:直写 workspace 域 global,无官方 API 可用。注册表把域 global 快照到
336
- // 进程内 state 且只在自身写路径同步,直写后必须把快照一并改掉:否则注册表后续
337
- // 全量写回(含自动归档)与 workspace.list 重连基线都会复活该 id,官方归档的幂等
338
- // 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
339
- // 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
340
- // 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
341
- // 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
342
- // pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
343
- // 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
344
- async function removeArchivedId(sessionId) {
345
- const domain = ctx.storageDomain.get(WORKSPACE_DOMAIN_NAME)
346
- if (domain === undefined) throw new Error('workspace 域未打开')
347
- const durable = domain.global.get()
348
- const cached = ctx.workspaceRegistry.state
349
- if (cached === undefined) throw new Error('workspace 注册表快照不可用')
350
- const merged = new Set(durable.archivedSessionIds.map(String))
351
- for (const id of cached.archivedSessionIds) merged.add(String(id))
352
- if (!merged.delete(String(sessionId))) return
353
- const next = { ...durable, archivedSessionIds: [...merged] }
354
- await domain.global.set(next)
355
- ctx.workspaceRegistry.state = next
356
- }
357
-
358
- // 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
359
- // ledgerChain 串行化所有读改写:get→set 交错时后写者会用旧快照整体覆盖,丢条目。
360
- // 逐 job 超时兜底:宿主存储域挂起时超时放行,防链条死锁与 inFlightDeletes 泄漏;
361
- // 超时的写操作后台落盘仍安全(合并/移除均为幂等语义)
362
- let ledgerChain = Promise.resolve()
363
- function withLedgerLock(job) {
364
- const runJob = () => Promise.race([
365
- job(),
366
- new Promise((_, reject) => setTimeout(
367
- () => reject(new Error('台账操作超时(' + LEDGER_OP_TIMEOUT_MS / 1000 + 's)')),
368
- LEDGER_OP_TIMEOUT_MS)),
369
- ])
370
- const run = ledgerChain.then(runJob, runJob)
371
- ledgerChain = run.catch(() => {})
372
- return run
373
- }
374
-
375
- async function recordDeletedEntry(sessionId, path, heldPath) {
376
- return withLedgerLock(async () => {
377
- const ledger = await ledgerReady
378
- const current = ledger.global.get()
379
- const entry = { sessionId, path, deletedAt: Date.now(), ...(heldPath !== undefined ? { heldPath } : {}) }
380
- const merged = mergeDeletedEntry(current.deleted, entry)
381
- await ledger.global.set({ ...current, deleted: merged.slice(0, LEDGER_MAX_ENTRIES) })
382
- })
383
- }
384
-
385
- // 台账移除:幂等,未命中不产生写回
386
- async function removeLedgerEntry(sessionId) {
387
- return withLedgerLock(async () => {
388
- const ledger = await ledgerReady
389
- const current = ledger.global.get()
390
- const { deleted, removed } = removeDeletedEntry(current.deleted, sessionId)
391
- if (removed) await ledger.global.set({ ...current, deleted })
392
- })
393
- }
394
-
395
- // 宽限记账:取消归档时更新宽限起点(进程内变量,无持久化副作用)
396
- function recordUnarchiveGrace() {
397
- unarchivedAtMs = Date.now()
398
- }
399
-
400
- // 台账命中查询:同 id 曾删除过(重删场景的幽灵资格依据);域不可用时按未命中
401
- async function ledgerHasEntry(sessionId) {
402
- try {
403
- const ledger = await ledgerReady
404
- return ledger.global.get().deleted.some((item) => item.sessionId === sessionId)
405
- } catch {
406
- return false
407
- }
408
- }
409
-
410
- // 台账条目读取:重挂载移回依据;域不可用或未命中均按无条目
411
- async function ledgerEntryFor(sessionId) {
412
- try {
413
- const ledger = await ledgerReady
414
- return ledger.global.get().deleted.find((item) => item.sessionId === sessionId)
415
- } catch {
416
- return undefined
417
- }
418
- }
419
-
420
- // 幽灵残留清理:产物已缺失的会话仅解除列表可见性(detach + 归档清理),
421
- // 无回收与台账动作;失败点聚合成后缀,重试即补全
422
- async function cleanupDeletedSession(sessionId) {
423
- let suffix = ''
424
- const detachError = await detachSession(ctx, sessionId)
425
- if (detachError !== undefined) {
426
- ctx.logger && ctx.logger.warn('session-manager 幽灵清理 detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
427
- suffix = MESSAGES.ghostDetach
428
- }
429
- try {
430
- await removeArchivedId(sessionId)
431
- } catch (error) {
432
- ctx.logger && ctx.logger.warn('session-manager 幽灵清理归档移除失败(' + sessionId + '): ' + String(error && error.stack || error))
433
- suffix += MESSAGES.ghostArchiveSuffix
434
- }
435
- return suffix
436
- }
437
-
438
- // 重挂载前把回收区暂存产物移回原位置:回收区条目的找回动作就是重挂载本身,
439
- // 不要求用户手工搬移。原位置已有产物(已自行还原)或暂存缺失时不动
440
- async function restoreHeldArtifact(sessionId) {
441
- const entry = await ledgerEntryFor(sessionId)
442
- if (entry === undefined || entry.heldPath === undefined) return
443
- const originalGone = await stat(entry.path).then(() => false, (error) => {
444
- if (error && error.code === 'ENOENT') return true
445
- throw error
446
- })
447
- if (!originalGone) return
448
- const heldExists = await stat(entry.heldPath).then(() => true, (error) => {
449
- if (error && error.code === 'ENOENT') return false
450
- throw error
451
- })
452
- if (!heldExists) throw new Error(MESSAGES.heldMissing)
453
- try {
454
- await executor.restoreFromQuarantine(entry.heldPath, entry.path)
455
- } catch (error) {
456
- throw new Error(MESSAGES.restoreFailed + ': ' + String(error && error.message || error))
457
- }
458
- }
459
-
460
- const routes = [
461
- {
462
- path: '/api/session-manager/unarchive',
463
- handler: async (req, res) => {
464
- if (!rejectMethod(req, res, 'POST')) return
465
- try {
466
- const sessionId = await requireSessionId(req)
467
- await removeArchivedId(sessionId)
468
- await recordUnarchiveGrace()
469
- sendJson(res, 200, { ok: true })
470
- } catch (error) {
471
- respondError(ctx, res, error)
472
- }
473
- },
474
- },
475
- {
476
- path: '/api/session-manager/info',
477
- handler: async (req, res) => {
478
- if (!rejectMethod(req, res, 'POST')) return
479
- try {
480
- const sessionId = await requireSessionId(req)
481
- const header = await findHeader(ctx, sessionId, headerCache)
482
- if (header === undefined) {
483
- sendJson(res, 400, { error: MESSAGES.unknownSession })
484
- return
485
- }
486
- const persistence = ctx.get('sessionPersistence')
487
- const location = persistence ? persistence.locate(header) : undefined
488
- if (location === undefined) {
489
- sendJson(res, 200, { supported: false })
490
- return
491
- }
492
- let info
493
- try {
494
- info = await stat(location.path)
495
- } catch (error) {
496
- // live 记录可指向已删除会话,产物缺失是常规状态而非故障
497
- if (error && error.code === 'ENOENT') {
498
- sendJson(res, 200, { supported: true, sizeBytes: 0, missing: true })
499
- return
500
- }
501
- throw error
502
- }
503
- sendJson(res, 200, { supported: true, sizeBytes: info.size })
504
- } catch (error) {
505
- respondError(ctx, res, error)
506
- }
507
- },
508
- },
509
- {
510
- path: '/api/session-manager/delete',
511
- handler: async (req, res) => {
512
- if (!rejectMethod(req, res, 'POST')) return
513
- const sessionId = await requireSessionId(req).catch((error) => {
514
- respondError(ctx, res, error)
515
- return undefined
516
- })
517
- if (sessionId === undefined) return
518
- // 同 id 去重:并发第二次删除会走幽灵清理,语义混乱且响应文案不一致
519
- if (inFlightDeletes.has(sessionId)) {
520
- sendJson(res, 400, { error: MESSAGES.inFlight })
521
- return
522
- }
523
- inFlightDeletes.add(sessionId)
524
- try {
525
- const header = await findHeader(ctx, sessionId, headerCache)
526
- if (header === undefined) {
527
- sendJson(res, 400, { error: MESSAGES.unknownSession })
528
- return
529
- }
530
- // 运行中守卫:与归档评估同款判据,落实"运行中会话永不被删除"不变量
531
- if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
532
- sendJson(res, 400, { error: MESSAGES.running })
533
- return
534
- }
535
- const persistence = ctx.get('sessionPersistence')
536
- const location = persistence ? persistence.locate(header) : undefined
537
- if (location === undefined) {
538
- sendJson(res, 400, { error: MESSAGES.unsupportedBackend })
539
- return
540
- }
541
- // locate 返回会话目录下的日志文件;回收对象是目录整体,残留空目录会让
542
- // 已删除会话在面板以空壳复活。缺失判定按目录级:日志缺失但目录在(半删除
543
- // 残留)仍走完整回收,目录消失才是幽灵
544
- const artifactDir = dirname(location.path)
545
- let artifactMissing = false
546
- try {
547
- await stat(artifactDir)
548
- } catch (error) {
549
- if (error && error.code !== 'ENOENT') throw error
550
- artifactMissing = true
551
- }
552
- if (artifactMissing) {
553
- // 幽灵资格:已归档或台账有记录(同 id 重删的残留清理)才放行;
554
- // 两者皆无的 ENOENT 可能是新建会话尚未落盘,剥离活会话属于破坏性误删
555
- const archivedNow = ctx.workspaceRegistry.archivedSessionIds.map(String).includes(sessionId)
556
- if (!archivedNow && !(await ledgerHasEntry(sessionId))) {
557
- sendJson(res, 400, { error: MESSAGES.notArchived })
558
- return
559
- }
560
- const cleanupError = await cleanupDeletedSession(sessionId)
561
- if (cleanupError !== '') {
562
- sendJson(res, 200, { ok: true, partial: true, message: cleanupError })
563
- return
564
- }
565
- sendJson(res, 200, { ok: true, message: MESSAGES.ghostCleanup })
566
- return
567
- }
568
- // host 端到端权威:归档资格在执行前校验
569
- const eligibility = deleteEligibility({
570
- archivedIds: ctx.workspaceRegistry.archivedSessionIds.map(String),
571
- sessionId,
572
- })
573
- if (!eligibility.ok) {
574
- sendJson(res, 400, { error: MESSAGES.notArchived })
575
- return
576
- }
577
- // TOCTOU 复检:守卫通过到执行间存在多个 await 间隙,会话可能已恢复运行
578
- if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
579
- sendJson(res, 400, { error: MESSAGES.running })
580
- return
581
- }
582
- // 处置:优先系统回收站;不可用(容器无 gio/dbus 等)降级插件回收区
583
- // rename 搬移,仍可经重挂载移回原位置。双失败才会话保留并拒绝,
584
- // 原始回收站错误落日志(降级环境的诊断依据),拒绝响应携回收区错误
585
- let heldPath
586
- try {
587
- await executor.trashPath(artifactDir)
588
- } catch (trashError) {
589
- ctx.logger && ctx.logger.warn('session-manager 系统回收站不可用(' + sessionId + '),尝试回收区降级: ' + String(trashError))
590
- try {
591
- heldPath = await executor.moveToQuarantine(artifactDir, quarantineDir)
592
- } catch (heldError) {
593
- sendJson(res, 400, { error: MESSAGES.trashFailed + '(系统回收站与插件回收区均不可用): ' + String(heldError && heldError.message || heldError) })
594
- return
595
- }
596
- }
597
- const quarantined = heldPath !== undefined
598
- // 执行期翻转检测:OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行。
599
- // 产物已移走,中断只会更糟,照常完成收尾,但把不变量破坏变为可观测事件
600
- const runningDuringTrash = isSessionRunning({ agents: ctx.get('agents'), sessionId })
601
- if (runningDuringTrash) {
602
- ctx.logger && ctx.logger.warn('session-manager 删除执行期间会话恢复运行: ' + sessionId)
603
- }
604
- // 台账在处置成功后立即记录:产物已离开原位置,后续任何半失败都不影响还原资格;
605
- // 台账失败只降级重挂载便利,不回滚删除
606
- let ledgerError
607
- try {
608
- await recordDeletedEntry(sessionId, artifactDir, heldPath)
609
- } catch (error) {
610
- ledgerError = error
611
- ctx.logger && ctx.logger.warn('session-manager 台账记录失败(' + sessionId + '): ' + String(error && error.stack || error))
612
- }
613
- const detachError = await detachSession(ctx, sessionId)
614
- if (detachError !== undefined) {
615
- ctx.logger && ctx.logger.warn('session-manager detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
616
- }
617
- // 归档集合同步清理:残留 id 会让面板行持续可见;detach 失败时不清理,
618
- // 保留归档资格供重试
619
- let archiveCleanupFailed = false
620
- if (detachError === undefined) {
621
- try {
622
- await removeArchivedId(sessionId)
623
- } catch (error) {
624
- archiveCleanupFailed = true
625
- ctx.logger && ctx.logger.warn('session-manager 归档清理失败(' + sessionId + '): ' + String(error && error.stack || error))
626
- }
627
- }
628
- sendJson(res, 200, {
629
- ...aggregateDeleteOutcome({
630
- detachFailed: detachError !== undefined,
631
- archiveCleanupFailed,
632
- ledgerFailed: ledgerError !== undefined,
633
- runningDuringTrash,
634
- quarantined,
635
- }),
636
- mode: quarantined ? 'quarantine' : 'os',
637
- })
638
- } catch (error) {
639
- respondError(ctx, res, error)
640
- } finally {
641
- inFlightDeletes.delete(sessionId)
642
- }
643
- },
644
- },
645
- {
646
- path: '/api/session-manager/status',
647
- handler: async (req, res) => {
648
- if (!rejectMethod(req, res, 'GET')) return
649
- sendJson(res, 200, { periodic: { ...periodic } })
650
- },
651
- },
652
- {
653
- // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
654
- // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
655
- path: '/api/session-manager/auto-archive',
656
- handler: async (req, res) => {
657
- try {
658
- if (req.method === 'GET') {
659
- const current = readSettings(ctx, config)
660
- sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
661
- return
662
- }
663
- if (!rejectMethod(req, res, 'POST')) return
664
- let body
665
- try {
666
- body = JSON.parse(await readBody(req))
667
- } catch {
668
- throw new Error(MESSAGES.badJsonBody)
669
- }
670
- const patch = readAutoArchivePatch(body)
671
- // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
672
- // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
673
- // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
674
- // await 保证回显为新值,persist 失败走 respondError
675
- const settings = ctx.get('settings')
676
- if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
677
- await settings.update(NAMESPACE, patch)
678
- } else {
679
- const editor = ctx.get('configEditor')
680
- const entry = ctx.fiber?.entry
681
- if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
682
- await editor.edit(entry, (current) => ({ ...current, ...patch }))
683
- }
684
- const current = readSettings(ctx, config)
685
- sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
686
- } catch (error) {
687
- respondError(ctx, res, error)
688
- }
689
- },
690
- },
691
- {
692
- path: '/api/session-manager/deleted',
693
- handler: async (req, res) => {
694
- if (!rejectMethod(req, res, 'GET')) return
695
- try {
696
- const ledger = await ledgerReady
697
- sendJson(res, 200, { deleted: ledger.global.get().deleted })
698
- } catch (error) {
699
- respondError(ctx, res, error)
700
- }
701
- },
702
- },
703
- {
704
- path: '/api/session-manager/remount',
705
- handler: async (req, res) => {
706
- if (!rejectMethod(req, res, 'POST')) return
707
- try {
708
- const sessionId = await requireSessionId(req)
709
- // 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
710
- // 静默按无条目处理会让回收区产物永远找不回,还误导用户去系统回收站
711
- try {
712
- await ledgerReady
713
- } catch {
714
- sendJson(res, 400, { error: MESSAGES.systemError })
715
- return
716
- }
717
- // 回收区暂存的产物先移回原位置(host 按产物现状列会话,移回后 header 可见);
718
- // OS 回收站条目无 heldPath,此调用为空操作
719
- await restoreHeldArtifact(sessionId)
720
- // 产物还原的判定即持久层能重新读到 header:未还原时在此拒绝
721
- const header = await findHeader(ctx, sessionId, headerCache)
722
- if (header === undefined) {
723
- sendJson(res, 400, { error: MESSAGES.notRestored })
724
- return
725
- }
726
- const workspace = await findWorkspaceByCwd(ctx, header.cwd)
727
- if (workspace === undefined) {
728
- sendJson(res, 400, { error: MESSAGES.noWorkspace })
729
- return
730
- }
731
- // 官方挂载通道:自带 header cwd 校验,失败即整体拒绝,台账保留供重试
732
- await workspace.attachSession(sessionId)
733
- // 挂载已成功:台账清除失败只影响收尾,不得报成挂载失败
734
- try {
735
- await removeLedgerEntry(sessionId)
736
- } catch {
737
- sendJson(res, 200, { ok: true, partial: true, message: MESSAGES.ledgerCleanup })
738
- return
739
- }
740
- sendJson(res, 200, { ok: true })
741
- } catch (error) {
742
- respondError(ctx, res, error)
743
- }
744
- },
745
- },
746
- {
747
- path: '/api/session-manager/forget',
748
- handler: async (req, res) => {
749
- if (!rejectMethod(req, res, 'POST')) return
750
- try {
751
- await removeLedgerEntry(await requireSessionId(req))
752
- sendJson(res, 200, { ok: true })
753
- } catch (error) {
754
- respondError(ctx, res, error)
755
- }
756
- },
757
- },
758
- ]
759
-
760
- for (const route of routes) {
761
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
762
- 'dsh-session-manager ' + route.path)
763
- }
764
-
765
- // 周期评估软依赖 timer:顶层注册(不嵌套在 settings 回调内,settings 服务重启会
766
- // 重跑其回调并新建 fiber,嵌套会使周期轮随重启累积复制)。服务未激活则回调不执行,
767
- // 插件其余能力不受影响,设置页提示降级
768
- ctx.inject(['timer'], (timerCtx) => {
769
- if (typeof timerCtx.interval !== 'function') {
770
- periodic.reason = '宿主定时服务不可用'
771
- return () => {
772
- periodic.running = false
773
- }
774
- }
775
- // 固定短 tick + 到期判断:间隔设置变更自下一周期生效,缩短经 earliest 钳制前移
776
- let nextDueAt = null
777
- let lastArmedAt = 0
778
- const scheduleNext = () => {
779
- const { intervalHours } = readSettings(ctx, config)
780
- lastArmedAt = Date.now()
781
- nextDueAt = intervalHours > 0 ? lastArmedAt + intervalHours * HOUR_MS : null
782
- }
783
- try {
784
- const dispose = timerCtx.interval(() => {
785
- const { intervalHours } = readSettings(ctx, config)
786
- // 设置变更对账:0 即时暂停;缺失 due(0 重启用)时补排,防周期轮静默死亡;
787
- // 缩短间隔时以 lastArmedAt 锚定前移,最长等新间隔而非等满旧周期
788
- if (intervalHours === 0) {
789
- nextDueAt = null
790
- return
791
- }
792
- const earliest = lastArmedAt > 0 ? lastArmedAt + intervalHours * HOUR_MS : Infinity
793
- if (nextDueAt !== null && nextDueAt > earliest) nextDueAt = earliest
794
- if (nextDueAt === null || Date.now() >= nextDueAt) {
795
- // 门闩占用(他源评估在途)时保持到期态,60s 后下个 tick 重试;
796
- // 仅评估真正启动才重排周期,被丢弃的到期轮不会静默失效一个周期
797
- if (evaluateArchives()) scheduleNext()
798
- }
799
- }, PERIODIC_TICK_MS)
800
- // disposer 挂本 inject fiber:timer 服务移除/重启时复位降级状态并清理 interval
801
- periodic.running = true
802
- return () => {
803
- dispose()
804
- periodic.running = false
805
- }
806
- } catch (error) {
807
- periodic.reason = '宿主定时服务调用失败: ' + String(error && error.message || error)
808
- return undefined
809
- }
810
- })
811
-
812
- ctx.inject(['settings'], (settingsCtx) => {
813
- // 方法面守卫:settings 服务在但缺 register(宿主升级变更面)时跳过注册,
814
- // 周期轮与启动补扫按 readSettings 的默认值降级运行,不因服务形变炸 fiber;
815
- // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
816
- if (typeof settingsCtx.settings.register !== 'function') {
817
- if (typeof settingsCtx.settings?.configure === 'function') {
818
- settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
819
- }
820
- return
821
- }
822
- settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
823
- // 启动补扫不依赖定时服务:settings 就绪即评估一轮,清掉停机期间积压的超期会话
824
- evaluateArchives()
825
- })
826
- }
827
-
828
- // 产物缺失时按无活跃处理(null),不阻断整轮评估;null 与真实 mtime=0 不混淆
829
- async function safeMtime(path) {
830
- try {
831
- return (await stat(path)).mtimeMs
832
- } catch {
833
- return null
834
- }
835
- }
836
-
837
- // header 查找缓存:inputs 路由每次请求都要查 header,listSessions 为全量扫描
838
- // (单次数百 ms,与后台对齐并发时成倍放大),而 sessionId→header 基本不变——
839
- // 扫一次进缓存,TTL 界定新鲜度,容量上限防无界增长;只缓存命中,
840
- // 未命中不缓存(新会话创建竞态下次扫描即命中,负缓存会误伤)
841
- const HEADER_CACHE_TTL_MS = 10 * 60 * 1000
842
- const HEADER_CACHE_MAX = 500
843
-
844
- function findHeader(ctx, sessionId, cache) {
845
- const hit = cache.get(sessionId)
846
- if (hit && Date.now() - hit.at < HEADER_CACHE_TTL_MS) return Promise.resolve(hit.header)
847
- return ctx.sessionQuery.listSessions().then((records) => {
848
- const found = records.find((recordItem) => String(recordItem.header.id) === sessionId)
849
- if (!found) return undefined
850
- if (cache.size >= HEADER_CACHE_MAX) cache.delete(cache.keys().next().value)
851
- cache.set(sessionId, { header: found.header, at: Date.now() })
852
- return found.header
853
- })
854
- }
855
-
856
- // detach 幂等:遍历全部工作区逐个移除,不因首个工作区操作失败提前终止;
857
- // 返回首个失败供响应,全部失败进服务端日志(部分失败范围可观测)
858
- async function detachSession(ctx, sessionId) {
859
- const errors = []
860
- for (const workspace of ctx.workspaceRegistry.list()) {
861
- if (!workspace.sessionIds.some((id) => String(id) === sessionId)) continue
862
- try {
863
- await workspace.detachSession(sessionId)
864
- } catch (error) {
865
- errors.push(error)
866
- }
867
- }
868
- if (errors.length > 1 && ctx.logger) {
869
- ctx.logger.warn('session-manager detach 多工作区失败(' + sessionId + '): ' + errors.map(String).join('; '))
870
- }
871
- return errors[0]
872
- }
873
-
874
- // win32 路径大小写不敏感:realpath 已规范大小写,但比对侧可能存在大小写漂移
875
- function samePath(left, right) {
876
- if (left === right) return true
877
- return process.platform === 'win32' && String(left).toLowerCase() === String(right).toLowerCase()
878
- }
879
-
880
- // 按会话 cwd 找归属工作区:两侧都 realpath 后比对(工作区路径本身也可能含
881
- // 符号链接/大小写漂移),任一侧不可解析时按原值兜底,
882
- // 最终归属校验仍由官方 attachSession 完成
883
- async function findWorkspaceByCwd(ctx, cwd) {
884
- if (cwd === undefined) return undefined
885
- let resolved = cwd
886
- try {
887
- resolved = await realpath(cwd)
888
- } catch {
889
- // 目录不存在时按原值比对
890
- }
891
- for (const workspace of ctx.workspaceRegistry.list()) {
892
- let workspacePath = workspace.path
893
- try {
894
- workspacePath = await realpath(workspacePath)
895
- } catch {
896
- // 按原值比对
897
- }
898
- if (samePath(workspacePath, resolved)) return workspace
899
- }
900
- return undefined
901
- }
1
+ // dsh-session-manager Host 半区:自动归档评估 + 取消归档 + 删除(回收站) + 已删除台账与重挂载。
2
+ // 评估三触发源共用同一门闩:新会话创建(session/created,按工作区限定)、settings 就绪后的
3
+ // 启动补扫(全量)、每日周期轮(全量,经宿主 timer 软依赖);取消归档与删除后的归档清理经
4
+ // workspace 域 global 直写 archivedSessionIds 并同步注册表进程内快照(官方无 unarchive 表面,
5
+ // 域写入经 domain/changed 触发 workspace.follow 的 archived 帧);删除按失败矩阵
6
+ // 执行 locate → trash → detach → 归档清理,台账记录删除产物路径供回收站还原后
7
+ // 一键重挂载(官方 attachSession)。面板数据不在此处:client 侧以 session.list
8
+ // 行与归档集合做交集。
9
+
10
+ import { open, realpath, stat } from 'node:fs/promises'
11
+ import { homedir } from 'node:os'
12
+ import { dirname, join } from 'node:path'
13
+
14
+ import schemastery from '@deepseek-ai/schemastery'
15
+ import { z } from 'zod'
16
+ import { defineDomain } from '@deepseek-ai/dsh-storage-domain'
17
+
18
+ import {
19
+ DAY_MS,
20
+ DEFAULT_AUTO_ARCHIVE_DAYS,
21
+ DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
22
+ aggregateDeleteOutcome,
23
+ artifactLooksBlank,
24
+ deleteEligibility,
25
+ isSessionRunning,
26
+ mergeDeletedEntry,
27
+ removeDeletedEntry,
28
+ selectArchiveCandidates,
29
+ UNARCHIVE_GRACE_MS,
30
+ unarchiveGraceActive,
31
+ updatedAtOf,
32
+ } from './core.mjs'
33
+ import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
34
+
35
+ export const name = 'dsh-session-manager'
36
+
37
+ export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
38
+
39
+ const NAMESPACE = 'session-manager'
40
+ const WORKSPACE_DOMAIN_NAME = 'workspace'
41
+
42
+ // 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息;
43
+ // heldPath 仅回收区降级条目携带(暂存路径,重挂载时移回 path 原位置)
44
+ const LEDGER_SPEC = defineDomain({
45
+ name: 'session_manager',
46
+ version: 1,
47
+ tables: {},
48
+ global: {
49
+ schema: z.object({
50
+ deleted: z.array(z.object({
51
+ sessionId: z.string(),
52
+ path: z.string(),
53
+ heldPath: z.string().optional(),
54
+ deletedAt: z.number(),
55
+ })),
56
+ }),
57
+ initial: { deleted: [] },
58
+ },
59
+ })
60
+
61
+ const BLANK_PROBE_CHUNK_BYTES = 64 * 1024
62
+ // 台账条目上限:超过即裁掉最旧条目(数组头部为新,尾部最旧),防无界增长写放大
63
+ const LEDGER_MAX_ENTRIES = 200
64
+ // 自动归档评估整体超时:宿主服务挂起时不因门闩未复位而永久停摆
65
+ const EVALUATION_TIMEOUT_MS = 30 * 1000
66
+ const HOUR_MS = 60 * 60 * 1000
67
+ // 周期评估固定短 tick:tick 内做到期判断,间隔设置变更自下一周期生效
68
+ const PERIODIC_TICK_MS = 60 * 1000
69
+ // 台账单次读改写超时:宿主存储域挂起时锁内 job 超时放行,防互斥链死锁与 inFlight 泄漏
70
+ const LEDGER_OP_TIMEOUT_MS = 10 * 1000
71
+
72
+ // 路由响应文案;导出供测试断言与实现同步。
73
+ // trash 半失败聚合 5 条在 core.DELETE_MESSAGES(经 aggregateDeleteOutcome 组装),此处不重复
74
+ export const MESSAGES = {
75
+ unsupportedBackend: '当前存储后端不支持按会话删除',
76
+ notArchived: '仅已归档会话可删除',
77
+ unknownSession: '会话不存在',
78
+ running: '运行中的会话不可删除',
79
+ trashFailed: '移入回收站失败',
80
+ notRestored: '会话产物不在持久层,请先到系统回收站还原后重试',
81
+ ghostCleanup: '产物已不存在,已完成列表清理',
82
+ ghostDetach: '产物已不存在,但移除列表记录失败',
83
+ ghostArchiveSuffix: ',且移除归档记录失败',
84
+ noWorkspace: '未找到会话所属工作区,无法重新挂载',
85
+ ledgerCleanup: '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾',
86
+ inFlight: '该会话正在删除中,请稍后重试',
87
+ badJsonBody: '请求体不是合法 JSON',
88
+ systemError: '操作失败(系统级错误,详见服务端日志)',
89
+ heldMissing: '回收区暂存已缺失,原位置亦无产物,无法找回',
90
+ restoreFailed: '回收区还原失败',
91
+ }
92
+
93
+ // trash 执行器出口:进程级唯一 OS 副作用注入点(回收站/回收区搬移/移回),测试经此桩替
94
+ export const executor = { trashPath, moveToQuarantine, restoreFromQuarantine }
95
+
96
+ const SETTINGS_SCHEMA = schemastery.object({
97
+ autoArchiveDays: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_DAYS)
98
+ .description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
99
+ autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
100
+ .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
101
+ })
102
+
103
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
104
+ // settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
105
+ // 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
106
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
107
+
108
+ function volatileWrap(schema) {
109
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
110
+ }
111
+
112
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
113
+ // legacy 宿主 config 为普通对象原样透传
114
+ function unwrapVolatile(value) {
115
+ return typeof value?.get === 'function' ? value.get() : value
116
+ }
117
+
118
+ function sendJson(res, status, payload) {
119
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
120
+ res.end(JSON.stringify(payload))
121
+ }
122
+
123
+ // 错误响应出口:业务错误(Error 无错误码)原样透传消息;系统级错误(带 fs 错误码,
124
+ // message 内嵌绝对路径)收敛为固定文案,原始错误仅进服务端日志
125
+ function respondError(ctx, res, error) {
126
+ const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
127
+ if (isSystem && ctx.logger) ctx.logger.warn('session-manager 系统级错误: ' + String(error && error.stack || error))
128
+ const message = isSystem
129
+ ? MESSAGES.systemError
130
+ : (error && error.message ? error.message : String(error))
131
+ sendJson(res, 400, { error: message })
132
+ }
133
+
134
+ function readSettings(ctx, config) {
135
+ const settings = ctx.get('settings')
136
+ // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
137
+ // config 整节解包。方法面守卫兼防:服务在但缺 get(宿主升级变更面)时回落默认值,
138
+ // 防 timer 回调抛错崩进程
139
+ const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
140
+ const days = Number(value && value.autoArchiveDays)
141
+ const hours = Number(value && value.autoArchiveIntervalHours)
142
+ return {
143
+ days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
144
+ intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
145
+ }
146
+ }
147
+
148
+ // auto-archive 路由请求体 → settings patch:仅接受出现且为非负整数的字段(部分更新),
149
+ // 一个合法字段都没有即拒绝;返回形态错误以业务错误抛出,由路由统一转 400
150
+ const AUTO_ARCHIVE_FIELDS = [['days', 'autoArchiveDays'], ['intervalHours', 'autoArchiveIntervalHours']]
151
+
152
+ function readAutoArchivePatch(body) {
153
+ const patch = {}
154
+ for (const [input, setting] of AUTO_ARCHIVE_FIELDS) {
155
+ const raw = body ? body[input] : undefined
156
+ if (raw === undefined) continue
157
+ if (!Number.isInteger(raw) || raw < 0) throw new Error('自动归档配置须为非负整数: ' + input)
158
+ patch[setting] = raw
159
+ }
160
+ if (Object.keys(patch).length === 0) throw new Error('缺少可更新的配置字段')
161
+ return patch
162
+ }
163
+
164
+ function readBody(req) {
165
+ const BODY_MAX_BYTES = 64 * 1024
166
+ return new Promise((resolve, reject) => {
167
+ let size = 0
168
+ const chunks = []
169
+ req.on('data', (chunk) => {
170
+ size += chunk.length
171
+ if (size > BODY_MAX_BYTES) {
172
+ reject(new Error('请求体超过上限'))
173
+ req.destroy()
174
+ return
175
+ }
176
+ chunks.push(chunk)
177
+ })
178
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
179
+ req.on('error', reject)
180
+ })
181
+ }
182
+
183
+ async function requireSessionId(req) {
184
+ // 协议错误与业务错误分通道:JSON 解析失败给固定中文文案,不透出引擎 SyntaxError
185
+ let body
186
+ try {
187
+ body = JSON.parse(await readBody(req))
188
+ } catch {
189
+ throw new Error(MESSAGES.badJsonBody)
190
+ }
191
+ const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
192
+ if (sessionId.length === 0) throw new Error('sessionId 不能为空')
193
+ return sessionId
194
+ }
195
+
196
+ /** JSONL 产物空白探测:读文件头部,不足两行即视为空白会话。 */
197
+ async function artifactIsBlank(path) {
198
+ let handle
199
+ try {
200
+ handle = await open(path, 'r')
201
+ const { buffer, bytesRead } = await handle.read(
202
+ Buffer.allocUnsafe(BLANK_PROBE_CHUNK_BYTES), 0, BLANK_PROBE_CHUNK_BYTES, 0)
203
+ const headText = buffer.toString('utf8', 0, bytesRead)
204
+ return artifactLooksBlank(headText, bytesRead === BLANK_PROBE_CHUNK_BYTES)
205
+ } catch {
206
+ // 产物不可读(已被移走等)时按非空白处理,宁可漏归档不可误归档
207
+ return false
208
+ } finally {
209
+ if (handle !== undefined) await handle.close().catch(() => {})
210
+ }
211
+ }
212
+
213
+ function rejectMethod(req, res, method) {
214
+ if (req.method === method) return true
215
+ sendJson(res, 405, { error: 'method not allowed' })
216
+ return false
217
+ }
218
+
219
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
220
+ export function apply(ctx, config) {
221
+ let evaluating = false
222
+ // 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
223
+ // 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
224
+ let unarchivedAtMs = 0
225
+ // 删除路由同 id 并发去重(进程内)
226
+ const inFlightDeletes = new Set()
227
+
228
+ // 插件回收区:系统回收站不可用环境的删除降级暂存地,重挂载时移回原位置。
229
+ // 测试经 env DSH_QUARANTINE_DIR 注入临时目录
230
+ const quarantineDir = process.env.DSH_QUARANTINE_DIR
231
+ || join(homedir(), '.dsh', 'trash', 'session-manager')
232
+
233
+ // header 查找缓存:归档/删除/重挂载路由共用(inputs 浮层已迁 dsh-context-manager)
234
+ const headerCache = new Map()
235
+ ctx.effect(() => () => headerCache.clear(), 'session-manager header cache')
236
+
237
+ // 台账域:应用装载即打开,路由内 await;打开失败由路由响应,此处仅防未处理拒绝
238
+ const ledgerReady = ctx.storageDomain.open(LEDGER_SPEC)
239
+ ledgerReady.catch(() => {})
240
+ ctx.effect(() => () => {
241
+ void ledgerReady.then((domain) => domain.close()).catch(() => {})
242
+ }, 'session-manager ledger domain')
243
+
244
+ // 周期评估状态:running 供设置页降级提示;timer 为软依赖,服务缺失不阻塞插件装载
245
+ const periodic = { running: false, reason: '' }
246
+
247
+ // 自动归档评估:筛出待归档会话并逐个走官方 archiveSession。cwd 缺省时全量评估
248
+ // (启动补扫 / 周期轮),传入 cwd 时仅评估该工作区(新建会话触发)。
249
+ // 门闩与超时守卫对全部触发源生效。两级筛选:先用 createdAt 纯内存预筛(mtime 只会
250
+ // 增大 updatedAt,createdAt 未超期必不超期),仅对预筛存活者做产物 stat/blank 探测,
251
+ // 活跃会话零 IO。返回是否真正启动:被门闩/关闭挡下的调用方(周期 tick)保持到期态
252
+ // 由下个 tick 重试,不得据此重排周期
253
+ function evaluateArchives(cwd) {
254
+ const { days } = readSettings(ctx, config)
255
+ if (days === 0 || evaluating) return false
256
+ evaluating = true
257
+ let timeoutGuard
258
+ const work = (async () => {
259
+ const registry = ctx.workspaceRegistry
260
+ const archived = new Set(registry.archivedSessionIds.map(String))
261
+ const sessionsService = ctx.get('sessions')
262
+ const agents = ctx.get('agents')
263
+ const persistence = ctx.get('sessionPersistence')
264
+ const records = await ctx.sessionQuery.listSessions()
265
+ const nowMs = Date.now()
266
+ // 全局宽限短路:恢复动作后 graceMs 内暂停归档,保护「恢复 → 首条消息」窗口
267
+ if (unarchiveGraceActive(unarchivedAtMs, nowMs)) return
268
+ const cutoff = nowMs - days * DAY_MS
269
+ const candidates = []
270
+ for (const recordItem of records) {
271
+ const header = recordItem.header
272
+ if (cwd !== undefined && header.cwd !== cwd) continue
273
+ // subagent 内部会话不属主列表(官方 sessionVisible 以 origin 排除),归档集合
274
+ // 仅服务用户会话:误归档只产出不可辨认的面板行与无意义通知
275
+ if (header.origin === 'subagent') continue
276
+ // 预筛:createdAt 未超期必不超期(见上),跳过产物 IO
277
+ if (header.createdAt >= cutoff) continue
278
+ // 已归档记录永不是候选,集合随历史增长,提前跳过省产物 IO
279
+ if (archived.has(String(header.id))) continue
280
+ const live = sessionsService && sessionsService.get(header.id)
281
+ const running = isSessionRunning({ agents, sessionId: header.id })
282
+ // 运行中永不入选归档,超期 running 在此早退免付产物 IO(64KB 读);
283
+ // core.selectArchiveCandidates 的 !running 过滤保留作第二道防线
284
+ if (running) continue
285
+ const located = persistence && persistence.locate(header)
286
+ // locate 缺失(第三方后端不支持定位)与产物不可读同规跳过:仅凭 createdAt
287
+ // 判活跃会绕过 mtime 保护,与 delete 的 unsupportedBackend 拒绝口径对齐
288
+ if (!located) continue
289
+ const activityAt = await safeMtime(located.path)
290
+ // 产物不可读视为已删除,不参与归档:否则删除后的会话(重连前仍在持久层
291
+ // 列表中)会因超期被重新归档,面板行复活且无法再删
292
+ if (activityAt === null) continue
293
+ // stat 后即可判活跃度:未超期不必做空白探测
294
+ const updatedAt = updatedAtOf(header, activityAt)
295
+ if (updatedAt >= cutoff) continue
296
+ const blank = live ? live.seq === 0 : Boolean(await artifactIsBlank(located.path))
297
+ candidates.push({
298
+ id: String(header.id),
299
+ archived: false,
300
+ running,
301
+ blank,
302
+ updatedAt,
303
+ })
304
+ }
305
+ for (const id of selectArchiveCandidates({ records: candidates, nowMs, thresholdDays: days })) {
306
+ try {
307
+ await registry.archiveSession(id)
308
+ ctx.logger && ctx.logger.info && ctx.logger.info('session-manager 已归档会话: ' + id)
309
+ } catch (error) {
310
+ // 单个归档失败不中断整轮,剩余候选继续;失败者由下一次触发重试
311
+ ctx.logger && ctx.logger.warn('session-manager 归档 ' + id + ' 失败: ' + String(error))
312
+ }
313
+ }
314
+ })()
315
+ // 门闩挂起兜底:宿主服务永不返回时超时复位,保证后续评估不被永久跳过
316
+ const timeout = new Promise((_, reject) => {
317
+ timeoutGuard = setTimeout(() => reject(new Error('自动归档评估超时')), EVALUATION_TIMEOUT_MS)
318
+ })
319
+ void Promise.race([work, timeout]).catch((error) => {
320
+ ctx.logger && ctx.logger.warn('session-manager 自动归档评估失败: ' + String(error))
321
+ }).finally(() => {
322
+ clearTimeout(timeoutGuard)
323
+ evaluating = false
324
+ void work.catch(() => {})
325
+ })
326
+ return true
327
+ }
328
+
329
+ ctx.on('session/created', (session) => {
330
+ const cwd = session.header && session.header.cwd
331
+ if (cwd === undefined) return
332
+ evaluateArchives(cwd)
333
+ })
334
+
335
+ // 取消归档:直写 workspace 域 global,无官方 API 可用。注册表把域 global 快照到
336
+ // 进程内 state 且只在自身写路径同步,直写后必须把快照一并改掉:否则注册表后续
337
+ // 全量写回(含自动归档)与 workspace.list 重连基线都会复活该 id,官方归档的幂等
338
+ // 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
339
+ // 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
340
+ // 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
341
+ // 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
342
+ // pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
343
+ // 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
344
+ async function removeArchivedId(sessionId) {
345
+ const domain = ctx.storageDomain.get(WORKSPACE_DOMAIN_NAME)
346
+ if (domain === undefined) throw new Error('workspace 域未打开')
347
+ const durable = domain.global.get()
348
+ const cached = ctx.workspaceRegistry.state
349
+ if (cached === undefined) throw new Error('workspace 注册表快照不可用')
350
+ const merged = new Set(durable.archivedSessionIds.map(String))
351
+ for (const id of cached.archivedSessionIds) merged.add(String(id))
352
+ if (!merged.delete(String(sessionId))) return
353
+ const next = { ...durable, archivedSessionIds: [...merged] }
354
+ await domain.global.set(next)
355
+ ctx.workspaceRegistry.state = next
356
+ }
357
+
358
+ // 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
359
+ // ledgerChain 串行化所有读改写:get→set 交错时后写者会用旧快照整体覆盖,丢条目。
360
+ // 逐 job 超时兜底:宿主存储域挂起时超时放行,防链条死锁与 inFlightDeletes 泄漏;
361
+ // 超时的写操作后台落盘仍安全(合并/移除均为幂等语义)
362
+ let ledgerChain = Promise.resolve()
363
+ function withLedgerLock(job) {
364
+ const runJob = () => Promise.race([
365
+ job(),
366
+ new Promise((_, reject) => setTimeout(
367
+ () => reject(new Error('台账操作超时(' + LEDGER_OP_TIMEOUT_MS / 1000 + 's)')),
368
+ LEDGER_OP_TIMEOUT_MS)),
369
+ ])
370
+ const run = ledgerChain.then(runJob, runJob)
371
+ ledgerChain = run.catch(() => {})
372
+ return run
373
+ }
374
+
375
+ async function recordDeletedEntry(sessionId, path, heldPath) {
376
+ return withLedgerLock(async () => {
377
+ const ledger = await ledgerReady
378
+ const current = ledger.global.get()
379
+ const entry = { sessionId, path, deletedAt: Date.now(), ...(heldPath !== undefined ? { heldPath } : {}) }
380
+ const merged = mergeDeletedEntry(current.deleted, entry)
381
+ await ledger.global.set({ ...current, deleted: merged.slice(0, LEDGER_MAX_ENTRIES) })
382
+ })
383
+ }
384
+
385
+ // 台账移除:幂等,未命中不产生写回
386
+ async function removeLedgerEntry(sessionId) {
387
+ return withLedgerLock(async () => {
388
+ const ledger = await ledgerReady
389
+ const current = ledger.global.get()
390
+ const { deleted, removed } = removeDeletedEntry(current.deleted, sessionId)
391
+ if (removed) await ledger.global.set({ ...current, deleted })
392
+ })
393
+ }
394
+
395
+ // 宽限记账:取消归档时更新宽限起点(进程内变量,无持久化副作用)
396
+ function recordUnarchiveGrace() {
397
+ unarchivedAtMs = Date.now()
398
+ }
399
+
400
+ // 台账命中查询:同 id 曾删除过(重删场景的幽灵资格依据);域不可用时按未命中
401
+ async function ledgerHasEntry(sessionId) {
402
+ try {
403
+ const ledger = await ledgerReady
404
+ return ledger.global.get().deleted.some((item) => item.sessionId === sessionId)
405
+ } catch {
406
+ return false
407
+ }
408
+ }
409
+
410
+ // 台账条目读取:重挂载移回依据;域不可用或未命中均按无条目
411
+ async function ledgerEntryFor(sessionId) {
412
+ try {
413
+ const ledger = await ledgerReady
414
+ return ledger.global.get().deleted.find((item) => item.sessionId === sessionId)
415
+ } catch {
416
+ return undefined
417
+ }
418
+ }
419
+
420
+ // 幽灵残留清理:产物已缺失的会话仅解除列表可见性(detach + 归档清理),
421
+ // 无回收与台账动作;失败点聚合成后缀,重试即补全
422
+ async function cleanupDeletedSession(sessionId) {
423
+ let suffix = ''
424
+ const detachError = await detachSession(ctx, sessionId)
425
+ if (detachError !== undefined) {
426
+ ctx.logger && ctx.logger.warn('session-manager 幽灵清理 detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
427
+ suffix = MESSAGES.ghostDetach
428
+ }
429
+ try {
430
+ await removeArchivedId(sessionId)
431
+ } catch (error) {
432
+ ctx.logger && ctx.logger.warn('session-manager 幽灵清理归档移除失败(' + sessionId + '): ' + String(error && error.stack || error))
433
+ suffix += MESSAGES.ghostArchiveSuffix
434
+ }
435
+ return suffix
436
+ }
437
+
438
+ // 重挂载前把回收区暂存产物移回原位置:回收区条目的找回动作就是重挂载本身,
439
+ // 不要求用户手工搬移。原位置已有产物(已自行还原)或暂存缺失时不动
440
+ async function restoreHeldArtifact(sessionId) {
441
+ const entry = await ledgerEntryFor(sessionId)
442
+ if (entry === undefined || entry.heldPath === undefined) return
443
+ const originalGone = await stat(entry.path).then(() => false, (error) => {
444
+ if (error && error.code === 'ENOENT') return true
445
+ throw error
446
+ })
447
+ if (!originalGone) return
448
+ const heldExists = await stat(entry.heldPath).then(() => true, (error) => {
449
+ if (error && error.code === 'ENOENT') return false
450
+ throw error
451
+ })
452
+ if (!heldExists) throw new Error(MESSAGES.heldMissing)
453
+ try {
454
+ await executor.restoreFromQuarantine(entry.heldPath, entry.path)
455
+ } catch (error) {
456
+ throw new Error(MESSAGES.restoreFailed + ': ' + String(error && error.message || error))
457
+ }
458
+ }
459
+
460
+ const routes = [
461
+ {
462
+ path: '/api/session-manager/unarchive',
463
+ handler: async (req, res) => {
464
+ if (!rejectMethod(req, res, 'POST')) return
465
+ try {
466
+ const sessionId = await requireSessionId(req)
467
+ await removeArchivedId(sessionId)
468
+ await recordUnarchiveGrace()
469
+ sendJson(res, 200, { ok: true })
470
+ } catch (error) {
471
+ respondError(ctx, res, error)
472
+ }
473
+ },
474
+ },
475
+ {
476
+ path: '/api/session-manager/info',
477
+ handler: async (req, res) => {
478
+ if (!rejectMethod(req, res, 'POST')) return
479
+ try {
480
+ const sessionId = await requireSessionId(req)
481
+ const header = await findHeader(ctx, sessionId, headerCache)
482
+ if (header === undefined) {
483
+ sendJson(res, 400, { error: MESSAGES.unknownSession })
484
+ return
485
+ }
486
+ const persistence = ctx.get('sessionPersistence')
487
+ const location = persistence ? persistence.locate(header) : undefined
488
+ if (location === undefined) {
489
+ sendJson(res, 200, { supported: false })
490
+ return
491
+ }
492
+ let info
493
+ try {
494
+ info = await stat(location.path)
495
+ } catch (error) {
496
+ // live 记录可指向已删除会话,产物缺失是常规状态而非故障
497
+ if (error && error.code === 'ENOENT') {
498
+ sendJson(res, 200, { supported: true, sizeBytes: 0, missing: true })
499
+ return
500
+ }
501
+ throw error
502
+ }
503
+ sendJson(res, 200, { supported: true, sizeBytes: info.size })
504
+ } catch (error) {
505
+ respondError(ctx, res, error)
506
+ }
507
+ },
508
+ },
509
+ {
510
+ path: '/api/session-manager/delete',
511
+ handler: async (req, res) => {
512
+ if (!rejectMethod(req, res, 'POST')) return
513
+ const sessionId = await requireSessionId(req).catch((error) => {
514
+ respondError(ctx, res, error)
515
+ return undefined
516
+ })
517
+ if (sessionId === undefined) return
518
+ // 同 id 去重:并发第二次删除会走幽灵清理,语义混乱且响应文案不一致
519
+ if (inFlightDeletes.has(sessionId)) {
520
+ sendJson(res, 400, { error: MESSAGES.inFlight })
521
+ return
522
+ }
523
+ inFlightDeletes.add(sessionId)
524
+ try {
525
+ const header = await findHeader(ctx, sessionId, headerCache)
526
+ if (header === undefined) {
527
+ sendJson(res, 400, { error: MESSAGES.unknownSession })
528
+ return
529
+ }
530
+ // 运行中守卫:与归档评估同款判据,落实"运行中会话永不被删除"不变量
531
+ if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
532
+ sendJson(res, 400, { error: MESSAGES.running })
533
+ return
534
+ }
535
+ const persistence = ctx.get('sessionPersistence')
536
+ const location = persistence ? persistence.locate(header) : undefined
537
+ if (location === undefined) {
538
+ sendJson(res, 400, { error: MESSAGES.unsupportedBackend })
539
+ return
540
+ }
541
+ // locate 返回会话目录下的日志文件;回收对象是目录整体,残留空目录会让
542
+ // 已删除会话在面板以空壳复活。缺失判定按目录级:日志缺失但目录在(半删除
543
+ // 残留)仍走完整回收,目录消失才是幽灵
544
+ const artifactDir = dirname(location.path)
545
+ let artifactMissing = false
546
+ try {
547
+ await stat(artifactDir)
548
+ } catch (error) {
549
+ if (error && error.code !== 'ENOENT') throw error
550
+ artifactMissing = true
551
+ }
552
+ if (artifactMissing) {
553
+ // 幽灵资格:已归档或台账有记录(同 id 重删的残留清理)才放行;
554
+ // 两者皆无的 ENOENT 可能是新建会话尚未落盘,剥离活会话属于破坏性误删
555
+ const archivedNow = ctx.workspaceRegistry.archivedSessionIds.map(String).includes(sessionId)
556
+ if (!archivedNow && !(await ledgerHasEntry(sessionId))) {
557
+ sendJson(res, 400, { error: MESSAGES.notArchived })
558
+ return
559
+ }
560
+ const cleanupError = await cleanupDeletedSession(sessionId)
561
+ if (cleanupError !== '') {
562
+ sendJson(res, 200, { ok: true, partial: true, message: cleanupError })
563
+ return
564
+ }
565
+ sendJson(res, 200, { ok: true, message: MESSAGES.ghostCleanup })
566
+ return
567
+ }
568
+ // host 端到端权威:归档资格在执行前校验
569
+ const eligibility = deleteEligibility({
570
+ archivedIds: ctx.workspaceRegistry.archivedSessionIds.map(String),
571
+ sessionId,
572
+ })
573
+ if (!eligibility.ok) {
574
+ sendJson(res, 400, { error: MESSAGES.notArchived })
575
+ return
576
+ }
577
+ // TOCTOU 复检:守卫通过到执行间存在多个 await 间隙,会话可能已恢复运行
578
+ if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
579
+ sendJson(res, 400, { error: MESSAGES.running })
580
+ return
581
+ }
582
+ // 处置:优先系统回收站;不可用(容器无 gio/dbus 等)降级插件回收区
583
+ // rename 搬移,仍可经重挂载移回原位置。双失败才会话保留并拒绝,
584
+ // 原始回收站错误落日志(降级环境的诊断依据),拒绝响应携回收区错误
585
+ let heldPath
586
+ try {
587
+ await executor.trashPath(artifactDir)
588
+ } catch (trashError) {
589
+ ctx.logger && ctx.logger.warn('session-manager 系统回收站不可用(' + sessionId + '),尝试回收区降级: ' + String(trashError))
590
+ try {
591
+ heldPath = await executor.moveToQuarantine(artifactDir, quarantineDir)
592
+ } catch (heldError) {
593
+ sendJson(res, 400, { error: MESSAGES.trashFailed + '(系统回收站与插件回收区均不可用): ' + String(heldError && heldError.message || heldError) })
594
+ return
595
+ }
596
+ }
597
+ const quarantined = heldPath !== undefined
598
+ // 执行期翻转检测:OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行。
599
+ // 产物已移走,中断只会更糟,照常完成收尾,但把不变量破坏变为可观测事件
600
+ const runningDuringTrash = isSessionRunning({ agents: ctx.get('agents'), sessionId })
601
+ if (runningDuringTrash) {
602
+ ctx.logger && ctx.logger.warn('session-manager 删除执行期间会话恢复运行: ' + sessionId)
603
+ }
604
+ // 台账在处置成功后立即记录:产物已离开原位置,后续任何半失败都不影响还原资格;
605
+ // 台账失败只降级重挂载便利,不回滚删除
606
+ let ledgerError
607
+ try {
608
+ await recordDeletedEntry(sessionId, artifactDir, heldPath)
609
+ } catch (error) {
610
+ ledgerError = error
611
+ ctx.logger && ctx.logger.warn('session-manager 台账记录失败(' + sessionId + '): ' + String(error && error.stack || error))
612
+ }
613
+ const detachError = await detachSession(ctx, sessionId)
614
+ if (detachError !== undefined) {
615
+ ctx.logger && ctx.logger.warn('session-manager detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
616
+ }
617
+ // 归档集合同步清理:残留 id 会让面板行持续可见;detach 失败时不清理,
618
+ // 保留归档资格供重试
619
+ let archiveCleanupFailed = false
620
+ if (detachError === undefined) {
621
+ try {
622
+ await removeArchivedId(sessionId)
623
+ } catch (error) {
624
+ archiveCleanupFailed = true
625
+ ctx.logger && ctx.logger.warn('session-manager 归档清理失败(' + sessionId + '): ' + String(error && error.stack || error))
626
+ }
627
+ }
628
+ sendJson(res, 200, {
629
+ ...aggregateDeleteOutcome({
630
+ detachFailed: detachError !== undefined,
631
+ archiveCleanupFailed,
632
+ ledgerFailed: ledgerError !== undefined,
633
+ runningDuringTrash,
634
+ quarantined,
635
+ }),
636
+ mode: quarantined ? 'quarantine' : 'os',
637
+ })
638
+ } catch (error) {
639
+ respondError(ctx, res, error)
640
+ } finally {
641
+ inFlightDeletes.delete(sessionId)
642
+ }
643
+ },
644
+ },
645
+ {
646
+ path: '/api/session-manager/status',
647
+ handler: async (req, res) => {
648
+ if (!rejectMethod(req, res, 'GET')) return
649
+ sendJson(res, 200, { periodic: { ...periodic } })
650
+ },
651
+ },
652
+ {
653
+ // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
654
+ // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
655
+ path: '/api/session-manager/auto-archive',
656
+ handler: async (req, res) => {
657
+ try {
658
+ if (req.method === 'GET') {
659
+ const current = readSettings(ctx, config)
660
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
661
+ return
662
+ }
663
+ if (!rejectMethod(req, res, 'POST')) return
664
+ let body
665
+ try {
666
+ body = JSON.parse(await readBody(req))
667
+ } catch {
668
+ throw new Error(MESSAGES.badJsonBody)
669
+ }
670
+ const patch = readAutoArchivePatch(body)
671
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
672
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
673
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
674
+ // await 保证回显为新值,persist 失败走 respondError
675
+ const settings = ctx.get('settings')
676
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
677
+ await settings.update(NAMESPACE, patch)
678
+ } else {
679
+ const editor = ctx.get('configEditor')
680
+ const entry = ctx.fiber?.entry
681
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
682
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
683
+ }
684
+ const current = readSettings(ctx, config)
685
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
686
+ } catch (error) {
687
+ respondError(ctx, res, error)
688
+ }
689
+ },
690
+ },
691
+ {
692
+ path: '/api/session-manager/deleted',
693
+ handler: async (req, res) => {
694
+ if (!rejectMethod(req, res, 'GET')) return
695
+ try {
696
+ const ledger = await ledgerReady
697
+ sendJson(res, 200, { deleted: ledger.global.get().deleted })
698
+ } catch (error) {
699
+ respondError(ctx, res, error)
700
+ }
701
+ },
702
+ },
703
+ {
704
+ path: '/api/session-manager/remount',
705
+ handler: async (req, res) => {
706
+ if (!rejectMethod(req, res, 'POST')) return
707
+ try {
708
+ const sessionId = await requireSessionId(req)
709
+ // 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
710
+ // 静默按无条目处理会让回收区产物永远找不回,还误导用户去系统回收站
711
+ try {
712
+ await ledgerReady
713
+ } catch {
714
+ sendJson(res, 400, { error: MESSAGES.systemError })
715
+ return
716
+ }
717
+ // 回收区暂存的产物先移回原位置(host 按产物现状列会话,移回后 header 可见);
718
+ // OS 回收站条目无 heldPath,此调用为空操作
719
+ await restoreHeldArtifact(sessionId)
720
+ // 产物还原的判定即持久层能重新读到 header:未还原时在此拒绝
721
+ const header = await findHeader(ctx, sessionId, headerCache)
722
+ if (header === undefined) {
723
+ sendJson(res, 400, { error: MESSAGES.notRestored })
724
+ return
725
+ }
726
+ const workspace = await findWorkspaceByCwd(ctx, header.cwd)
727
+ if (workspace === undefined) {
728
+ sendJson(res, 400, { error: MESSAGES.noWorkspace })
729
+ return
730
+ }
731
+ // 官方挂载通道:自带 header cwd 校验,失败即整体拒绝,台账保留供重试
732
+ await workspace.attachSession(sessionId)
733
+ // 挂载已成功:台账清除失败只影响收尾,不得报成挂载失败
734
+ try {
735
+ await removeLedgerEntry(sessionId)
736
+ } catch {
737
+ sendJson(res, 200, { ok: true, partial: true, message: MESSAGES.ledgerCleanup })
738
+ return
739
+ }
740
+ sendJson(res, 200, { ok: true })
741
+ } catch (error) {
742
+ respondError(ctx, res, error)
743
+ }
744
+ },
745
+ },
746
+ {
747
+ path: '/api/session-manager/forget',
748
+ handler: async (req, res) => {
749
+ if (!rejectMethod(req, res, 'POST')) return
750
+ try {
751
+ await removeLedgerEntry(await requireSessionId(req))
752
+ sendJson(res, 200, { ok: true })
753
+ } catch (error) {
754
+ respondError(ctx, res, error)
755
+ }
756
+ },
757
+ },
758
+ ]
759
+
760
+ for (const route of routes) {
761
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
762
+ 'dsh-session-manager ' + route.path)
763
+ }
764
+
765
+ // 周期评估软依赖 timer:顶层注册(不嵌套在 settings 回调内,settings 服务重启会
766
+ // 重跑其回调并新建 fiber,嵌套会使周期轮随重启累积复制)。服务未激活则回调不执行,
767
+ // 插件其余能力不受影响,设置页提示降级
768
+ ctx.inject(['timer'], (timerCtx) => {
769
+ if (typeof timerCtx.interval !== 'function') {
770
+ periodic.reason = '宿主定时服务不可用'
771
+ return () => {
772
+ periodic.running = false
773
+ }
774
+ }
775
+ // 固定短 tick + 到期判断:间隔设置变更自下一周期生效,缩短经 earliest 钳制前移
776
+ let nextDueAt = null
777
+ let lastArmedAt = 0
778
+ const scheduleNext = () => {
779
+ const { intervalHours } = readSettings(ctx, config)
780
+ lastArmedAt = Date.now()
781
+ nextDueAt = intervalHours > 0 ? lastArmedAt + intervalHours * HOUR_MS : null
782
+ }
783
+ try {
784
+ const dispose = timerCtx.interval(() => {
785
+ const { intervalHours } = readSettings(ctx, config)
786
+ // 设置变更对账:0 即时暂停;缺失 due(0 重启用)时补排,防周期轮静默死亡;
787
+ // 缩短间隔时以 lastArmedAt 锚定前移,最长等新间隔而非等满旧周期
788
+ if (intervalHours === 0) {
789
+ nextDueAt = null
790
+ return
791
+ }
792
+ const earliest = lastArmedAt > 0 ? lastArmedAt + intervalHours * HOUR_MS : Infinity
793
+ if (nextDueAt !== null && nextDueAt > earliest) nextDueAt = earliest
794
+ if (nextDueAt === null || Date.now() >= nextDueAt) {
795
+ // 门闩占用(他源评估在途)时保持到期态,60s 后下个 tick 重试;
796
+ // 仅评估真正启动才重排周期,被丢弃的到期轮不会静默失效一个周期
797
+ if (evaluateArchives()) scheduleNext()
798
+ }
799
+ }, PERIODIC_TICK_MS)
800
+ // disposer 挂本 inject fiber:timer 服务移除/重启时复位降级状态并清理 interval
801
+ periodic.running = true
802
+ return () => {
803
+ dispose()
804
+ periodic.running = false
805
+ }
806
+ } catch (error) {
807
+ periodic.reason = '宿主定时服务调用失败: ' + String(error && error.message || error)
808
+ return undefined
809
+ }
810
+ })
811
+
812
+ ctx.inject(['settings'], (settingsCtx) => {
813
+ // 方法面守卫:settings 服务在但缺 register(宿主升级变更面)时跳过注册,
814
+ // 周期轮与启动补扫按 readSettings 的默认值降级运行,不因服务形变炸 fiber;
815
+ // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
816
+ if (typeof settingsCtx.settings.register !== 'function') {
817
+ if (typeof settingsCtx.settings?.configure === 'function') {
818
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
819
+ }
820
+ return
821
+ }
822
+ settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
823
+ // 启动补扫不依赖定时服务:settings 就绪即评估一轮,清掉停机期间积压的超期会话
824
+ evaluateArchives()
825
+ })
826
+ }
827
+
828
+ // 产物缺失时按无活跃处理(null),不阻断整轮评估;null 与真实 mtime=0 不混淆
829
+ async function safeMtime(path) {
830
+ try {
831
+ return (await stat(path)).mtimeMs
832
+ } catch {
833
+ return null
834
+ }
835
+ }
836
+
837
+ // header 查找缓存:inputs 路由每次请求都要查 header,listSessions 为全量扫描
838
+ // (单次数百 ms,与后台对齐并发时成倍放大),而 sessionId→header 基本不变——
839
+ // 扫一次进缓存,TTL 界定新鲜度,容量上限防无界增长;只缓存命中,
840
+ // 未命中不缓存(新会话创建竞态下次扫描即命中,负缓存会误伤)
841
+ const HEADER_CACHE_TTL_MS = 10 * 60 * 1000
842
+ const HEADER_CACHE_MAX = 500
843
+
844
+ function findHeader(ctx, sessionId, cache) {
845
+ const hit = cache.get(sessionId)
846
+ if (hit && Date.now() - hit.at < HEADER_CACHE_TTL_MS) return Promise.resolve(hit.header)
847
+ return ctx.sessionQuery.listSessions().then((records) => {
848
+ const found = records.find((recordItem) => String(recordItem.header.id) === sessionId)
849
+ if (!found) return undefined
850
+ if (cache.size >= HEADER_CACHE_MAX) cache.delete(cache.keys().next().value)
851
+ cache.set(sessionId, { header: found.header, at: Date.now() })
852
+ return found.header
853
+ })
854
+ }
855
+
856
+ // detach 幂等:遍历全部工作区逐个移除,不因首个工作区操作失败提前终止;
857
+ // 返回首个失败供响应,全部失败进服务端日志(部分失败范围可观测)
858
+ async function detachSession(ctx, sessionId) {
859
+ const errors = []
860
+ for (const workspace of ctx.workspaceRegistry.list()) {
861
+ if (!workspace.sessionIds.some((id) => String(id) === sessionId)) continue
862
+ try {
863
+ await workspace.detachSession(sessionId)
864
+ } catch (error) {
865
+ errors.push(error)
866
+ }
867
+ }
868
+ if (errors.length > 1 && ctx.logger) {
869
+ ctx.logger.warn('session-manager detach 多工作区失败(' + sessionId + '): ' + errors.map(String).join('; '))
870
+ }
871
+ return errors[0]
872
+ }
873
+
874
+ // win32 路径大小写不敏感:realpath 已规范大小写,但比对侧可能存在大小写漂移
875
+ function samePath(left, right) {
876
+ if (left === right) return true
877
+ return process.platform === 'win32' && String(left).toLowerCase() === String(right).toLowerCase()
878
+ }
879
+
880
+ // 按会话 cwd 找归属工作区:两侧都 realpath 后比对(工作区路径本身也可能含
881
+ // 符号链接/大小写漂移),任一侧不可解析时按原值兜底,
882
+ // 最终归属校验仍由官方 attachSession 完成
883
+ async function findWorkspaceByCwd(ctx, cwd) {
884
+ if (cwd === undefined) return undefined
885
+ let resolved = cwd
886
+ try {
887
+ resolved = await realpath(cwd)
888
+ } catch {
889
+ // 目录不存在时按原值比对
890
+ }
891
+ for (const workspace of ctx.workspaceRegistry.list()) {
892
+ let workspacePath = workspace.path
893
+ try {
894
+ workspacePath = await realpath(workspacePath)
895
+ } catch {
896
+ // 按原值比对
897
+ }
898
+ if (samePath(workspacePath, resolved)) return workspace
899
+ }
900
+ return undefined
901
+ }