Files
Unlucky-Launcher/crates/cli/src/commands/agent/ports/diagnose.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

188 lines
6.8 KiB
Rust

//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
//!
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
use std::collections::BTreeMap;
use unlucky_agent::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
};
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 super::events::{tail_join, SessionSource};
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// M15 规则引擎的端口实现。
#[derive(Debug, Clone)]
pub struct CoreDiagnose {
source: SessionSource,
tail: usize,
}
impl CoreDiagnose {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
tail: DEFAULT_TAIL,
}
}
}
fn map_kind(kind: CoreFixKind) -> FixActionKind {
match kind {
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
CoreFixKind::Reinstall => FixActionKind::Reinstall,
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
}
}
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
let core_candidate = finding.to_candidate();
DiagnosisCandidate {
id: core_candidate.id,
severity: core_candidate.severity.to_string(),
category: core_candidate.category.to_string(),
confidence: core_candidate.confidence,
message_key: core_candidate.message_key,
message: core_candidate.message,
detail: core_candidate.detail.unwrap_or_default(),
evidence: core_candidate.evidence,
fix_actions: core_candidate
.fix_actions
.into_iter()
.map(|action| FixAction {
kind: map_kind(action.kind),
tool: action.tool.to_string(),
label: String::new(),
args: action
.args
.into_iter()
.collect::<BTreeMap<String, String>>(),
})
.collect(),
}
}
impl DiagnosePort for CoreDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
let entry = self.source.find(session_id)?;
let events = self.source.events(&entry)?;
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr.join("\n"),
);
let mut input = DiagnosisInput::new().with_exit(exit);
let stdout_text = tail_join(&stdout, self.tail);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
let stderr_text = tail_join(&stderr, self.tail);
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);
Ok(CrashDiagnosis {
session_id: entry.session_id.clone(),
source: "rules".to_string(),
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(),
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn seed(root: &std::path::Path, stderr_line: &str) {
let game = root.join("game");
std::fs::create_dir_all(&game).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:45:05.123Z",
"duration_ms": 4000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 1,
"log_path": "20261029T124501-9f3c2a.jsonl",
"crash_found": true,
"diagnosis_top": null
});
std::fs::write(game.join("index.jsonl"), 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(
game.join("20261029T124501-9f3c2a.jsonl"),
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}");
}
}