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
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
188 lines
6.8 KiB
Rust
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}");
|
|
}
|
|
}
|