diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs index cb16fcf..0558925 100644 --- a/crates/cli/src/commands/agent/ports/diagnose.rs +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -2,9 +2,25 @@ //! //! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算 //! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。 +//! +//! ## M242:结论读**会话里落盘的那份**,不在脱敏文本上重跑 +//! +//! 会话 JSONL 是写前脱敏的,而脱敏拿不准形状时是**有损**的:`≥64` 的不透明串、带点的票据串 +//! 都会被换成占位符。M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的 +//! 那条 `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**: +//! 形状一变(或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」, +//! 是**换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found` +//! (warning),把「Fabric Loader 版本不配套」说成「库没下全」。M241 起写会话时已经在 +//! **原始行**上算过一次结论并落成 `diagnosis` 事件,这里优先读它;没有该事件的老会话才回落到 +//! 重跑(与今天的读法完全一致)。 +//! +//! **隐私选择不变**:给模型的游戏日志仍是脱敏文本(`read_game_log` 与这里的 `evidence` +//! 片段都没动)。变的只是「结论从哪儿来」——盘上那份结论自己也是写入时过 `scrub_json` 的, +//! 所以既没把原文写进盘,也没把原文交给模型。 use std::collections::BTreeMap; +use serde_json::Value; use unlucky_agent::port::{ CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind, PortResult, @@ -13,12 +29,20 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding}; use unlucky_core::diagnose::loader::RuleLoader; use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind; +use unlucky_core::launch::session_index::SessionIndexEntry; use super::events::{tail_join, SessionSource}; +#[cfg(test)] +#[path = "diagnose/tests.rs"] +mod tests; + /// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。 pub const DEFAULT_TAIL: usize = 2000; +/// 会话级证据片段条数(与重跑路径同值:`stderr` 末尾 3 行)。 +const EVIDENCE_LINES: usize = 3; + /// M15 规则引擎的端口实现。 #[derive(Debug, Clone)] pub struct CoreDiagnose { @@ -82,12 +106,159 @@ fn candidate_of(finding: &Finding) -> DiagnosisCandidate { } } +// ─────────────────── 落盘的结论(`diagnosis` 事件) ─────────────────── +// +// 下面三个是 `unlucky_core::diagnose::finding::{DiagnosisRecord, Candidate, +// MaterializedFixAction}` 的**只读镜像**:core 侧那些类型只 derive `Serialize`(写盘用), +// agent 侧的类型也只 derive `Serialize`(回灌给模型用),所以「读回来」这一件事在这里落一次。 + +/// `diagnosis` 事件的 payload。 +#[derive(Debug, Default, serde::Deserialize)] +struct StoredRecord { + #[serde(default)] + source: Option, + #[serde(default)] + needs_more_info: bool, + #[serde(default)] + candidates: Vec, + #[serde(default)] + secondary: Vec, +} + +/// 一条落盘的候选结论。 +#[derive(Debug, Default, serde::Deserialize)] +struct StoredCandidate { + #[serde(default)] + id: String, + #[serde(default)] + severity: String, + #[serde(default)] + category: String, + #[serde(default)] + confidence: f32, + #[serde(default)] + message_key: String, + #[serde(default)] + message: String, + #[serde(default)] + detail: Option, + #[serde(default)] + evidence: Vec, + #[serde(default)] + fix_actions: Vec, +} + +/// 一条落盘的修复动作(`kind` 是 `snake_case` 的六维之一)。 +#[derive(Debug, Default, serde::Deserialize)] +struct StoredAction { + #[serde(default)] + kind: String, + #[serde(default)] + tool: String, + #[serde(default)] + args: BTreeMap, +} + +impl StoredRecord { + /// 事件流里最后一条**有主因**的 `diagnosis` 事件。 + /// + /// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。 + /// 没有候选的 payload 与「没结论」等价(`conclude` 也不会写它)→ 返回 `None`,让调用方回落重跑。 + fn find(events: &[Value]) -> Option { + let payload = events + .iter() + .rev() + .find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))? + .get("payload")?; + let record: Self = serde_json::from_value(payload.clone()).ok()?; + (!record.candidates.is_empty()).then_some(record) + } + + /// 落盘结论 → 给模型的 [`CrashDiagnosis`]。 + /// + /// `exit` 仍取自索引条目(launch 报的事实,与盘上那份结论的计算输入同源), + /// `evidence` 仍是**脱敏**的 stderr 尾窗——隐私选择不变,只有结论换了来源。 + fn into_diagnosis(self, entry: &SessionIndexEntry, evidence: Vec) -> CrashDiagnosis { + CrashDiagnosis { + session_id: entry.session_id.clone(), + source: self.source.unwrap_or_else(|| "rules".to_string()), + needs_more_info: self.needs_more_info, + candidates: self + .candidates + .into_iter() + .map(StoredCandidate::into_candidate) + .collect(), + secondary: self + .secondary + .into_iter() + .map(StoredCandidate::into_candidate) + .collect(), + evidence, + exit: ExitSummary { + code: entry.exit_code, + exit_type: entry.exit_type.clone(), + silent_failure: entry.silent_failure, + }, + } + } +} + +impl StoredCandidate { + fn into_candidate(self) -> DiagnosisCandidate { + DiagnosisCandidate { + id: self.id, + severity: self.severity, + category: self.category, + confidence: self.confidence, + message_key: self.message_key, + message: self.message, + detail: self.detail.unwrap_or_default(), + evidence: self.evidence, + fix_actions: self + .fix_actions + .into_iter() + .filter_map(StoredAction::into_action) + .collect(), + } + } +} + +impl StoredAction { + /// 未知维度返回 `None`:宁可少一条建议,也不瞎猜成别的维度(六维口径在 core 一处)。 + fn into_action(self) -> Option { + let kind = match self.kind.as_str() { + "change_java" => FixActionKind::ChangeJava, + "adjust_memory" => FixActionKind::AdjustMemory, + "change_graphics" => FixActionKind::ChangeGraphics, + "remove_mod" => FixActionKind::RemoveMod, + "reinstall" => FixActionKind::Reinstall, + "seek_help" => FixActionKind::SeekHelp, + _ => return None, + }; + Some(FixAction { + kind, + tool: self.tool, + label: String::new(), + args: self.args, + }) + } +} + impl DiagnosePort for CoreDiagnose { fn diagnose(&self, session_id: &str) -> PortResult { let entry = self.source.find(session_id)?; let events = self.source.events(&entry)?; let (stdout, stderr, error_lines) = SessionSource::split_text(&events); + // 会话级证据片段:仍是**脱敏**文本(隐私选择不变)。 + let evidence: Vec = stderr.iter().rev().take(EVIDENCE_LINES).cloned().collect(); + + // 盘上有结论就用它——那是写会话时在原始行上算的,不是这份脱敏文本能重算出来的。 + if let Some(record) = StoredRecord::find(&events) { + return Ok(record.into_diagnosis(&entry, evidence)); + } + + // 老会话(M241 之前的 JSONL):回落到在脱敏文本上重跑。 let exit = ExitSignal::classify( entry.exit_code, Some(entry.duration_ms), @@ -114,7 +285,7 @@ impl DiagnosePort for CoreDiagnose { needs_more_info: report.needs_more_info(), candidates: report.findings.iter().map(candidate_of).collect(), secondary: report.secondary.iter().map(candidate_of).collect(), - evidence: stderr.iter().rev().take(3).cloned().collect(), + evidence, exit: ExitSummary { code: entry.exit_code, exit_type: entry.exit_type.clone(), @@ -123,76 +294,3 @@ impl DiagnosePort for CoreDiagnose { }) } } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use unlucky_core::launch::session_index; - - /// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/.jsonl`。 - fn seed(root: &std::path::Path, stderr_line: &str) { - let sid = "20261029T124501-9f3c2a"; - let log_path = session_index::log_path_for(sid); - let index = session_index::index_path(root); - std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录"); - let entry = json!({ - "v": 1, - "session_id": sid, - "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:45:05.123Z", - "duration_ms": 4000, - "exit_code": 1, - "exit_type": "application_error", - "silent_failure": false, - "window_detected": false, - "event_count": 1, - "log_path": log_path.clone(), - "crash_found": true, - "diagnosis_top": null - }); - std::fs::write(index, format!("{entry}\n")).expect("写索引"); - let event = json!({ - "seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0, - "kind": "stderr", - "payload": { "line": stderr_line, "level": "error" } - }); - std::fs::write( - session_index::event_file(root, &log_path), - format!("{event}\n"), - ) - .expect("写事件"); - } - - #[test] - fn runs_the_rule_engine_over_a_session() { - let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - seed( - &root, - "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", - ); - - let port = CoreDiagnose::new(root.clone()); - let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断"); - assert_eq!(report.session_id, "20261029T124501-9f3c2a"); - assert_eq!(report.source, "rules"); - assert_eq!(report.exit.code, Some(1)); - // 规则集里应有 JVM/完整性类结论命中这条 stderr - assert!(!report.candidates.is_empty(), "应至少命中一条规则"); - - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn unknown_session_is_a_readable_error() { - let root = - std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id())); - let port = CoreDiagnose::new(root); - let err = port.diagnose("nope").unwrap_err(); - assert!(err.contains("找不到会话"), "{err}"); - } -} diff --git a/crates/cli/src/commands/agent/ports/diagnose/tests.rs b/crates/cli/src/commands/agent/ports/diagnose/tests.rs new file mode 100644 index 0000000..49853f5 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/diagnose/tests.rs @@ -0,0 +1,187 @@ +//! M242:AI 侧 `diagnose_crash` 的结论来源(落盘那份优先,老会话回落重跑)。 +//! +//! 两条端到端用例都走**真链路**:真 `SessionWriter` 写盘 → 真工具注册( +//! `unlucky_agent::tools::diagnose::register`)→ 真 handler。判据是「命中哪条规则」, +//! 因为脱敏有损:在脱敏文本上重跑会**换一条**规则(`fabric.loader_0_12` → +//! `integrity.no_class_def_found`)。 +//! +//! ## 夹具为什么把类路径登记成 secret +//! +//! M240 收窄了「`≥64` 不透明串」那条规则(路径形状的长串现在保留),所以 `SessionWriter` +//! 落盘时这条证据行今天**不会被抹**。要复现「读回脱敏文本就丢了证据」这个世界(M239 实测的 +//! 老落盘形状),夹具把类路径登记成 secret:写盘前它变成 ``,而结论是在 +//! **原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。 +//! +//! 变异证明(改完已还原): +//! - 删掉 `diagnose()` 里的 `StoredRecord::find(&events)` 分支(等于每次都重跑)⇒ +//! `ai_gets_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。 + +use std::path::Path; +use std::sync::Arc; + +use serde_json::{json, Value}; +use unlucky_agent::port::DiagnosePort; +use unlucky_agent::registry::{ToolCtx, ToolRegistry}; +use unlucky_agent::tools::diagnose::DIAGNOSE_CRASH; +use unlucky_core::launch::exit_analysis::{self, ExitSignals}; +use unlucky_core::launch::pump::{LogLine, Stream}; +use unlucky_core::launch::redact::Redactor; +use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter}; +use unlucky_core::launch::session_index; + +use super::CoreDiagnose; + +/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。 +const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy"; +/// 真凭据(写盘前必须被抹掉的形状)。 +const TOKEN: &str = "SECRET-TOKEN-123456"; + +/// 那条完整的证据行。 +fn evidence_line() -> String { + format!("java.lang.NoClassDefFoundError: {CLASS_PATH}") +} + +/// 一个临时数据根(`` 当数据根,会话根就是 ``——端口只认会话根这一层)。 +fn temp_root(tag: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + root +} + +/// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/.jsonl`。 +fn seed(root: &std::path::Path, stderr_line: &str) { + let sid = "20261029T124501-9f3c2a"; + let log_path = session_index::log_path_for(sid); + let index = session_index::index_path(root); + std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": sid, + "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:45:05.123Z", + "duration_ms": 4000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 1, + "log_path": log_path.clone(), + "crash_found": true, + "diagnosis_top": null + }); + std::fs::write(index, format!("{entry}\n")).expect("写索引"); + let event = json!({ + "seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0, + "kind": "stderr", + "payload": { "line": stderr_line, "level": "error" } + }); + std::fs::write( + session_index::event_file(root, &log_path), + format!("{event}\n"), + ) + .expect("写事件"); +} + +/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论。 +fn write_real_session(sessions_root: &Path) -> String { + let session_id = "20261102T091500-m242ai"; + let mut writer = SessionWriter::create_with_id( + sessions_root, + session_id, + SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"), + // 类路径登记成 secret ⇒ 盘上那条证据行被抹掉(见模块文档:为什么要人为造这份失真)。 + Redactor::new().with_secret(TOKEN).with_secret(CLASS_PATH), + ) + .expect("建会话"); + writer.session_start().expect("session_start"); + let lines = vec![ + LogLine::new(Stream::Stderr, "Failed to start Fabric loader"), + LogLine::new(Stream::Stderr, evidence_line()), + LogLine::new(Stream::Stdout, format!("auth: token={TOKEN}")), + ]; + for line in &lines { + writer.line(line).expect("写行"); + } + let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines)); + writer.exit(&analysis).expect("exit"); + writer.finish(EndReason::Exit).expect("finish"); + session_id.to_string() +} + +/// 走真工具入口调 `diagnose_crash`:注册表 + schema + handler 与 `ull agent serve` 同一份。 +fn call_diagnose_crash(sessions_root: &Path, session_id: &str) -> Value { + let mut registry = ToolRegistry::new(); + unlucky_agent::tools::diagnose::register( + &mut registry, + Arc::new(CoreDiagnose::new(sessions_root)), + ) + .expect("注册 diagnose_crash"); + let spec = registry.get(DIAGNOSE_CRASH).expect("工具已注册").clone(); + let ctx = ToolCtx { + agent_session_id: "m242".to_string(), + confirmed: false, + }; + let output = + (spec.handler)(&ctx, &json!({ "session_id": session_id })).expect("diagnose_crash 应成功"); + output.content +} + +#[test] +fn ai_gets_the_stored_conclusion_instead_of_a_rerun_on_redacted_text() { + let root = temp_root("ai-stored"); + let session_id = write_real_session(&root); + + let content = call_diagnose_crash(&root, &session_id); + assert_eq!(content["session_id"], session_id.as_str()); + assert_eq!( + content["candidates"][0]["id"], "fabric.loader_0_12", + "落盘的结论必须原样交给模型(重跑会降级成 integrity.no_class_def_found):{content}" + ); + assert_eq!(content["needs_more_info"], false); + let confidence = content["top_confidence"].as_f64().expect("置信度是数字"); + assert!( + (confidence - 0.85).abs() < 1e-3, + "落盘结论的置信度应原样透传:{content}" + ); + + // 隐私选择不变:给模型的文本片段仍是脱敏的(类路径与凭据都不许出现)。 + let rendered = content.to_string(); + assert!( + !rendered.contains("FabricMixinTransformerProxy"), + "{rendered}" + ); + assert!(!rendered.contains(TOKEN), "{rendered}"); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn runs_the_rule_engine_over_a_session() { + let root = temp_root("diagnose"); + seed( + &root, + "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", + ); + + let port = CoreDiagnose::new(root.clone()); + let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断"); + assert_eq!(report.session_id, "20261029T124501-9f3c2a"); + assert_eq!(report.source, "rules"); + assert_eq!(report.exit.code, Some(1)); + // 老会话(没有 `diagnosis` 事件)→ 回落重跑:规则集里应有完整性类结论命中这条 stderr。 + assert!(!report.candidates.is_empty(), "应至少命中一条规则"); + assert_eq!(report.candidates[0].id, "integrity.no_class_def_found"); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn unknown_session_is_a_readable_error() { + let root = temp_root("diagnose-none"); + let port = CoreDiagnose::new(root); + let err = port.diagnose("nope").unwrap_err(); + assert!(err.contains("找不到会话"), "{err}"); +} diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index a2831ef..e33585c 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -7,14 +7,19 @@ //! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容: //! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。 //! -//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。 +//! `diag` 的结论优先取会话里**落盘的那份**,老会话才把脱敏文本喂回 M15 的内嵌规则集 +//! (M242,为什么与代价见 `sessions/diagnosis.rs` 的模块文档)。 + +mod diagnosis; + +/// M242:`ull diag` 的结论来源(落盘那份优先)。拆文件守「单文件 ≤400 行」。 +#[cfg(test)] +#[path = "sessions/m242_tests.rs"] +mod m242_tests; use std::path::Path; use serde_json::{json, Value}; -use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; -use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; -use unlucky_core::diagnose::loader::RuleLoader; use unlucky_core::launch::session_index::{self, SessionIndexEntry}; use crate::cli::{DiagArgs, LogsArgs}; @@ -118,7 +123,12 @@ pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result { )) } -/// `ull diag [--tail N]`:规则引擎诊断。 +/// `ull diag [--tail N]`:给会话下诊断结论。 +/// +/// 结论来源**先是会话里落盘的 `diagnosis` 事件**(M242),没有它的老会话才在脱敏文本上重跑 +/// ——为什么、代价如何见 `sessions/diagnosis.rs` 的模块文档。`--tail` 只影响重跑那条路。 +/// +/// `exit` 仍取自索引(launch 报的事实),不是 M15 的结论:换来源只换结论。 /// /// # Errors /// @@ -127,74 +137,10 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result { let entry = find_entry(ctx, &args.session_id)?; let path = event_path(ctx, &entry); let events = read_events(&path)?; + let found = diagnosis::conclusion(&entry, &events, args.tail)?; - let mut stdout_lines: Vec<&str> = Vec::new(); - let mut stderr_lines: Vec<&str> = Vec::new(); - let mut error_lines: u32 = 0; - for event in &events { - let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { - continue; - }; - match event.get("kind").and_then(Value::as_str) { - Some("stdout") => stdout_lines.push(line), - Some("stderr") => { - stderr_lines.push(line); - if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { - error_lines += 1; - } - } - _ => {} - } - } - let stdout_text = tail_join(&stdout_lines, args.tail); - let stderr_text = tail_join(&stderr_lines, args.tail); - - let exit = ExitSignal::classify( - entry.exit_code, - Some(entry.duration_ms), - error_lines, - &stderr_text, - ); - let mut input = DiagnosisInput::new().with_exit(exit); - if !stdout_text.is_empty() { - input.push_text(SignalOrigin::Stdout, stdout_text); - } - if !stderr_text.is_empty() { - input.push_text(SignalOrigin::Stderr, stderr_text); - } - - let loader = RuleLoader::embedded(); - let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); - let report = diagnoser.analyze(&input); - let record = serde_json::to_value(report.to_record()) - .map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?; - - let mut human = vec![format!( - "会话 {} · {} · {} · {}", - entry.session_id, - entry.instance_name, - entry.game_version, - exit_label(&entry) - )]; - match report.primary() { - Some(finding) => { - human.push(format!("主因: {}", finding.message)); - if let Some(detail) = &finding.detail { - human.push(format!("说明: {detail}")); - } - for evidence in finding.evidence.iter().take(3) { - human.push(format!("证据: {}", evidence.render())); - } - } - None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()), - } - human.push(format!( - "规则评估 {} 条 · 结论 {} 条 · 次生 {} 条", - report.rules_evaluated, - report.findings.len(), - report.secondary.len() - )); - let human: Vec = human + // 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。 + let human: Vec = diagnosis::lines(&entry, &found) .iter() .map(|line| unlucky_agent::redact::redact_text(line)) .collect(); @@ -209,7 +155,8 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result { "silent_failure": entry.silent_failure, "duration_ms": entry.duration_ms, }, - "diagnosis": record, + "conclusion_source": found.provenance.as_str(), + "diagnosis": found.record, })); Ok(Report::new(payload, human)) } @@ -288,15 +235,6 @@ fn read_events(path: &Path) -> Result, CliError> { .collect()) } -fn tail_join(lines: &[&str], tail: usize) -> String { - let start = lines.len().saturating_sub(tail.max(1)); - let mut text = lines[start..].join("\n"); - if !text.is_empty() { - text.push('\n'); - } - text -} - fn render_event(event: &Value) -> String { let ts = event.get("ts").and_then(Value::as_str).unwrap_or(""); let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?"); diff --git a/crates/cli/src/commands/sessions/diagnosis.rs b/crates/cli/src/commands/sessions/diagnosis.rs new file mode 100644 index 0000000..5738822 --- /dev/null +++ b/crates/cli/src/commands/sessions/diagnosis.rs @@ -0,0 +1,240 @@ +//! `ull diag` 的结论来源:**先读会话里落盘的那份**,老会话才重跑规则(M242)。 +//! +//! ## 为什么不能每次都在会话文本上重跑 +//! +//! 会话 JSONL 是**写前脱敏**的(`launch::redact`,落盘即脱敏),而脱敏拿不准形状时是**有损**的: +//! `≥64` 的不透明串、带点的票据串都会被换成占位符(`` / ``)。 +//! M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的那条 +//! `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**:形状一变 +//! (或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」,是 +//! **换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found` +//! (warning:把「Fabric Loader 版本不配套」说成「库没下全」)。 +//! +//! M241 起写会话时已经在**原始行**上算过一次结论(`SessionWriter::finish` → +//! `diagnose::session::conclude`),落成 `diagnosis` 事件;那份结论自己还要再走一遍 +//! `Redactor::scrub_json`,所以盘上只有脱敏后的结论,没有原文。M242 换的就是**证据**, +//! 不是判据:规则集的 pattern 一个字符都没动。 +//! +//! ## 回落 +//! +//! 没有 `diagnosis` 事件的老会话仍走原来的重跑(行为与 M242 之前完全一致):短字面量的规则 +//! (`memory.out_of_memory` 之类)在脱敏文本上照样命中;只有「靠长串证据」那类规则会降级 +//! —— 那是老数据本身的代价,输出里的「结论来源」一行会把它说清。 + +use serde_json::Value; +use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; +use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; +use unlucky_core::diagnose::loader::RuleLoader; +use unlucky_core::launch::session_index::SessionIndexEntry; + +use crate::error::CliError; + +use super::exit_label; + +/// 结论从哪儿来(人类输出与 `--json` 都要说清,否则用户分不清「盘上的」与「刚算的」—— +/// 这两者在长串证据类规则上给出的结论**可能不同**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provenance { + /// 会话里落盘的 `diagnosis` 事件(M241 起,写会话时在原始行上算的)。 + Stored, + /// 老会话:在脱敏文本上重跑规则。 + Rerun, +} + +impl Provenance { + /// `--json` 里的值(`stored` / `rerun`)。 + pub const fn as_str(self) -> &'static str { + match self { + Self::Stored => "stored", + Self::Rerun => "rerun", + } + } + + /// 人类输出里的一行说明。 + pub const fn label(self) -> &'static str { + match self { + Self::Stored => "会话落盘的 diagnosis 事件(写会话时在原始行上算的,已脱敏后落盘)", + Self::Rerun => "在脱敏文本上重跑规则(老会话没有落盘结论,靠长串证据的规则可能降级)", + } + } +} + +/// 一次诊断:结论本体 + 出处。 +#[derive(Debug, Clone)] +pub struct Conclusion { + /// `diagnosis` 记录(`DiagnosisRecord` 的 JSON),两条来源同形。 + pub record: Value, + /// 从哪儿来。 + pub provenance: Provenance, + /// 重跑时评估了多少条规则(落盘那份不记这个数,所以是 `Option`)。 + pub rules_evaluated: Option, +} + +/// 取这个会话的诊断结论:优先落盘的那份,老会话才重跑。 +/// +/// # Errors +/// +/// 重跑路径上结论序列化失败时返回内部错误。 +pub fn conclusion( + entry: &SessionIndexEntry, + events: &[Value], + tail: usize, +) -> Result { + if let Some(record) = stored(events) { + return Ok(Conclusion { + record, + provenance: Provenance::Stored, + rules_evaluated: None, + }); + } + let (record, rules_evaluated) = rerun(entry, events, tail)?; + Ok(Conclusion { + record, + provenance: Provenance::Rerun, + rules_evaluated: Some(rules_evaluated), + }) +} + +/// 事件流里最后一条 `diagnosis` 事件的 payload。 +/// +/// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。 +/// 没有主因(`candidates` 空)的 payload 与「没结论」等价——`conclude` 也不会写它——此时 +/// 回落到重跑,而不是拿一份空结论顶掉。 +fn stored(events: &[Value]) -> Option { + let payload = events + .iter() + .rev() + .find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))? + .get("payload")?; + let has_primary = payload + .get("candidates") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()); + has_primary.then(|| payload.clone()) +} + +/// 老会话的回落:在脱敏文本上重跑规则,返回 `(结论记录, 评估条数)`。 +fn rerun( + entry: &SessionIndexEntry, + events: &[Value], + tail: usize, +) -> Result<(Value, usize), CliError> { + let mut stdout_lines: Vec<&str> = Vec::new(); + let mut stderr_lines: Vec<&str> = Vec::new(); + let mut error_lines: u32 = 0; + for event in events { + let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { + continue; + }; + match event.get("kind").and_then(Value::as_str) { + Some("stdout") => stdout_lines.push(line), + Some("stderr") => { + stderr_lines.push(line); + if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { + error_lines += 1; + } + } + _ => {} + } + } + let stdout_text = tail_join(&stdout_lines, tail); + let stderr_text = tail_join(&stderr_lines, tail); + + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr_text, + ); + let mut input = DiagnosisInput::new().with_exit(exit); + if !stdout_text.is_empty() { + input.push_text(SignalOrigin::Stdout, stdout_text); + } + if !stderr_text.is_empty() { + input.push_text(SignalOrigin::Stderr, stderr_text); + } + + let loader = RuleLoader::embedded(); + let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); + let report = diagnoser.analyze(&input); + let record = serde_json::to_value(report.to_record()) + .map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?; + Ok((record, report.rules_evaluated)) +} + +/// 人类可读的几行(未脱敏——调用方统一过一次 `redact_text`)。 +pub fn lines(entry: &SessionIndexEntry, found: &Conclusion) -> Vec { + let mut human = vec![ + format!( + "会话 {} · {} · {} · {}", + entry.session_id, + entry.instance_name, + entry.game_version, + exit_label(entry) + ), + format!("结论来源: {}", found.provenance.label()), + ]; + match found.record.get("candidates").and_then(Value::as_array) { + Some(items) if !items.is_empty() => { + let primary = &items[0]; + if let Some(message) = text_at(primary, "message") { + human.push(format!("主因: {message}")); + } + if let Some(detail) = text_at(primary, "detail") { + human.push(format!("说明: {detail}")); + } + for evidence in evidence_of(primary, 3) { + human.push(format!("证据: {evidence}")); + } + } + _ => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()), + } + human.push(counts_line(&found.record, found.rules_evaluated)); + human +} + +/// `规则评估 N 条 · 结论 N 条 · 次生 N 条`(落盘那份没有「评估条数」,省掉第一段)。 +fn counts_line(record: &Value, rules_evaluated: Option) -> String { + let count = |key: &str| { + record + .get(key) + .and_then(Value::as_array) + .map_or(0, Vec::len) + }; + let mut line = String::new(); + if let Some(evaluated) = rules_evaluated { + line.push_str(&format!("规则评估 {evaluated} 条 · ")); + } + line.push_str(&format!( + "结论 {} 条 · 次生 {} 条", + count("candidates"), + count("secondary") + )); + line +} + +fn text_at<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn evidence_of(candidate: &Value, limit: usize) -> Vec { + candidate + .get("evidence") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .take(limit) + .map(str::to_string) + .collect() +} + +/// 取末尾 `tail` 行并拼成文本(与重跑路径的老行为逐字一致)。 +fn tail_join(lines: &[&str], tail: usize) -> String { + let start = lines.len().saturating_sub(tail.max(1)); + let mut text = lines[start..].join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} diff --git a/crates/cli/src/commands/sessions/m242_tests.rs b/crates/cli/src/commands/sessions/m242_tests.rs new file mode 100644 index 0000000..ea62bb2 --- /dev/null +++ b/crates/cli/src/commands/sessions/m242_tests.rs @@ -0,0 +1,221 @@ +//! M242:`ull diag ` 的结论来源。两条端到端用例:**真 `SessionWriter` 写盘 → 真 CLI 入口**。 +//! +//! 判据是「命中哪条规则」而不是「有没有结论」:脱敏有损,重跑出来的结论会**换一条**规则 +//! (`fabric.loader_0_12` → `integrity.no_class_def_found`)。 +//! +//! ## 夹具为什么把类路径登记成 secret +//! +//! M240 把「`≥64` 不透明串」这条收窄了:**路径形状**的长串(被 `/` 切开且每段都是词形名字) +//! 现在整条保留,所以 `SessionWriter` 落盘时这条证据行**今天不会被抹**。要复现「读回脱敏文本 +//! 就丢了证据」这个世界(M239 实测的那份老落盘形状,也是脱敏规则以后再收紧时的形状),夹具 +//! 把类路径登记成 secret 交给 `Redactor`:写盘前它被抹成 ``,而结论是 +//! 在**原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。 +//! +//! 变异证明(改完已还原): +//! - 把 [`super::diagnosis::conclusion`] 里的 `stored(...)` 分支删掉(等于每次都重跑)⇒ +//! `diag_reads_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use unlucky_core::launch::exit_analysis::{self, ExitSignals}; +use unlucky_core::launch::pump::{LogLine, Stream}; +use unlucky_core::launch::redact::Redactor; +use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter}; +use unlucky_core::launch::session_index; + +use crate::cli::Globals; +use crate::context::Ctx; + +/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。 +const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy"; + +/// 那条完整的证据行。 +fn evidence_line() -> String { + format!("java.lang.NoClassDefFoundError: {CLASS_PATH}") +} + +/// 一个临时数据根(`Drop` 时清掉,与 `crates/cli/tests/support` 同口径)。 +struct TempRoot(PathBuf); + +impl TempRoot { + fn new(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时数据根"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 与生产同一条解析(`--data-dir` → `Ctx`):会话根不在测试里手拼,避免「夹具说另一种形状」。 +fn ctx_for(root: &Path) -> Ctx { + let globals = Globals { + data_dir: Some(root.to_path_buf()), + ..Globals::default() + }; + Ctx::resolve_in(&globals, None).expect("解析数据根") +} + +/// 跑一次真 CLI 命令(`crate::run` = 解析 + `Ctx` + 命令实现),返回 `--json` 载荷的 `data`。 +fn run_diag_json(root: &Path, session_id: &str) -> Value { + let args: Vec = vec![ + "diag".to_string(), + session_id.to_string(), + "--json".to_string(), + "--data-dir".to_string(), + root.display().to_string(), + ]; + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = crate::run(&args, &mut out, &mut err); + let stdout = String::from_utf8_lossy(&out).into_owned(); + let stderr = String::from_utf8_lossy(&err).into_owned(); + assert_eq!(code, 0, "`ull diag` 应成功:{stderr}\n{stdout}"); + let value: Value = serde_json::from_str(&stdout).unwrap_or_else(|error| { + panic!("`--json` 输出不是 JSON:{error}\n{stdout}"); + }); + value["data"].clone() +} + +/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论 → 索引 + JSONL。 +fn write_real_session(root: &Path) -> String { + let session_id = "20261102T090000-m242abc"; + let mut writer = SessionWriter::create_with_id( + &ctx_for(root).sessions_root(), + session_id, + SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"), + // 登记成 secret ⇒ 盘上那条证据行的类路径被抹掉(见模块文档:为什么要人为造这份失真)。 + Redactor::new().with_secret(CLASS_PATH), + ) + .expect("建会话"); + writer.session_start().expect("session_start"); + let lines = vec![ + LogLine::new(Stream::Stderr, "Failed to start Fabric loader"), + LogLine::new(Stream::Stderr, evidence_line()), + LogLine::new(Stream::Stdout, "[main/INFO]: Loading Minecraft 1.20.1"), + ]; + for line in &lines { + writer.line(line).expect("写行"); + } + let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines)); + writer.exit(&analysis).expect("exit"); + writer.finish(EndReason::Exit).expect("finish"); + session_id.to_string() +} + +/// 落盘的会话文件(诊断事件也在这份里)。 +fn event_file(root: &Path, session_id: &str) -> PathBuf { + session_index::event_file( + &ctx_for(root).sessions_root(), + &session_index::log_path_for(session_id), + ) +} + +/// 主判据:结论是**盘上那份**。只有读到 `diagnosis` 事件才能命中 `fabric.loader_0_12`—— +/// 在脱敏文本上重跑永远命中不了它(证据行已被抹成占位符)。 +#[test] +fn diag_reads_the_stored_conclusion_instead_of_rerunning_redacted_text() { + let temp = TempRoot::new("diag-stored"); + let root = temp.path(); + let session_id = write_real_session(root); + + let data = run_diag_json(root, &session_id); + assert_eq!(data["session_id"], session_id.as_str()); + assert_eq!( + data["conclusion_source"], "stored", + "有 diagnosis 事件时不得重跑:{data}" + ); + assert_eq!( + data["diagnosis"]["candidates"][0]["id"], "fabric.loader_0_12", + "落盘的结论必须原样读出(重跑会降级成 integrity.no_class_def_found):{data}" + ); + assert_eq!(data["diagnosis"]["needs_more_info"], false); + + // 盘上那份**真的**丢了证据(不然这条用例证明不了「重跑会失真」),而修 bug ≠ 把原文写进盘。 + let disk = std::fs::read_to_string(event_file(root, &session_id)).expect("读会话文件"); + assert!( + disk.contains(""), + "夹具前提:盘上的证据行必须已被抹掉:{disk}" + ); + assert!(!disk.contains("FabricMixinTransformerProxy"), "{disk}"); + + // 人类路径也要说清结论从哪来(否则「盘上的」与「刚算的」分不开)。 + let args: Vec = vec![ + "diag".to_string(), + session_id.clone(), + "--data-dir".to_string(), + root.display().to_string(), + ]; + let (mut out, mut err) = (Vec::new(), Vec::new()); + assert_eq!(crate::run(&args, &mut out, &mut err), 0); + let human = String::from_utf8_lossy(&out); + assert!( + human.contains("结论来源: 会话落盘的 diagnosis 事件"), + "{human}" + ); + assert!(human.contains("主因: "), "{human}"); +} + +/// 老会话兼容:没有 `diagnosis` 事件(M241 之前写的 JSONL,且还在旧家)→ 回落重跑仍然可用。 +/// +/// 与上一条的差别只在**证据**:这里盘上只有脱敏文本,所以用的是短字面量规则 +/// `memory.out_of_memory`(脱敏抹不掉 `java.lang.OutOfMemoryError`)。长串证据那类规则 +/// 在老会话上只能降级,这是老数据本身的代价——`conclusion_source` 会标成 `rerun`。 +#[test] +fn legacy_session_without_a_stored_conclusion_falls_back_to_rerunning() { + let temp = TempRoot::new("diag-legacy"); + let root = temp.path(); + let session_id = "20261030T101500-legacy"; + let log_path = session_index::log_path_for(session_id); + // M188 之前的生产落点(`data/agent/sessions`,只读兼容):老会话的「老」还多一层。 + let legacy_root = ctx_for(root).legacy_sessions_root(); + let index = session_index::index_path(&legacy_root); + std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": session_id, + "instance_id": "inst-1", + "instance_name": "1.20.1", + "game_version": "1.20.1", + "started_at": "2026-10-30T10:15:00Z", + "ended_at": "2026-10-30T10:15:06Z", + "duration_ms": 5000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 2, + "log_path": log_path, + "crash_found": false, + "diagnosis_top": null + }); + std::fs::write(&index, format!("{entry}\n")).expect("写索引"); + let file = session_index::event_file(&legacy_root, &log_path); + std::fs::write( + &file, + concat!( + "{\"v\":1,\"seq\":0,\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.OutOfMemoryError: Java heap space\",\"level\":\"error\"}}\n", + "{\"v\":1,\"seq\":1,\"kind\":\"session_end\",\"payload\":{\"reason\":\"exit\"}}\n" + ), + ) + .expect("写老会话事件流"); + + let data = run_diag_json(root, session_id); + assert_eq!( + data["conclusion_source"], "rerun", + "没有 diagnosis 事件时必须回落重跑:{data}" + ); + assert_eq!( + data["diagnosis"]["candidates"][0]["id"], "memory.out_of_memory", + "回落路径仍要给出结论:{data}" + ); +}