diff --git a/.gitignore b/.gitignore index f77e7f4..97d0cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ *.dll *.so *.dylib -bat +/bat # Test binary, built with `go test -c` *.test diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 6abaf9b..4810c41 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -10,7 +10,7 @@ ## 1. 总体判断 -当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go CLI/API/Web 仍未形成产品入口** 阶段。 +当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。 Rust 侧官方日服资源链路已经从实验验证推进到正式入口: @@ -198,12 +198,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: ### 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` 已存在。 -- 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` 这类最小演示能力。 - `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),当前处于长期运行测试阶段,运行报告将在后续提供。 -- Go CLI 端到端测试,因为 Go 产品入口尚未完成。 +- Go CLI 端到端测试,因为 Go 产品入口尚未完成;`internal/backendrpc` 已有 fake transport 单测覆盖。 - Web/API 测试,因为 Web/API 尚未实现。 --- @@ -269,7 +270,7 @@ GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue 下一阶段必须优先完成: 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;是否继续作为长期产品入口需要单独收敛。 +2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。 5. Issue #2(P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 @@ -291,5 +292,5 @@ GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 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 解析起步。 diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 9e95690..97ef9b0 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 文档索引 -- **更新时间**:2026-07-20 +- **更新时间**:2026-07-24 - **说明**:本索引用于快速定位当前权威文档和历史资料。 --- @@ -14,6 +14,7 @@ - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 +- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。 - `CHANGELOG.md`:版本变更记录。 - `AGENTS.md`:AI agent 和自动化开发助手长期规则。 - `CONTRIBUTING.md`:贡献者协作、提交和验证要求。 @@ -25,6 +26,7 @@ - `docs/architecture/README.md`:总体架构设计。 - `docs/api/README.md`:API 设计入口。 +- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。 - `docs/guides/development.md`:开发指南。 - `docs/guides/deployment.md`:部署指南。 - `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。 @@ -78,12 +80,13 @@ 3. `docs/guides/official-resource-test-pull.md` 4. `docs/guides/official-full-pull-smoke.md` 5. `docs/architecture/official-resource-backend.md` -6. `docs/reports/CURRENT_GAPS.md` -7. `docs/guides/baseline.md` -8. `docs/architecture/README.md` -9. `docs/guides/development.md` -10. `CONTRIBUTING.md` -11. `AGENTS.md` +6. `docs/reference/rpc-backend-api.md` +7. `docs/reports/CURRENT_GAPS.md` +8. `docs/guides/baseline.md` +9. `docs/architecture/README.md` +10. `docs/guides/development.md` +11. `CONTRIBUTING.md` +12. `AGENTS.md` --- @@ -98,6 +101,7 @@ - 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` 校验。 +- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。 - 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。 - `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。 - Addressables 当前真实形态 fixture/golden 覆盖。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 20a5061..e091335 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -40,7 +40,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。 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 兼容层。 5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。 6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 @@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 **目标**:完成可长期使用的 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 可见模型。 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 后台运维命令返回。 -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。 7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。 8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。 @@ -380,7 +380,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ## 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 client,Go `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009): 1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。 2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。 diff --git a/README.md b/README.md index 65bb1c1..07e7324 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **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-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 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 的默认路径。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 @@ -156,6 +157,7 @@ BlueArchiveToolkit/ │ ├── bat-assetbundle/ │ ├── bat-patch/ │ └── bat-ffi/ # 可选无状态 C ABI 兼容层 +├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client ├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径 ├── cmd/ # Go CLI 试验骨架与后续产品入口 ├── pkg/ # Go SDK 包,尚未实现 diff --git a/USERGUIDE.md b/USERGUIDE.md index 8ebdad3..c8a5d8d 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -239,7 +239,9 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon ## 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 diff --git a/cmd/bat/doctor.go b/cmd/bat/doctor.go new file mode 100644 index 0000000..b6089a2 --- /dev/null +++ b/cmd/bat/doctor.go @@ -0,0 +1,10 @@ +package main + +import ( + "fmt" +) + +func runDoctor() error { + fmt.Println("bat doctor: ok") + return nil +} diff --git a/cmd/bat/ffi.go b/cmd/bat/ffi.go new file mode 100644 index 0000000..1c8ad69 --- /dev/null +++ b/cmd/bat/ffi.go @@ -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() +} diff --git a/cmd/bat/main.go b/cmd/bat/main.go new file mode 100644 index 0000000..82b10e4 --- /dev/null +++ b/cmd/bat/main.go @@ -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 ") + fmt.Println(" bat sync plan [previous-json]") +} diff --git a/cmd/bat/manifest.go b/cmd/bat/manifest.go new file mode 100644 index 0000000..0509bb1 --- /dev/null +++ b/cmd/bat/manifest.go @@ -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 ") + } + + 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 +} diff --git a/cmd/bat/sync.go b/cmd/bat/sync.go new file mode 100644 index 0000000..d9fd5fa --- /dev/null +++ b/cmd/bat/sync.go @@ -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 [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 +} diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 96d9fb9..3e3f0ea 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -189,7 +189,7 @@ 集成边界: -1. 当前生产集成路径是运行 `bat --json` 并消费结构化 report;未来 Go CLI 若继续作为产品入口,也应优先使用该进程边界或 daemon RPC。 +1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPC,one-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。 2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。 3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。 @@ -280,6 +280,7 @@ Linux 生产路径: daemon(`bat --daemon`)在 `/bat.sock` 上提供 Unix socket JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。 +稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。 ### 7.1 协议契约 @@ -304,8 +305,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 ### 7.2 Go 层职责边界 - Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发, - 以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个 - JSON-RPC 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。 + 以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon + (连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口 + 尚未完成,`cmd/bat` 仍是试验骨架。 - Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、 版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。 - Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源 diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index f9f992c..6d255c5 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -43,8 +43,8 @@ go vet ./... 说明: -1. 当前已有 `cmd/bat` 和 `internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成。 -2. 如果后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。 +1. 当前已有 `internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成。 +2. 后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。 3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。 diff --git a/docs/guides/development.md b/docs/guides/development.md index 985a144..799176d 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -145,7 +145,7 @@ go test ./... 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` 二进制测试。 -`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` 进程边界。 ### 集成测试 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md new file mode 100644 index 0000000..37ed9f1 --- /dev/null +++ b/docs/reference/rpc-backend-api.md @@ -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` 作为常规调用路径。 + diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 71111b2..c927c52 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -177,14 +177,14 @@ 现象: - 当前可用的用户同步/运维入口是 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 检查和端到端测试。 - 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md`、`PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。 验收: - `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` 契约一致。 - `go test ./...`、`go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。 @@ -471,7 +471,7 @@ ## 6. 当前关闭顺序建议 1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。 -2. issue #1:Rust daemon/backend API 边界收口;`resource.repair`、`resource.list`、`daemon.doctor` 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。 +2. issue #1:Rust 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。 4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。 5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。 diff --git a/go.mod b/go.mod index ebd519c..260f8cf 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module bluearchive-toolkit +module bat-api go 1.26.4 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 9c91842..0568c0e 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -3036,8 +3036,17 @@ struct CommandReport { } fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { - if let Some(rpc_method) = sync_command_rpc_method(options, command_name) - .filter(|_| daemon_rpc_available(&options.state_dir)) + run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call) +} + +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) -> anyhow::Result, +) -> 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 params = if rpc_method == RPC_METHOD_REFRESH { @@ -3045,7 +3054,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow: } else { 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)?; return Ok(()); } @@ -7264,6 +7273,44 @@ mod tests { 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)>::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] fn explicit_no_quiet_up_to_date_overrides_watch_default() { let options = parse(&[ diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go new file mode 100644 index 0000000..071c4ad --- /dev/null +++ b/internal/backendrpc/client.go @@ -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 "" + } + 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 "" + } + 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 +} diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go new file mode 100644 index 0000000..2472e67 --- /dev/null +++ b/internal/backendrpc/client_test.go @@ -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, ¶ms); 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") + } +}