Skip to main content

j_agent/storage/
session.rs

1use super::config::agent_data_dir;
2use super::types::{ChatMessage, MessageRole, SessionEvent, SessionMetrics, SessionOp};
3use crate::constants::MESSAGE_PREVIEW_MAX_LEN;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// 获取 sessions 目录: ~/.jdata/agent/data/sessions/
11pub fn sessions_dir() -> PathBuf {
12    let dir = agent_data_dir().join("sessions");
13    let _ = fs::create_dir_all(&dir);
14    dir
15}
16
17/// 获取单个 session 的 JSONL 文件路径(兼容别名,指向新布局主文件)
18pub fn session_file_path(session_id: &str) -> PathBuf {
19    SessionPaths::new(session_id).transcript()
20}
21
22/// Session 目录布局抽象。
23///
24/// 布局:`sessions/<id>/transcript.jsonl`。
25#[derive(Debug)]
26pub struct SessionPaths {
27    dir: PathBuf,
28}
29
30impl SessionPaths {
31    pub fn new(session_id: &str) -> Self {
32        let dir = sessions_dir().join(session_id);
33        Self { dir }
34    }
35
36    pub fn dir(&self) -> &Path {
37        &self.dir
38    }
39
40    /// 主数据文件:`sessions/<id>/transcript.jsonl`
41    pub fn transcript(&self) -> PathBuf {
42        self.dir.join("transcript.jsonl")
43    }
44
45    /// 元数据文件:`sessions/<id>/session.json`
46    pub fn meta_file(&self) -> PathBuf {
47        self.dir.join("session.json")
48    }
49
50    /// compact 快照目录:`sessions/<id>/.transcripts/`
51    pub fn transcripts_dir(&self) -> PathBuf {
52        self.dir.join(".transcripts")
53    }
54
55    /// Teammate 状态文件:`sessions/<id>/teammates.json`
56    pub fn teammates_file(&self) -> PathBuf {
57        self.dir.join("teammates.json")
58    }
59
60    /// Display 消息 JSONL:`sessions/<id>/display.jsonl`
61    pub fn display(&self) -> PathBuf {
62        self.dir.join("display.jsonl")
63    }
64
65    /// Teammate 独立目录根:`sessions/<id>/teammates/`
66    pub fn teammates_dir(&self) -> PathBuf {
67        self.dir.join("teammates")
68    }
69
70    /// 单个 teammate 的独立子目录:`sessions/<id>/teammates/<sanitized_name>/`
71    pub fn teammate_dir(&self, sanitized_name: &str) -> PathBuf {
72        self.teammates_dir().join(sanitized_name)
73    }
74
75    /// 单个 teammate 的 transcript JSONL 路径:`sessions/<id>/teammates/<sanitized_name>/transcript.jsonl`
76    pub fn teammate_transcript(&self, sanitized_name: &str) -> PathBuf {
77        self.teammate_dir(sanitized_name).join("transcript.jsonl")
78    }
79
80    /// 单个 teammate 的 todo 文件路径:`sessions/<id>/teammates/<sanitized_name>/todos.json`
81    pub fn teammate_todos_file(&self, sanitized_name: &str) -> PathBuf {
82        self.teammate_dir(sanitized_name).join("todos.json")
83    }
84
85    /// SubAgent 状态文件:`sessions/<id>/subagents.json`
86    pub fn subagents_file(&self) -> PathBuf {
87        self.dir.join("subagents.json")
88    }
89
90    /// SubAgent 独立目录根:`sessions/<id>/subagents/`
91    pub fn subagents_dir(&self) -> PathBuf {
92        self.dir.join("subagents")
93    }
94
95    /// 单个 subagent 的独立子目录:`sessions/<id>/subagents/<sub_id>/`
96    pub fn subagent_dir(&self, sub_id: &str) -> PathBuf {
97        self.subagents_dir().join(sub_id)
98    }
99
100    /// 单个 subagent 的 transcript JSONL 路径:`sessions/<id>/subagents/<sub_id>/transcript.jsonl`
101    pub fn subagent_transcript(&self, sub_id: &str) -> PathBuf {
102        self.subagent_dir(sub_id).join("transcript.jsonl")
103    }
104
105    /// 单个 subagent 的 todo 文件路径:`sessions/<id>/subagents/<sub_id>/todos.json`
106    pub fn subagent_todos_file(&self, sub_id: &str) -> PathBuf {
107        self.subagent_dir(sub_id).join("todos.json")
108    }
109
110    /// Task 状态文件:`sessions/<id>/tasks.json`
111    pub fn tasks_file(&self) -> PathBuf {
112        self.dir.join("tasks.json")
113    }
114
115    /// Todo 状态文件:`sessions/<id>/todos.json`
116    pub fn todos_file(&self) -> PathBuf {
117        self.dir.join("todos.json")
118    }
119
120    /// Plan 状态文件:`sessions/<id>/plan.json`
121    pub fn plan_file(&self) -> PathBuf {
122        self.dir.join("plan.json")
123    }
124
125    /// InvokedSkills 状态文件:`sessions/<id>/skills.json`
126    pub fn skills_file(&self) -> PathBuf {
127        self.dir.join("skills.json")
128    }
129
130    /// Session Hook 状态文件:`sessions/<id>/hooks.json`
131    pub fn hooks_file(&self) -> PathBuf {
132        self.dir.join("hooks.json")
133    }
134
135    /// Sandbox 状态文件:`sessions/<id>/sandbox.json`
136    pub fn sandbox_file(&self) -> PathBuf {
137        self.dir.join("sandbox.json")
138    }
139
140    /// LoadTool 已加载的 deferred 工具:`sessions/<id>/loaded_deferred.json`
141    pub fn loaded_deferred_file(&self) -> PathBuf {
142        self.dir.join("loaded_deferred.json")
143    }
144
145    /// 操作审计文件:sessions/<id>/ops.jsonl
146    pub fn ops_file(&self) -> PathBuf {
147        self.dir.join("ops.jsonl")
148    }
149
150    /// 性能指标文件:sessions/<id>/metrics.json
151    pub fn metrics_file(&self) -> PathBuf {
152        self.dir.join("metrics.json")
153    }
154
155    pub fn ensure_dir(&self) -> std::io::Result<()> {
156        fs::create_dir_all(&self.dir)
157    }
158
159    /// 返回 session ID(即目录名)
160    #[allow(dead_code)]
161    pub fn id(&self) -> &str {
162        self.dir.file_name().and_then(|s| s.to_str()).unwrap_or("")
163    }
164}
165
166/// 追加一个事件到 session JSONL 文件(append-only,POSIX 下原子安全)
167///
168/// 同时增量更新 `session.json` 元数据。
169pub fn append_session_event(session_id: &str, event: &SessionEvent) -> bool {
170    let paths = SessionPaths::new(session_id);
171    if paths.ensure_dir().is_err() {
172        return false;
173    }
174    let path = paths.transcript();
175    let ok = match serde_json::to_string(event) {
176        Ok(line) => match fs::OpenOptions::new().create(true).append(true).open(&path) {
177            Ok(mut file) => writeln!(file, "{}", line).is_ok(),
178            Err(_) => false,
179        },
180        Err(_) => false,
181    };
182    if ok {
183        update_session_meta_on_event(session_id, event);
184    }
185    ok
186}
187
188/// 追加一条操作审计记录到 ops.jsonl(append-only,与 append_session_event 同模式)
189pub fn append_session_op(session_id: &str, op: &SessionOp) -> bool {
190    let paths = SessionPaths::new(session_id);
191    if paths.ensure_dir().is_err() {
192        return false;
193    }
194    let path = paths.ops_file();
195    match serde_json::to_string(op) {
196        Ok(line) => match fs::OpenOptions::new().create(true).append(true).open(&path) {
197            Ok(mut file) => writeln!(file, "{}", line).is_ok(),
198            Err(_) => false,
199        },
200        Err(_) => false,
201    }
202}
203
204/// 读取 session 的所有操作审计记录
205#[allow(dead_code)]
206pub fn load_session_ops(session_id: &str) -> Vec<SessionOp> {
207    let path = SessionPaths::new(session_id).ops_file();
208    let content = match fs::read_to_string(&path) {
209        Ok(c) => c,
210        Err(_) => return Vec::new(),
211    };
212    let mut ops = Vec::new();
213    for line in content.lines() {
214        let line = line.trim();
215        if line.is_empty() {
216            continue;
217        }
218        if let Ok(op) = serde_json::from_str::<SessionOp>(line) {
219            ops.push(op);
220        }
221    }
222    ops
223}
224
225/// 增量更新 session.json 元数据(追加事件后调用)
226fn update_session_meta_on_event(session_id: &str, event: &SessionEvent) {
227    let now = SystemTime::now()
228        .duration_since(UNIX_EPOCH)
229        .unwrap_or_default()
230        .as_secs();
231    let mut meta = load_session_meta_file(session_id).unwrap_or_else(|| SessionMetaFile {
232        id: session_id.to_string(),
233        title: String::new(),
234        message_count: 0,
235        created_at: now,
236        updated_at: now,
237        model: None,
238        auto_approve: false,
239    });
240    meta.updated_at = now;
241    match event {
242        SessionEvent::Msg { message: msg, .. } => {
243            meta.message_count += 1;
244            if meta.title.is_empty() && msg.role == MessageRole::User && !msg.content.is_empty() {
245                meta.title = msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect();
246            }
247        }
248        SessionEvent::Clear => {
249            meta.message_count = 0;
250        }
251        SessionEvent::Restore { messages } => {
252            meta.message_count = messages.len();
253            if meta.title.is_empty()
254                && let Some(first_user) = messages
255                    .iter()
256                    .find(|m| m.role == MessageRole::User && !m.content.is_empty())
257            {
258                meta.title = first_user
259                    .content
260                    .chars()
261                    .take(MESSAGE_PREVIEW_MAX_LEN)
262                    .collect();
263            }
264        }
265        SessionEvent::Metrics { .. } => {}
266    }
267    let _ = save_session_meta_file(&meta);
268}
269
270/// 查找最近修改的 session ID(用于 --continue)
271pub fn find_latest_session_id() -> Option<String> {
272    let dir = sessions_dir();
273    let mut entries: Vec<(std::time::SystemTime, String)> = Vec::new();
274    let read_dir = match fs::read_dir(&dir) {
275        Ok(rd) => rd,
276        Err(_) => return None,
277    };
278    for entry in read_dir.flatten() {
279        let path = entry.path();
280        if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
281            continue;
282        }
283        let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
284            continue;
285        };
286        let transcript = path.join("transcript.jsonl");
287        if let Ok(meta) = transcript.metadata()
288            && let Ok(modified) = meta.modified()
289        {
290            entries.push((modified, id.to_string()));
291        }
292    }
293    entries.sort_by_key(|b| std::cmp::Reverse(b.0));
294    entries.into_iter().next().map(|(_, id)| id)
295}
296
297/// 从 JSONL 文件 replay 出消息列表(供 resume 等功能使用)
298pub fn load_session(session_id: &str) -> Vec<ChatMessage> {
299    let path = SessionPaths::new(session_id).transcript();
300    if !path.exists() {
301        return Vec::new();
302    }
303    let content = match fs::read_to_string(&path) {
304        Ok(c) => c,
305        Err(_) => return Vec::new(),
306    };
307    let mut messages: Vec<ChatMessage> = Vec::new();
308    for line in content.lines() {
309        let line = line.trim();
310        if line.is_empty() {
311            continue;
312        }
313        match serde_json::from_str::<SessionEvent>(line) {
314            Ok(event) => match event {
315                SessionEvent::Msg { message, .. } => messages.push(message),
316                SessionEvent::Clear => messages.clear(),
317                SessionEvent::Restore { messages: restored } => messages = restored,
318                SessionEvent::Metrics { .. } => {}
319            },
320            Err(_) => {
321                // 损坏行直接跳过,继续处理剩余行
322            }
323        }
324    }
325
326    // ★ 存量清理:移除历史遗留的孤立 tool_call / tool_result。
327    //   检测到变化时追加一条 Restore 事件,让 jsonl 下次加载直接从干净快照出发,
328    //   orphan 不再反复出现在 sanitize 日志里。
329    if let Some(sanitized) = sanitize_loaded_messages(&messages) {
330        let restore_event = SessionEvent::Restore {
331            messages: sanitized.clone(),
332        };
333        append_session_event(session_id, &restore_event);
334        messages = sanitized;
335    }
336
337    messages
338}
339
340/// 从 display.jsonl replay 出 display 消息列表。
341///
342/// 逻辑同 `load_session`,但只返回 `Vec<ChatMessage>`,
343/// 也不做 sanitize(display 消息无需配对校验)。
344pub fn load_display_session(session_id: &str) -> Vec<ChatMessage> {
345    let path = SessionPaths::new(session_id).display();
346    if !path.exists() {
347        return Vec::new();
348    }
349    let content = match fs::read_to_string(&path) {
350        Ok(c) => c,
351        Err(_) => return Vec::new(),
352    };
353    let mut messages: Vec<ChatMessage> = Vec::new();
354    for line in content.lines() {
355        let line = line.trim();
356        if line.is_empty() {
357            continue;
358        }
359        if let Ok(event) = serde_json::from_str::<SessionEvent>(line) {
360            match event {
361                SessionEvent::Msg { message, .. } => messages.push(message),
362                SessionEvent::Clear => messages.clear(),
363                SessionEvent::Restore { messages: restored } => messages = restored,
364                SessionEvent::Metrics { .. } => {}
365            }
366        }
367    }
368    messages
369}
370
371/// 双向配对清理:
372///   - 移除 tool_call_id 为空或在任何 assistant tool_calls 中找不到对应项的 tool result
373///   - 移除 assistant tool_calls 中 id 为空或找不到对应 tool result 的条目;
374///     tool_calls 被全部清空时置为 None(保留 content 文本)
375///
376/// 返回 `Some(sanitized)` 表示发生了变化,`None` 表示原样可用。
377fn sanitize_loaded_messages(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
378    let tool_result_ids: std::collections::HashSet<String> = messages
379        .iter()
380        .filter(|m| m.role == MessageRole::Tool)
381        .filter_map(|m| m.tool_call_id.clone())
382        .filter(|id| !id.is_empty())
383        .collect();
384
385    let assistant_tool_call_ids: std::collections::HashSet<String> = messages
386        .iter()
387        .filter(|m| m.role == MessageRole::Assistant)
388        .flat_map(|m| m.tool_calls.as_deref().unwrap_or(&[]))
389        .map(|tc| tc.id.clone())
390        .filter(|id| !id.is_empty())
391        .collect();
392
393    let mut changed = false;
394    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
395    for msg in messages {
396        if msg.role == MessageRole::Tool {
397            let id = msg.tool_call_id.as_deref().unwrap_or("");
398            if id.is_empty() || !assistant_tool_call_ids.contains(id) {
399                changed = true;
400                continue;
401            }
402            out.push(msg.clone());
403        } else if msg.role == MessageRole::Assistant {
404            if let Some(ref tcs) = msg.tool_calls {
405                let kept: Vec<_> = tcs
406                    .iter()
407                    .filter(|tc| !tc.id.is_empty() && tool_result_ids.contains(&tc.id))
408                    .cloned()
409                    .collect();
410                if kept.len() != tcs.len() {
411                    changed = true;
412                    let mut new_msg = msg.clone();
413                    new_msg.tool_calls = if kept.is_empty() { None } else { Some(kept) };
414                    // 若 tool_calls 被清空且没有文本内容,整条消息也无意义——跳过
415                    if new_msg.tool_calls.is_none() && new_msg.content.trim().is_empty() {
416                        continue;
417                    }
418                    out.push(new_msg);
419                } else {
420                    out.push(msg.clone());
421                }
422            } else {
423                out.push(msg.clone());
424            }
425        } else {
426            out.push(msg.clone());
427        }
428    }
429
430    if changed { Some(out) } else { None }
431}
432
433/// 从 JSONL 文件按出现顺序读取 `(ChatMessage, timestamp_ms)` 列表。
434///
435/// 供 teammate / subagent 等独立 transcript 的读取使用:保留时间戳、不做 Clear/Restore 处理。
436#[allow(dead_code)]
437pub fn read_transcript_with_timestamps(path: &Path) -> Vec<(ChatMessage, u64)> {
438    let content = match fs::read_to_string(path) {
439        Ok(c) => c,
440        Err(_) => return Vec::new(),
441    };
442    let mut out: Vec<(ChatMessage, u64)> = Vec::new();
443    for line in content.lines() {
444        let line = line.trim();
445        if line.is_empty() {
446            continue;
447        }
448        if let Ok(SessionEvent::Msg {
449            message,
450            timestamp_ms,
451        }) = serde_json::from_str::<SessionEvent>(line)
452        {
453            out.push((message, timestamp_ms));
454        }
455    }
456    out
457}
458
459/// 向任意路径的 JSONL 文件 append 一条事件(append-only;用于 teammate/subagent 独立 transcript)。
460pub fn append_event_to_path(path: &Path, event: &SessionEvent) -> bool {
461    if let Some(parent) = path.parent() {
462        let _ = fs::create_dir_all(parent);
463    }
464    let line = match serde_json::to_string(event) {
465        Ok(s) => s,
466        Err(_) => return false,
467    };
468    match fs::OpenOptions::new().create(true).append(true).open(path) {
469        Ok(mut file) => writeln!(file, "{}", line).is_ok(),
470        Err(_) => false,
471    }
472}
473
474// ========== 会话元数据 ==========
475
476/// session.json 元数据文件内容(持久化到 `sessions/<id>/session.json`)
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct SessionMetaFile {
479    /// 会话 ID
480    pub id: String,
481    /// 会话标题(首条 user 消息截断)
482    #[serde(default)]
483    pub title: String,
484    /// 消息计数
485    pub message_count: usize,
486    /// 创建时间戳(epoch seconds)
487    pub created_at: u64,
488    /// 最后更新时间戳(epoch seconds)
489    pub updated_at: u64,
490    /// 使用的模型名称
491    #[serde(default)]
492    pub model: Option<String>,
493    /// 是否自动批准所有操作(bypass 模式)
494    #[serde(default)]
495    pub auto_approve: bool,
496}
497
498/// 会话元数据(用于会话列表展示)
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct SessionMeta {
501    pub id: String,
502    /// 会话标题(从 session.json 读取)
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub title: Option<String>,
505    pub message_count: usize,
506    pub first_message_preview: Option<String>,
507    pub updated_at: u64,
508}
509
510/// 加载 session.json 元数据(不存在返回 None)
511pub fn load_session_meta_file(session_id: &str) -> Option<SessionMetaFile> {
512    let path = SessionPaths::new(session_id).meta_file();
513    let content = fs::read_to_string(path).ok()?;
514    serde_json::from_str(&content).ok()
515}
516
517/// 保存 session.json 元数据
518pub fn save_session_meta_file(meta: &SessionMetaFile) -> bool {
519    let paths = SessionPaths::new(&meta.id);
520    if paths.ensure_dir().is_err() {
521        return false;
522    }
523    match serde_json::to_string_pretty(meta) {
524        Ok(json) => fs::write(paths.meta_file(), json).is_ok(),
525        Err(_) => false,
526    }
527}
528
529/// 从 transcript.jsonl 逐行扫描生成元数据(懒生成 / 迁移用)
530fn derive_session_meta_from_transcript(session_id: &str) -> Option<SessionMetaFile> {
531    let paths = SessionPaths::new(session_id);
532    let transcript = paths.transcript();
533    let content = fs::read_to_string(&transcript).ok()?;
534
535    let mut message_count: usize = 0;
536    let mut first_user_preview: Option<String> = None;
537    for line in content.lines() {
538        let line = line.trim();
539        if line.is_empty() {
540            continue;
541        }
542        if let Ok(event) = serde_json::from_str::<SessionEvent>(line) {
543            match event {
544                SessionEvent::Msg {
545                    message: ref msg, ..
546                } => {
547                    message_count += 1;
548                    if first_user_preview.is_none()
549                        && msg.role == MessageRole::User
550                        && !msg.content.is_empty()
551                    {
552                        first_user_preview =
553                            Some(msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
554                    }
555                }
556                SessionEvent::Clear => {
557                    message_count = 0;
558                    first_user_preview = None;
559                }
560                SessionEvent::Restore { ref messages } => {
561                    message_count = messages.len();
562                    first_user_preview = messages
563                        .iter()
564                        .find(|m| m.role == MessageRole::User && !m.content.is_empty())
565                        .map(|m| m.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
566                }
567                SessionEvent::Metrics { .. } => {}
568            }
569        }
570    }
571
572    let updated_at = transcript
573        .metadata()
574        .ok()
575        .and_then(|m| m.modified().ok())
576        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
577        .map(|d| d.as_secs())
578        .unwrap_or(0);
579
580    Some(SessionMetaFile {
581        id: session_id.to_string(),
582        title: first_user_preview.clone().unwrap_or_default(),
583        message_count,
584        created_at: updated_at,
585        updated_at,
586        model: None,
587        auto_approve: false,
588    })
589}
590
591/// 列出所有会话的元数据,按更新时间倒序
592///
593/// 优先读 `session.json` 元数据文件(O(1)),不存在时 fallback 到逐行扫描
594/// `transcript.jsonl` 并懒生成 `session.json`。
595pub fn list_sessions() -> Vec<SessionMeta> {
596    let dir = sessions_dir();
597    let read_dir = match fs::read_dir(&dir) {
598        Ok(rd) => rd,
599        Err(_) => return Vec::new(),
600    };
601
602    let mut ids: Vec<String> = Vec::new();
603    for entry in read_dir.flatten() {
604        let path = entry.path();
605        if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
606            continue;
607        }
608        let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
609            continue;
610        };
611        if path.join("transcript.jsonl").exists() {
612            ids.push(id.to_string());
613        }
614    }
615
616    let mut sessions: Vec<SessionMeta> = Vec::with_capacity(ids.len());
617    for id in ids {
618        // 优先读 session.json
619        if let Some(meta_file) = load_session_meta_file(&id) {
620            sessions.push(SessionMeta {
621                id: meta_file.id,
622                title: if meta_file.title.is_empty() {
623                    None
624                } else {
625                    Some(meta_file.title)
626                },
627                message_count: meta_file.message_count,
628                first_message_preview: None,
629                updated_at: meta_file.updated_at,
630            });
631            continue;
632        }
633
634        // fallback:逐行扫描 transcript 并懒生成 session.json
635        if let Some(derived) = derive_session_meta_from_transcript(&id) {
636            let title = if derived.title.is_empty() {
637                None
638            } else {
639                Some(derived.title.clone())
640            };
641            let preview_for_ui = title.clone();
642            let _ = save_session_meta_file(&derived);
643            sessions.push(SessionMeta {
644                id: derived.id,
645                title,
646                message_count: derived.message_count,
647                first_message_preview: preview_for_ui,
648                updated_at: derived.updated_at,
649            });
650        }
651    }
652    sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
653    sessions
654}
655
656/// 生成会话 ID(时间戳微秒 + 进程 ID,无需外部依赖)
657pub fn generate_session_id() -> String {
658    let ts = SystemTime::now()
659        .duration_since(UNIX_EPOCH)
660        .unwrap_or_default()
661        .as_micros();
662    let pid = std::process::id();
663    format!("{:x}-{:x}", ts, pid)
664}
665
666/// 删除指定 session 目录
667pub fn delete_session(session_id: &str) -> bool {
668    let paths = SessionPaths::new(session_id);
669    let dir = paths.dir().to_path_buf();
670    if dir.exists()
671        && let Err(e) = fs::remove_dir_all(&dir)
672    {
673        eprintln!("[ERROR] ✖️ 删除 session 目录失败: {}", e);
674        return false;
675    }
676    true
677}
678
679/// 将 SessionMetrics 写入 sessions/<id>/metrics.json(覆盖写,JSON pretty)
680pub fn write_session_metrics(session_id: &str, metrics: &SessionMetrics) -> bool {
681    let paths = SessionPaths::new(session_id);
682    let path = paths.metrics_file();
683    match serde_json::to_string_pretty(metrics) {
684        Ok(json) => fs::write(&path, json).is_ok(),
685        Err(_) => false,
686    }
687}