@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/README.md +82 -0
- package/cordis.patch.yml +12 -0
- package/package.json +51 -0
- package/src/client.js +835 -0
- package/src/core.mjs +190 -0
- package/src/history-cache.mjs +94 -0
- package/src/index.js +1069 -0
- package/src/trash.mjs +49 -0
- package/test/client-id.test.mjs +16 -0
- package/test/client-toast.test.mjs +89 -0
- package/test/core.test.mjs +307 -0
- package/test/index.test.mjs +1851 -0
- package/test/parity.test.mjs +160 -0
- package/test/trash.test.mjs +73 -0
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
|
+
}
|