feat(rpc): 完成 issue #1 Go 调用边界
bat-rust / Build and test Rust (push) Failing after 3m17s

This commit is contained in:
2026-07-24 17:58:34 +08:00
parent ecda08ed97
commit 102b49b666
20 changed files with 1011 additions and 34 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
bat /bat
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
+7 -6
View File
@@ -10,7 +10,7 @@
## 1. 总体判断 ## 1. 总体判断
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go CLI/API/Web 仍未形成产品入口** 阶段。 当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。
Rust 侧官方日服资源链路已经从实验验证推进到正式入口: Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
@@ -198,12 +198,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
### Go / API / Web ### Go / API / Web
状态:**Go 产品入口仍未完成,仅有可选 CGO 兼容包装和试验性 `cmd/bat` 骨架** 状态:**Go 产品入口仍未完成`bat-api` 已有 typed Rust daemon RPC client、可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
当前情况: 当前情况:
- `internal/backendrpc` 已提供 Go typed Unix socket JSON-RPC client,作为 `bat-api` 调用 Rust daemon 的默认路径。
- `internal/ffi/ffi.go` 已存在。 - `internal/ffi/ffi.go` 已存在。
- Go CLI 的稳定集成方向仍应优先通过 Rust `bat --json` 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。 - Go CLI 的稳定集成方向仍应优先通过 Rust daemon RPC 或 Rust `bat --json` one-shot 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
- `cmd/``pkg/``api/``web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor``manifest inspect``sync plan` 这类最小演示能力。 - `cmd/``pkg/``api/``web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor``manifest inspect``sync plan` 这类最小演示能力。
- `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。 - `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。
@@ -234,7 +235,7 @@ git status --short --branch
未执行: 未执行:
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。 - 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
- Go CLI 端到端测试,因为 Go 产品入口尚未完成。 - Go CLI 端到端测试,因为 Go 产品入口尚未完成`internal/backendrpc` 已有 fake transport 单测覆盖
- Web/API 测试,因为 Web/API 尚未实现。 - Web/API 测试,因为 Web/API 尚未实现。
--- ---
@@ -269,7 +270,7 @@ GitHub issue 状态:#1 已升为 P0#17 的实现已合入 HEAD,但 issue
下一阶段必须优先完成: 下一阶段必须优先完成:
1. Issue #1P0,主体已实现):`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 循环互斥;任务历史持久化于 `<state-dir>/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,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 1. Issue #1P0,主体已实现):`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 循环互斥;任务历史持久化于 `<state-dir>/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,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. `cmd/bat` Go CLI 骨架:当前只实现 `doctor``manifest inspect``sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。 2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor``manifest inspect``sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
4. Issue #3P2):AssetBundle UnityFS 基础解析校验。 4. Issue #3P2):AssetBundle UnityFS 基础解析校验。
5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
@@ -291,5 +292,5 @@ GitHub issue 状态:#1 已升为 P0#17 的实现已合入 HEAD,但 issue
--- ---
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。 - **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go 产品入口、`bat-api`、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。 - **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
- **下一工程里程碑**Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。 - **下一工程里程碑**Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
+11 -7
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引 # BlueArchiveToolkit 文档索引
- **更新时间**2026-07-20 - **更新时间**2026-07-24
- **说明**:本索引用于快速定位当前权威文档和历史资料。 - **说明**:本索引用于快速定位当前权威文档和历史资料。
--- ---
@@ -14,6 +14,7 @@
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `CHANGELOG.md`:版本变更记录。 - `CHANGELOG.md`:版本变更记录。
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。 - `AGENTS.md`:AI agent 和自动化开发助手长期规则。
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。 - `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
@@ -25,6 +26,7 @@
- `docs/architecture/README.md`:总体架构设计。 - `docs/architecture/README.md`:总体架构设计。
- `docs/api/README.md`API 设计入口。 - `docs/api/README.md`API 设计入口。
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `docs/guides/development.md`:开发指南。 - `docs/guides/development.md`:开发指南。
- `docs/guides/deployment.md`:部署指南。 - `docs/guides/deployment.md`:部署指南。
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。 - `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
@@ -78,12 +80,13 @@
3. `docs/guides/official-resource-test-pull.md` 3. `docs/guides/official-resource-test-pull.md`
4. `docs/guides/official-full-pull-smoke.md` 4. `docs/guides/official-full-pull-smoke.md`
5. `docs/architecture/official-resource-backend.md` 5. `docs/architecture/official-resource-backend.md`
6. `docs/reports/CURRENT_GAPS.md` 6. `docs/reference/rpc-backend-api.md`
7. `docs/guides/baseline.md` 7. `docs/reports/CURRENT_GAPS.md`
8. `docs/architecture/README.md` 8. `docs/guides/baseline.md`
9. `docs/guides/development.md` 9. `docs/architecture/README.md`
10. `CONTRIBUTING.md` 10. `docs/guides/development.md`
11. `AGENTS.md` 11. `CONTRIBUTING.md`
12. `AGENTS.md`
--- ---
@@ -98,6 +101,7 @@
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。 - CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
- 文档整理和路线图重制。 - 文档整理和路线图重制。
- Rust 官方资源同步闭环:`bat``--auto-discover``--watch``--daemon`、Unix socket JSON-RPC 后台控制、`status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。 - Rust 官方资源同步闭环:`bat``--auto-discover``--watch``--daemon`、Unix socket JSON-RPC 后台控制、`status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。 - 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。 - `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。 - Addressables 当前真实形态 fixture/golden 覆盖。
+4 -4
View File
@@ -40,7 +40,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。 1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。 2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
3. Go CLI/API/SDK 仍没有产品级入口;当前只有 `cmd/bat``internal/ffi` 的试验/兼容骨架 3. Go CLI/API/SDK 仍没有产品级入口;当前 `bat-api` module 只有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架`internal/ffi` 兼容包装
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。 5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:完成可长期使用的 Content Addressable Storage。 **目标**:完成可长期使用的 Content Addressable Storage。
**当前状态**:已完成 CAS V1。Go CLI 产品入口尚未完成,当前仅存在 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。 **当前状态**:已完成 CAS V1。Go CLI/API 产品入口尚未完成`bat-api` 已有 `internal/backendrpc` typed RPC client 和 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
交付物: 交付物:
@@ -174,7 +174,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。 2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。 3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。 4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
5. Go 产品入口:**未完成**。当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。 5. Go 产品入口:**未完成**。`internal/backendrpc` 已提供 Go 调 Rust daemon 的 typed RPC client当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。 6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。 7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig` 8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`
@@ -380,7 +380,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 6. 近期具体任务 ## 6. 近期具体任务
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,Go `cmd/bat` 仍只是试验骨架,`bat-api` 是独立的 Go HTTP 服务目标(issue #19 / G-009): 优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC clientGo `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。 1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。 2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
+3 -1
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go 侧目前只有试验性的 `cmd/bat` 骨架`internal/ffi` 兼容包装产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。 当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go module 名为 `bat-api`,目前包含试验性的 `cmd/bat` 骨架、`internal/backendrpc` Rust daemon RPC client `internal/ffi` 兼容包装产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
--- ---
@@ -14,6 +14,7 @@
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 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.*`)。 - `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 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.*`)。
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -156,6 +157,7 @@ BlueArchiveToolkit/
│ ├── bat-assetbundle/ │ ├── bat-assetbundle/
│ ├── bat-patch/ │ ├── bat-patch/
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层 │ └── bat-ffi/ # 可选无状态 C ABI 兼容层
├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径 ├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
├── cmd/ # Go CLI 试验骨架与后续产品入口 ├── cmd/ # Go CLI 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现 ├── pkg/ # Go SDK 包,尚未实现
+3 -1
View File
@@ -239,7 +239,9 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
## 6. Daemon RPC 接口 ## 6. Daemon RPC 接口
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 稳定 contract 以 `docs/reference/rpc-backend-api.md` 为准,本节保留常用说明和命令行示例
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh`/`repair` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 或执行 `bat` binary 后再解析 stdout。
### 传输与 envelope ### 传输与 envelope
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"fmt"
)
func runDoctor() error {
fmt.Println("bat doctor: ok")
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "bat-api/internal/ffi"
func InspectManifest(rawJSON string) (string, error) {
return ffi.InspectManifest(rawJSON)
}
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON)
}
func batVersion() (string, error) {
return ffi.Version()
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
var err error
switch os.Args[1] {
case "doctor":
err = runDoctor()
case "manifest":
err = runManifest(os.Args[2:])
case "sync":
err = runSync(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println("bat - BlueArchiveToolkit CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" bat doctor")
fmt.Println(" bat manifest inspect <file>")
fmt.Println(" bat sync plan <current-json> [previous-json]")
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runManifest(args []string) error {
if len(args) < 2 || args[0] != "inspect" {
return fmt.Errorf("usage: bat manifest inspect <file>")
}
path := args[1]
data, err := os.ReadFile(path)
if err != nil {
return err
}
result, err := InspectManifest(string(data))
if err != nil {
return err
}
fmt.Println(result)
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runSync(args []string) error {
if len(args) < 2 || args[0] != "plan" {
return fmt.Errorf("usage: bat sync plan <current-json> [previous-json]")
}
current := args[1]
previous := ""
if len(args) > 2 {
previous = args[2]
}
result, err := BuildSyncPlan(current, previous)
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, result)
return nil
}
@@ -189,7 +189,7 @@
集成边界: 集成边界:
1. 当前生产集成路径是运行 `bat --json` 并消费结构化 report;未来 Go CLI 若继续作为产品入口,也应优先使用该进程边界或 daemon RPC 1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPCone-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。 2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。 3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
@@ -280,6 +280,7 @@ Linux 生产路径:
daemon`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket daemon`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket
JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。 JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。
稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。
### 7.1 协议契约 ### 7.1 协议契约
@@ -304,8 +305,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
### 7.2 Go 层职责边界 ### 7.2 Go 层职责边界
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发, - Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个 以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
JSON-RPC 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。 (连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
尚未完成,`cmd/bat` 仍是试验骨架。
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、 - Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。 版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源 - Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
+2 -2
View File
@@ -43,8 +43,8 @@ go vet ./...
说明: 说明:
1. 当前已有 `cmd/bat``internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成。 1. 当前已有 `internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成。
2. 如果后续新增 Go 产品 package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。 2. 后续新增 Go 产品 package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。 3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture` 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
+2 -2
View File
@@ -145,7 +145,7 @@ go test ./...
go vet ./... go vet ./...
``` ```
Go 产品入口尚未完成,但仓库已有 `cmd/bat``internal/ffi` 骨架。提交前应运行 `go test ./...``go vet ./...`目前输出可能只有 `[no test files]`,这代表缺少 Go 产品测试覆盖,不代表 Go CLI 已完成。 Go 产品入口尚未完成,但仓库已有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架`internal/ffi` 兼容包装。提交前应运行 `go test ./...``go vet ./...``internal/backendrpc` 使用 fake transport 覆盖 JSON-RPC envelope、错误和 typed helper,不能把这些测试误认为 Go 产品级 CLI/API 已完成。
### 常用聚焦命令 ### 常用聚焦命令
@@ -160,7 +160,7 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure``bat` 二进制测试。 官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure``bat` 二进制测试。
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`;未来 Go 产品入口和生产同步默认应通过 Rust `bat --json` 或 daemon RPC 进程边界集成 `bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`;未来 Go 产品入口和生产同步默认应通过 `internal/backendrpc` 调用 daemon RPC,或在 one-shot/fallback 场景使用 Rust `bat --json` 进程边界。
### 集成测试 ### 集成测试
+169
View File
@@ -0,0 +1,169 @@
# Rust Resource Backend RPC API
本文档冻结本机 Rust Resource Backend API 的稳定调用边界。Go 项目
`bat-api`、Go 服务层、运维脚本和 `bat` CLI 都应以这里的 JSON-RPC
contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主路径。
## 传输
- 传输:Unix domain socket。
- 默认 socket`/tmp/bat-pid/bat.sock`
- 协议:JSON-RPC 2.0,每行一个 request,每行一个 response。
- 编码:UTF-8 JSON。
- 访问控制:依赖本机文件权限和状态目录权限;不要把 socket 暴露到公网。
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"resource.repair","params":null}
```
成功响应的 JSON-RPC 顶层 `result` 一律是应用层 envelope
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"status": "accepted",
"data": {"task_id": "task-1234-1", "kind": "resource.repair"},
"request_id": "req-1234-1"
}
}
```
应用层失败也放在 `result` 的 envelope 中:
```json
{
"ok": false,
"status": "error",
"error": {
"code": "BAT-ERR-700003",
"kind": "not_implemented",
"domain": "rpc",
"location": "rpc.dispatch",
"message": "方法尚未实现:daemon.clean-stable",
"retryable": false
},
"request_id": "req-1234-2"
}
```
只有 JSON 解析失败等传输层错误使用 JSON-RPC 顶层 `error`
## Envelope
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 应用层是否成功。 |
| `status` | string | `ok``accepted``error`。 |
| `data` | object/null | 成功结果。失败时省略。 |
| `error` | object/null | `ApiError`。成功时省略。 |
| `request_id` | string | daemon 进程内请求 ID,用于日志关联。 |
`ApiError` 结构以 `core/src/error_code.rs` 码表为准:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | string | `BAT-ERR-<6位>`。 |
| `kind` | string | 错误类别。 |
| `domain` | string | 错误域。 |
| `location` | string | Rust 侧出错位置。 |
| `message` | string | 可诊断错误信息。 |
| `retryable` | bool | 调用方是否可以按策略重试。 |
## 方法
### daemon
| 方法 | 状态 | params | data |
|---|---|---|---|
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
`bat.status``bat.stop``bat.reload``bat.refresh``bat.logs`
`bat.doctor``bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`
### resource
| 方法 | 状态 | params | data |
|---|---|---|---|
| `resource.state` | 已实现 | `null` | 资源发布根、版本状态、上次同步结果。 |
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
`limit` 范围是 `1..=1000`,非法参数返回 `BAT-ERR-700002`
### catalog
| 方法 | 状态 | params | data |
|---|---|---|---|
| `catalog.status` | 已实现 | `null` | 当前已发布 catalog 概览。 |
| `catalog.versions` | 已实现 | `null` | current / in_progress / previous / failed。 |
| `catalog.diff` | 已实现 | `null` | 当前 snapshot 相对上一可用版本的差异。 |
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
只读查询在没有可用版本时返回 `ok=true``data.available=false`
### task
| 方法 | 状态 | params | data |
|---|---|---|---|
| `task.status` | 已实现 | `{ "task_id": "..." }` | 单个任务记录。 |
| `task.list` | 已实现 | `null` | `{ "tasks": [...] }`。 |
| `task.cancel` | 已实现 | `{ "task_id": "..." }` | cancel ack。 |
| `task.logs` | 已实现 | `{ "task_id": "..." }` | `{ "task_id": "...", "lines": [...] }`。 |
| `task.create` | 保留 | object | 不开放通用任务入口;由语义方法创建任务。 |
任务记录:
```json
{
"id": "task-1234-1",
"kind": "resource.repair",
"status": "queued",
"stage": null,
"message": null,
"created_at": 1780000000,
"updated_at": 1780000000,
"started_at": null,
"finished_at": null,
"error": null,
"result": null
}
```
`status` 取值:`queued``running``succeeded``failed``cancelled`
daemon 重启后仍处于 `queued``running` 的历史任务会被标记为
`failed`,错误码为 `BAT-ERR-700005`
### patch / unityfs
`patch.*``unityfs.*` 是已规划命名空间,目前返回
`BAT-ERR-700003`。它们依赖后续 `bat-patch``bat-assetbundle`
引擎,不作为 issue #1 的关闭阻塞项。
## Go 调用边界
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
禁止事项:
- Go 服务层不直接读写 `bat-status.json``bat-tasks.json` 等 daemon 内部状态文件。
- Go 服务层不扩展 `bat-ffi` 为主控制面。
- Go 服务层不通过 stdout 解析 `bat status --json` 作为常规调用路径。
+3 -3
View File
@@ -177,14 +177,14 @@
现象: 现象:
- 当前可用的用户同步/运维入口是 Rust `bat` binary。 - 当前可用的用户同步/运维入口是 Rust `bat` binary。
- `cmd/bat` 已存在,但仅有 `doctor``manifest inspect``sync plan` 试验能力;`doctor` 只输出固定 `ok``manifest`/`sync` 依赖可选 CGO/FFI helper。 - `internal/backendrpc` 已提供 Go 到 Rust daemon 的 typed JSON-RPC client`cmd/bat` 已存在,但仅有 `doctor``manifest inspect``sync plan` 试验能力;`doctor` 只输出固定 `ok``manifest`/`sync` 依赖可选 CGO/FFI helper。
- Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。 - Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md``PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。 - 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md``PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
验收: 验收:
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。 - `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
- `cmd/bat sync` 能通过 Rust `bat --json` 或 RPC 触发/查询官方同步,不走 FFI 控制下载器或 daemon。 - `cmd/bat sync` 能通过 `internal/backendrpc` daemon RPC 或 Rust `bat --json` one-shot/fallback 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
- human/json 输出、退出码和错误码与 Rust `bat` 契约一致。 - human/json 输出、退出码和错误码与 Rust `bat` 契约一致。
- `go test ./...``go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。 - `go test ./...``go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。
@@ -471,7 +471,7 @@
## 6. 当前关闭顺序建议 ## 6. 当前关闭顺序建议
1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。 1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。
2. issue #1Rust daemon/backend API 边界收口;`resource.repair``resource.list``daemon.doctor` 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。 2. issue #1Rust daemon/backend API 边界收口;`resource.repair``resource.list``daemon.doctor``internal/backendrpc` Go client 和稳定 RPC reference 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。
3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。 3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。
4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。 4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。 5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
+1 -1
View File
@@ -1,3 +1,3 @@
module bluearchive-toolkit module bat-api
go 1.26.4 go 1.26.4
+50 -3
View File
@@ -3036,8 +3036,17 @@ struct CommandReport<T> {
} }
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
if let Some(rpc_method) = sync_command_rpc_method(options, command_name) run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call)
.filter(|_| daemon_rpc_available(&options.state_dir)) }
fn run_sync_command_with_rpc(
options: &CliOptions,
command_name: &'static str,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
if let Some(rpc_method) =
sync_command_rpc_method(options, command_name).filter(|_| rpc_available(&options.state_dir))
{ {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let params = if rpc_method == RPC_METHOD_REFRESH { let params = if rpc_method == RPC_METHOD_REFRESH {
@@ -3045,7 +3054,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
} else { } else {
None None
}; };
let report = daemon_rpc_call(&options.state_dir, rpc_method, params)?; let report = rpc_call(&options.state_dir, rpc_method, params)?;
print_json_value(options.output_format, &report)?; print_json_value(options.output_format, &report)?;
return Ok(()); return Ok(());
} }
@@ -7264,6 +7273,44 @@ mod tests {
assert_eq!(sync_command_rpc_method(&options, "repair"), None); assert_eq!(sync_command_rpc_method(&options, "repair"), None);
} }
#[test]
fn repair_command_uses_rpc_when_default_daemon_shape() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let seen = Arc::new(Mutex::new(Vec::<(String, Option<serde_json::Value>)>::new()));
let seen_calls = Arc::clone(&seen);
let options = parse(&[
"bat",
"repair",
"--json",
"--state-dir",
state_dir.to_str().unwrap(),
])
.unwrap();
run_sync_command_with_rpc(
&options,
"repair",
|_| true,
move |_state_dir, method, params| {
seen_calls
.lock()
.unwrap()
.push((method.to_string(), params.clone()));
Ok(serde_json::json!({
"task_id": "task-fake-repair",
"kind": "resource.repair",
}))
},
)
.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_REPAIR);
assert_eq!(seen[0].1, None);
}
#[test] #[test]
fn explicit_no_quiet_up_to_date_overrides_watch_default() { fn explicit_no_quiet_up_to_date_overrides_watch_default() {
let options = parse(&[ let options = parse(&[
+423
View File
@@ -0,0 +1,423 @@
// Package backendrpc is the Go client for the local Rust Resource Backend.
//
// The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0.
// It is the default Go integration path for bat-api; do not shell out to the
// bat binary for normal service calls.
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sync/atomic"
"time"
)
const jsonRPCVersion = "2.0"
// Client calls the local Rust daemon through a Unix domain socket.
type Client struct {
SocketPath string
Timeout time.Duration
// DialContext exists for tests and alternative local transports.
DialContext func(ctx context.Context, network string, address string) (net.Conn, error)
seq atomic.Uint64
}
// New returns a client for the given bat.sock path.
func New(socketPath string) *Client {
return &Client{SocketPath: socketPath, Timeout: 30 * time.Second}
}
// APIError mirrors the Rust ApiError envelope payload.
type APIError struct {
Code string `json:"code"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Location string `json:"location"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
func (e *APIError) Error() string {
if e == nil {
return "<nil api error>"
}
if e.Code == "" {
return e.Message
}
return fmt.Sprintf("%s %s", e.Code, e.Message)
}
// JSONRPCError is a transport-level JSON-RPC error.
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *JSONRPCError) Error() string {
if e == nil {
return "<nil json-rpc error>"
}
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
}
// Envelope is the application-level payload carried in JSON-RPC result.
type Envelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data json.RawMessage `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
// Call invokes method and decodes the envelope data into out when out is non-nil.
func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) {
envelope, err := c.callEnvelope(ctx, method, params)
if err != nil {
return nil, err
}
if !envelope.OK {
if envelope.Error != nil {
return envelope, envelope.Error
}
return envelope, errors.New("backend rpc returned ok=false without error payload")
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return envelope, fmt.Errorf("decode %s response data: %w", method, err)
}
}
return envelope, nil
}
func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) {
if c.SocketPath == "" {
return nil, errors.New("backend rpc socket path is empty")
}
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline)
}
req := rpcRequest{
JSONRPC: jsonRPCVersion,
ID: c.seq.Add(1),
Method: method,
Params: params,
}
if err := json.NewEncoder(conn).Encode(&req); err != nil {
return nil, fmt.Errorf("write backend rpc request %s: %w", method, err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read backend rpc response %s: %w", method, err)
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err)
}
if resp.Error != nil {
return nil, resp.Error
}
if len(resp.Result) == 0 {
return nil, fmt.Errorf("backend rpc %s response missing result", method)
}
var envelope Envelope
if err := json.Unmarshal(resp.Result, &envelope); err != nil {
return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err)
}
return &envelope, nil
}
func (c *Client) dial(ctx context.Context) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, "unix", c.SocketPath)
}
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", c.SocketPath)
}
func (c *Client) deadline(ctx context.Context) (time.Time, bool) {
if deadline, ok := ctx.Deadline(); ok {
return deadline, true
}
if c.Timeout > 0 {
return time.Now().Add(c.Timeout), true
}
return time.Time{}, false
}
type boolParam struct {
Force bool `json:"force"`
}
type pageParam struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type tailParam struct {
Tail int `json:"tail"`
}
type taskIDParam struct {
TaskID string `json:"task_id"`
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
Force *bool `json:"force,omitempty"`
}
// TaskAccepted is returned when an async backend task is queued.
type TaskAccepted struct {
TaskID string `json:"task_id"`
Kind string `json:"kind"`
}
// TaskRecord is the pollable task state.
type TaskRecord struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Stage *string `json:"stage,omitempty"`
Message *string `json:"message,omitempty"`
CreatedAt uint64 `json:"created_at"`
UpdatedAt uint64 `json:"updated_at"`
StartedAt *uint64 `json:"started_at,omitempty"`
FinishedAt *uint64 `json:"finished_at,omitempty"`
Error *APIError `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
type TaskList struct {
Tasks []TaskRecord `json:"tasks"`
}
type TaskCancelResult struct {
TaskID string `json:"task_id"`
CancelRequested bool `json:"cancel_requested"`
Note string `json:"note,omitempty"`
}
type TaskLogs struct {
TaskID string `json:"task_id"`
Lines []string `json:"lines"`
}
type DoctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message"`
}
type DoctorReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
Healthy bool `json:"healthy"`
Checks []DoctorCheck `json:"checks"`
}
type LogsReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
LogPath string `json:"log_path"`
Exists bool `json:"exists"`
Empty bool `json:"empty"`
Bytes int `json:"bytes"`
TotalLines int `json:"total_lines"`
ReturnedLines int `json:"returned_lines"`
Content string `json:"content"`
}
type DaemonStatusReport struct {
Status string `json:"status"`
Message string `json:"message"`
Running bool `json:"running"`
PID *int `json:"pid,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
RPCAvailable bool `json:"rpc_available"`
CurrentStage *string `json:"current_stage,omitempty"`
CurrentMessage *string `json:"current_message,omitempty"`
}
type ResourceState struct {
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
VersionState json.RawMessage `json:"version_state,omitempty"`
LastUpdateStatus *string `json:"last_update_status,omitempty"`
LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"`
}
type ResourceManifestEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes *uint64 `json:"bytes,omitempty"`
BLAKE3 string `json:"blake3,omitempty"`
}
type ResourceManifestPage struct {
Available bool `json:"available"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Entries []ResourceManifestEntry `json:"entries,omitempty"`
}
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
var out DaemonStatusReport
_, err := c.Call(ctx, "daemon.status", nil, &out)
return &out, err
}
func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) {
var out LogsReport
_, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out)
return &out, err
}
func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.stop", nil, &out)
return &out, err
}
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.reload", nil, &out)
return &out, err
}
func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) {
var out DoctorReport
_, err := c.Call(ctx, "daemon.doctor", nil, &out)
return &out, err
}
func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) {
var out ResourceState
_, err := c.Call(ctx, "resource.state", nil, &out)
return &out, err
}
func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.verify", nil, &out)
return &out, err
}
func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.repair", nil, &out)
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.versions", nil)
}
func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil)
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
var out TaskRecord
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskList(ctx context.Context) (*TaskList, error) {
var out TaskList
_, err := c.Call(ctx, "task.list", nil, &out)
return &out, err
}
func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) {
var out TaskCancelResult
_, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) {
var out TaskLogs
_, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) {
envelope, err := c.Call(ctx, method, params, nil)
if err != nil {
return nil, err
}
return envelope.Data, nil
}
+207
View File
@@ -0,0 +1,207 @@
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"testing"
"time"
)
type testRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type testEnvelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type testResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client {
t.Helper()
client := New("bat.sock")
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func(conn net.Conn) {
defer conn.Close()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
}
var req testRequest
if err := json.Unmarshal(line, &req); err != nil {
return
}
resp := handler(t, req)
if len(resp.ID) == 0 {
resp.ID = req.ID
}
if resp.JSONRPC == "" {
resp.JSONRPC = jsonRPCVersion
}
_ = json.NewEncoder(conn).Encode(resp)
}(serverConn)
return clientConn, nil
}
return client
}
func TestResourceRepairQueuesTask(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.repair" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "accepted",
RequestID: "req-test-1",
Data: map[string]any{
"task_id": "task-1",
"kind": "resource.repair",
},
},
}
})
task, err := client.ResourceRepair(context.Background())
if err != nil {
t.Fatalf("ResourceRepair error: %v", err)
}
if task.TaskID != "task-1" || task.Kind != "resource.repair" {
t.Fatalf("unexpected task: %#v", task)
}
}
func TestResourceListSendsPagination(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.list" {
t.Fatalf("method = %s", req.Method)
}
var params pageParam
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Offset != 2 || params.Limit != 1 {
t.Fatalf("params = %#v", params)
}
bytes := uint64(42)
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-2",
Data: map[string]any{
"available": true,
"resource_root": "/tmp/resources/current",
"manifest_version": 1,
"total_entries": 3,
"offset": 2,
"limit": 1,
"entries": []map[string]any{
{
"url": "https://example.invalid/a.zip",
"destination": "a.zip",
"bytes": bytes,
"blake3": "abc",
},
},
},
},
}
})
page, err := client.ResourceList(context.Background(), 2, 1)
if err != nil {
t.Fatalf("ResourceList error: %v", err)
}
if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 {
t.Fatalf("unexpected page: %#v", page)
}
if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 {
t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: false,
Status: "error",
Error: APIError{
Code: "BAT-ERR-700004",
Kind: "task_not_found",
Domain: "rpc",
Location: "task.status",
Message: "任务不存在:missing",
Retryable: false,
},
RequestID: "req-test-3",
},
}
})
_, err := client.TaskStatus(context.Background(), "missing")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if apiErr.Code != "BAT-ERR-700004" {
t.Fatalf("code = %s", apiErr.Code)
}
}
func TestTransportErrorReturnsJSONRPCError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
return testResponse{
Error: &JSONRPCError{
Code: -32700,
Message: "parse error",
},
}
})
_, err := client.DaemonStatus(context.Background())
var rpcErr *JSONRPCError
if !errors.As(err, &rpcErr) {
t.Fatalf("expected JSONRPCError, got %T %v", err, err)
}
if rpcErr.Code != -32700 {
t.Fatalf("code = %d", rpcErr.Code)
}
}
func TestContextDeadlineIsApplied(t *testing.T) {
client := New("bat.sock")
client.Timeout = time.Millisecond
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
_, err := client.DaemonStatus(ctx)
if err == nil {
t.Fatal("expected dial error")
}
}