feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.ULL/logs/launcher.log) - presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme]) 当场刷盘,不再是「只有开过一局游戏才有日志」。 - app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope), 含 boot/boot::fallback/state/update 各线。 - boot 那一拍补一行后端事实(request/effective/fallback)。 - 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。 会话日志搬家:data/agent/sessions → logs - LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。 - host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。 - 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。 入口与文案 - 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。 - 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。 - README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有), docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。 守门(每条都有变异证明,见报告) - 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。 - launcher.log 滚动 ⇒ 干掉 rename 变红。 - 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。 - 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
This commit is contained in:
1 parent
efc663a3cb
commit
5fd5cd47dc
47 files changed
+1371
-119
No files matched your search
@@ -4,6 +4,9 @@
|
||||
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
|
||||
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
|
||||
//!
|
||||
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
|
||||
//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。
|
||||
//!
|
||||
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
|
||||
|
||||
use std::path::Path;
|
||||
@@ -13,6 +16,7 @@ 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 unlucky_core::platform::layout::GAME_LOG_DIR;
|
||||
|
||||
use crate::cli::{DiagArgs, LogsArgs};
|
||||
use crate::context::Ctx;
|
||||
@@ -26,8 +30,7 @@ use crate::output::Report;
|
||||
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
|
||||
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 entries = merged_entries(ctx)?;
|
||||
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
|
||||
|
||||
let rows: Vec<Value> = shown
|
||||
@@ -56,6 +59,15 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
|
||||
shown.len(),
|
||||
index.display()
|
||||
)];
|
||||
// M188:旧家的会话仍然列得出来,就得说清它们从哪来——否则用户会以为「新目录里怎么没有」。
|
||||
let legacy = ctx.legacy_session_index();
|
||||
if legacy.is_file() {
|
||||
human.push(format!(
|
||||
" 旧会话目录仍然只读兼容:{}(不搬不删)",
|
||||
legacy.display()
|
||||
));
|
||||
}
|
||||
human.push(format!(" 启动器日志:{}", ctx.logs_dir().display()));
|
||||
if shown.is_empty() {
|
||||
human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
|
||||
}
|
||||
@@ -205,8 +217,7 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
||||
|
||||
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))?;
|
||||
let entries = merged_entries(ctx)?;
|
||||
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
@@ -228,7 +239,48 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
|
||||
}
|
||||
|
||||
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
|
||||
ctx.sessions_root().join("game").join(&entry.log_path)
|
||||
// M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。
|
||||
// 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。
|
||||
let primary = ctx
|
||||
.sessions_root()
|
||||
.join(GAME_LOG_DIR)
|
||||
.join(&entry.log_path);
|
||||
if primary.is_file() {
|
||||
return primary;
|
||||
}
|
||||
let legacy = ctx
|
||||
.legacy_sessions_root()
|
||||
.join(GAME_LOG_DIR)
|
||||
.join(&entry.log_path);
|
||||
if legacy.is_file() {
|
||||
return legacy;
|
||||
}
|
||||
primary
|
||||
}
|
||||
|
||||
/// 两个索引合并后的条目(新根 `logs/` 在前,旧家只读兼容在后;同 id 以新根为准)。
|
||||
fn merged_entries(ctx: &Ctx) -> Result<Vec<SessionIndexEntry>, CliError> {
|
||||
let mut indexes = vec![ctx.session_index()];
|
||||
let legacy = ctx.legacy_session_index();
|
||||
if legacy.is_file() {
|
||||
indexes.push(legacy);
|
||||
}
|
||||
let mut merged: Vec<SessionIndexEntry> = Vec::new();
|
||||
for index in indexes {
|
||||
for entry in session_index::read_all(&index)
|
||||
.map_err(|error| CliError::io("读会话索引", &index, &error))?
|
||||
{
|
||||
if merged
|
||||
.iter()
|
||||
.any(|item| item.session_id == entry.session_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
merged.sort_by(|left, right| right.started_at.cmp(&left.started_at));
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
|
||||
|
||||
Reference in new issue
Block a user