Files
Unlucky-Launcher/crates/cli/src/commands/agent/ports/session.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

202 lines
6.8 KiB
Rust

//! 会话端口:索引 → `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<std::path::PathBuf>) -> 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<Vec<GameSessionSummary>> {
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<LogPage> {
let entry = self.source.find(session_id)?;
let raw = self.source.events(&entry)?;
let events: Vec<LogEvent> = 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::<String>();
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);
}
}