Files
Unlucky-Launcher/crates/cli/src/commands/sessions.rs
T
AskaEth 2f8bbd1746 fix(cli): 程序名只替换模板占位符,不再改写用户数据(M82 r2 复审 P2)
r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。

改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。

同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。

回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。

占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
2026-11-02 08:08:13 +08:00

293 lines
10 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.
//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。
//!
//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5:
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
//!
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
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};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull sessions list [--limit N]`。
///
/// # Errors
///
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
let rows: Vec<Value> = shown
.iter()
.map(|entry| {
json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"instance_name": entry.instance_name,
"game_version": entry.game_version,
"started_at": entry.started_at,
"ended_at": entry.ended_at,
"duration_ms": entry.duration_ms,
"exit_code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"window_detected": entry.window_detected,
"crash_found": entry.crash_found,
"diagnosis_top": entry.diagnosis_top,
})
})
.collect();
let mut human = vec![format!(
"会话 {} 条(索引 {})",
shown.len(),
index.display()
)];
if shown.is_empty() {
human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
}
for entry in &shown {
human.push(format!(
" {} {} {} {} {}",
entry.session_id,
entry.instance_name,
entry.game_version,
entry.started_at,
exit_label(entry),
));
}
Ok(Report::new(json!({ "sessions": rows }), human))
}
/// `ull logs <sid> [--tail N]`:打印事件流末尾 N 条。
///
/// # Errors
///
/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。
pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
let start = events.len().saturating_sub(args.tail.max(1));
let shown = &events[start..];
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
let shown: Vec<Value> = shown.iter().map(redact_value).collect();
let mut human = vec![format!(
"会话 {} · {} · 事件 {} 条(显示末尾 {})",
entry.session_id,
entry.instance_name,
events.len(),
shown.len()
)];
human.extend(shown.iter().map(render_event));
Ok(Report::new(
json!({
"session_id": entry.session_id,
"path": path.display().to_string(),
"total": events.len(),
"events": shown,
}),
human,
))
}
/// `ull diag <sid> [--tail N]`:规则引擎诊断。
///
/// # Errors
///
/// 会话不存在、事件流读不了、结论序列化失败时返回错误。
pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
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<String> = human
.iter()
.map(|line| unlucky_agent::redact::redact_text(line))
.collect();
let payload = redact_value(&json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"game_version": entry.game_version,
"exit": {
"code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"duration_ms": entry.duration_ms,
},
"diagnosis": record,
}));
Ok(Report::new(payload, human))
}
fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
let matches: Vec<&SessionIndexEntry> = entries
.iter()
.filter(|entry| entry.session_id.starts_with(key))
.collect();
match matches.as_slice() {
[only] => Ok((*only).clone()),
[] => Err(
CliError::user("session_not_found", format!("找不到会话 `{key}`"))
.with_hint(format!("索引文件:{}", index.display())),
),
_ => Err(CliError::user(
"session_ambiguous",
format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()),
)),
}
}
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
ctx.sessions_root().join("game").join(&entry.log_path)
}
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
let text = std::fs::read_to_string(path)
.map_err(|error| CliError::io("读会话事件流", path, &error))?;
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.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("?");
let detail = event
.pointer("/payload/line")
.and_then(Value::as_str)
.or_else(|| event.pointer("/payload/summary").and_then(Value::as_str))
.unwrap_or_default();
format!("[{ts}] {kind} {detail}")
}
/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。
fn redact_value(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)),
Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), redact_value(child)))
.collect(),
),
other => other.clone(),
}
}
fn exit_label(entry: &SessionIndexEntry) -> String {
let code = entry
.exit_code
.map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}"));
let exit_type = entry
.exit_type
.clone()
.unwrap_or_else(|| "unknown".to_string());
let silent = if entry.silent_failure {
" · 静默失败"
} else {
""
};
format!("{code}/{exit_type}{silent}")
}