feat(daemon): 任务历史文件态持久化与 .env 无参启动(issue #1)

任务持久化:
- 任务历史落 <state-dir>/bat-tasks.json(版本化、0600 原子写、不跟随
  symlink),生命周期转换时 write-through;seq 持久化避免 pid 复用撞 ID
- daemon 重启恢复历史,中断时仍 queued/running 的任务标记 failed,
  新增错误码 TASK_INTERRUPTED(BAT-ERR-700005)
- 损坏文件改名 .corrupt 留证后从空历史开始;无法识别的记录计数跳过
- core 新增 ErrorCode::ALL 公开码表与 from_id 反查(持久化错误码往返)

.env 配置:
- 首次启动在二进制所在目录释放 .env 模板(0600、create_new 防竞态),
  之后每次启动加载为进程环境变量(不覆盖已存在变量)
- 优先级:CLI > 进程环境变量 > .env > 内置默认;BAT_* 键映射到
  CliOptions 默认值,BAT_WATCH/BAT_DAEMON 仅对无子命令 Run 生效且
  CLI 显式模式/dry-run 时让位;Redis 键预留(未接入)
- 工具/代理"非默认"判断改按 env 应用后基线,status/stop/logs 在
  .env 存在时不误判;BAT_SKIP_ENV_FILE=1 整体禁用

验证:新增 10 个单测(env 解析/优先级/守卫回归/持久化往返/中断标记/
损坏恢复/未知类型跳过);真机 e2e:.env 释放加载、daemon 纯 .env 启动、
catalog.refresh 任务带类型化错误码落盘并跨 daemon 重启恢复(含日志);
fmt / clippy --workspace --all-targets -D warnings / test --workspace 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:15:47 -07:00
co-authored by Claude Fable 5
parent ec40ed4660
commit 3edbe7ccee
5 changed files with 908 additions and 66 deletions
+78 -50
View File
@@ -221,10 +221,76 @@ impl ErrorCode {
pub const RPC_NOT_IMPLEMENTED: Self = Self::new(700_003, "rpc_not_implemented", false);
/// 任务不存在。
pub const TASK_NOT_FOUND: Self = Self::new(700_004, "task_not_found", false);
/// 任务因 daemon 停止/重启而中断(未跑完即失去执行器)。
pub const TASK_INTERRUPTED: Self = Self::new(700_005, "task_interrupted", false);
// ---- 900xxx 内部/未知 ----
/// 未归类的内部错误。
pub const INTERNAL: Self = Self::new(900_001, "internal", false);
/// 全部命名错误码的码表:查表、唯一性校验和从持久化形态反查共用。
/// 新增错误码必须同步登记到这里。
pub const ALL: &'static [Self] = &[
Self::MISSING_APP_VERSION,
Self::MISSING_CONNECTION_GROUP,
Self::MISSING_SERVER_INFO_SOURCE,
Self::INVALID_PROXY_SCHEME,
Self::INVALID_ARGUMENT,
Self::DANGEROUS_OUTPUT_ROOT,
Self::PATH_ESCAPE,
Self::SYMLINK_REJECTED,
Self::FILE_PERMISSION,
Self::HTTP_FORBIDDEN,
Self::HTTP_NOT_FOUND,
Self::HTTP_CLIENT_ERROR,
Self::HTTP_TOO_MANY_REQUESTS,
Self::HTTP_SERVER_ERROR,
Self::NETWORK_DNS,
Self::NETWORK_CONNECT,
Self::NETWORK_TIMEOUT,
Self::NETWORK_TLS,
Self::NETWORK_INTERRUPTED,
Self::NETWORK_OTHER,
Self::RETRY_EXHAUSTED,
Self::QUARANTINED,
Self::NON_OFFICIAL_URL,
Self::LAUNCHER_API_REJECTED,
Self::PROXY_FAILURE,
Self::BLAKE3_MISMATCH,
Self::OFFICIAL_HASH_MISMATCH,
Self::SIZE_MISMATCH,
Self::ZIP_STRUCTURE_INVALID,
Self::RESOURCE_LOCKED,
Self::STAGING_PREPARE_FAILED,
Self::PUBLISH_FAILED,
Self::VERSION_STATE_WRITE_FAILED,
Self::CAS_HASH_MISMATCH,
Self::CAS_OBJECT_NOT_FOUND,
Self::CAS_REFERENCE_UNDERFLOW,
Self::CAS_DATABASE,
Self::MANIFEST_PARSE_FAILED,
Self::UNITYFS_PARSE_FAILED,
Self::GAME_MAIN_CONFIG_FAILED,
Self::LAUNCHER_RESPONSE_INVALID,
Self::RPC_UNKNOWN_METHOD,
Self::RPC_INVALID_PARAMS,
Self::RPC_NOT_IMPLEMENTED,
Self::TASK_NOT_FOUND,
Self::TASK_INTERRUPTED,
Self::INTERNAL,
];
/// 按错误码数字反查命名常量;未登记的数字返回 `None`。
pub fn from_number_lookup(number: u32) -> Option<Self> {
Self::ALL.iter().copied().find(|code| code.number == number)
}
/// 按展示 ID`BAT-ERR-NNNNNN`)反查命名常量;格式非法或未登记返回 `None`。
/// 用于从持久化/传输形态恢复错误码。
pub fn from_id(id: &str) -> Option<Self> {
let number = id.strip_prefix("BAT-ERR-")?.parse::<u32>().ok()?;
Self::from_number_lookup(number)
}
}
/// 结构化 API 错误:进入 RPC envelope、CLI `--json` 输出和结构化日志的统一形态。
@@ -348,59 +414,21 @@ mod tests {
#[test]
fn error_codes_are_unique() {
// 防止新增码值撞号:列举全部命名常量,断言 number 唯一。
let codes = [
ErrorCode::MISSING_APP_VERSION,
ErrorCode::MISSING_CONNECTION_GROUP,
ErrorCode::MISSING_SERVER_INFO_SOURCE,
ErrorCode::INVALID_PROXY_SCHEME,
ErrorCode::INVALID_ARGUMENT,
ErrorCode::DANGEROUS_OUTPUT_ROOT,
ErrorCode::PATH_ESCAPE,
ErrorCode::SYMLINK_REJECTED,
ErrorCode::FILE_PERMISSION,
ErrorCode::HTTP_FORBIDDEN,
ErrorCode::HTTP_NOT_FOUND,
ErrorCode::HTTP_CLIENT_ERROR,
ErrorCode::HTTP_TOO_MANY_REQUESTS,
ErrorCode::HTTP_SERVER_ERROR,
ErrorCode::NETWORK_DNS,
ErrorCode::NETWORK_CONNECT,
ErrorCode::NETWORK_TIMEOUT,
ErrorCode::NETWORK_TLS,
ErrorCode::NETWORK_INTERRUPTED,
ErrorCode::NETWORK_OTHER,
ErrorCode::RETRY_EXHAUSTED,
ErrorCode::QUARANTINED,
ErrorCode::NON_OFFICIAL_URL,
ErrorCode::LAUNCHER_API_REJECTED,
ErrorCode::PROXY_FAILURE,
ErrorCode::BLAKE3_MISMATCH,
ErrorCode::OFFICIAL_HASH_MISMATCH,
ErrorCode::SIZE_MISMATCH,
ErrorCode::ZIP_STRUCTURE_INVALID,
ErrorCode::RESOURCE_LOCKED,
ErrorCode::STAGING_PREPARE_FAILED,
ErrorCode::PUBLISH_FAILED,
ErrorCode::VERSION_STATE_WRITE_FAILED,
ErrorCode::CAS_HASH_MISMATCH,
ErrorCode::CAS_OBJECT_NOT_FOUND,
ErrorCode::CAS_REFERENCE_UNDERFLOW,
ErrorCode::CAS_DATABASE,
ErrorCode::MANIFEST_PARSE_FAILED,
ErrorCode::UNITYFS_PARSE_FAILED,
ErrorCode::GAME_MAIN_CONFIG_FAILED,
ErrorCode::LAUNCHER_RESPONSE_INVALID,
ErrorCode::RPC_UNKNOWN_METHOD,
ErrorCode::RPC_INVALID_PARAMS,
ErrorCode::RPC_NOT_IMPLEMENTED,
ErrorCode::TASK_NOT_FOUND,
ErrorCode::INTERNAL,
];
let mut numbers: Vec<u32> = codes.iter().map(ErrorCode::number).collect();
// 防止新增码值撞号:公开码表 `ALL` 即全部命名常量,断言 number 唯一。
let mut numbers: Vec<u32> = ErrorCode::ALL.iter().map(ErrorCode::number).collect();
let total = numbers.len();
numbers.sort_unstable();
numbers.dedup();
assert_eq!(numbers.len(), total, "存在重复的错误码 number");
}
#[test]
fn from_id_round_trips_registered_codes() {
// 持久化恢复路径依赖 from_id 反查:全部登记码必须可经展示 ID 往返。
for code in ErrorCode::ALL {
assert_eq!(ErrorCode::from_id(&code.id()), Some(*code));
}
assert_eq!(ErrorCode::from_id("BAT-ERR-999999"), None);
assert_eq!(ErrorCode::from_id("not-an-id"), None);
}
}