diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 54c54da..6abaf9b 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -264,11 +264,11 @@ cargo run -p bat-infrastructure --bin bat -- \ ## 6. 当前阻塞项 -GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、#17(P2)、#19(P2)。其中 #17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。 +GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准。 下一阶段必须优先完成: -1. Issue #1(P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh)、`resource.*`(state/sync/verify/manifest)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 +1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 2. `cmd/bat` Go CLI 骨架:当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。 @@ -283,7 +283,7 @@ GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、 立即任务: -1. Issue #1 收尾:协议基础设施、最小方法集及 `catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进。 +1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`task.*`、`resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。 2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`。 3. 跟进官方同步长期运行测试,收集并归档运行报告。 4. 开始 AssetBundle parser 的 UnityFS header/block/directory(issue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f89337c..20a5061 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。 5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。 6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。 -7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable` 运维命令。 +7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`);`restart` 和 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 diff --git a/README.md b/README.md index 2336ff4..65bb1c1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 -- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。 +- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`)。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 @@ -80,7 +80,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --error-retry 60s ``` -后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh` 等控制命令: +后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令: ```bash cargo run -p bat-infrastructure --bin bat -- \ @@ -94,7 +94,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。 +`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。 `bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 diff --git a/USERGUIDE.md b/USERGUIDE.md index 3516c97..8ebdad3 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -271,10 +271,12 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `daemon.stop` | ✅ | 请求停止(`accepted`) | | `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) | | `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) | +| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) | | `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 | | `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` | | `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` | -| `resource.manifest` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) | +| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` | +| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) | | `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) | | `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed | | `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL) | @@ -283,14 +285,14 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `task.list` | ✅ | 列出全部任务(最新在前) | | `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 | | `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) | -| `resource.repair` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);repair 待引擎支持独立修复模式,patch/unityfs 待引擎实现 | +| `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 | | 未知方法 | — | `BAT-ERR-700001`(unknown method) | -只读查询(`resource.state` / `resource.manifest` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 +只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 ### 任务模型 -`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task--", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: +`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task--", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: ```json { "id": "task-1234-1", "kind": "resource.sync", @@ -324,4 +326,8 @@ printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \ # 分页读取当前版本的下载 manifest printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \ | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock + +# 触发本地资源审计+修复任务 +printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock ``` diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 025415b..96d9fb9 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -208,7 +208,7 @@ 11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。 12. 将 staging rename 为 `/versions/`,再原子替换 `/current` symlink 指向该 versioned 目录。 -该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 +该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 对应实现主要在: @@ -246,7 +246,7 @@ Linux 生产路径: - pull plan 会同时包含 discovery URLs 和 content URLs - 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 -- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 +- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`);`restart` 与 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN - `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因 - 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 @@ -289,16 +289,17 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - `error` 为统一 `ApiError`:`code`(`BAT-ERR-<6 位>`)、`kind`、 `domain`、`location`、`message`、`retryable`。码表以 `core/src/error_code.rs` 为准。 -- 长任务(`resource.sync` / `resource.verify` / `catalog.refresh`) +- 长任务(`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh`) 入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs` 轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与 watch 循环经进程内锁互斥。任务历史持久化于 `/bat-tasks.json` (版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务 仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。 - 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6: - `daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现; - `patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair` - 按设计暂缓。 + `daemon.status/logs/stop/reload/refresh/doctor`、`resource.state/sync/verify/repair/manifest/list`、 + `catalog.*` 与 `task.status/list/cancel/logs` 已实现;`patch.*` / `unityfs.*` + 待引擎;`task.create` 按设计暂不开放通用任务入口;`daemon.restart` / + `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 ### 7.2 Go 层职责边界 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 4bf2187..2acf9ec 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -250,7 +250,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state ``` -`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。 +`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。 不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。 @@ -287,7 +287,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor --output /var/lib/bluearchiv sudo -u bat /opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit ``` -如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。 +如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;默认形态的 `repair` 会通过 `resource.repair` RPC 入队本地 manifest 审计+修复任务并返回 `task_id`。带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl、unzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。 ### 升级 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 502d5fd..44d9d6a 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -45,7 +45,7 @@ target/release/bat \ --watch ``` -默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `/.staging/`,校验完成后移动到 `/versions/`,再原子切换 `/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。 +默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `/.staging/`,校验完成后移动到 `/versions/`,再原子切换 `/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。 同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 @@ -265,7 +265,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 +`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`: diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 383d4fd..71111b2 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -471,7 +471,7 @@ ## 6. 当前关闭顺序建议 1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。 -2. issue #1:Rust daemon/backend API 边界收口,确认 `patch.*` / `unityfs.*` / `resource.repair` / `task.create` 的剩余阻塞。 +2. issue #1:Rust daemon/backend API 边界收口;`resource.repair`、`resource.list`、`daemon.doctor` 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。 3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。 4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。 5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 970915d..9c91842 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -641,13 +641,18 @@ struct DaemonRpcAck { // 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STOP: &str = "daemon.stop"; +const RPC_METHOD_RESTART: &str = "daemon.restart"; const RPC_METHOD_RELOAD: &str = "daemon.reload"; const RPC_METHOD_REFRESH: &str = "daemon.refresh"; const RPC_METHOD_LOGS: &str = "daemon.logs"; +const RPC_METHOD_DOCTOR: &str = "daemon.doctor"; +const RPC_METHOD_CLEAN_STABLE: &str = "daemon.clean-stable"; const RPC_METHOD_RESOURCE_STATE: &str = "resource.state"; const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync"; const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify"; +const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair"; const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; +const RPC_METHOD_RESOURCE_LIST: &str = "resource.list"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; @@ -667,6 +672,7 @@ const MAX_TASK_LOG_LINES: usize = 200; enum TaskKind { Sync, Verify, + Repair, /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 Refresh, } @@ -676,6 +682,7 @@ impl TaskKind { match self { Self::Sync => RPC_METHOD_RESOURCE_SYNC, Self::Verify => RPC_METHOD_RESOURCE_VERIFY, + Self::Repair => RPC_METHOD_RESOURCE_REPAIR, Self::Refresh => RPC_METHOD_CATALOG_REFRESH, } } @@ -695,6 +702,12 @@ impl TaskKind { config.repair = false; config.force = false; } + Self::Repair => { + config.dry_run = false; + config.audit_local = true; + config.repair = true; + config.force = false; + } Self::Refresh => { config.dry_run = true; config.plan = true; @@ -800,6 +813,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> { match kind { RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), + RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR), RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), _ => None, } @@ -1233,21 +1247,28 @@ fn canonical_rpc_method(method: &str) -> &str { match method { "bat.status" => RPC_METHOD_STATUS, "bat.stop" => RPC_METHOD_STOP, + "bat.restart" => RPC_METHOD_RESTART, "bat.reload" => RPC_METHOD_RELOAD, "bat.refresh" => RPC_METHOD_REFRESH, "bat.logs" => RPC_METHOD_LOGS, + "bat.doctor" => RPC_METHOD_DOCTOR, + "bat.clean-stable" => RPC_METHOD_CLEAN_STABLE, + RPC_METHOD_RESOURCE_LIST => RPC_METHOD_RESOURCE_MANIFEST, other => other, } } /// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。 fn is_pending_rpc_method(method: &str) -> bool { - // task.create:任务统一由 resource.sync / resource.verify / catalog.refresh + // task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh // 等语义方法创建,通用创建接口暂不开放。 - // resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。 + // daemon.restart / daemon.clean-stable:CLI 侧按进程生命周期处理; + // live RPC 内不做自重启或在线清理。 // patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。 - matches!(method, "task.create" | "resource.repair") - || method.starts_with("patch.") + matches!( + method, + "task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE + ) || method.starts_with("patch.") || method.starts_with("unityfs.") } @@ -1831,6 +1852,12 @@ fn dispatch_rpc_method( .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), ) } + RPC_METHOD_DOCTOR => rpc_envelope_from_result( + request_id, + "daemon.doctor", + build_doctor_report(state_dir, &tasks.base_config) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ), RPC_METHOD_STOP => { daemon_control_mark_stop_requested(control); let _ = update_daemon_state_only(state_dir, "stopping"); @@ -1879,6 +1906,9 @@ fn dispatch_rpc_method( RPC_METHOD_RESOURCE_VERIFY => { enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id) } + RPC_METHOD_RESOURCE_REPAIR => { + enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id) + } RPC_METHOD_RESOURCE_MANIFEST => { let (offset, limit) = match rpc_page_params(request.params.as_ref()) { Ok(page) => page, @@ -3006,19 +3036,27 @@ struct CommandReport { } fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { - if refresh_should_use_daemon_rpc(options, command_name) - && daemon_rpc_available(&options.state_dir) + if let Some(rpc_method) = sync_command_rpc_method(options, command_name) + .filter(|_| daemon_rpc_available(&options.state_dir)) { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - let report = daemon_rpc_call( - &options.state_dir, - RPC_METHOD_REFRESH, - Some(serde_json::json!({ "force": options.config.force })), - )?; + let params = if rpc_method == RPC_METHOD_REFRESH { + Some(serde_json::json!({ "force": options.config.force })) + } else { + None + }; + let report = daemon_rpc_call(&options.state_dir, rpc_method, params)?; print_json_value(options.output_format, &report)?; return Ok(()); } + run_sync_command_foreground(options, command_name) +} + +fn run_sync_command_foreground( + options: &CliOptions, + command_name: &'static str, +) -> anyhow::Result<()> { assert_no_live_daemon_output_conflict(options, command_name)?; let mut config = options.config.clone(); if command_name == "repair" { @@ -3044,13 +3082,9 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow: Ok(()) } -fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool { - if command_name != "refresh" { - return false; - } +fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> { let defaults = OfficialUpdateConfig::default(); - options.command == CliCommand::Refresh - && !options.watch + let default_daemon_shape = !options.watch && !options.daemon && !options.daemon_child && !options.output_explicit @@ -3068,7 +3102,19 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo && !options.config.dry_run && !options.config.plan && options.config.audit_local == defaults.audit_local - && options.config.repair == defaults.repair + && options.config.repair == defaults.repair; + if !default_daemon_shape { + return None; + } + match (options.command, command_name) { + (CliCommand::Refresh, "refresh") => Some(RPC_METHOD_REFRESH), + (CliCommand::Repair, "repair") if !options.config.force => Some(RPC_METHOD_RESOURCE_REPAIR), + _ => None, + } +} + +fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool { + sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH) } fn print_report(format: OutputFormat, report: &T) -> anyhow::Result<()> @@ -3806,30 +3852,29 @@ struct DoctorReport { checks: Vec, } -fn run_doctor_command(options: &CliOptions) -> anyhow::Result { +fn build_doctor_report( + state_dir: &Path, + config: &OfficialUpdateConfig, +) -> anyhow::Result { let mut checks = vec![ - path_check("state_dir", &options.state_dir, "后台状态目录可用"), - path_check( - "output_root", - &options.config.output_root, - "资源输出目录可用", - ), + path_check("state_dir", state_dir, "后台状态目录可用"), + path_check("output_root", &config.output_root, "资源输出目录可用"), safety_check( "output_root_safety", - validate_output_root(&options.config.output_root), + validate_output_root(&config.output_root), "资源输出目录安全边界通过", ), safety_check( "state_dir_safety", - validate_runtime_state_dir(&options.state_dir), + validate_runtime_state_dir(state_dir), "后台状态目录安全边界通过", ), - command_check("curl", &options.config.curl_command), - proxy_check(&options.config.curl_proxy), - command_check("unzip", &options.config.unzip_command), + command_check("curl", &config.curl_command), + proxy_check(&config.curl_proxy), + command_check("unzip", &config.unzip_command), ]; - let pid_path = daemon_pid_path(&options.state_dir); + let pid_path = daemon_pid_path(state_dir); let daemon_running = read_pid_file(&pid_path) .ok() .flatten() @@ -3857,9 +3902,9 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }), } - let socket_path = daemon_socket_path(&options.state_dir); + let socket_path = daemon_socket_path(state_dir); let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false); - let socket_available = daemon_rpc_available(&options.state_dir); + let socket_available = daemon_rpc_available(state_dir); checks.push(DoctorCheck { name: "daemon_rpc", ok: if daemon_running { @@ -3884,7 +3929,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }, }); - let lock_path = options.config.lock_path(); + let lock_path = config.lock_path(); checks.push(match classify_pid_lock_file(&lock_path)? { PidLockState::Missing => DoctorCheck { name: "resource_lock", @@ -3917,7 +3962,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }, }); - let control_lock_path = daemon_control_lock_path(&options.state_dir); + let control_lock_path = daemon_control_lock_path(state_dir); checks.push(match classify_pid_lock_file(&control_lock_path)? { PidLockState::Missing => DoctorCheck { name: "daemon_control_lock", @@ -3950,7 +3995,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }, }); - let status_path = daemon_status_path(&options.state_dir); + let status_path = daemon_status_path(state_dir); // ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。 let daemon_status_result = read_daemon_status_file(&status_path); checks.push(DoctorCheck { @@ -3964,7 +4009,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }); let healthy = checks.iter().all(|check| check.ok); - let report = DoctorReport { + Ok(DoctorReport { command: "doctor", status: if healthy { "ok" } else { "issues_found" }, message: if healthy { @@ -3974,7 +4019,12 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { }, healthy, checks, - }; + }) +} + +fn run_doctor_command(options: &CliOptions) -> anyhow::Result { + let report = build_doctor_report(&options.state_dir, &options.config)?; + let healthy = report.healthy; print_report(options.output_format, &report)?; Ok(healthy) } @@ -5919,11 +5969,13 @@ mod tests { "404", )); }); + let repair_id = registry.create(TaskKind::Repair); + registry.update(&repair_id, |record| record.status = "succeeded"); drop(registry); // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 let (registry, summary) = TaskRegistry::with_persistence(state_dir); - assert!(summary.contains("恢复任务历史 3 条"), "{summary}"); + assert!(summary.contains("恢复任务历史 4 条"), "{summary}"); assert!(summary.contains("标记中断 1 条"), "{summary}"); let finished = registry.get(&finished_id).unwrap(); assert_eq!(finished.status, "succeeded"); @@ -5944,16 +5996,19 @@ mod tests { failed.error.as_ref().unwrap().code(), ErrorCode::HTTP_NOT_FOUND ); + let repair = registry.get(&repair_id).unwrap(); + assert_eq!(repair.kind, "resource.repair"); + assert_eq!(repair.status, "succeeded"); // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 let new_id = registry.create(TaskKind::Sync); assert!( - [&finished_id, &running_id, &failed_id] + [&finished_id, &running_id, &failed_id, &repair_id] .iter() .all(|id| **id != new_id), "新任务 ID {new_id} 与历史撞号" ); - assert_eq!(registry.list().len(), 4); + assert_eq!(registry.list().len(), 5); } #[test] @@ -6368,8 +6423,13 @@ mod tests { #[test] fn canonical_rpc_method_resolves_aliases() { assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS); + assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR); assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH); assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS); + assert_eq!( + canonical_rpc_method("resource.list"), + RPC_METHOD_RESOURCE_MANIFEST + ); assert_eq!(canonical_rpc_method("resource.state"), "resource.state"); assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method"); } @@ -6379,17 +6439,20 @@ mod tests { assert!(is_pending_rpc_method("patch.apply")); assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("task.create")); - assert!(is_pending_rpc_method("resource.repair")); - // sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现, - // 不再是 pending。 + assert!(is_pending_rpc_method("daemon.restart")); + assert!(is_pending_rpc_method("daemon.clean-stable")); + // sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现。 assert!(!is_pending_rpc_method("resource.sync")); assert!(!is_pending_rpc_method("resource.verify")); + assert!(!is_pending_rpc_method("resource.repair")); assert!(!is_pending_rpc_method("resource.manifest")); + assert!(!is_pending_rpc_method("resource.list")); assert!(!is_pending_rpc_method("catalog.status")); assert!(!is_pending_rpc_method("catalog.refresh")); assert!(!is_pending_rpc_method("task.cancel")); assert!(!is_pending_rpc_method("task.logs")); assert!(!is_pending_rpc_method("daemon.status")); + assert!(!is_pending_rpc_method("daemon.doctor")); } #[test] @@ -6502,6 +6565,36 @@ mod tests { assert_eq!(value["error"]["code"], "BAT-ERR-700004"); } + #[test] + fn dispatch_daemon_doctor_returns_report() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let mut base_config = OfficialUpdateConfig::default(); + base_config.output_root = output_root; + let (queue, _rx) = mpsc::channel::(); + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("daemon.doctor", None), + &state_dir, + &new_daemon_control(), + &context, + "req-doctor-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "ok"); + assert_eq!(value["data"]["command"], "doctor"); + assert!(value["data"]["healthy"].is_boolean()); + let checks = value["data"]["checks"].as_array().unwrap(); + assert!(checks.iter().any(|check| check["name"] == "daemon_rpc")); + } + #[test] fn dispatch_resource_sync_enqueues_task() { let temp = tempfile::TempDir::new().unwrap(); @@ -6554,6 +6647,48 @@ mod tests { assert_eq!(status_value["data"]["status"], "queued"); } + #[test] + fn dispatch_resource_repair_enqueues_repair_task() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 + let (queue, rx) = mpsc::channel::(); + let mut base_config = OfficialUpdateConfig::default(); + base_config.force = true; + base_config.dry_run = true; + base_config.audit_local = false; + base_config.repair = false; + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("resource.repair", None), + temp.path(), + &control, + &context, + "req-repair-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + assert_eq!(value["data"]["kind"], "resource.repair"); + let task_id = value["data"]["task_id"].as_str().unwrap(); + let record = context.registry.get(task_id).unwrap(); + assert_eq!(record.kind, "resource.repair"); + assert_eq!(record.status, "queued"); + + // repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。 + let job = rx.try_recv().unwrap(); + assert_eq!(job.id, task_id); + assert!(!job.config.dry_run); + assert!(job.config.audit_local); + assert!(job.config.repair); + assert!(!job.config.force); + } + #[test] fn task_registry_create_update_and_prune() { let registry = TaskRegistry::new(); @@ -7092,21 +7227,41 @@ mod tests { } #[test] - fn refresh_rpc_selection_only_for_default_daemon_shape() { + fn sync_command_rpc_selection_only_for_default_daemon_shape() { let options = parse(&["bat", "refresh"]).unwrap(); assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!( + sync_command_rpc_method(&options, "refresh"), + Some(RPC_METHOD_REFRESH) + ); let options = parse(&["bat", "refresh", "--force"]).unwrap(); assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!( + sync_command_rpc_method(&options, "refresh"), + Some(RPC_METHOD_REFRESH) + ); let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap(); assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap(); assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); let options = parse(&["bat", "repair"]).unwrap(); assert!(!refresh_should_use_daemon_rpc(&options, "repair")); + assert_eq!( + sync_command_rpc_method(&options, "repair"), + Some(RPC_METHOD_RESOURCE_REPAIR) + ); + + let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "repair"), None); + + let options = parse(&["bat", "repair", "--force"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "repair"), None); } #[test] @@ -7591,17 +7746,34 @@ mod tests { let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700002"); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.list", + Some(serde_json::json!({ "offset": 2, "limit": 1 })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["destination"], "c"); } #[test] - fn dispatch_resource_repair_reports_not_implemented() { + fn dispatch_daemon_clean_stable_reports_not_implemented() { let temp = tempfile::TempDir::new().unwrap(); let envelope = dispatch_rpc_method( - &rpc_request("resource.repair", None), + &rpc_request("daemon.clean-stable", None), temp.path(), &new_daemon_control(), &test_task_context(), - "req-rep-1".to_string(), + "req-clean-1".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false);