/** * IPC Session Handlers — 会话管理域(P2-9 从 handlers.ts 拆分) * * 会话 CRUD、消息加载/删除/清空、Trace 持久化。 * P2-11: 新增 sessions:truncateAfter(编辑重发/重新生成的消息截断)。 */ import { ipcMain } from 'electron'; import { join, resolve as resolvePath, sep } from 'path'; import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import type { IPCContext } from './context'; // v0.8.1 P0-3: 会话终态清除流式回放缓冲 import { clearReplayBuffer } from './replay-buffer'; /** M-34 修复: 统一 sessionId 校验辅助函数 */ const isValidSessionId = (id: unknown): id is string => typeof id === 'string' && id.length > 0 && id.length <= 200; /** v0.8.0 P2-2: 会话录制文件名白名单(防路径逃逸/任意文件读取) */ const RECORDING_NAME_RE = /^session_[A-Za-z0-9_-]+\.jsonl$/; /** v0.8.0 P2-2: 单个录制文件读取上限(防超大文件撑爆 IPC) */ const MAX_RECORDING_BYTES = 20 * 1024 * 1024; export function registerSessionHandlers(ctx: IPCContext): void { const { sessionService, confirmationHook, agentEngineManager } = ctx; ipcMain.handle('sessions:list', async () => { return sessionService.list(); }); ipcMain.handle('sessions:create', async (_event, title?: string) => { return sessionService.create(title); }); ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => { // M-34 修复: 校验 sessionId 和 title if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' }; return { success: sessionService.rename(sessionId, title) }; }); ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => { // M-34 修复: 校验 sessionId if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; // v0.7.4 P3-5: 删除会话前先中断其进行中的 run(若在运行) // —— 防止删除后引擎仍在后台执行工具副作用 if (agentEngineManager.isRunning(sessionId)) { agentEngineManager.abort(sessionId); await agentEngineManager.waitForAbort(sessionId); } // v0.8.0 P2-1: 软删除 —— 会话进入回收站(deleted_at),30 天后自动清理, // 可经 sessions:restore 恢复 / sessions:purge 彻底删除 const result = { success: sessionService.delete(sessionId) }; // v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理 // (防 rememberedDecisions 随会话数累积泄漏) if (result.success) { confirmationHook.forgetSession(sessionId); // v0.8.1 P0-2: 会话终态联动清理工作记忆(此前 clearWorkingMemory 零调用方, // working_memories 随会话数永久残留) ctx.memoryManager.clearWorkingMemory(sessionId); // v0.8.1 P0-3: 清除该会话的流式回放缓冲(最多 4MB/会话) clearReplayBuffer(sessionId); // v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口) // —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留 agentEngineManager.disposeEngine(sessionId); } return result; }); // ===== v0.8.0 P2-1: 回收站 ===== ipcMain.handle('sessions:listTrash', async () => { return sessionService.listDeleted(); }); ipcMain.handle('sessions:restore', async (_event, sessionId: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; return { success: sessionService.restore(sessionId) }; }); ipcMain.handle('sessions:purge', async (_event, sessionId: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; // 彻底删除 = 终态:决策记忆/引擎与软删除同口径清理 const result = { success: sessionService.purge(sessionId) }; if (result.success) { confirmationHook.forgetSession(sessionId); agentEngineManager.disposeEngine(sessionId); // v0.8.1 P0-2: 终态联动清理工作记忆 ctx.memoryManager.clearWorkingMemory(sessionId); // v0.8.1 P0-3: 清除该会话的流式回放缓冲 clearReplayBuffer(sessionId); } return result; }); ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown, options?: unknown) => { // M-34 修复: 校验 sessionId if (!isValidSessionId(sessionId)) return []; // v0.8.1 P2-3: 游标分页选项(limit 1-1000 / beforeRowid 正整数),非法值忽略 let safeOptions: { limit?: number; beforeRowid?: number } | undefined; if (options && typeof options === 'object') { const o = options as Record; safeOptions = {}; if (typeof o.limit === 'number' && Number.isFinite(o.limit) && o.limit >= 1) { safeOptions.limit = Math.min(1000, Math.floor(o.limit)); } if ( typeof o.beforeRowid === 'number' && Number.isFinite(o.beforeRowid) && o.beforeRowid > 0 ) { safeOptions.beforeRowid = Math.floor(o.beforeRowid); } if (Object.keys(safeOptions).length === 0) safeOptions = undefined; } return sessionService.getMessages(sessionId, safeOptions); }); // v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。 // 此前"仅保存"只改前端 store,会话重载即丢失(与"保存并重发"的截断语义割裂)。 ipcMain.handle( 'sessions:updateMessageContent', async (_event, sessionId: unknown, messageId: unknown, content: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' }; if (typeof content !== 'string') return { success: false, error: 'Invalid content' }; try { const updated = sessionService.updateMessageContent(sessionId, messageId, content); return { success: updated }; } catch (error) { return { success: false, error: (error as Error).message }; } }, ); ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' }; return { success: sessionService.pin(sessionId, pinned) }; }); ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' }; return { success: sessionService.archive(sessionId, archived) }; }); // v0.7.3 P1-3: sessions:deleteMessage 已删除 —— 渲染层零调用方(死通道), // 且原实现不回减 sessions.message_count(计数漂移面)。消息删除语义由 // sessions:truncateAfter(编辑重发/重新生成)与会话删除级联完整覆盖。 // v0.7.2 A1 根治: 语义修正 —— "清空会话"的成功判定是"操作完成"而非"有行被删除"。 // 原实现透传 DELETE 影响行数(changes > 0),空会话清空会返回 success:false, // 导致前端误报失败而不清空本地状态(/clear 断链的 IPC 侧根源之一)。 ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; try { sessionService.clearMessages(sessionId); // v0.8.1 P0-2: 清空会话 = 会话内容重置 —— 工作记忆一并清理 ctx.memoryManager.clearWorkingMemory(sessionId); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } }); // P2-11: 消息截断(编辑重发 / 重新生成) ipcMain.handle( 'sessions:truncateAfter', async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' }; const isInclusive = typeof inclusive === 'boolean' ? inclusive : true; try { const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive); return { success: true, truncated }; } catch (error) { return { success: false, error: (error as Error).message }; } }, ); // v0.5.0: 全文搜索会话内容(FTS5,跨会话) ipcMain.handle('sessions:searchContent', async (_event, query: unknown) => { if (typeof query !== 'string' || !query.trim()) return { success: false, error: 'Invalid query' }; try { const results = sessionService.searchMessages(query); return { success: true, data: results }; } catch (error) { return { success: false, error: (error as Error).message }; } }); ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => { // M-34 修复: 校验 sessionId 和 data if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; // 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在 if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' }; const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown }; if (!Array.isArray(traceData.traceSteps)) { return { success: false, error: 'Invalid trace data: traceSteps must be an array' }; } if (traceData.tokenUsage === undefined) { return { success: false, error: 'Invalid trace data: tokenUsage is required' }; } try { sessionService.saveTraceData(sessionId, { traceSteps: traceData.traceSteps, tokenUsage: traceData.tokenUsage, }); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } }); ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => { if (!isValidSessionId(sessionId)) return null; return sessionService.getTraceData(sessionId); }); // ===== v0.8.0 P2-2: 会话回放(JSONL 录制读取) ===== ipcMain.handle('sessions:listRecordings', async (_event, sessionId: unknown) => { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; try { const logsDir = join(ctx.workspaceService.getPath(), 'logs'); if (!existsSync(logsDir)) return { success: true, data: [] }; const prefix = `session_${sessionId}_`; const files = readdirSync(logsDir) .filter((n) => n.startsWith(prefix) && RECORDING_NAME_RE.test(n)) .map((name) => { try { const st = statSync(join(logsDir, name)); return { name, size: st.size, mtime: st.mtimeMs }; } catch { return null; } }) .filter((f): f is { name: string; size: number; mtime: number } => f !== null) .sort((a, b) => b.mtime - a.mtime); return { success: true, data: files }; } catch (error) { return { success: false, error: (error as Error).message }; } }); ipcMain.handle('sessions:readRecording', async (_event, name: unknown) => { if (typeof name !== 'string' || !RECORDING_NAME_RE.test(name)) { return { success: false, error: 'Invalid recording file name' }; } try { const logsDir = resolvePath(ctx.workspaceService.getPath(), 'logs'); const filePath = resolvePath(logsDir, name); // 目录边界校验(双保险:文件名白名单已杜绝子目录,此处防 symlink/拼接逃逸) if (!filePath.startsWith(logsDir + sep)) { return { success: false, error: 'Recording path escapes logs directory' }; } if (!existsSync(filePath)) return { success: false, error: 'Recording file not found' }; const size = statSync(filePath).size; if (size > MAX_RECORDING_BYTES) { return { success: false, error: `Recording file too large (${size} bytes)` }; } const raw = readFileSync(filePath, 'utf-8'); // 逐行解析,坏行跳过并计数(录制文件允许尾部截断) const events: Array> = []; let skipped = 0; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; try { events.push(JSON.parse(trimmed) as Record); } catch { skipped++; } } return { success: true, data: { name, events, skipped } }; } catch (error) { return { success: false, error: (error as Error).message }; } }); }