Files
Unlucky-Launcher/crates/core/src/launch/session.rs
T
AskaEth bc41dfcf7d
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

376 lines
12 KiB
Rust
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.
//! JSONL 会话事件流:`<sessions_root>/game/<session-id>.jsonl`。
//!
//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与
//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。
//!
//! 两条纪律写死在实现里:
//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken);
//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{AssemblyError, LaunchError, Result};
use crate::launch::exit_analysis::ExitAnalysis;
use crate::launch::natives::NativesReport;
use crate::launch::pump::{LogLine, Stream};
use crate::launch::redact::Redactor;
use crate::launch::session_index::{self, SessionIndexEntry};
use crate::launch::session_payload as payload;
use crate::launch::time::{self, SessionClock};
use crate::launch::window_detect::Detection;
/// 事件 schema 版本。
pub const SCHEMA_VERSION: u8 = 1;
/// 事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
/// 会话开始。
SessionStart,
/// Java 选择结果。
JavaSelected,
/// 命令行组装完成。
CmdBuilt,
/// natives 解压结果。
NativesExtracted,
/// 启动前失败(没有进程)。
AssemblyFailed,
/// 进程已启动。
ProcStart,
/// stdout 行。
Stdout,
/// stderr 行。
Stderr,
/// 检测到游戏窗口。
WindowDetected,
/// 进程退出。
Exit,
/// 发现崩溃报告 / hs_err。
CrashFound,
/// 诊断结论(M15 产出)。
Diagnosis,
/// 被主动结束。
Killed,
/// 会话结束。
SessionEnd,
}
impl EventKind {
/// JSONL 里的 `kind` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::JavaSelected => "java_selected",
Self::CmdBuilt => "cmd_built",
Self::NativesExtracted => "natives_extracted",
Self::AssemblyFailed => "assembly_failed",
Self::ProcStart => "proc_start",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::WindowDetected => "window_detected",
Self::Exit => "exit",
Self::CrashFound => "crash_found",
Self::Diagnosis => "diagnosis",
Self::Killed => "killed",
Self::SessionEnd => "session_end",
}
}
}
/// 会话结束原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndReason {
/// 进程自然退出。
Exit,
/// 被主动结束。
Killed,
/// 启动前失败(组装/组件检查)。
AssemblyFailed,
/// 进程派生失败。
SpawnFailed,
}
impl EndReason {
/// JSONL 里的 `reason` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::Exit => "exit",
Self::Killed => "killed",
Self::AssemblyFailed => "assembly_failed",
Self::SpawnFailed => "spawn_failed",
}
}
}
pub use crate::launch::session_meta::SessionMeta;
pub use crate::launch::session_sink::SessionSink;
#[derive(Serialize)]
struct EventLine<'a> {
v: u8,
seq: u64,
ts: String,
t_ms: u64,
kind: &'a str,
session_id: &'a str,
payload: &'a Value,
}
/// 会话事件流写入器。
#[derive(Debug)]
pub struct SessionWriter {
session_id: String,
clock: SessionClock,
file: BufWriter<File>,
path: PathBuf,
index_path: PathBuf,
seq: u64,
event_count: u64,
redactor: Redactor,
meta: SessionMeta,
started_at: String,
window_detected: bool,
crash_found: bool,
exit_code: Option<i32>,
exit_type: Option<String>,
silent_failure: bool,
diagnosis_top: Option<String>,
finished: bool,
}
impl SessionWriter {
/// 新建会话(id 由时间 + pid 生成)。
pub fn create(sessions_root: &Path, meta: SessionMeta, redactor: Redactor) -> Result<Self> {
let session_id = time::make_session_id(time::now(), std::process::id());
Self::create_with_id(sessions_root, &session_id, meta, redactor)
}
/// 用指定 id 新建会话(测试与“续写”场景)。
pub fn create_with_id(
sessions_root: &Path,
session_id: &str,
meta: SessionMeta,
redactor: Redactor,
) -> Result<Self> {
let dir = sessions_root.join("game");
fs::create_dir_all(&dir).map_err(|source| LaunchError::Io {
op: "create_session_dir",
path: dir.clone(),
source,
})?;
let path = dir.join(format!("{session_id}.jsonl"));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|source| LaunchError::Io {
op: "create_session_file",
path: path.clone(),
source,
})?;
let clock = SessionClock::start_new();
let started_at = clock.started_at_iso();
Ok(Self {
session_id: session_id.to_string(),
clock,
file: BufWriter::new(file),
path,
index_path: session_index::index_path(sessions_root),
seq: 0,
event_count: 0,
redactor,
meta,
started_at,
window_detected: false,
crash_found: false,
exit_code: None,
exit_type: None,
silent_failure: false,
diagnosis_top: None,
finished: false,
})
}
/// 会话 id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 事件文件路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 已写入事件数。
pub fn event_count(&self) -> u64 {
self.event_count
}
/// 是否已结束。
pub fn is_finished(&self) -> bool {
self.finished
}
/// 写一条事件。
pub fn record(&mut self, kind: EventKind, payload: Value) -> Result<()> {
let seq = self.seq;
let line = EventLine {
v: SCHEMA_VERSION,
seq,
ts: self.clock.now_iso(),
t_ms: self.clock.elapsed_ms(),
kind: kind.as_str(),
session_id: &self.session_id,
payload: &payload,
};
let serialized = serde_json::to_string(&line).map_err(|err| LaunchError::Session {
path: self.path.clone(),
reason: err.to_string(),
})?;
self.file
.write_all(serialized.as_bytes())
.and_then(|()| self.file.write_all(b"\n"))
.and_then(|()| self.file.flush())
.map_err(|source| LaunchError::Io {
op: "write_session_event",
path: self.path.clone(),
source,
})?;
self.seq += 1;
self.event_count += 1;
Ok(())
}
/// `session_start`。
pub fn session_start(&mut self) -> Result<()> {
let payload = payload::session_start(&self.meta, SCHEMA_VERSION);
self.record(EventKind::SessionStart, payload)
}
/// `cmd_built`:完整 argv(已脱敏)+ classpath 摘要。
pub fn cmd_built(&mut self, command: &AssembledCommand) -> Result<()> {
let payload = payload::cmd_built(command, &self.redactor);
self.record(EventKind::CmdBuilt, payload)
}
/// `assembly_failed`:启动前失败,附结构化缺失清单(M15 据此下结论)。
pub fn assembly_failed(&mut self, error: &AssemblyError) -> Result<()> {
let payload = payload::assembly_failed(error);
self.record(EventKind::AssemblyFailed, payload)
}
/// `proc_start`。
pub fn proc_start(&mut self, pid: u32) -> Result<()> {
let payload = payload::proc_start(pid, &self.clock.now_iso());
self.record(EventKind::ProcStart, payload)
}
/// `natives_extracted`:natives 解压结果(启动期 `UnsatisfiedLinkError` 的第一嫌疑)。
pub fn natives_extracted(&mut self, report: &NativesReport) -> Result<()> {
let payload = payload::natives_extracted(report, &self.redactor);
self.record(EventKind::NativesExtracted, payload)
}
/// `stdout` / `stderr` 行事件。
pub fn line(&mut self, line: &LogLine) -> Result<()> {
let kind = match line.stream {
Stream::Stdout => EventKind::Stdout,
Stream::Stderr => EventKind::Stderr,
};
let text = self.redactor.scrub(&line.text).into_owned();
let payload = payload::log_line(&text, line.level());
self.record(kind, payload)
}
/// `window_detected`。
pub fn window_detected(&mut self, detection: &Detection) -> Result<()> {
self.window_detected = true;
let payload = payload::window_detected(detection, &self.redactor);
self.record(EventKind::WindowDetected, payload)
}
/// 是否已检测到游戏窗口(索引摘要用)。
pub fn is_window_detected(&self) -> bool {
self.window_detected
}
/// `killed`。
pub fn killed(&mut self, by: &str, signal: Option<&str>) -> Result<()> {
let payload = payload::killed(by, signal);
self.record(EventKind::Killed, payload)
}
/// `exit`。
pub fn exit(&mut self, analysis: &ExitAnalysis) -> Result<()> {
self.exit_code = Some(analysis.code);
self.exit_type = Some(analysis.exit_type.as_str().to_string());
self.silent_failure = analysis.silent_failure.is_some();
let payload = payload::exit(analysis);
self.record(EventKind::Exit, payload)
}
/// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。
pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> {
self.crash_found = true;
let payload = payload::crash_found(source, path, excerpt, &self.redactor);
self.record(EventKind::CrashFound, payload)
}
/// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。
///
/// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的
/// 用户主目录路径与 token 会由此带进结论,所以这里必须再过一遍 [`Redactor`]
/// (只改字符串,字段名/数值/布尔不动——`launch-接口约定.md` §9.3)。
pub fn diagnosis(&mut self, record: Value) -> Result<()> {
let record = self.redactor.scrub_json(&record);
if self.diagnosis_top.is_none() {
self.diagnosis_top = record
.get("candidates")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
.map(str::to_string);
}
self.record(EventKind::Diagnosis, record)
}
/// `session_end` + 追加索引,返回索引条目。
pub fn finish(&mut self, reason: EndReason) -> Result<SessionIndexEntry> {
let duration_ms = self.clock.elapsed_ms();
let payload = payload::session_end(reason.as_str(), duration_ms);
self.record(EventKind::SessionEnd, payload)?;
self.finished = true;
let entry = SessionIndexEntry {
v: SCHEMA_VERSION,
session_id: self.session_id.clone(),
instance_id: self.meta.instance_id.clone(),
instance_name: self.meta.instance_name.clone(),
game_version: self.meta.game_version.clone(),
started_at: self.started_at.clone(),
ended_at: self.clock.now_iso(),
duration_ms,
exit_code: self.exit_code,
exit_type: self.exit_type.clone(),
silent_failure: self.silent_failure,
window_detected: self.window_detected,
event_count: self.event_count,
log_path: format!("game/{}.jsonl", self.session_id),
crash_found: self.crash_found,
diagnosis_top: self.diagnosis_top.clone(),
};
session_index::append(&self.index_path, &entry).map_err(|source| LaunchError::Io {
op: "append_session_index",
path: self.index_path.clone(),
source,
})?;
Ok(entry)
}
}