//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。 use serde_json::{json, Value}; use unlucky_agent::port::{ GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult, }; use super::events::SessionSource; /// M18 的会话索引 + 事件流。 #[derive(Debug, Clone)] pub struct CoreSessions { source: SessionSource, } impl CoreSessions { /// `root` = `<数据根>/.ULL/data/agent/sessions`。 #[must_use] pub fn new(root: impl Into) -> Self { Self { source: SessionSource::new(root), } } } fn summary_of( entry: &unlucky_core::launch::session_index::SessionIndexEntry, ) -> GameSessionSummary { GameSessionSummary { session_id: entry.session_id.clone(), instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()), instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()), game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()), started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()), ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()), duration_ms: entry.duration_ms, exit_code: entry.exit_code, exit_type: entry.exit_type.clone(), silent_failure: entry.silent_failure, window_detected: entry.window_detected, event_count: entry.event_count, crash_found: entry.crash_found, diagnosis_top: entry.diagnosis_top.clone(), } } fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent { LogEvent { seq: event .get("seq") .and_then(Value::as_u64) .unwrap_or(fallback_seq), ts: event .get("ts") .and_then(Value::as_str) .unwrap_or_default() .to_string(), t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0), kind: event .get("kind") .and_then(Value::as_str) .unwrap_or_default() .to_string(), payload: event.get("payload").cloned().unwrap_or(Value::Null), } } impl GameSessionPort for CoreSessions { fn list_sessions( &self, limit: usize, instance_id: Option<&str>, ) -> PortResult> { let mut entries = self.source.entries()?; if let Some(instance) = instance_id { entries.retain(|entry| entry.instance_id == instance); } entries.truncate(limit); Ok(entries.iter().map(summary_of).collect()) } fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult { let entry = self.source.find(session_id)?; let raw = self.source.events(&entry)?; let events: Vec = raw .iter() .enumerate() .map(|(index, event)| to_log_event(event, index as u64)) .collect(); let mut kind_counts = std::collections::BTreeMap::new(); for event in &events { *kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1; } let matched: Vec<&LogEvent> = events .iter() .filter(|e| query.from_seq.is_none_or(|from| e.seq >= from)) .filter(|e| query.to_seq.is_none_or(|to| e.seq <= to)) .filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind)) .collect(); let max = if query.max_events == 0 { matched.len() } else { query.max_events }; let truncated = matched.len() > max; Ok(LogPage { session_id: entry.session_id, total_events: events.len() as u64, matched_events: matched.len() as u64, truncated, kind_counts, first_ts: events.first().map(|e| e.ts.clone()), last_ts: events.last().map(|e| e.ts.clone()), events: matched.into_iter().take(max).cloned().collect(), }) } } /// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。 #[must_use] pub fn log_event_json(event: &LogEvent) -> Value { json!({ "seq": event.seq, "ts": event.ts, "t_ms": event.t_ms, "kind": event.kind, "payload": event.payload, }) } #[cfg(test)] mod tests { use super::*; use std::path::Path; fn seed_session(root: &Path) { let game_dir = root.join("game"); std::fs::create_dir_all(&game_dir).expect("建目录"); let entry = json!({ "v": 1, "session_id": "20261029T124501-9f3c2a", "instance_id": "inst-1", "instance_name": "1.20.1 Forge", "game_version": "1.20.1", "started_at": "2026-10-29T12:45:01.123Z", "ended_at": "2026-10-29T12:46:31.123Z", "duration_ms": 90000, "exit_code": 1, "exit_type": "application_error", "silent_failure": false, "window_detected": true, "event_count": 2, "log_path": "20261029T124501-9f3c2a.jsonl", "crash_found": true, "diagnosis_top": "integrity.forge_missing_library" }); std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引"); let events = [ json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}), json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}), ]; let text: String = events .iter() .map(|event| format!("{event}\n")) .collect::(); std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件"); } #[test] fn lists_and_slices_a_session() { let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); seed_session(&root); let port = CoreSessions::new(root.clone()); let sessions = port.list_sessions(10, None).expect("应能列出"); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].exit_code, Some(1)); assert!(sessions[0].crash_found); let page = port .read_log( "20261029T124501", &LogQuery { max_events: 1, kinds: vec!["stderr".to_string()], ..LogQuery::default() }, ) .expect("应能读"); assert_eq!(page.total_events, 2); assert_eq!(page.events.len(), 1); assert_eq!(page.events[0].kind, "stderr"); assert!(page.kind_counts.contains_key("stdout")); let _ = std::fs::remove_dir_all(&root); } }