@mzzsfy/dsh-session-manager 0.3.1

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 ADDED
@@ -0,0 +1,1069 @@
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
+ HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
23
+ HISTORY_ALIGN_SLICE_MS,
24
+ HISTORY_ALIGN_THROTTLE_MS,
25
+ HISTORY_ALIGN_YIELD_MS,
26
+ HISTORY_FOCUS_WAIT_MS,
27
+ HISTORY_INPUT_LIMIT,
28
+ HISTORY_INPUT_MAX_CHARS,
29
+ HISTORY_SCOPES,
30
+ HISTORY_SESSION_SCAN_LIMIT,
31
+ HISTORY_PROMPTS_MAX,
32
+ HISTORY_STARTUP_DELAY_MS,
33
+ HISTORY_STARTUP_SCAN_LIMIT,
34
+ aggregateDeleteOutcome,
35
+ aggregateInputs,
36
+ artifactLooksBlank,
37
+ deleteEligibility,
38
+ extractUserInputs,
39
+ isSessionRunning,
40
+ mergeDeletedEntry,
41
+ removeDeletedEntry,
42
+ selectArchiveCandidates,
43
+ updatedAtOf,
44
+ } from './core.mjs'
45
+ import { ensureCacheDir, listWorkspaceCaches, readPrompts, readWorkspaceCache, writePrompts, writeWorkspaceCache } from './history-cache.mjs'
46
+ import { trashPath } from './trash.mjs'
47
+
48
+ export const name = 'dsh-session-manager'
49
+
50
+ export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
51
+
52
+ const NAMESPACE = 'session-manager'
53
+ const WORKSPACE_DOMAIN_NAME = 'workspace'
54
+
55
+ // 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息
56
+ const LEDGER_SPEC = defineDomain({
57
+ name: 'session_manager',
58
+ version: 1,
59
+ tables: {},
60
+ global: {
61
+ schema: z.object({
62
+ deleted: z.array(z.object({
63
+ sessionId: z.string(),
64
+ path: z.string(),
65
+ deletedAt: z.number(),
66
+ })),
67
+ }),
68
+ initial: { deleted: [] },
69
+ },
70
+ })
71
+
72
+ const BLANK_PROBE_CHUNK_BYTES = 64 * 1024
73
+ // 台账条目上限:超过即裁掉最旧条目(数组头部为新,尾部最旧),防无界增长写放大
74
+ const LEDGER_MAX_ENTRIES = 200
75
+ // 自动归档评估整体超时:宿主服务挂起时不因门闩未复位而永久停摆
76
+ const EVALUATION_TIMEOUT_MS = 30 * 1000
77
+ const HOUR_MS = 60 * 60 * 1000
78
+ // 周期评估固定短 tick:tick 内做到期判断,间隔设置变更自下一周期生效
79
+ const PERIODIC_TICK_MS = 60 * 1000
80
+ // 台账单次读改写超时:宿主存储域挂起时锁内 job 超时放行,防互斥链死锁与 inFlight 泄漏
81
+ const LEDGER_OP_TIMEOUT_MS = 10 * 1000
82
+
83
+ // 路由响应文案;导出供测试断言与实现同步。
84
+ // trash 半失败聚合 5 条在 core.DELETE_MESSAGES(经 aggregateDeleteOutcome 组装),此处不重复
85
+ export const MESSAGES = {
86
+ unsupportedBackend: '当前存储后端不支持按会话删除',
87
+ notArchived: '仅已归档会话可删除',
88
+ unknownSession: '会话不存在',
89
+ running: '运行中的会话不可删除',
90
+ trashFailed: '移入回收站失败',
91
+ notRestored: '会话产物不在持久层,请先到系统回收站还原后重试',
92
+ ghostCleanup: '产物已不存在,已完成列表清理',
93
+ ghostDetach: '产物已不存在,但移除列表记录失败',
94
+ ghostArchiveSuffix: ',且移除归档记录失败',
95
+ noWorkspace: '未找到会话所属工作区,无法重新挂载',
96
+ ledgerCleanup: '已重新挂载,但清除台账记录失败;可在「已删除」区移除记录收尾',
97
+ inFlight: '该会话正在删除中,请稍后重试',
98
+ badJsonBody: '请求体不是合法 JSON',
99
+ systemError: '操作失败(系统级错误,详见服务端日志)',
100
+ }
101
+
102
+ // trash 执行器出口:进程级唯一 OS 副作用注入点,测试经此桩替
103
+ export const executor = { trashPath }
104
+
105
+ const SETTINGS_SCHEMA = schemastery.object({
106
+ autoArchiveDays: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_DAYS)
107
+ .description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
108
+ autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
109
+ .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
110
+ historyEnabled: schemastery.boolean().default(true)
111
+ .description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
112
+ })
113
+
114
+ function sendJson(res, status, payload) {
115
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
116
+ res.end(JSON.stringify(payload))
117
+ }
118
+
119
+ // 错误响应出口:业务错误(Error 无错误码)原样透传消息;系统级错误(带 fs 错误码,
120
+ // message 内嵌绝对路径)收敛为固定文案,原始错误仅进服务端日志
121
+ function respondError(ctx, res, error) {
122
+ const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
123
+ if (isSystem && ctx.logger) ctx.logger.warn('session-manager 系统级错误: ' + String(error && error.stack || error))
124
+ const message = isSystem
125
+ ? MESSAGES.systemError
126
+ : (error && error.message ? error.message : String(error))
127
+ sendJson(res, 400, { error: message })
128
+ }
129
+
130
+ function readSettings(ctx) {
131
+ const settings = ctx.get('settings')
132
+ const value = settings ? settings.get(NAMESPACE) : undefined
133
+ const days = Number(value && value.autoArchiveDays)
134
+ const hours = Number(value && value.autoArchiveIntervalHours)
135
+ return {
136
+ days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
137
+ intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
138
+ historyEnabled: !value || value.historyEnabled !== false,
139
+ }
140
+ }
141
+
142
+ function readBody(req) {
143
+ const BODY_MAX_BYTES = 64 * 1024
144
+ return new Promise((resolve, reject) => {
145
+ let size = 0
146
+ const chunks = []
147
+ req.on('data', (chunk) => {
148
+ size += chunk.length
149
+ if (size > BODY_MAX_BYTES) {
150
+ reject(new Error('请求体超过上限'))
151
+ req.destroy()
152
+ return
153
+ }
154
+ chunks.push(chunk)
155
+ })
156
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
157
+ req.on('error', reject)
158
+ })
159
+ }
160
+
161
+ async function requireSessionId(req) {
162
+ // 协议错误与业务错误分通道:JSON 解析失败给固定中文文案,不透出引擎 SyntaxError
163
+ let body
164
+ try {
165
+ body = JSON.parse(await readBody(req))
166
+ } catch {
167
+ throw new Error(MESSAGES.badJsonBody)
168
+ }
169
+ const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
170
+ if (sessionId.length === 0) throw new Error('sessionId 不能为空')
171
+ return sessionId
172
+ }
173
+
174
+ /** JSONL 产物空白探测:读文件头部,不足两行即视为空白会话。 */
175
+ async function artifactIsBlank(path) {
176
+ let handle
177
+ try {
178
+ handle = await open(path, 'r')
179
+ const { buffer, bytesRead } = await handle.read(
180
+ Buffer.allocUnsafe(BLANK_PROBE_CHUNK_BYTES), 0, BLANK_PROBE_CHUNK_BYTES, 0)
181
+ const headText = buffer.toString('utf8', 0, bytesRead)
182
+ return artifactLooksBlank(headText, bytesRead === BLANK_PROBE_CHUNK_BYTES)
183
+ } catch {
184
+ // 产物不可读(已被移走等)时按非空白处理,宁可漏归档不可误归档
185
+ return false
186
+ } finally {
187
+ if (handle !== undefined) await handle.close().catch(() => {})
188
+ }
189
+ }
190
+
191
+ function rejectMethod(req, res, method) {
192
+ if (req.method === method) return true
193
+ sendJson(res, 405, { error: 'method not allowed' })
194
+ return false
195
+ }
196
+
197
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
198
+ export function apply(ctx, config) {
199
+ let evaluating = false
200
+ // 删除路由同 id 并发去重(进程内)
201
+ const inFlightDeletes = new Set()
202
+
203
+ // 台账域:应用装载即打开,路由内 await;打开失败由路由响应,此处仅防未处理拒绝
204
+ const ledgerReady = ctx.storageDomain.open(LEDGER_SPEC)
205
+ ledgerReady.catch(() => {})
206
+ ctx.effect(() => () => {
207
+ void ledgerReady.then((domain) => domain.close()).catch(() => {})
208
+ }, 'session-manager ledger domain')
209
+
210
+ // 周期评估状态:running 供设置页降级提示;timer 为软依赖,服务缺失不阻塞插件装载
211
+ const periodic = { running: false, reason: '' }
212
+
213
+ // 自动归档评估:筛出待归档会话并逐个走官方 archiveSession。cwd 缺省时全量评估
214
+ // (启动补扫 / 周期轮),传入 cwd 时仅评估该工作区(新建会话触发)。
215
+ // 门闩与超时守卫对全部触发源生效。两级筛选:先用 createdAt 纯内存预筛(mtime 只会
216
+ // 增大 updatedAt,createdAt 未超期必不超期),仅对预筛存活者做产物 stat/blank 探测,
217
+ // 活跃会话零 IO。返回是否真正启动:被门闩/关闭挡下的调用方(周期 tick)保持到期态
218
+ // 由下个 tick 重试,不得据此重排周期
219
+ function evaluateArchives(cwd) {
220
+ const { days } = readSettings(ctx)
221
+ if (days === 0 || evaluating) return false
222
+ evaluating = true
223
+ let timeoutGuard
224
+ const work = (async () => {
225
+ const registry = ctx.workspaceRegistry
226
+ const archived = new Set(registry.archivedSessionIds.map(String))
227
+ const sessionsService = ctx.get('sessions')
228
+ const agents = ctx.get('agents')
229
+ const persistence = ctx.get('sessionPersistence')
230
+ const records = await ctx.sessionQuery.listSessions()
231
+ const nowMs = Date.now()
232
+ const cutoff = nowMs - days * DAY_MS
233
+ const candidates = []
234
+ for (const recordItem of records) {
235
+ const header = recordItem.header
236
+ if (cwd !== undefined && header.cwd !== cwd) continue
237
+ // 预筛:createdAt 未超期必不超期(见上),跳过产物 IO
238
+ if (header.createdAt >= cutoff) continue
239
+ // 已归档记录永不是候选,集合随历史增长,提前跳过省产物 IO
240
+ if (archived.has(String(header.id))) continue
241
+ const live = sessionsService && sessionsService.get(header.id)
242
+ const running = isSessionRunning({ agents, sessionId: header.id })
243
+ // 运行中永不入选归档,超期 running 在此早退免付产物 IO(64KB 读);
244
+ // core.selectArchiveCandidates 的 !running 过滤保留作第二道防线
245
+ if (running) continue
246
+ const located = persistence && persistence.locate(header)
247
+ // locate 缺失(第三方后端不支持定位)与产物不可读同规跳过:仅凭 createdAt
248
+ // 判活跃会绕过 mtime 保护,与 delete 的 unsupportedBackend 拒绝口径对齐
249
+ if (!located) continue
250
+ const activityAt = await safeMtime(located.path)
251
+ // 产物不可读视为已删除,不参与归档:否则删除后的会话(重连前仍在持久层
252
+ // 列表中)会因超期被重新归档,面板行复活且无法再删
253
+ if (activityAt === null) continue
254
+ // stat 后即可判活跃度:未超期不必做空白探测
255
+ const updatedAt = updatedAtOf(header, activityAt)
256
+ if (updatedAt >= cutoff) continue
257
+ const blank = live ? live.seq === 0 : Boolean(await artifactIsBlank(located.path))
258
+ candidates.push({
259
+ id: String(header.id),
260
+ archived: false,
261
+ running,
262
+ blank,
263
+ updatedAt,
264
+ })
265
+ }
266
+ for (const id of selectArchiveCandidates({ records: candidates, nowMs, thresholdDays: days })) {
267
+ try {
268
+ await registry.archiveSession(id)
269
+ } catch (error) {
270
+ // 单个归档失败不中断整轮,剩余候选继续;失败者由下一次触发重试
271
+ ctx.logger && ctx.logger.warn('session-manager 归档 ' + id + ' 失败: ' + String(error))
272
+ }
273
+ }
274
+ })()
275
+ // 门闩挂起兜底:宿主服务永不返回时超时复位,保证后续评估不被永久跳过
276
+ const timeout = new Promise((_, reject) => {
277
+ timeoutGuard = setTimeout(() => reject(new Error('自动归档评估超时')), EVALUATION_TIMEOUT_MS)
278
+ })
279
+ void Promise.race([work, timeout]).catch((error) => {
280
+ ctx.logger && ctx.logger.warn('session-manager 自动归档评估失败: ' + String(error))
281
+ }).finally(() => {
282
+ clearTimeout(timeoutGuard)
283
+ evaluating = false
284
+ void work.catch(() => {})
285
+ })
286
+ return true
287
+ }
288
+
289
+ ctx.on('session/created', (session) => {
290
+ const cwd = session.header && session.header.cwd
291
+ if (cwd === undefined) return
292
+ evaluateArchives(cwd)
293
+ })
294
+
295
+ // 取消归档:直写 workspace 域 global,无官方 API 可用。注册表把域 global 快照到
296
+ // 进程内 state 且只在自身写路径同步,直写后必须把快照一并改掉:否则注册表后续
297
+ // 全量写回(含自动归档)与 workspace.list 重连基线都会复活该 id,官方归档的幂等
298
+ // 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
299
+ // 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
300
+ // 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
301
+ // 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
302
+ // pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
303
+ // 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
304
+ async function removeArchivedId(sessionId) {
305
+ const domain = ctx.storageDomain.get(WORKSPACE_DOMAIN_NAME)
306
+ if (domain === undefined) throw new Error('workspace 域未打开')
307
+ const durable = domain.global.get()
308
+ const cached = ctx.workspaceRegistry.state
309
+ if (cached === undefined) throw new Error('workspace 注册表快照不可用')
310
+ const merged = new Set(durable.archivedSessionIds.map(String))
311
+ for (const id of cached.archivedSessionIds) merged.add(String(id))
312
+ if (!merged.delete(String(sessionId))) return
313
+ const next = { ...durable, archivedSessionIds: [...merged] }
314
+ await domain.global.set(next)
315
+ ctx.workspaceRegistry.state = next
316
+ }
317
+
318
+ // 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
319
+ // ledgerChain 串行化所有读改写:get→set 交错时后写者会用旧快照整体覆盖,丢条目。
320
+ // 逐 job 超时兜底:宿主存储域挂起时超时放行,防链条死锁与 inFlightDeletes 泄漏;
321
+ // 超时的写操作后台落盘仍安全(合并/移除均为幂等语义)
322
+ let ledgerChain = Promise.resolve()
323
+ function withLedgerLock(job) {
324
+ const runJob = () => Promise.race([
325
+ job(),
326
+ new Promise((_, reject) => setTimeout(
327
+ () => reject(new Error('台账操作超时(' + LEDGER_OP_TIMEOUT_MS / 1000 + 's)')),
328
+ LEDGER_OP_TIMEOUT_MS)),
329
+ ])
330
+ const run = ledgerChain.then(runJob, runJob)
331
+ ledgerChain = run.catch(() => {})
332
+ return run
333
+ }
334
+
335
+ async function recordDeletedEntry(sessionId, path) {
336
+ return withLedgerLock(async () => {
337
+ const ledger = await ledgerReady
338
+ const current = ledger.global.get()
339
+ const merged = mergeDeletedEntry(current.deleted, { sessionId, path, deletedAt: Date.now() })
340
+ await ledger.global.set({ ...current, deleted: merged.slice(0, LEDGER_MAX_ENTRIES) })
341
+ })
342
+ }
343
+
344
+ // 台账移除:幂等,未命中不产生写回
345
+ async function removeLedgerEntry(sessionId) {
346
+ return withLedgerLock(async () => {
347
+ const ledger = await ledgerReady
348
+ const current = ledger.global.get()
349
+ const { deleted, removed } = removeDeletedEntry(current.deleted, sessionId)
350
+ if (removed) await ledger.global.set({ ...current, deleted })
351
+ })
352
+ }
353
+
354
+ // 台账命中查询:同 id 曾删除过(重删场景的幽灵资格依据);域不可用时按未命中
355
+ async function ledgerHasEntry(sessionId) {
356
+ try {
357
+ const ledger = await ledgerReady
358
+ return ledger.global.get().deleted.some((item) => item.sessionId === sessionId)
359
+ } catch {
360
+ return false
361
+ }
362
+ }
363
+
364
+ // 历史输入:工作区粒度持久缓存(~/.dsh/historyPrompt/<工作区>-<hash>.json)。
365
+ // 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内最近会话产物,
366
+ // 与缓存合并去重后写回——运行中会话也参与(实时追加其新输入)。
367
+ // 解压不重复执行:每会话提取结果持久化在工作区缓存 extracts 段,附产物 stat 指纹
368
+ // (mtimeMs+size),对齐时先 stat 比对,产物没变零解压——判断全部基于磁盘,跨重启生效。
369
+ // 运行中会话(当前会话)提取结果只保留内存不落盘(产物持续变化,落盘指纹立即失效)。
370
+ // 解压是同步 CPU 操作:对齐中连续占用超 SLICE 即让出 YIELD,启动对齐延迟
371
+ // STARTUP_DELAY 再跑,绝不阻塞宿主启动。测试经 env DSH_HISTORY_CACHE_DIR 注入临时目录
372
+ const cacheDir = process.env.DSH_HISTORY_CACHE_DIR
373
+ || join(homedir(), '.dsh', 'historyPrompt')
374
+ const alignThrottle = new Map()
375
+ const runtimeExtracts = new Map()
376
+ const headerCache = new Map()
377
+ ctx.effect(() => () => { alignThrottle.clear(); runtimeExtracts.clear(); headerCache.clear() }, 'session-manager history align state')
378
+
379
+ // 双车道对齐队列:批量车道(启动/请求触发,一次解一整个窗口)与焦点车道
380
+ // (单会话实时追加,延时敏感)各自串行、互不等待——焦点车道不被冷启动
381
+ // 批量任务的分钟级解压堵死。两车道并发写同一工作区缓存时,最坏丢一次
382
+ // 更新,但批量窗口必含活跃会话(其自身重读即收敛),下轮指纹失配自愈
383
+ function makeLane() {
384
+ const tasks = []
385
+ let draining = false
386
+ return (body) => new Promise((resolve) => {
387
+ tasks.push(() => body().then(resolve, resolve))
388
+ if (draining) return
389
+ draining = true
390
+ void (async () => {
391
+ while (tasks.length > 0) await tasks.shift()()
392
+ draining = false
393
+ })()
394
+ })
395
+ }
396
+ const enqueueBulkAlign = makeLane()
397
+ const enqueueFocusAlign = makeLane()
398
+
399
+ function sleep(ms) {
400
+ return new Promise((resolve) => setTimeout(resolve, ms))
401
+ }
402
+
403
+ async function safeStat(path) {
404
+ try {
405
+ const info = await stat(path)
406
+ return { mtimeMs: info.mtimeMs, size: info.size }
407
+ } catch {
408
+ return null
409
+ }
410
+ }
411
+
412
+ // 单会话提取:产物 stat 指纹与缓存一致即复用(零解压);否则解压并更新缓存。
413
+ // 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts
414
+ async function extractSession(recordItem, fingerprint, extracts, isRunning) {
415
+ const sessionId = recordItem.header.id
416
+ const known = isRunning ? runtimeExtracts.get(sessionId) : extracts[sessionId]
417
+ if (fingerprint !== null && known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size) {
418
+ return known.entries
419
+ }
420
+ const snapshot = await ctx.sessionQuery.readSession(sessionId)
421
+ const entries = extractUserInputs(snapshot.events)
422
+ const record = { mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
423
+ if (isRunning) runtimeExtracts.set(sessionId, record)
424
+ else if (fingerprint !== null) extracts[sessionId] = record
425
+ return entries
426
+ }
427
+
428
+ // 批量对齐执行体:列出范围会话,逐个按指纹增量提取(磁盘 extracts 复用),
429
+ // 与缓存合并后原子写回,extracts 裁剪到完整扫描窗口。经批量队列入队。
430
+ // 超过 MAX_ARTIFACT 的巨产物直接跳过:单次 readSession 内部同步解压不可让出,
431
+ // 巨会话一解卡死主循环;其历史来自缓存 entries 的既有贡献
432
+ function alignWorkspaceNow(cwd) {
433
+ return enqueueBulkAlign(async () => {
434
+ const agents = ctx.get('agents')
435
+ const persistence = ctx.get('sessionPersistence')
436
+ const records = await ctx.sessionQuery.listSessions()
437
+ const window = records
438
+ .filter((recordItem) => recordItem.header.cwd !== undefined && samePath(recordItem.header.cwd, cwd))
439
+ .slice(0, HISTORY_SESSION_SCAN_LIMIT)
440
+ const cached = await readWorkspaceCache(cacheDir, cwd)
441
+ const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
442
+ const fresh = []
443
+ let sliceStart = Date.now()
444
+ for (const recordItem of window) {
445
+ const isRunning = isSessionRunning({ agents, sessionId: recordItem.header.id })
446
+ const located = persistence ? persistence.locate(recordItem.header) : undefined
447
+ const fingerprint = located ? await safeStat(located.path) : null
448
+ if (fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) continue
449
+ try {
450
+ const entries = await extractSession(recordItem, fingerprint, extracts, isRunning)
451
+ fresh.push(...entries.map((entry) => ({ ...entry, sid: recordItem.header.id })))
452
+ } catch (error) {
453
+ ctx.logger && ctx.logger.warn('session-manager 历史输入对齐失败(' + recordItem.header.id + '): ' + String(error && error.stack || error))
454
+ }
455
+ // 解压(可能命中缓存跳过)后检查连续占用:超 SLICE 让出 YIELD 不霸占主循环
456
+ if (Date.now() - sliceStart > HISTORY_ALIGN_SLICE_MS) {
457
+ await sleep(HISTORY_ALIGN_YIELD_MS)
458
+ sliceStart = Date.now()
459
+ }
460
+ }
461
+ // extracts 裁剪:仅保留窗口内非运行会话(运行中走内存,窗口外已淘汰)
462
+ const windowIds = new Set(window.map((recordItem) => recordItem.header.id))
463
+ const prunedExtracts = {}
464
+ for (const [sessionId, record] of Object.entries(extracts)) {
465
+ if (windowIds.has(sessionId) && !isSessionRunning({ agents, sessionId })) prunedExtracts[sessionId] = record
466
+ }
467
+ const merged = aggregateInputs(
468
+ (cached ? cached.entries : []).concat(fresh),
469
+ { limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS },
470
+ )
471
+ await writeWorkspaceCache(cacheDir, cwd, { entries: merged, extracts: prunedExtracts })
472
+ })
473
+ }
474
+
475
+ // 焦点对齐执行体:只解目标会话并写回(实时追加路径),走独立焦点车道,
476
+ // 不被批量车道的分钟级解压堵死。不裁剪 extracts(裁剪由批量对齐负责),
477
+ // 只新增/更新目标会话指纹
478
+ function alignSessionFocus(cwd, header) {
479
+ return enqueueFocusAlign(async () => {
480
+ const sessionId = header.id
481
+ const persistence = ctx.get('sessionPersistence')
482
+ const located = persistence ? persistence.locate(header) : undefined
483
+ const fingerprint = located ? await safeStat(located.path) : null
484
+ if (fingerprint === null || fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) return
485
+ const isRunning = isSessionRunning({ agents: ctx.get('agents'), sessionId })
486
+ const cached = await readWorkspaceCache(cacheDir, cwd)
487
+ const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
488
+ const entries = await extractSession({ header }, fingerprint, extracts, isRunning)
489
+ const merged = aggregateInputs(
490
+ (cached ? cached.entries : []).concat(entries.map((entry) => ({ ...entry, sid: sessionId }))),
491
+ { limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS },
492
+ )
493
+ await writeWorkspaceCache(cacheDir, cwd, { entries: merged, extracts })
494
+ })
495
+ }
496
+
497
+ // 节流壳:请求触发的后台对齐入口,30s 内同工作区只排队一次
498
+ function alignWorkspace(cwd) {
499
+ const now = Date.now()
500
+ const last = alignThrottle.get(cwd)
501
+ if (last !== undefined && now - last < HISTORY_ALIGN_THROTTLE_MS) return
502
+ alignThrottle.set(cwd, now)
503
+ void alignWorkspaceNow(cwd)
504
+ }
505
+
506
+ // 启动:只确保目录存在,**零解压**——历史直接读磁盘缓存(重启前的输入本就在
507
+ // entries 里),宿主启动零负担;全量对齐延迟 STARTUP_DELAY 再跑(只解产物变过
508
+ // 的会话)。定时器随插件销毁清理
509
+ const startupTimers = []
510
+ ctx.effect(() => () => { for (const timer of startupTimers) clearTimeout(timer) }, 'session-manager history startup timers')
511
+
512
+ function scheduleStartupAlign() {
513
+ startupTimers.push(setTimeout(() => {
514
+ void (async () => {
515
+ await ensureCacheDir(cacheDir)
516
+ const records = await ctx.sessionQuery.listSessions()
517
+ const workspaces = []
518
+ for (const recordItem of records.slice(0, HISTORY_STARTUP_SCAN_LIMIT)) {
519
+ const recordCwd = recordItem.header.cwd
520
+ if (recordCwd === undefined || workspaces.some((known) => samePath(known, recordCwd))) continue
521
+ workspaces.push(recordCwd)
522
+ }
523
+ for (const workspaceCwd of workspaces) alignWorkspace(workspaceCwd)
524
+ })().catch((error) => {
525
+ ctx.logger && ctx.logger.warn('session-manager 历史缓存启动对齐失败: ' + String(error && error.stack || error))
526
+ })
527
+ }, HISTORY_STARTUP_DELAY_MS))
528
+ }
529
+ scheduleStartupAlign()
530
+
531
+ // 聚焦对齐去重:同会话在队列/执行中不重复入队(轮询重入须挡住风暴)。
532
+ // 值为在途 promise,路由据此有界等待同一次对齐
533
+ const pendingFocus = new Map()
534
+ ctx.effect(() => () => pendingFocus.clear(), 'session-manager history pending focus')
535
+
536
+ // 按范围读取历史:全部直接读持久缓存(毫秒级)立即返回,对齐由后台进行;
537
+ // aligned=false 表示数据未就绪(首次无缓存,或请求会话产物已变而缓存未跟上),
538
+ // client 据此提示等待并静默重拉
539
+ async function collectInputs(scope, sessionId, header) {
540
+ const aggregateOptions = { limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS }
541
+ const cwd = header.cwd
542
+ if (scope === 'prompts') {
543
+ const items = await readPrompts(cacheDir)
544
+ return { inputs: aggregateInputs(items, aggregateOptions), aligned: true }
545
+ }
546
+ if (scope === 'session') {
547
+ if (cwd === undefined) return { inputs: [], aligned: true }
548
+ alignWorkspace(cwd)
549
+ const read = async () => {
550
+ const cached = await readWorkspaceCache(cacheDir, cwd)
551
+ return {
552
+ cached,
553
+ mine: aggregateInputs(cached ? cached.entries.filter((entry) => entry.sid === sessionId) : [], aggregateOptions),
554
+ }
555
+ }
556
+ // 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
557
+ // 只解该会话并写回。路由有界等待本次对齐(首条 <3s 预算):正常会话单次
558
+ // 请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,后续
559
+ // 轮询流式补齐。巨产物跳过聚焦(单次解压不可让出),维持 aligned=true
560
+ const check = async (cached) => {
561
+ const agents = ctx.get('agents')
562
+ const running = isSessionRunning({ agents, sessionId })
563
+ const known = running ? runtimeExtracts.get(sessionId) : cached && cached.extracts ? cached.extracts[sessionId] : undefined
564
+ const located = ctx.get('sessionPersistence') ? ctx.get('sessionPersistence').locate(header) : undefined
565
+ const fingerprint = located ? await safeStat(located.path) : null
566
+ return {
567
+ stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
568
+ oversize: fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
569
+ }
570
+ }
571
+ let { cached, mine } = await read()
572
+ let { stale, oversize } = await check(cached)
573
+ if (stale && !oversize) {
574
+ let focus = pendingFocus.get(sessionId)
575
+ if (!focus) {
576
+ focus = alignSessionFocus(cwd, header).finally(() => pendingFocus.delete(sessionId))
577
+ pendingFocus.set(sessionId, focus)
578
+ }
579
+ const done = await Promise.race([focus.then(() => true), sleep(HISTORY_FOCUS_WAIT_MS).then(() => false)])
580
+ ;({ cached, mine } = await read())
581
+ ;({ stale } = await check(cached))
582
+ if (!done || stale) return { inputs: mine, aligned: false }
583
+ return { inputs: mine, aligned: true }
584
+ }
585
+ return { inputs: mine, aligned: cached !== null }
586
+ }
587
+ if (scope === 'global') {
588
+ const caches = await listWorkspaceCaches(cacheDir)
589
+ for (const cache of caches) alignWorkspace(cache.cwd)
590
+ return { inputs: aggregateInputs(caches.flatMap((cache) => cache.entries), aggregateOptions), aligned: true }
591
+ }
592
+ const cached = await readWorkspaceCache(cacheDir, cwd)
593
+ if (cwd !== undefined) alignWorkspace(cwd)
594
+ return { inputs: aggregateInputs(cached ? cached.entries : [], aggregateOptions), aligned: cached !== null }
595
+ }
596
+
597
+ // 幽灵残留清理:产物已缺失的会话仅解除列表可见性(detach + 归档清理),
598
+ // 无回收与台账动作;失败点聚合成后缀,重试即补全
599
+ async function cleanupDeletedSession(sessionId) {
600
+ let suffix = ''
601
+ const detachError = await detachSession(ctx, sessionId)
602
+ if (detachError !== undefined) {
603
+ ctx.logger && ctx.logger.warn('session-manager 幽灵清理 detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
604
+ suffix = MESSAGES.ghostDetach
605
+ }
606
+ try {
607
+ await removeArchivedId(sessionId)
608
+ } catch (error) {
609
+ ctx.logger && ctx.logger.warn('session-manager 幽灵清理归档移除失败(' + sessionId + '): ' + String(error && error.stack || error))
610
+ suffix += MESSAGES.ghostArchiveSuffix
611
+ }
612
+ return suffix
613
+ }
614
+
615
+ const routes = [
616
+ {
617
+ path: '/api/session-manager/unarchive',
618
+ handler: async (req, res) => {
619
+ if (!rejectMethod(req, res, 'POST')) return
620
+ try {
621
+ await removeArchivedId(await requireSessionId(req))
622
+ sendJson(res, 200, { ok: true })
623
+ } catch (error) {
624
+ respondError(ctx, res, error)
625
+ }
626
+ },
627
+ },
628
+ {
629
+ path: '/api/session-manager/info',
630
+ handler: async (req, res) => {
631
+ if (!rejectMethod(req, res, 'POST')) return
632
+ try {
633
+ const sessionId = await requireSessionId(req)
634
+ const header = await findHeader(ctx, sessionId, headerCache)
635
+ if (header === undefined) {
636
+ sendJson(res, 400, { error: MESSAGES.unknownSession })
637
+ return
638
+ }
639
+ const persistence = ctx.get('sessionPersistence')
640
+ const location = persistence ? persistence.locate(header) : undefined
641
+ if (location === undefined) {
642
+ sendJson(res, 200, { supported: false })
643
+ return
644
+ }
645
+ let info
646
+ try {
647
+ info = await stat(location.path)
648
+ } catch (error) {
649
+ // live 记录可指向已删除会话,产物缺失是常规状态而非故障
650
+ if (error && error.code === 'ENOENT') {
651
+ sendJson(res, 200, { supported: true, sizeBytes: 0, missing: true })
652
+ return
653
+ }
654
+ throw error
655
+ }
656
+ sendJson(res, 200, { supported: true, sizeBytes: info.size })
657
+ } catch (error) {
658
+ respondError(ctx, res, error)
659
+ }
660
+ },
661
+ },
662
+ {
663
+ path: '/api/session-manager/delete',
664
+ handler: async (req, res) => {
665
+ if (!rejectMethod(req, res, 'POST')) return
666
+ const sessionId = await requireSessionId(req).catch((error) => {
667
+ respondError(ctx, res, error)
668
+ return undefined
669
+ })
670
+ if (sessionId === undefined) return
671
+ // 同 id 去重:并发第二次删除会走幽灵清理,语义混乱且响应文案不一致
672
+ if (inFlightDeletes.has(sessionId)) {
673
+ sendJson(res, 400, { error: MESSAGES.inFlight })
674
+ return
675
+ }
676
+ inFlightDeletes.add(sessionId)
677
+ try {
678
+ const header = await findHeader(ctx, sessionId, headerCache)
679
+ if (header === undefined) {
680
+ sendJson(res, 400, { error: MESSAGES.unknownSession })
681
+ return
682
+ }
683
+ // 运行中守卫:与归档评估同款判据,落实"运行中会话永不被删除"不变量
684
+ if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
685
+ sendJson(res, 400, { error: MESSAGES.running })
686
+ return
687
+ }
688
+ const persistence = ctx.get('sessionPersistence')
689
+ const location = persistence ? persistence.locate(header) : undefined
690
+ if (location === undefined) {
691
+ sendJson(res, 400, { error: MESSAGES.unsupportedBackend })
692
+ return
693
+ }
694
+ // locate 返回会话目录下的日志文件;回收对象是目录整体,残留空目录会让
695
+ // 已删除会话在面板以空壳复活。缺失判定按目录级:日志缺失但目录在(半删除
696
+ // 残留)仍走完整回收,目录消失才是幽灵
697
+ const artifactDir = dirname(location.path)
698
+ let artifactMissing = false
699
+ try {
700
+ await stat(artifactDir)
701
+ } catch (error) {
702
+ if (error && error.code !== 'ENOENT') throw error
703
+ artifactMissing = true
704
+ }
705
+ if (artifactMissing) {
706
+ // 幽灵资格:已归档或台账有记录(同 id 重删的残留清理)才放行;
707
+ // 两者皆无的 ENOENT 可能是新建会话尚未落盘,剥离活会话属于破坏性误删
708
+ const archivedNow = ctx.workspaceRegistry.archivedSessionIds.map(String).includes(sessionId)
709
+ if (!archivedNow && !(await ledgerHasEntry(sessionId))) {
710
+ sendJson(res, 400, { error: MESSAGES.notArchived })
711
+ return
712
+ }
713
+ const cleanupError = await cleanupDeletedSession(sessionId)
714
+ if (cleanupError !== '') {
715
+ sendJson(res, 200, { ok: true, partial: true, message: cleanupError })
716
+ return
717
+ }
718
+ sendJson(res, 200, { ok: true, message: MESSAGES.ghostCleanup })
719
+ return
720
+ }
721
+ // host 端到端权威:归档资格在执行前校验
722
+ const eligibility = deleteEligibility({
723
+ archivedIds: ctx.workspaceRegistry.archivedSessionIds.map(String),
724
+ sessionId,
725
+ })
726
+ if (!eligibility.ok) {
727
+ sendJson(res, 400, { error: MESSAGES.notArchived })
728
+ return
729
+ }
730
+ // TOCTOU 复检:守卫通过到执行间存在多个 await 间隙,会话可能已恢复运行
731
+ if (isSessionRunning({ agents: ctx.get('agents'), sessionId })) {
732
+ sendJson(res, 400, { error: MESSAGES.running })
733
+ return
734
+ }
735
+ let trashError
736
+ try {
737
+ await executor.trashPath(artifactDir)
738
+ } catch (error) {
739
+ trashError = error
740
+ }
741
+ if (trashError !== undefined) {
742
+ sendJson(res, 400, { error: MESSAGES.trashFailed + ': ' + String(trashError) })
743
+ return
744
+ }
745
+ // 执行期翻转检测:OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行。
746
+ // 产物已移走,中断只会更糟,照常完成收尾,但把不变量破坏变为可观测事件
747
+ const runningDuringTrash = isSessionRunning({ agents: ctx.get('agents'), sessionId })
748
+ if (runningDuringTrash) {
749
+ ctx.logger && ctx.logger.warn('session-manager 删除执行期间会话恢复运行: ' + sessionId)
750
+ }
751
+ // 台账在 trash 成功后立即记录:产物已进回收站,后续任何半失败都不影响还原资格;
752
+ // 台账失败只降级重挂载便利,不回滚删除
753
+ let ledgerError
754
+ try {
755
+ await recordDeletedEntry(sessionId, artifactDir)
756
+ } catch (error) {
757
+ ledgerError = error
758
+ ctx.logger && ctx.logger.warn('session-manager 台账记录失败(' + sessionId + '): ' + String(error && error.stack || error))
759
+ }
760
+ const detachError = await detachSession(ctx, sessionId)
761
+ if (detachError !== undefined) {
762
+ ctx.logger && ctx.logger.warn('session-manager detach 失败(' + sessionId + '): ' + String(detachError && detachError.stack || detachError))
763
+ }
764
+ // 归档集合同步清理:残留 id 会让面板行持续可见;detach 失败时不清理,
765
+ // 保留归档资格供重试
766
+ let archiveCleanupFailed = false
767
+ if (detachError === undefined) {
768
+ try {
769
+ await removeArchivedId(sessionId)
770
+ } catch (error) {
771
+ archiveCleanupFailed = true
772
+ ctx.logger && ctx.logger.warn('session-manager 归档清理失败(' + sessionId + '): ' + String(error && error.stack || error))
773
+ }
774
+ }
775
+ sendJson(res, 200, aggregateDeleteOutcome({
776
+ detachFailed: detachError !== undefined,
777
+ archiveCleanupFailed,
778
+ ledgerFailed: ledgerError !== undefined,
779
+ runningDuringTrash,
780
+ }))
781
+ } catch (error) {
782
+ respondError(ctx, res, error)
783
+ } finally {
784
+ inFlightDeletes.delete(sessionId)
785
+ }
786
+ },
787
+ },
788
+ {
789
+ path: '/api/session-manager/status',
790
+ handler: async (req, res) => {
791
+ if (!rejectMethod(req, res, 'GET')) return
792
+ sendJson(res, 200, { periodic: { ...periodic } })
793
+ },
794
+ },
795
+ {
796
+ path: '/api/session-manager/inputs',
797
+ handler: async (req, res) => {
798
+ if (!rejectMethod(req, res, 'GET')) return
799
+ try {
800
+ const url = new URL(req.url, 'http://localhost')
801
+ const sessionId = (url.searchParams.get('sessionId') || '').trim()
802
+ if (sessionId === '') throw new Error('sessionId 不能为空')
803
+ const scopeRaw = url.searchParams.get('scope') || 'session'
804
+ const scope = HISTORY_SCOPES.includes(scopeRaw) ? scopeRaw : 'session'
805
+ const header = await findHeader(ctx, sessionId, headerCache)
806
+ if (header === undefined) {
807
+ sendJson(res, 400, { error: MESSAGES.unknownSession })
808
+ return
809
+ }
810
+ // workspace 范围依赖 cwd 归属;无 cwd 会话在 session 范围无从定位工作区缓存,
811
+ // global 不依赖 cwd 照常聚合
812
+ const result = scope === 'workspace' && header.cwd === undefined
813
+ ? { inputs: [], aligned: true }
814
+ : await collectInputs(scope, sessionId, header)
815
+ sendJson(res, 200, result)
816
+ } catch (error) {
817
+ respondError(ctx, res, error)
818
+ }
819
+ },
820
+ },
821
+ {
822
+ // 常用提示词收藏切换:text 已收藏则移除,否则收藏(截断与上限同历史输入)
823
+ path: '/api/session-manager/prompts/toggle',
824
+ handler: async (req, res) => {
825
+ if (!rejectMethod(req, res, 'POST')) return
826
+ try {
827
+ let body
828
+ try {
829
+ body = JSON.parse(await readBody(req))
830
+ } catch {
831
+ throw new Error(MESSAGES.badJsonBody)
832
+ }
833
+ const text = typeof body.text === 'string' ? body.text.trim() : ''
834
+ if (text === '') throw new Error('text 不能为空')
835
+ const items = await readPrompts(cacheDir)
836
+ const at = items.findIndex((item) => item.text === text)
837
+ let collected
838
+ if (at >= 0) {
839
+ items.splice(at, 1)
840
+ collected = false
841
+ } else {
842
+ items.unshift({ text: text.slice(0, HISTORY_INPUT_MAX_CHARS), at: Date.now() })
843
+ if (items.length > HISTORY_PROMPTS_MAX) items.length = HISTORY_PROMPTS_MAX
844
+ collected = true
845
+ }
846
+ await writePrompts(cacheDir, items)
847
+ sendJson(res, 200, { collected })
848
+ } catch (error) {
849
+ respondError(ctx, res, error)
850
+ }
851
+ },
852
+ },
853
+ {
854
+ // 历史浮层启停:读走 settings(原生设置页与本面板开关同一存储),
855
+ // 写经 settings.update,原生设置页与本面板即时同值
856
+ path: '/api/session-manager/history-enabled',
857
+ handler: async (req, res) => {
858
+ try {
859
+ if (req.method === 'GET') {
860
+ sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
861
+ return
862
+ }
863
+ if (!rejectMethod(req, res, 'POST')) return
864
+ let body
865
+ try {
866
+ body = JSON.parse(await readBody(req))
867
+ } catch {
868
+ throw new Error(MESSAGES.badJsonBody)
869
+ }
870
+ const settings = ctx.get('settings')
871
+ if (!settings) throw new Error('宿主设置服务不可用')
872
+ settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
873
+ sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
874
+ } catch (error) {
875
+ respondError(ctx, res, error)
876
+ }
877
+ },
878
+ },
879
+ {
880
+ path: '/api/session-manager/deleted',
881
+ handler: async (req, res) => {
882
+ if (!rejectMethod(req, res, 'GET')) return
883
+ try {
884
+ const ledger = await ledgerReady
885
+ sendJson(res, 200, { deleted: ledger.global.get().deleted })
886
+ } catch (error) {
887
+ respondError(ctx, res, error)
888
+ }
889
+ },
890
+ },
891
+ {
892
+ path: '/api/session-manager/remount',
893
+ handler: async (req, res) => {
894
+ if (!rejectMethod(req, res, 'POST')) return
895
+ try {
896
+ const sessionId = await requireSessionId(req)
897
+ // 产物还原的判定即持久层能重新读到 header:未还原时在此拒绝
898
+ const header = await findHeader(ctx, sessionId, headerCache)
899
+ if (header === undefined) {
900
+ sendJson(res, 400, { error: MESSAGES.notRestored })
901
+ return
902
+ }
903
+ const workspace = await findWorkspaceByCwd(ctx, header.cwd)
904
+ if (workspace === undefined) {
905
+ sendJson(res, 400, { error: MESSAGES.noWorkspace })
906
+ return
907
+ }
908
+ // 官方挂载通道:自带 header cwd 校验,失败即整体拒绝,台账保留供重试
909
+ await workspace.attachSession(sessionId)
910
+ // 挂载已成功:台账清除失败只影响收尾,不得报成挂载失败
911
+ try {
912
+ await removeLedgerEntry(sessionId)
913
+ } catch {
914
+ sendJson(res, 200, { ok: true, partial: true, message: MESSAGES.ledgerCleanup })
915
+ return
916
+ }
917
+ sendJson(res, 200, { ok: true })
918
+ } catch (error) {
919
+ respondError(ctx, res, error)
920
+ }
921
+ },
922
+ },
923
+ {
924
+ path: '/api/session-manager/forget',
925
+ handler: async (req, res) => {
926
+ if (!rejectMethod(req, res, 'POST')) return
927
+ try {
928
+ await removeLedgerEntry(await requireSessionId(req))
929
+ sendJson(res, 200, { ok: true })
930
+ } catch (error) {
931
+ respondError(ctx, res, error)
932
+ }
933
+ },
934
+ },
935
+ ]
936
+
937
+ for (const route of routes) {
938
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
939
+ 'dsh-session-manager ' + route.path)
940
+ }
941
+
942
+ // 周期评估软依赖 timer:顶层注册(不嵌套在 settings 回调内,settings 服务重启会
943
+ // 重跑其回调并新建 fiber,嵌套会使周期轮随重启累积复制)。服务未激活则回调不执行,
944
+ // 插件其余能力不受影响,设置页提示降级
945
+ ctx.inject(['timer'], (timerCtx) => {
946
+ if (typeof timerCtx.interval !== 'function') {
947
+ periodic.reason = '宿主定时服务不可用'
948
+ return () => {
949
+ periodic.running = false
950
+ }
951
+ }
952
+ // 固定短 tick + 到期判断:间隔设置变更自下一周期生效,缩短经 earliest 钳制前移
953
+ let nextDueAt = null
954
+ let lastArmedAt = 0
955
+ const scheduleNext = () => {
956
+ const { intervalHours } = readSettings(ctx)
957
+ lastArmedAt = Date.now()
958
+ nextDueAt = intervalHours > 0 ? lastArmedAt + intervalHours * HOUR_MS : null
959
+ }
960
+ try {
961
+ const dispose = timerCtx.interval(() => {
962
+ const { intervalHours } = readSettings(ctx)
963
+ // 设置变更对账:0 即时暂停;缺失 due(0 重启用)时补排,防周期轮静默死亡;
964
+ // 缩短间隔时以 lastArmedAt 锚定前移,最长等新间隔而非等满旧周期
965
+ if (intervalHours === 0) {
966
+ nextDueAt = null
967
+ return
968
+ }
969
+ const earliest = lastArmedAt > 0 ? lastArmedAt + intervalHours * HOUR_MS : Infinity
970
+ if (nextDueAt !== null && nextDueAt > earliest) nextDueAt = earliest
971
+ if (nextDueAt === null || Date.now() >= nextDueAt) {
972
+ // 门闩占用(他源评估在途)时保持到期态,60s 后下个 tick 重试;
973
+ // 仅评估真正启动才重排周期,被丢弃的到期轮不会静默失效一个周期
974
+ if (evaluateArchives()) scheduleNext()
975
+ }
976
+ }, PERIODIC_TICK_MS)
977
+ // disposer 挂本 inject fiber:timer 服务移除/重启时复位降级状态并清理 interval
978
+ periodic.running = true
979
+ return () => {
980
+ dispose()
981
+ periodic.running = false
982
+ }
983
+ } catch (error) {
984
+ periodic.reason = '宿主定时服务调用失败: ' + String(error && error.message || error)
985
+ return undefined
986
+ }
987
+ })
988
+
989
+ ctx.inject(['settings'], (settingsCtx) => {
990
+ settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
991
+ // 启动补扫不依赖定时服务:settings 就绪即评估一轮,清掉停机期间积压的超期会话
992
+ evaluateArchives()
993
+ })
994
+ }
995
+
996
+ // 产物缺失时按无活跃处理(null),不阻断整轮评估;null 与真实 mtime=0 不混淆
997
+ async function safeMtime(path) {
998
+ try {
999
+ return (await stat(path)).mtimeMs
1000
+ } catch {
1001
+ return null
1002
+ }
1003
+ }
1004
+
1005
+ // header 查找缓存:inputs 路由每次请求都要查 header,listSessions 为全量扫描
1006
+ // (单次数百 ms,与后台对齐并发时成倍放大),而 sessionId→header 基本不变——
1007
+ // 扫一次进缓存,TTL 界定新鲜度,容量上限防无界增长;只缓存命中,
1008
+ // 未命中不缓存(新会话创建竞态下次扫描即命中,负缓存会误伤)
1009
+ const HEADER_CACHE_TTL_MS = 10 * 60 * 1000
1010
+ const HEADER_CACHE_MAX = 500
1011
+
1012
+ function findHeader(ctx, sessionId, cache) {
1013
+ const hit = cache.get(sessionId)
1014
+ if (hit && Date.now() - hit.at < HEADER_CACHE_TTL_MS) return Promise.resolve(hit.header)
1015
+ return ctx.sessionQuery.listSessions().then((records) => {
1016
+ const found = records.find((recordItem) => String(recordItem.header.id) === sessionId)
1017
+ if (!found) return undefined
1018
+ if (cache.size >= HEADER_CACHE_MAX) cache.delete(cache.keys().next().value)
1019
+ cache.set(sessionId, { header: found.header, at: Date.now() })
1020
+ return found.header
1021
+ })
1022
+ }
1023
+
1024
+ // detach 幂等:遍历全部工作区逐个移除,不因首个工作区操作失败提前终止;
1025
+ // 返回首个失败供响应,全部失败进服务端日志(部分失败范围可观测)
1026
+ async function detachSession(ctx, sessionId) {
1027
+ const errors = []
1028
+ for (const workspace of ctx.workspaceRegistry.list()) {
1029
+ if (!workspace.sessionIds.some((id) => String(id) === sessionId)) continue
1030
+ try {
1031
+ await workspace.detachSession(sessionId)
1032
+ } catch (error) {
1033
+ errors.push(error)
1034
+ }
1035
+ }
1036
+ if (errors.length > 1 && ctx.logger) {
1037
+ ctx.logger.warn('session-manager detach 多工作区失败(' + sessionId + '): ' + errors.map(String).join('; '))
1038
+ }
1039
+ return errors[0]
1040
+ }
1041
+
1042
+ // win32 路径大小写不敏感:realpath 已规范大小写,但比对侧可能存在大小写漂移
1043
+ function samePath(left, right) {
1044
+ if (left === right) return true
1045
+ return process.platform === 'win32' && String(left).toLowerCase() === String(right).toLowerCase()
1046
+ }
1047
+
1048
+ // 按会话 cwd 找归属工作区:两侧都 realpath 后比对(工作区路径本身也可能含
1049
+ // 符号链接/大小写漂移),任一侧不可解析时按原值兜底,
1050
+ // 最终归属校验仍由官方 attachSession 完成
1051
+ async function findWorkspaceByCwd(ctx, cwd) {
1052
+ if (cwd === undefined) return undefined
1053
+ let resolved = cwd
1054
+ try {
1055
+ resolved = await realpath(cwd)
1056
+ } catch {
1057
+ // 目录不存在时按原值比对
1058
+ }
1059
+ for (const workspace of ctx.workspaceRegistry.list()) {
1060
+ let workspacePath = workspace.path
1061
+ try {
1062
+ workspacePath = await realpath(workspacePath)
1063
+ } catch {
1064
+ // 按原值比对
1065
+ }
1066
+ if (samePath(workspacePath, resolved)) return workspace
1067
+ }
1068
+ return undefined
1069
+ }