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
@@ -65,9 +65,17 @@ impl HostEnv {
|
||||
}
|
||||
|
||||
/// 由解析结果建环境。
|
||||
///
|
||||
/// **顺带做一件有副作用的事**:把本进程的日志目录登记给 `launch::launcher_log`(M188)。
|
||||
/// 这是**启动早期那几行诊断**能落到 `<.ULL>/logs/launcher.log` 的唯一时机——app 层在
|
||||
/// presenter 打开之前就在打 `[probe]` / `[font]` / `[theme]`,那些行先被 core 缓冲
|
||||
/// (上限 200 行),到这里一次刷出。此前只有「启动一次游戏」或「导出诊断包」才登记,
|
||||
/// 于是「双击 → 启动 → 崩」的进程根本不产生 launcher.log——正是用户投诉的那一半。
|
||||
pub fn from_data(data: ResolvedData) -> Self {
|
||||
let layout = data.layout();
|
||||
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
||||
Self {
|
||||
layout: data.layout(),
|
||||
layout,
|
||||
game: GameLayout::new(data.game_home.clone()),
|
||||
platform: Platform::current(),
|
||||
data,
|
||||
@@ -84,11 +92,21 @@ impl HostEnv {
|
||||
SettingStore::load(self.launcher_config(), launcher_schema())
|
||||
}
|
||||
|
||||
/// 启动会话根(`.ULL/data/agent/sessions`)。
|
||||
/// 启动会话根(M188 起 = `logs/`;`LauncherConfig` 在其下建 `game/`)。
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.layout.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话日志的**旧家**(`data/agent/sessions/`):只读兼容,M188 起不再往里写。
|
||||
pub fn legacy_sessions_root(&self) -> PathBuf {
|
||||
self.layout.agent_sessions()
|
||||
}
|
||||
|
||||
/// 启动器日志目录(`logs/`:`launcher.log` + `game/` 会话)。
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.layout.logs()
|
||||
}
|
||||
|
||||
/// 数据目录模式。
|
||||
pub fn mode(&self) -> DataMode {
|
||||
self.data.mode
|
||||
@@ -137,14 +155,133 @@ pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn at_root_splits_launcher_and_game_homes() {
|
||||
let env = HostEnv::at_root("D:/tmp/ull-demo");
|
||||
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
|
||||
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-root-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.layout.home(), root.join(".ULL"));
|
||||
assert_eq!(env.game.root(), root.join(".minecraft"));
|
||||
assert_eq!(env.mode(), DataMode::Portable);
|
||||
assert!(env.data_line().contains("portable"));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
||||
///
|
||||
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
||||
///
|
||||
/// | 模式 | 期望的 `<home>` | 期望的日志根 |
|
||||
/// |---|---|---|
|
||||
/// | portable | `<exe>/.ULL` | `<exe>/.ULL/logs` |
|
||||
/// | standard | `%APPDATA%/ULL` | `%APPDATA%/ULL/logs` |
|
||||
/// | custom | `<root>/.ULL` | `<root>/.ULL/logs` |
|
||||
///
|
||||
/// 这里只碰解析结果、不建 `HostEnv`:`from_data` 会真的去登记日志目录(那是产品行为,
|
||||
/// 单测里拿假绝对路径调它会在盘根建目录)。
|
||||
/// 变异证明:把 `LauncherLayout::logs` 改成返回 `cache/`,三条全红。
|
||||
#[test]
|
||||
fn logs_root_follows_the_three_data_modes() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let exe_dir = PathBuf::from("/games/ULL");
|
||||
// custom 模式的根必须是**绝对路径**(Windows 上 `/custom-root` 不算绝对,要带盘符)。
|
||||
let custom_root = std::env::temp_dir().join("ull-m188-custom");
|
||||
let resolver = Resolver::with_env(Platform::current(), Some(exe_dir.clone()), &env);
|
||||
|
||||
let cases = [
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
exe_dir.join(".ULL/logs"),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Standard,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
PathBuf::new(),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(custom_root.clone()),
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
custom_root.join(".ULL/logs"),
|
||||
),
|
||||
];
|
||||
|
||||
for (request, expected) in cases {
|
||||
let Ok(data) = resolver.resolve(&request) else {
|
||||
// 标准模式在某些平台拿不到(没有 HOME/APPDATA):这一档只能跳过,不许假绿。
|
||||
assert_eq!(request.mode, DataMode::Standard, "{request:?}");
|
||||
continue;
|
||||
};
|
||||
let home = data.launcher_home.clone();
|
||||
let layout = data.layout();
|
||||
assert_eq!(layout.logs(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.game_logs(), home.join("logs/game"), "{request:?}");
|
||||
assert_eq!(
|
||||
layout.agent_sessions(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
if !expected.as_os_str().is_empty() {
|
||||
assert_eq!(layout.logs(), expected, "{request:?}");
|
||||
}
|
||||
// 自定义模式的根在临时目录里,顺手把 `HostEnv` 那三个访问器也钉一遍——
|
||||
// 「layout 对了但 `HostEnv::logs_dir` 写死」这种假绿只有这一段抓得住。
|
||||
if request.mode == DataMode::Custom {
|
||||
let env = HostEnv::from_data(data);
|
||||
assert_eq!(env.logs_dir(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(env.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种模式的日志根**各不相同**(防止「都指向同一个写死的目录」这种假绿)。
|
||||
#[test]
|
||||
fn the_three_modes_do_not_share_one_logs_root() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let resolver =
|
||||
Resolver::with_env(Platform::current(), Some(PathBuf::from("/games/ULL")), &env);
|
||||
let portable = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("portable 一定解析得出");
|
||||
let custom = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(std::env::temp_dir().join("ull-m188-custom")),
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("custom 有绝对路径");
|
||||
assert_ne!(portable.layout().logs(), custom.layout().logs());
|
||||
}
|
||||
|
||||
/// `HostEnv` 自己的三格(日志根 / 会话根 / 旧会话根)指着同一份解析结果。
|
||||
#[test]
|
||||
fn env_paths_come_from_one_layout() {
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-paths-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.logs_dir(), root.join(".ULL/logs"));
|
||||
assert_eq!(env.sessions_root(), root.join(".ULL/logs"));
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
root.join(".ULL/data/agent/sessions")
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,6 @@ use unlucky_core::game::install::instance::{
|
||||
};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::{ProfileList, VersionJson};
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
use crate::dto::{
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||
@@ -34,10 +33,9 @@ pub fn profiles(env: &HostEnv) -> ProfileList {
|
||||
read_profiles(&env.layout).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串。
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串(`logs/` + 旧家合并)。
|
||||
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
||||
let index = session_index::index_path(&env.sessions_root());
|
||||
let entries = session_index::read_all(&index).unwrap_or_default();
|
||||
let entries = super::logs::entries(env);
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
|
||||
|
||||
@@ -142,8 +142,8 @@ impl CoreHost {
|
||||
if self.tail.session().is_none() {
|
||||
self.tail.attach(
|
||||
self.env
|
||||
.sessions_root()
|
||||
.join("game")
|
||||
.layout
|
||||
.game_logs()
|
||||
.join(format!("{session_id}.jsonl")),
|
||||
&session_id,
|
||||
);
|
||||
|
||||
@@ -219,6 +219,8 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let tx_progress = tx.clone();
|
||||
super::job::spawn("launch", move || {
|
||||
// M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。
|
||||
// M188 起 presenter 在构造 `HostEnv` 时已经登记过目录(缓冲那时就刷出去了),这里是
|
||||
// 幂等的一次兜底:数据根被 `redirect.json` 改过时以最后一次为准。
|
||||
let logs_dir = env.layout.logs();
|
||||
launcher_log::set_dir(&logs_dir);
|
||||
let started_at = std::time::SystemTime::now();
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
|
||||
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
|
||||
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
|
||||
//!
|
||||
//! ## 会话日志在哪(M188)
|
||||
//!
|
||||
//! | 位置 | 角色 |
|
||||
//! |---|---|
|
||||
//! | `<.ULL>/logs/game/<session-id>.jsonl` + `index.jsonl` | **新会话写这里** |
|
||||
//! | `<.ULL>/data/agent/sessions/game/…` | 旧家:**只读兼容**,不搬不删 |
|
||||
//!
|
||||
//! 两个根的合并、落点记录都在 [`location`] 里;本文件只做「读出来 → DTO」的翻译。
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,7 +20,8 @@ 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::{self, SessionIndexEntry};
|
||||
use unlucky_core::launch::session_index::SessionIndexEntry;
|
||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
||||
|
||||
use crate::dto::{
|
||||
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||
@@ -19,25 +29,17 @@ use crate::dto::{
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
#[path = "logs/location.rs"]
|
||||
mod location;
|
||||
|
||||
pub use location::{entries, legacy_root, note_location, root, sessions};
|
||||
|
||||
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
|
||||
pub const DEFAULT_TAIL: usize = 2000;
|
||||
|
||||
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
|
||||
pub const MAX_RENDERED_LINES: usize = 2_000;
|
||||
|
||||
/// 会话索引目录(`.ULL/data/agent/sessions`)。
|
||||
pub fn root(env: &HostEnv) -> PathBuf {
|
||||
env.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话列表(新→旧)。
|
||||
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let mut entries = session_index::read_all(&index).unwrap_or_default();
|
||||
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
entries.iter().map(session_of).collect()
|
||||
}
|
||||
|
||||
/// 索引条目 → 左栏会话行。
|
||||
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
|
||||
LogSession {
|
||||
@@ -78,15 +80,26 @@ pub fn display_time(started_at: &str) -> String {
|
||||
format!("{date} {time}")
|
||||
}
|
||||
|
||||
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
|
||||
/// 事件流文件路径(`<logs>/game/<log_path>`;新根没有时回落到旧家)。
|
||||
///
|
||||
/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。
|
||||
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
|
||||
root(env).join("game").join(&entry.log_path)
|
||||
let primary = root(env).join(GAME_LOG_DIR).join(&entry.log_path);
|
||||
if primary.is_file() {
|
||||
return primary;
|
||||
}
|
||||
if let Some(legacy) = legacy_root(env) {
|
||||
let fallback = legacy.join(GAME_LOG_DIR).join(&entry.log_path);
|
||||
if fallback.is_file() {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
primary
|
||||
}
|
||||
|
||||
/// 会话 id 或唯一前缀 → 索引条目。
|
||||
/// 会话 id 或唯一前缀 → 索引条目(两个根的合并视图)。
|
||||
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let entries = session_index::read_all(&index).ok()?;
|
||||
let entries = entries(env);
|
||||
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Some(exact.clone());
|
||||
}
|
||||
@@ -328,13 +341,15 @@ impl Tail {
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
|
||||
/// 会话是否已收尾(任一索引里出现 `ended_at` 或文件停止增长超过一拍)。
|
||||
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
|
||||
let index = session_index::index_path(&root(env));
|
||||
session_index::read_all(&index)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
|
||||
location::roots(env).into_iter().any(|dir| {
|
||||
let index = unlucky_core::launch::session_index::index_path(&dir);
|
||||
unlucky_core::launch::session_index::read_all(&index)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 会话日志的**落点**:新家 `logs/`、旧家 `data/agent/sessions/`,以及两者合并的读法(M188)。
|
||||
//!
|
||||
//! ## 为什么要搬
|
||||
//!
|
||||
//! 旧落点是 `<.ULL>/data/agent/sessions/game/`:目录名里的 `agent` 是历史遗留(它从来只装**游戏**
|
||||
//! 会话,与 AI 无关),而 `data/` 分区是「可重建但优先保留」的状态数据。用户投诉原话是「我们的
|
||||
//! 启动日志放在哪?…他找半天没找到崩溃日志」——把日志放在 `data/agent/…` 底下,找不着是必然的。
|
||||
//!
|
||||
//! 新落点 `<.ULL>/logs/game/`:与启动器自己的 `launcher.log` 同一个分区、同一个顶层目录,
|
||||
//! 「打开日志文件夹」一次就能看见两者。
|
||||
//!
|
||||
//! ## 旧数据怎么办:**不搬**
|
||||
//!
|
||||
//! 搬动用户已有数据是不可逆动作(中途失败留半份会话、崩溃弹窗的会话关联会断),收益只是目录好看。
|
||||
//! 所以旧目录**原样留着、继续被读**:本模块的 [`entries`] 是两个根的合并视图,同 id 以新根为准。
|
||||
//! 「我们没动你的数据」这件事写进 [`note_location`] 的记录里,事后可查。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
||||
|
||||
use crate::dto::LogSession;
|
||||
|
||||
use super::{session_of, HostEnv};
|
||||
|
||||
/// 会话日志落点记录的文件名(`<logs>/session-log-location.json`,见 [`note_location`])。
|
||||
pub const SESSION_LOCATION_FILE: &str = "session-log-location.json";
|
||||
|
||||
/// 会话根(**新会话写在这里**:`logs/`;`launch::LauncherConfig` 在其下建 `game/`)。
|
||||
pub fn root(env: &HostEnv) -> PathBuf {
|
||||
env.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话日志的**旧家**(`data/agent/sessions/`):M188 起只读兼容。
|
||||
///
|
||||
/// 目录不存在时给 `None`——「兼容」不等于「凭空造一个旧目录出来」。
|
||||
pub fn legacy_root(env: &HostEnv) -> Option<PathBuf> {
|
||||
let dir = env.legacy_sessions_root();
|
||||
dir.is_dir().then_some(dir)
|
||||
}
|
||||
|
||||
/// 读会话要看的根,**新根在前**(同 id 撞车时新根赢)。
|
||||
pub(super) fn roots(env: &HostEnv) -> Vec<PathBuf> {
|
||||
let mut roots = vec![root(env)];
|
||||
roots.extend(legacy_root(env));
|
||||
roots
|
||||
}
|
||||
|
||||
/// 全部索引条目(两个根合并;同 id 以**先出现的**为准,即 `logs/` 那条)。
|
||||
///
|
||||
/// 为什么两个根都读:用户已有的会话在 `data/agent/sessions/` 下,M188 只改「新会话写到哪」,
|
||||
/// 不改「旧会话还能不能看」——否则崩溃弹窗里的会话关联会突然断掉。
|
||||
pub fn entries(env: &HostEnv) -> Vec<SessionIndexEntry> {
|
||||
let mut merged: Vec<SessionIndexEntry> = Vec::new();
|
||||
for dir in roots(env) {
|
||||
let index = session_index::index_path(&dir);
|
||||
for entry in session_index::read_all(&index).unwrap_or_default() {
|
||||
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));
|
||||
merged
|
||||
}
|
||||
|
||||
/// 会话列表(新→旧;`logs/` 与旧家合并)。
|
||||
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
|
||||
entries(env).iter().map(session_of).collect()
|
||||
}
|
||||
|
||||
/// 会话日志「换了个家」的记录:迁了什么、什么时候、旧路径还在不在。
|
||||
///
|
||||
/// ```json
|
||||
/// {"v":1,"at":"…","sessions_root":"<home>/logs","legacy_root":"<home>/data/agent/sessions",
|
||||
/// "legacy_present":true,"legacy_files":3,"moved":0,"policy":"read-only-compat"}
|
||||
/// ```
|
||||
///
|
||||
/// best-effort:写不进去就算了(日志目录不可写时不该让启动器起不来)。
|
||||
pub fn note_location(env: &HostEnv) -> Option<PathBuf> {
|
||||
let legacy = env.legacy_sessions_root();
|
||||
let legacy_present = legacy.is_dir();
|
||||
let legacy_files = std::fs::read_dir(legacy.join(GAME_LOG_DIR))
|
||||
.map(|items| items.filter_map(Result::ok).count())
|
||||
.unwrap_or(0);
|
||||
let body = serde_json::json!({
|
||||
"v": 1,
|
||||
"at": unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()),
|
||||
"sessions_root": root(env).display().to_string(),
|
||||
"legacy_root": legacy.display().to_string(),
|
||||
"legacy_present": legacy_present,
|
||||
"legacy_files": legacy_files,
|
||||
"moved": 0,
|
||||
"policy": "read-only-compat",
|
||||
});
|
||||
let path = env.logs_dir().join(SESSION_LOCATION_FILE);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok()?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&body).ok()?).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! host/logs.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
#[test]
|
||||
fn time_display_and_outcomes() {
|
||||
@@ -84,3 +85,170 @@ fn confidence_percent_is_rounded_and_clamped() {
|
||||
assert_eq!(confidence_percent(-1.0), 0);
|
||||
assert_eq!(confidence_percent(2.0), 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- M188:会话日志搬家
|
||||
|
||||
/// 一个临时数据根(`<root>/.ULL` + `<root>/.minecraft`)。
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m188-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
root
|
||||
}
|
||||
|
||||
fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry {
|
||||
SessionIndexEntry {
|
||||
v: 1,
|
||||
session_id: id.to_string(),
|
||||
instance_id: "inst".to_string(),
|
||||
instance_name: "实例".to_string(),
|
||||
game_version: "1.20.1".to_string(),
|
||||
started_at: started_at.to_string(),
|
||||
ended_at: String::new(),
|
||||
duration_ms: 0,
|
||||
exit_code: Some(0),
|
||||
exit_type: Some("normal".to_string()),
|
||||
silent_failure: false,
|
||||
window_detected: true,
|
||||
event_count: 1,
|
||||
log_path: file.to_string(),
|
||||
crash_found: false,
|
||||
diagnosis_top: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 守门①前半:**旧家的会话仍然列得出来、打得开**(只读兼容)。
|
||||
///
|
||||
/// 变异证明:
|
||||
/// - 把 [`entries`] 改成只读 `root(env)`(新根)⇒ 列表里没有 `old-session`,本条红;
|
||||
/// - 把 `event_path` 的新旧回落删掉(只认新根)⇒ `open` 拿到 0 行,本条红。
|
||||
#[test]
|
||||
fn legacy_sessions_are_still_listed_and_openable() {
|
||||
let root = temp_root("legacy");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
let entry = sample_entry("old-session", "old-session.jsonl", "2026-11-01T10:00:00.000Z");
|
||||
session_index::append(
|
||||
&session_index::index_path(&env.legacy_sessions_root()),
|
||||
&entry,
|
||||
)
|
||||
.expect("写旧索引");
|
||||
std::fs::write(
|
||||
legacy_game.join("old-session.jsonl"),
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧会话这一行\"}}\n",
|
||||
)
|
||||
.expect("写旧事件流");
|
||||
|
||||
let listed = sessions(&env);
|
||||
assert!(
|
||||
listed.iter().any(|session| session.id == "old-session"),
|
||||
"旧家的会话没有出现在列表里:{listed:?}"
|
||||
);
|
||||
assert_eq!(event_path(&env, &entry), legacy_game.join("old-session.jsonl"));
|
||||
let (lines, _) = open(&env, "old-session");
|
||||
assert!(
|
||||
lines.iter().any(|line| line.text.contains("旧会话这一行")),
|
||||
"旧家的事件流没读出来:{lines:?}"
|
||||
);
|
||||
// 「兼容」不等于「复制一份到新根」:旧文件原地不动、新根里没有它。
|
||||
assert!(legacy_game.join("old-session.jsonl").is_file());
|
||||
assert!(!root.join(".ULL/logs/game/old-session.jsonl").is_file());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 守门①后半:**新会话写在 `logs/game/`**,索引与事件流都在那儿。
|
||||
///
|
||||
/// 变异证明:把 `HostEnv::sessions_root` 改回 `layout.agent_sessions()` ⇒ 第一条断言红;
|
||||
/// 把 [`entries`] 改成只读旧家 ⇒ 列表里没有 `fresh`,本条红。
|
||||
#[test]
|
||||
fn new_sessions_are_written_under_logs_game() {
|
||||
let root = temp_root("newhome");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let index = session_index::index_path(&env.sessions_root());
|
||||
assert_eq!(index, root.join(".ULL/logs/game/index.jsonl"));
|
||||
|
||||
let entry = sample_entry("fresh", "fresh.jsonl", "2026-11-08T09:00:00.000Z");
|
||||
session_index::append(&index, &entry).expect("写新索引");
|
||||
let file = env.sessions_root().join("game/fresh.jsonl");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"新会话\"}}\n",
|
||||
)
|
||||
.expect("写新事件流");
|
||||
|
||||
let listed = sessions(&env);
|
||||
assert_eq!(
|
||||
listed.first().map(|session| session.id.as_str()),
|
||||
Some("fresh"),
|
||||
"{listed:?}"
|
||||
);
|
||||
assert_eq!(event_path(&env, &entry), file);
|
||||
// 旧目录不许被凭空造出来(我们没搬东西,也没义务建一个旧家)。
|
||||
assert!(!env.legacy_sessions_root().exists());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 同一个 id 同时出现在两个根时**新根赢**(用户在新位置又跑了一局同名会话)。
|
||||
#[test]
|
||||
fn the_new_root_wins_when_both_indexes_know_the_same_id() {
|
||||
let root = temp_root("both");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
let old = sample_entry("same", "same.jsonl", "2026-10-01T00:00:00.000Z");
|
||||
session_index::append(&session_index::index_path(&env.legacy_sessions_root()), &old)
|
||||
.expect("写旧索引");
|
||||
std::fs::write(
|
||||
legacy_game.join("same.jsonl"),
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧\"}}\n",
|
||||
)
|
||||
.expect("写旧事件流");
|
||||
|
||||
let fresh = sample_entry("same", "same.jsonl", "2026-11-08T00:00:00.000Z");
|
||||
session_index::append(&session_index::index_path(&env.sessions_root()), &fresh)
|
||||
.expect("写新索引");
|
||||
let new_file = env.sessions_root().join("game/same.jsonl");
|
||||
std::fs::write(
|
||||
&new_file,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"新\"}}\n",
|
||||
)
|
||||
.expect("写新事件流");
|
||||
|
||||
let entries = super::entries(&env);
|
||||
let hits: Vec<&SessionIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.session_id == "same")
|
||||
.collect();
|
||||
assert_eq!(hits.len(), 1, "同一个 id 只能出现一次:{hits:?}");
|
||||
assert_eq!(hits[0].started_at, fresh.started_at, "新根那条应当赢");
|
||||
assert_eq!(event_path(&env, &fresh), new_file);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 落点记录:迁了什么(没有)、什么时候、旧路径还在不在。
|
||||
#[test]
|
||||
fn the_location_receipt_names_both_roots_and_the_policy() {
|
||||
let root = temp_root("receipt");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
std::fs::write(legacy_game.join("a.jsonl"), "{}\n").expect("写旧文件");
|
||||
|
||||
let path = note_location(&env).expect("记录应当写得出来");
|
||||
assert_eq!(path, env.logs_dir().join(super::location::SESSION_LOCATION_FILE));
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).expect("读记录")).expect("记录是 JSON");
|
||||
assert_eq!(value["policy"], "read-only-compat");
|
||||
assert_eq!(value["moved"], 0, "我们一个文件都没搬");
|
||||
assert_eq!(value["legacy_present"], true);
|
||||
assert_eq!(value["legacy_files"], 1);
|
||||
assert_eq!(
|
||||
value["sessions_root"],
|
||||
env.sessions_root().display().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
value["legacy_root"],
|
||||
env.legacy_sessions_root().display().to_string()
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -130,6 +130,9 @@ impl CoreHost {
|
||||
fn build(env: HostEnv, refresh: bool) -> Self {
|
||||
let (tx, rx) = job::channel();
|
||||
game::ensure_root(&env);
|
||||
// M188:把「会话日志换了家」这件事记一笔(旧目录还在不在、有几个文件、搬了什么)。
|
||||
// 策略是**不搬**,所以这份记录是「我们没动你的数据」的唯一凭据。
|
||||
logs::note_location(&env);
|
||||
// M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all`
|
||||
// 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。
|
||||
let update = update_state::Runtime::for_env(&env);
|
||||
|
||||
Reference in new issue
Block a user