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:
worker-logplace committed 2026-11-09 00:43:45 +08:00
1 parent efc663a3cb
commit 5fd5cd47dc
47 files changed
+1371 -119

No files matched your search

+142 -5
View File
@@ -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]
+2 -4
View File
@@ -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() {
+2 -2
View File
@@ -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,
);
+2
View File
@@ -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();
+40 -25
View File
@@ -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)]
+107
View File
@@ -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)
}
+168
View File
@@ -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();
}
+3
View File
@@ -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);