Files
metona-ai-desktop/electron/services/session-recorder.service.ts
T

308 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Session Recorder — 会话录制器(TRACE 层)
*
* 负责将完整的会话执行轨迹写入 session_*.jsonl 文件。
* 每行一条 JSON 事件,支持事后回放和分析。
*
* 日志格式:SSE-like JSON Lines
* 事件类型:session_start, context_built, iteration_start, llm_request,
* llm_response, tool_call, tool_result, iteration_end, session_end
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 全链路透明可追踪
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章
*/
import { join } from 'path';
import { appendFileSync, existsSync, mkdirSync, promises } from 'fs';
import log from 'electron-log';
// ===== 事件类型 =====
export type TraceEventType =
| 'session_start'
| 'context_built'
| 'iteration_start'
| 'llm_request'
| 'llm_response'
| 'tool_call'
| 'tool_result'
| 'iteration_end'
| 'session_end';
export interface TraceEvent {
seq: number;
ts: string;
event: TraceEventType;
sessionId: string;
[key: string]: unknown;
}
// ===== 服务类 =====
export class SessionRecorder {
private filePath: string | null = null;
private seq = 0;
private sessionId: string | null = null;
// #35 修复: 缓冲写入,避免高频 appendFileSync 阻塞主进程(30-50 次/秒 → 每 100ms 批量异步 flush
private buffer: string[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushing = false;
constructor(private workspacePath: string) {}
/**
* 开始录制会话
*/
startRecording(sessionId: string): void {
this.sessionId = sessionId;
this.seq = 0;
const logsDir = join(this.workspacePath, 'logs');
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
this.filePath = join(logsDir, `session_${sessionId}_${timestamp}.jsonl`);
// 写入 session_start 事件
this.writeEvent({
event: 'session_start',
sessionId,
workspace: this.workspacePath,
});
log.info(`Session recording started: ${this.filePath}`);
}
/**
* 停止录制
*/
stopRecording(params: {
totalIterations: number;
totalTokens: number;
durationMs: number;
terminationReason: string;
}): void {
if (!this.sessionId || !this.filePath) return;
this.writeEvent({
event: 'session_end',
sessionId: this.sessionId,
totalIterations: params.totalIterations,
totalTokens: params.totalTokens,
durationMs: params.durationMs,
terminationReason: params.terminationReason,
});
// #35 修复: 同步 flush 确保最后的 session_end 事件写入文件
this.flushSync();
log.info(`Session recording stopped: ${this.filePath}`);
this.filePath = null;
this.sessionId = null;
}
/**
* 记录上下文构建
*/
recordContextBuilt(params: { tokenCount: number; usageRatio: number }): void {
this.writeEvent({
event: 'context_built',
sessionId: this.sessionId!,
tokens: params.tokenCount,
ratio: params.usageRatio,
});
}
/**
* 记录迭代开始
*/
recordIterationStart(iteration: number): void {
this.writeEvent({
event: 'iteration_start',
sessionId: this.sessionId!,
iteration,
});
}
/**
* 记录 LLM 请求
*/
recordLLMRequest(params: {
iteration: number;
provider: string;
model: string;
messageCount: number;
}): void {
this.writeEvent({
event: 'llm_request',
sessionId: this.sessionId!,
iteration: params.iteration,
provider: params.provider,
model: params.model,
messageCount: params.messageCount,
});
}
/**
* 记录 LLM 响应
*/
recordLLMResponse(params: {
iteration: number;
content: string;
finishReason: string;
tokenUsage: { input: number; output: number; total: number };
}): void {
this.writeEvent({
event: 'llm_response',
sessionId: this.sessionId!,
iteration: params.iteration,
contentPreview: params.content.slice(0, 200),
finishReason: params.finishReason,
tokenUsage: params.tokenUsage,
});
}
/**
* 记录工具调用
*/
recordToolCall(params: {
iteration: number;
toolName: string;
args: Record<string, unknown>;
}): void {
this.writeEvent({
event: 'tool_call',
sessionId: this.sessionId!,
iteration: params.iteration,
tool: params.toolName,
args: params.args,
});
}
/**
* 记录工具结果
*/
recordToolResult(params: {
iteration: number;
toolName: string;
success: boolean;
durationMs: number;
resultPreview?: string;
error?: string;
}): void {
this.writeEvent({
event: 'tool_result',
sessionId: this.sessionId!,
iteration: params.iteration,
tool: params.toolName,
success: params.success,
durationMs: params.durationMs,
resultPreview: params.resultPreview?.slice(0, 500),
error: params.error,
});
}
/**
* 记录迭代结束
*/
recordIterationEnd(params: {
iteration: number;
durationMs: number;
}): void {
this.writeEvent({
event: 'iteration_end',
sessionId: this.sessionId!,
iteration: params.iteration,
durationMs: params.durationMs,
});
}
/**
* 获取录制文件路径
*/
getFilePath(): string | null {
return this.filePath;
}
// ===== 私有方法 =====
/**
* 写入事件到缓冲区
*
* #35 修复: 改为缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程
* 高频事件(30-50 次/秒)先 push 到内存 buffer,每 100ms 批量异步写入文件
*/
private writeEvent(data: Record<string, unknown>): void {
if (!this.filePath) return;
const event: TraceEvent = {
seq: this.seq++,
ts: new Date().toISOString(),
sessionId: this.sessionId!,
...data,
} as TraceEvent;
const line = JSON.stringify(event);
// 审查修复: buffer 上限防止 OOM — 高频事件持续 flush 失败时避免内存无限增长
const MAX_BUFFER_SIZE = 1000;
if (this.buffer.length >= MAX_BUFFER_SIZE) {
// 超限时强制同步写入,避免内存无限增长
this.flushSync();
}
this.buffer.push(line);
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, 100);
}
}
/**
* #35 修复: 异步 flush 缓冲区到文件
* 审查修复: 用局部变量保存 filePath,防止 stopRecording 将 filePath 置 null 后 appendFile 抛错;
* 失败时将数据 unshift 回 buffer 避免丢整批数据
*/
private async flush(): Promise<void> {
if (this.flushing || this.buffer.length === 0 || !this.filePath) return;
this.flushing = true;
const filePath = this.filePath; // 局部变量,防止中途变 null
const data = this.buffer.join('\n') + '\n';
this.buffer = [];
try {
await promises.appendFile(filePath, data, 'utf-8');
} catch (error) {
log.error('Trace event flush failed:', error);
// 审查修复: 失败时将数据放回 buffer 头部,下次 flush/flushSync 重试
this.buffer.unshift(data.trimEnd());
} finally {
this.flushing = false;
}
}
/**
* #35 修复: 同步 flush 缓冲区到文件
* 用于 stopRecording 确保最后的数据(如 session_end 事件)写入文件
* 审查修复: 如果异步 flush 正在进行(flushing=true),等待其完成后再写入,避免数据交叉/丢失
*/
private flushSync(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.buffer.length === 0 || !this.filePath) return;
const data = this.buffer.join('\n') + '\n';
this.buffer = [];
try {
appendFileSync(this.filePath, data, 'utf-8');
} catch (error) {
log.error('Trace event flushSync failed:', error);
// 审查修复: 失败时将数据放回 buffer,避免数据丢失
this.buffer.unshift(data.trimEnd());
}
}
}