feat(bat): 补全工作流校验与调度过滤
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。

Refs #43
This commit is contained in:
2026-08-03 23:47:59 +08:00
parent 0784d5b532
commit 1933d6acb0
19 changed files with 714 additions and 49 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized``bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized``bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。
15. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、离线翻译工作台、人工文本修改、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule CRUD 已通过 `bat.sock``schedule.*` RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md` 15. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本修改、工作台发布前校验、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD 已通过 `bat.sock``schedule.*` RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json` 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
+1 -1
View File
@@ -36,7 +36,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
11. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD、离线翻译工作台、人工文本修改、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 已经通过 `bat.sock``bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 11. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本修改、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 已经通过 `bat.sock``bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。
12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
### 仍是骨架或占位 ### 仍是骨架或占位
+3 -1
View File
@@ -47,9 +47,11 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `res pull` | 拉取官方资源;支持单次、限定次数和 `--watch` 周期执行 | | `res pull` | 拉取官方资源;支持单次、限定次数和 `--watch` 周期执行 |
| `res schedule` | 管理资源拉取计划;CLI、RPC 和 `bat-api` dashboard 共用计划状态 | | `res schedule` | 管理资源拉取计划;CLI、RPC 和 `bat-api` dashboard 共用计划状态 |
| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 | | `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 |
| `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 |
| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle | | `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle |
| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 | | `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 |
| `i18n set` | 手动修改一个翻译工作台条目 | | `i18n set` | 手动修改一个翻译工作台条目 |
| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 |
| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID | | `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID |
| `i18n schedule` | 管理翻译和汉化发布计划 | | `i18n schedule` | 管理翻译和汉化发布计划 |
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 | | `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
@@ -123,7 +125,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
| `GET /openapi.yaml` | bat-api OpenAPI 文档 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 |
| `GET /admin/` | 管理控制入口与允许操作列表 | | `GET /admin/` | 管理控制入口与允许操作列表 |
| `GET /admin/schedules` | 读取 Rust `bat` 调度计划;需要管理 token | | `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata``game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false``bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。 launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata``game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false``bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。
+31 -3
View File
@@ -52,6 +52,16 @@ bat parse run \
解析结果会刷新 `official-parse-cache.json``official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。 解析结果会刷新 `official-parse-cache.json``official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。
清理当前 release 的可再生解析缓存和离线翻译队列:
```bash
bat parse clear-cache \
--resource-root /tmp/bat-resources/versions/<release-id> \
--force
```
该命令不会删除 `translation-tasks.sqlite`;worker 状态必须通过任务接口单独维护。
批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset``string_field` 和受支持的语义 `field` 操作: 批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset``string_field` 和受支持的语义 `field` 操作:
```bash ```bash
@@ -81,6 +91,17 @@ bat i18n set \
也可以使用 `--translated-file` 读取 UTF-8 文本。工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 TextUnit 索引,拒绝过期 release、source text 或 patch 目标。 也可以使用 `--translated-file` 读取 UTF-8 文本。工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 TextUnit 索引,拒绝过期 release、source text 或 patch 目标。
发布前可只做工作台审计:
```bash
bat i18n validate \
--resource-root /tmp/bat-resources/versions/<release-id> \
--translation-file /tmp/bat-workbench.json
```
报告会区分未审核、原文未变化、可直接 `i18n publish` 的 TextAsset,以及必须使用
`parse repack` 的 TypeTree/嵌套 archive 条目。
发布汉化 release 发布汉化 release
```bash ```bash
@@ -123,7 +144,12 @@ bat res schedule run --state-dir /tmp/bat-schedule
`parse schedule add` 默认动作是 `run``i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack``--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。 `parse schedule add` 默认动作是 `run``i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack``--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。
`schedule update` 可以用 `--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。`schedule run --force` 会忽略到期时间立即执行指定计划。 `res/parse/i18n schedule list` 默认只显示对应一级命令的计划;也可以用
`--schedule-id``--schedule-enabled``--schedule-disabled` 过滤。计划删除和执行
会校验一级命令作用域,避免误操作其他工作流。`schedule update` 可以用
`--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
限制本轮最多执行 N 个到期计划。
## bat-api 调度接口 ## bat-api 调度接口
@@ -137,10 +163,12 @@ dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划
`bat-api` 对应接口为 `GET /admin/schedules` `bat-api` 对应接口为 `GET /admin/schedules`
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run` `POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`
均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。请求字段沿用 Rust 均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。列表接口支持 `id``group`
`enabled` query 过滤;请求字段沿用 Rust
contract`id``group``action``args``next_run_unix_seconds` contract`id``group``action``args``next_run_unix_seconds`
`delay_seconds``every_seconds``count``clear_args``clear_every` `delay_seconds``every_seconds``count``clear_args``clear_every`
`enabled``schedule.run` 额外接受 `force` `enabled``schedule.list` 额外接受 `id``group``enabled` 过滤,
`schedule.run` 额外接受 `group``force``max_runs`
## 边界 ## 边界
+5 -3
View File
@@ -167,11 +167,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| 方法 | 状态 | params | data | | 方法 | 状态 | params | data |
|---|---|---|---| |---|---|---|---|
| `schedule.list` | 已实现 | `null` | `{ "command": "schedule-list", "schedules": [...] }`。 | | `schedule.list` | 已实现 | `null``{ "id": "...", "group": "res", "enabled": true }` | `{ "command": "schedule-list", "query": {...}, "schedules": [...] }`。 |
| `schedule.add` | 已实现 | 调度 mutation | 新建 schedule report。 | | `schedule.add` | 已实现 | 调度 mutation | 新建 schedule report。 |
| `schedule.update` | 已实现 | 调度 mutation,必须有 `id` | 更新后的 schedule report。 | | `schedule.update` | 已实现 | 调度 mutation,必须有 `id` | 更新后的 schedule report。 |
| `schedule.remove` | 已实现 | `{ "id": "daily-pull" }` | 删除报告。 | | `schedule.remove` | 已实现 | `{ "id": "daily-pull" }` | 删除报告。 |
| `schedule.run` | 已实现 | `{ "id": "daily-pull", "force": true }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行所有到期计划。 | | `schedule.run` | 已实现 | `{ "id": "daily-pull", "group": "res", "force": true, "max_runs": 1 }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行指定 group 的到期计划。 |
调度 mutation 字段如下: 调度 mutation 字段如下:
@@ -179,7 +179,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|---|---|---| |---|---|---|
| `id` | string | 计划 IDadd 必填,update/remove 用于定位。 | | `id` | string | 计划 IDadd 必填,update/remove 用于定位。 |
| `group` | string | `res``parse``i18n`;对应一级工作流。 | | `group` | string | `res``parse``i18n`;对应一级工作流。 |
| `action` | string | `res``pull/refresh/verify/repair``parse``run/repack``i18n``run/export/publish`。 | | `action` | string | `res``pull/refresh/verify/repair``parse``run/repack/clear-cache``i18n``run/export/validate/publish`。 |
| `args` | string[] | 目标工作流的 CLI 参数。 | | `args` | string[] | 目标工作流的 CLI 参数。 |
| `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 | | `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 |
| `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 | | `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 |
@@ -189,6 +189,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `clear_every` | bool | update 时清除周期并转为单次计划。 | | `clear_every` | bool | update 时清除周期并转为单次计划。 |
| `enabled` | bool | 启用或停用计划。 | | `enabled` | bool | 启用或停用计划。 |
`schedule.run.max_runs` 必须大于 0,用于限制一次轮询最多领取的到期计划数。
`count > 1` 必须和周期同时存在;`schedule.run``force=true` 只忽略 `count > 1` 必须和周期同时存在;`schedule.run``force=true` 只忽略
到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后 到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后
再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。 再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。
+2 -1
View File
@@ -167,7 +167,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
- 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。 - 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。
- 文件级写入入口可用于隔离测试和上层工具显式产物生成。 - 文件级写入入口可用于隔离测试和上层工具显式产物生成。
- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack``i18n publish` 仅覆盖已有 patch 实现支持的安全子集。 - 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack``parse clear-cache``i18n validate``i18n publish` 仅覆盖已有 patch 实现支持的安全子集。
验收: 验收:
@@ -294,6 +294,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
- `translation-tasks.sqlite``schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。 - `translation-tasks.sqlite``schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。
- `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。 - `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。
- `translation.task.update` RPC 已提供 queued/running/failed/completed/skipped 状态回写契约,provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。 - `translation.task.update` RPC 已提供 queued/running/failed/completed/skipped 状态回写契约,provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。
- `i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。
- 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。 - 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。
验收: 验收:
+94 -12
View File
@@ -13,9 +13,9 @@ use bat_infrastructure::{
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
validate_output_root, validate_runtime_state_dir, write_file_atomic, validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole, LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole,
OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig,
OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource, OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource,
OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress,
@@ -24,10 +24,10 @@ use bat_infrastructure::{
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport, PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus, SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus,
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, LOCALIZED_CURRENT_LINK,
LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE,
MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE,
PRIVATE_FILE_MODE, OFFICIAL_TEXTUNIT_INDEX_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, PRIVATE_FILE_MODE,
}; };
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -79,7 +79,8 @@ use translation_query::{
update_translation_task_status_report, update_translation_task_status_report,
}; };
use workflow_commands::{ use workflow_commands::{
run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_set, run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
run_translation_set, run_translation_validate,
}; };
const EXIT_ERROR: i32 = 1; const EXIT_ERROR: i32 = 1;
@@ -192,10 +193,18 @@ fn run() -> anyhow::Result<i32> {
run_repeated_workflow(&options, "parse", run_parse_once)?; run_repeated_workflow(&options, "parse", run_parse_once)?;
Ok(0) Ok(0)
} }
CliCommand::ParseClearCache => {
run_parse_clear_cache(&options)?;
Ok(0)
}
CliCommand::Translate => { CliCommand::Translate => {
run_repeated_workflow(&options, "translate", run_translate_once)?; run_repeated_workflow(&options, "translate", run_translate_once)?;
Ok(0) Ok(0)
} }
CliCommand::TranslationValidate => {
run_translation_validate(&options)?;
Ok(0)
}
CliCommand::TranslationSet => { CliCommand::TranslationSet => {
run_translation_set(&options)?; run_translation_set(&options)?;
Ok(0) Ok(0)
@@ -356,6 +365,7 @@ struct CliOptions {
schedule_delay: Option<Duration>, schedule_delay: Option<Duration>,
schedule_every: Option<Duration>, schedule_every: Option<Duration>,
schedule_count: Option<usize>, schedule_count: Option<usize>,
schedule_max_runs: Option<usize>,
schedule_args: Vec<String>, schedule_args: Vec<String>,
schedule_clear_args: bool, schedule_clear_args: bool,
schedule_clear_every: bool, schedule_clear_every: bool,
@@ -438,6 +448,7 @@ impl Default for CliOptions {
schedule_delay: None, schedule_delay: None,
schedule_every: None, schedule_every: None,
schedule_count: None, schedule_count: None,
schedule_max_runs: None,
schedule_args: Vec::new(), schedule_args: Vec::new(),
schedule_clear_args: false, schedule_clear_args: false,
schedule_clear_every: false, schedule_clear_every: false,
@@ -504,7 +515,9 @@ enum CliCommand {
Run, Run,
Pull, Pull,
Parse, Parse,
ParseClearCache,
Translate, Translate,
TranslationValidate,
TranslationSet, TranslationSet,
Repack, Repack,
PublishLocalized, PublishLocalized,
@@ -1762,11 +1775,31 @@ fn dispatch_rpc_method(
"resource.state", "resource.state",
build_resource_state_report(state_dir), build_resource_state_report(state_dir),
), ),
RPC_METHOD_SCHEDULE_LIST => rpc_envelope_from_result( RPC_METHOD_SCHEDULE_LIST => {
let params = request
.params
.as_ref()
.filter(|params| !params.is_null())
.map(|params| serde_json::from_value(params.clone()))
.transpose()
.map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_SCHEDULE_LIST,
format!("params 无效:{error}"),
)
});
let params = match params {
Ok(Some(params)) => params,
Ok(None) => schedule_commands::ScheduleListRequest::default(),
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id, request_id,
RPC_METHOD_SCHEDULE_LIST, RPC_METHOD_SCHEDULE_LIST,
schedule_commands::schedule_list_report(state_dir), schedule_commands::schedule_list_report_with_request(state_dir, params),
), )
}
RPC_METHOD_SCHEDULE_ADD => { RPC_METHOD_SCHEDULE_ADD => {
let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>( let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>(
request.params.as_ref(), request.params.as_ref(),
@@ -6754,6 +6787,16 @@ fn parse_args_with_env(
options.schedule_count = Some(value); options.schedule_count = Some(value);
options.schedule_option_explicit = true; options.schedule_option_explicit = true;
} }
"--schedule-max-runs" => {
let value = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
if value == 0 {
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
}
options.schedule_max_runs = Some(value);
options.schedule_option_explicit = true;
}
"--schedule-arg" => { "--schedule-arg" => {
options options
.schedule_args .schedule_args
@@ -7120,11 +7163,40 @@ fn parse_args_with_env(
)); ));
} }
} }
CliCommand::ParseClearCache => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("parse clear-cache 只支持单次执行"));
}
if !options.config.force {
return Err(anyhow::anyhow!(
"parse clear-cache 是破坏性操作,必须显式指定 --force"
));
}
if options.config.dry_run || options.run_count.is_some() {
return Err(anyhow::anyhow!(
"parse clear-cache 不支持 --dry-run 或 --run-count"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::TranslationValidate => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
}
if options.config.force || options.config.dry_run || options.run_count.is_some() {
return Err(anyhow::anyhow!(
"i18n validate 不支持 --force、--dry-run 或 --run-count"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::TranslationSet | CliCommand::Repack => { CliCommand::TranslationSet | CliCommand::Repack => {
if options.watch || options.daemon || options.daemon_child { if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("translation set/repack 只支持单次执行")); return Err(anyhow::anyhow!("translation set/repack 只支持单次执行"));
} }
if options.config.force || options.sync_option_explicit { if options.config.force || options.sync_option_explicit || options.run_count.is_some() {
return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项")); return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项"));
} }
options.progress = false; options.progress = false;
@@ -7151,6 +7223,11 @@ fn parse_args_with_env(
if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) { if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询")); return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询"));
} }
if options.schedule_max_runs.is_some()
&& !matches!(options.command, CliCommand::ScheduleRun)
{
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
}
options.progress = false; options.progress = false;
options.banner = false; options.banner = false;
} }
@@ -7294,6 +7371,7 @@ fn parse_parse_command(
"status" => CliCommand::ParseStatus, "status" => CliCommand::ParseStatus,
"text-units" => CliCommand::ParseTextUnits, "text-units" => CliCommand::ParseTextUnits,
"errors" => CliCommand::ParseErrors, "errors" => CliCommand::ParseErrors,
"clear-cache" => CliCommand::ParseClearCache,
"schedule" => { "schedule" => {
options.schedule_group = Some("parse".to_string()); options.schedule_group = Some("parse".to_string());
return parse_schedule_command(args, options); return parse_schedule_command(args, options);
@@ -7315,6 +7393,7 @@ fn parse_translation_command(
"run" => CliCommand::Translate, "run" => CliCommand::Translate,
"export" => CliCommand::Translate, "export" => CliCommand::Translate,
"set" => CliCommand::TranslationSet, "set" => CliCommand::TranslationSet,
"validate" => CliCommand::TranslationValidate,
"publish" => CliCommand::PublishLocalized, "publish" => CliCommand::PublishLocalized,
"tasks" => CliCommand::TranslationTasks, "tasks" => CliCommand::TranslationTasks,
"handoff" => CliCommand::TranslationHandoff, "handoff" => CliCommand::TranslationHandoff,
@@ -7435,10 +7514,12 @@ fn print_usage(binary: &str) {
eprintln!(" res pull Pull official resources once or repeatedly"); eprintln!(" res pull Pull official resources once or repeatedly");
eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)"); eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)");
eprintln!(" parse run Parse current official release"); eprintln!(" parse run Parse current official release");
eprintln!(" parse clear-cache Clear regenerable parse and translation queue files");
eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec"); eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec");
eprintln!(" i18n run Refresh offline translation work"); eprintln!(" i18n run Refresh offline translation work");
eprintln!(" i18n export Export an editable translation workbench"); eprintln!(" i18n export Export an editable translation workbench");
eprintln!(" i18n set Update one translation workbench entry"); eprintln!(" i18n set Update one translation workbench entry");
eprintln!(" i18n validate Validate workbench against the current official release");
eprintln!(" i18n publish Publish a localized release"); eprintln!(" i18n publish Publish a localized release");
eprintln!(" i18n schedule Manage translation schedules"); eprintln!(" i18n schedule Manage translation schedules");
eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
@@ -7589,6 +7670,7 @@ fn print_usage(binary: &str) {
eprintln!(" --schedule-delay <DURATION> Delay first execution from now"); eprintln!(" --schedule-delay <DURATION> Delay first execution from now");
eprintln!(" --schedule-every <DURATION> Period between executions"); eprintln!(" --schedule-every <DURATION> Period between executions");
eprintln!(" --schedule-count <N> Bounded execution count"); eprintln!(" --schedule-count <N> Bounded execution count");
eprintln!(" --schedule-max-runs <N> Maximum plans executed by one schedule run");
eprintln!(" --schedule-arg <ARG> Argument passed to scheduled child command"); eprintln!(" --schedule-arg <ARG> Argument passed to scheduled child command");
eprintln!(" --schedule-clear-args Clear args during schedule update"); eprintln!(" --schedule-clear-args Clear args during schedule update");
eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot"); eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot");
+131 -2
View File
@@ -1,5 +1,8 @@
use super::*; use super::*;
use crate::app::schedule_commands::read_schedule_file; use crate::app::schedule_commands::{
read_schedule_file, schedule_add_report, schedule_list_report_with_request,
ScheduleListRequest, ScheduleMutationRequest,
};
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> { fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
parse_args_from(values.iter().map(|value| value.to_string())) parse_args_from(values.iter().map(|value| value.to_string()))
@@ -201,6 +204,30 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
.unwrap(); .unwrap();
assert_eq!(options.command, CliCommand::Repack); assert_eq!(options.command, CliCommand::Repack);
assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json"))); assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json")));
let options = parse(&[
"bat",
"parse",
"clear-cache",
"--resource-root",
"/tmp/official-release",
"--force",
])
.unwrap();
assert_eq!(options.command, CliCommand::ParseClearCache);
assert!(options.config.force);
let options = parse(&[
"bat",
"i18n",
"validate",
"--resource-root",
"/tmp/official-release",
"--translation-file",
"/tmp/workbench.json",
])
.unwrap();
assert_eq!(options.command, CliCommand::TranslationValidate);
} }
#[test] #[test]
@@ -233,16 +260,47 @@ fn grouped_command_long_aliases_and_schedule_options_are_accepted() {
assert_eq!(options.schedule_count, Some(4)); assert_eq!(options.schedule_count, Some(4));
assert_eq!(options.schedule_args, vec!["--auto-discover"]); assert_eq!(options.schedule_args, vec!["--auto-discover"]);
let options = parse(&["bat", "translate", "schedule", "run", "--force"]).unwrap(); let options = parse(&[
"bat",
"translate",
"schedule",
"run",
"--force",
"--schedule-max-runs",
"2",
])
.unwrap();
assert_eq!(options.command, CliCommand::ScheduleRun); assert_eq!(options.command, CliCommand::ScheduleRun);
assert_eq!(options.schedule_group.as_deref(), Some("i18n")); assert_eq!(options.schedule_group.as_deref(), Some("i18n"));
assert!(options.config.force); assert!(options.config.force);
assert_eq!(options.schedule_max_runs, Some(2));
} }
#[test] #[test]
fn repeated_workflow_requires_explicit_interval_after_first_run() { fn repeated_workflow_requires_explicit_interval_after_first_run() {
assert!(parse(&["bat", "parse", "run", "--run-count", "2"]).is_err()); assert!(parse(&["bat", "parse", "run", "--run-count", "2"]).is_err());
assert!(parse(&["bat", "parse", "run", "--run-count", "0"]).is_err()); assert!(parse(&["bat", "parse", "run", "--run-count", "0"]).is_err());
assert!(parse(&["bat", "parse", "clear-cache"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"validate",
"--force",
"--translation-file",
"/tmp/workbench.json"
])
.is_err());
assert!(parse(&[
"bat",
"res",
"schedule",
"add",
"--schedule-id",
"pull",
"--schedule-max-runs",
"1"
])
.is_err());
assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err()); assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err());
assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err()); assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err());
assert!(parse(&[ assert!(parse(&[
@@ -346,6 +404,59 @@ fn schedule_crud_persists_and_updates_a_workflow_plan() {
.is_empty()); .is_empty());
} }
#[test]
fn schedule_scope_filters_and_protects_cross_workflow_mutations() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path();
schedule_add_report(
state_dir,
ScheduleMutationRequest {
id: Some("res-pull".to_string()),
group: Some("res".to_string()),
action: Some("pull".to_string()),
delay_seconds: Some(1),
..ScheduleMutationRequest::default()
},
)
.unwrap();
schedule_add_report(
state_dir,
ScheduleMutationRequest {
id: Some("parse-run".to_string()),
group: Some("parse".to_string()),
action: Some("run".to_string()),
delay_seconds: Some(1),
..ScheduleMutationRequest::default()
},
)
.unwrap();
let report = schedule_list_report_with_request(
state_dir,
ScheduleListRequest {
group: Some("res".to_string()),
..ScheduleListRequest::default()
},
)
.unwrap();
assert_eq!(report["schedules"].as_array().unwrap().len(), 1);
assert_eq!(report["schedules"][0]["id"], "res-pull");
let cross_workflow_remove = parse(&[
"bat",
"res",
"schedule",
"remove",
"--state-dir",
state_dir.to_str().unwrap(),
"--schedule-id",
"parse-run",
])
.unwrap();
assert!(run_schedule_remove(&cross_workflow_remove).is_err());
assert_eq!(read_schedule_file(state_dir).unwrap().schedules.len(), 2);
}
#[test] #[test]
fn dispatch_schedule_crud_uses_shared_state_file() { fn dispatch_schedule_crud_uses_shared_state_file() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
@@ -383,6 +494,24 @@ fn dispatch_schedule_crud_uses_shared_state_file() {
let list = serde_json::to_value(list).unwrap(); let list = serde_json::to_value(list).unwrap();
assert_eq!(list["ok"], true); assert_eq!(list["ok"], true);
assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull"); assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull");
let filtered = dispatch_rpc_method(
&rpc_request(
"schedule.list",
Some(serde_json::json!({
"group": "res",
"enabled": true
})),
),
state_dir,
&control,
&test_task_context(),
"req-schedule-list-filtered".to_string(),
);
let filtered = serde_json::to_value(filtered).unwrap();
assert_eq!(filtered["ok"], true);
assert_eq!(filtered["data"]["query"]["group"], "res");
assert_eq!(filtered["data"]["schedules"].as_array().unwrap().len(), 1);
} }
#[test] #[test]
@@ -116,18 +116,37 @@ pub(super) struct ScheduleMutationRequest {
pub(super) enabled: Option<bool>, pub(super) enabled: Option<bool>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleListRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleRunRequest { pub(super) struct ScheduleRunRequest {
#[serde(default, alias = "schedule_id")] #[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>, pub(super) id: Option<String>,
#[serde(default)] #[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) force: bool, pub(super) force: bool,
#[serde(default)]
pub(super) max_runs: Option<usize>,
} }
pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> { pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> {
let request = ScheduleListRequest {
id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
enabled: options.schedule_enabled,
};
print_json_value( print_json_value(
options.output_format, options.output_format,
&schedule_list_report(&options.state_dir)?, &schedule_list_report_with_request(&options.state_dir, request)?,
) )
} }
@@ -163,7 +182,9 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
loop { loop {
let request = ScheduleRunRequest { let request = ScheduleRunRequest {
id: options.schedule_id.clone(), id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
force: options.config.force, force: options.config.force,
max_runs: options.schedule_max_runs,
}; };
print_json_value( print_json_value(
options.output_format, options.output_format,
@@ -176,17 +197,37 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
} }
} }
pub(super) fn schedule_list_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> { pub(super) fn schedule_list_report_with_request(
state_dir: &Path,
request: ScheduleListRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK let _guard = SCHEDULE_FILE_LOCK
.lock() .lock()
.unwrap_or_else(|poison| poison.into_inner()); .unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let file = read_schedule_file(state_dir)?; let file = read_schedule_file(state_dir)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?;
let schedules = file
.schedules
.iter()
.filter(|entry| request.id.as_deref().is_none_or(|id| id == entry.id))
.filter(|entry| group.as_deref().is_none_or(|group| group == entry.group))
.filter(|entry| {
request
.enabled
.is_none_or(|enabled| enabled == entry.enabled)
})
.collect::<Vec<_>>();
Ok(serde_json::json!({ Ok(serde_json::json!({
"command": "schedule-list", "command": "schedule-list",
"status": "ok", "status": "ok",
"state_file": schedule_file_path(state_dir), "state_file": schedule_file_path(state_dir),
"schedules": file.schedules, "query": request,
"schedules": schedules,
})) }))
} }
@@ -312,11 +353,27 @@ pub(super) fn schedule_remove_report(
.as_deref() .as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?; .ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?;
let mut file = read_schedule_file(state_dir)?; let mut file = read_schedule_file(state_dir)?;
let before = file.schedules.len(); let group = request
file.schedules.retain(|entry| entry.id != id); .group
if file.schedules.len() == before { .as_deref()
return Err(anyhow::anyhow!("schedule 不存在:{id}")); .map(normalize_schedule_group)
.transpose()?;
let index = file
.schedules
.iter()
.position(|entry| entry.id == id)
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
if let Some(group) = group {
if file.schedules[index].group != group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令删除",
id,
file.schedules[index].group,
group
));
} }
}
file.schedules.remove(index);
write_schedule_file(state_dir, &file)?; write_schedule_file(state_dir, &file)?;
Ok(serde_json::json!({ Ok(serde_json::json!({
"command": "schedule-remove", "command": "schedule-remove",
@@ -337,13 +394,37 @@ pub(super) fn schedule_run_report(
let now = unix_seconds_now(); let now = unix_seconds_now();
let selected_id = request.id.as_deref(); let selected_id = request.id.as_deref();
let mut file = read_schedule_file(state_dir)?; let mut file = read_schedule_file(state_dir)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?;
if let (Some(id), Some(group)) = (selected_id, group.as_deref()) {
if let Some(entry) = file.schedules.iter().find(|entry| entry.id == id) {
if entry.group != group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令执行",
id,
entry.group,
group
));
}
}
}
if request.max_runs == Some(0) {
return Err(anyhow::anyhow!("max_runs 必须大于 0"));
}
let mut results = Vec::new(); let mut results = Vec::new();
for index in 0..file.schedules.len() { for index in 0..file.schedules.len() {
if request.max_runs.is_some_and(|max| results.len() >= max) {
break;
}
let due = { let due = {
let entry = &file.schedules[index]; let entry = &file.schedules[index];
entry.enabled entry.enabled
&& (request.force || entry.next_run_unix_seconds <= now) && (request.force || entry.next_run_unix_seconds <= now)
&& selected_id.is_none_or(|id| id == entry.id) && selected_id.is_none_or(|id| id == entry.id)
&& group.as_deref().is_none_or(|group| group == entry.group)
}; };
if !due { if !due {
continue; continue;
@@ -540,6 +621,12 @@ fn validate_schedule_command_options(
if options.schedule_count == Some(0) { if options.schedule_count == Some(0) {
return Err(anyhow::anyhow!("--schedule-count 必须大于 0")); return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
} }
if options.schedule_max_runs == Some(0) {
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
}
if options.schedule_max_runs.is_some() && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
}
if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) { if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"--schedule-clear-args 只适用于 schedule update" "--schedule-clear-args 只适用于 schedule update"
@@ -556,8 +643,8 @@ fn validate_schedule_command_options(
fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> { fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> {
let valid = match group { let valid = match group {
"res" => matches!(action, "pull" | "refresh" | "verify" | "repair"), "res" => matches!(action, "pull" | "refresh" | "verify" | "repair"),
"parse" => matches!(action, "run" | "repack"), "parse" => matches!(action, "run" | "repack" | "clear-cache"),
"i18n" => matches!(action, "run" | "export" | "publish"), "i18n" => matches!(action, "run" | "export" | "validate" | "publish"),
_ => false, _ => false,
}; };
if valid { if valid {
@@ -28,6 +28,38 @@ pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
) )
} }
pub(super) fn run_parse_clear_cache(options: &CliOptions) -> anyhow::Result<()> {
let (resource_root, release_id) = current_official_release(options)?;
let artifact_names = [
OFFICIAL_PARSE_CACHE_FILE,
OFFICIAL_TEXTUNIT_INDEX_FILE,
OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE,
CROWDIN_TEXTUNIT_QUEUE_FILE,
];
let mut removed = Vec::new();
for name in artifact_names {
let path = resource_root.join(name);
if remove_regenerable_file(&path)? {
removed.push(path);
}
}
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
"removed": removed,
"translation_task_repository_preserved": true,
});
print_report(
options.output_format,
&CommandReport {
command: "parse-clear-cache",
status: "cleared",
message: "当前官方 release 的可再生解析缓存和翻译队列已清理",
data,
},
)
}
pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> { pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> {
let (resource_root, release_id) = current_official_release(options)?; let (resource_root, release_id) = current_official_release(options)?;
let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?; let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?;
@@ -64,6 +96,23 @@ pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> {
) )
} }
pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<()> {
let path = options
.translation_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
let (resource_root, release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(path)?;
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
"translation_file": path,
"validation": validation,
});
print_json_value(options.output_format, &data)
}
pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
let path = options let path = options
.translation_file .translation_file
@@ -191,3 +240,25 @@ fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, St
} }
Ok((resource_root, release_id)) Ok((resource_root, release_id))
} }
fn remove_regenerable_file(path: &Path) -> anyhow::Result<bool> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"拒绝删除符号链接形式的可再生文件:{}",
path.display()
));
}
if !metadata.is_file() {
return Err(anyhow::anyhow!(
"可再生缓存路径不是普通文件:{}",
path.display()
));
}
fs::remove_file(path)?;
Ok(true)
}
+3 -3
View File
@@ -141,9 +141,9 @@ pub use translation_tasks::{
}; };
pub use translation_workflow::{ pub use translation_workflow::{
export_translation_workbench, localized_text_asset_patches, read_translation_workbench, export_translation_workbench, localized_text_asset_patches, read_translation_workbench,
repack_bundle, set_translation, write_translation_workbench, RepackOperation, RepackReport, repack_bundle, set_translation, validate_translation_workbench, write_translation_workbench,
RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, REPACK_SPEC_VERSION, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
TRANSLATION_WORKBENCH_VERSION, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
}; };
/// Infrastructure 版本号 /// Infrastructure 版本号
+155 -1
View File
@@ -11,7 +11,7 @@ use bat_assetbundle::{
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeSet; use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -66,6 +66,27 @@ pub struct TranslationWorkbenchEntry {
pub text_source_kind: Option<String>, pub text_source_kind: Option<String>,
} }
/// Summary produced by `i18n validate`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TranslationWorkbenchValidationReport {
/// Stable command name.
pub command: &'static str,
/// Validation status.
pub status: &'static str,
/// Number of entries in the workbench.
pub total_entries: usize,
/// Entries without a human translation.
pub unreviewed_entries: usize,
/// Entries whose translation equals the source text.
pub unchanged_entries: usize,
/// Entries with a changed translation.
pub changed_entries: usize,
/// Changed direct TextAsset entries usable by `i18n publish`.
pub publishable_entries: usize,
/// Changed TypeTree or nested-archive entries requiring `parse repack`.
pub repack_entries: usize,
}
/// Exports the current official TextUnit index as an editable workbench. /// Exports the current official TextUnit index as an editable workbench.
pub fn export_translation_workbench( pub fn export_translation_workbench(
resource_root: &Path, resource_root: &Path,
@@ -145,6 +166,99 @@ pub fn set_translation(
Ok(updated) Ok(updated)
} }
/// Validates a workbench against the current official TextUnit index.
///
/// This checks the release identity and every stored source/target location
/// before a publish operation. Unsupported patch targets are reported as
/// `repack_entries` so reviewers can choose the appropriate command.
pub fn validate_translation_workbench(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
workbench.official_release_id,
official_release_id
));
}
if workbench.official_resource_root != expected_root {
return Err(anyhow::anyhow!(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<HashMap<_, _>>();
let mut seen_ids = BTreeSet::new();
let mut seen_patch_targets = BTreeSet::new();
let mut unreviewed_entries = 0;
let mut unchanged_entries = 0;
let mut changed_entries = 0;
let mut publishable_entries = 0;
let mut repack_entries = 0;
for entry in &workbench.entries {
if !seen_ids.insert(entry.id.as_str()) {
return Err(anyhow::anyhow!("翻译工作台包含重复 TextUnit:{}", entry.id));
}
let current = index_by_id
.get(entry.id.as_str())
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?;
validate_workbench_entry(entry, current)?;
let Some(translated_text) = entry.translated_text.as_ref() else {
unreviewed_entries += 1;
continue;
};
if translated_text == &entry.source_text {
unchanged_entries += 1;
continue;
}
changed_entries += 1;
let is_publishable = entry.archive_entry.is_none()
&& entry.text_source_kind.as_deref() == Some("text_asset");
if is_publishable {
let serialized_file = entry
.serialized_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 serialized_file", entry.id))?;
let path_id = entry
.path_id
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
if !seen_patch_targets.insert((
entry.destination.clone(),
serialized_file.clone(),
path_id,
)) {
return Err(anyhow::anyhow!(
"翻译工作台包含重复 patch 目标:{}",
entry.id
));
}
publishable_entries += 1;
} else {
repack_entries += 1;
}
}
Ok(TranslationWorkbenchValidationReport {
command: "translation-validate",
status: "valid",
total_entries: workbench.entries.len(),
unreviewed_entries,
unchanged_entries,
changed_entries,
publishable_entries,
repack_entries,
})
}
/// Converts reviewed direct TextAsset entries to localized patch operations. /// Converts reviewed direct TextAsset entries to localized patch operations.
/// ///
/// TypeTree fields and zip-inner bundles are intentionally rejected here. /// TypeTree fields and zip-inner bundles are intentionally rejected here.
@@ -541,4 +655,44 @@ mod tests {
let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err(); let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err();
assert!(error.to_string().contains("不存在 TextUnit")); assert!(error.to_string().contains("不存在 TextUnit"));
} }
#[test]
fn validation_reports_publishable_and_unreviewed_entries() {
let temp = tempfile::TempDir::new().unwrap();
let index = crate::official_parse::OfficialTextUnitIndex {
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
generated_unix_seconds: 1,
resource_root: temp.path().to_path_buf(),
summary: Default::default(),
units: vec![OfficialTextUnitIndexUnit {
id: "unit-1".to_string(),
parse_entry_key: "bundle".to_string(),
source_url: "https://example.invalid/bundle".to_string(),
destination: "bundles/test.bundle".to_string(),
archive_entry: None,
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
unity_version: None,
source_text: "原文".to_string(),
serialized_file: Some("CAB-test".to_string()),
path_id: Some(7),
class_id: Some(49),
field_path: None,
field_offset: None,
field_byte_size: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
asset_name: Some("Story".to_string()),
context: Default::default(),
}],
errors: Vec::new(),
};
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
let mut wb = workbench(temp.path());
wb.entries[0].translated_text = Some("译文".to_string());
let report = validate_translation_workbench(temp.path(), "release-1", &wb).unwrap();
assert_eq!(report.total_entries, 1);
assert_eq!(report.changed_entries, 1);
assert_eq!(report.publishable_entries, 1);
assert_eq!(report.unreviewed_entries, 0);
}
} }
+23 -1
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"bat-api/internal/backendrpc" "bat-api/internal/backendrpc"
@@ -161,7 +162,12 @@ func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable")
return return
} }
result, err := backend.ScheduleList(r.Context()) params, err := scheduleListParams(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_query", err.Error())
return
}
result, err := backend.ScheduleList(r.Context(), params)
if err != nil { if err != nil {
s.writeControlBackendError(w, "schedule-list", err) s.writeControlBackendError(w, "schedule-list", err)
return return
@@ -174,6 +180,22 @@ func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
writeNoStoreJSON(w, http.StatusOK, result) writeNoStoreJSON(w, http.StatusOK, result)
} }
func scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error) {
query := r.URL.Query()
params := backendrpc.ScheduleListParams{
ID: query.Get("id"),
Group: query.Get("group"),
}
if raw := query.Get("enabled"); raw != "" {
enabled, err := strconv.ParseBool(raw)
if err != nil {
return backendrpc.ScheduleListParams{}, errors.New("enabled must be a boolean")
}
params.Enabled = &enabled
}
return params, nil
}
func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(ScheduleBackend) backend, ok := s.backend.(ScheduleBackend)
if !ok || backend == nil { if !ok || backend == nil {
+19 -2
View File
@@ -591,11 +591,13 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
type scheduleBackend struct { type scheduleBackend struct {
*controlBackend *controlBackend
scheduleCalls []string scheduleCalls []string
scheduleListParams []backendrpc.ScheduleListParams
scheduleRaw json.RawMessage scheduleRaw json.RawMessage
} }
func (b *scheduleBackend) ScheduleList(ctx context.Context) (json.RawMessage, error) { func (b *scheduleBackend) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.list") b.scheduleCalls = append(b.scheduleCalls, "schedule.list")
b.scheduleListParams = append(b.scheduleListParams, params)
return b.scheduleRaw, nil return b.scheduleRaw, nil
} }
@@ -631,7 +633,7 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
} }
s := NewServer(cfg, backend, nil) s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/schedules", nil) request := httptest.NewRequest(http.MethodGet, "/admin/schedules?id=nightly-pull&group=res&enabled=true", nil)
request.Header.Set("Authorization", "Bearer schedule-token") request.Header.Set("Authorization", "Bearer schedule-token")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request) s.Handler().ServeHTTP(recorder, request)
@@ -641,6 +643,13 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
if !json.Valid(recorder.Body.Bytes()) { if !json.Valid(recorder.Body.Bytes()) {
t.Fatalf("list body is not JSON: %s", recorder.Body.String()) t.Fatalf("list body is not JSON: %s", recorder.Body.String())
} }
if len(backend.scheduleListParams) != 1 ||
backend.scheduleListParams[0].ID != "nightly-pull" ||
backend.scheduleListParams[0].Group != "res" ||
backend.scheduleListParams[0].Enabled == nil ||
!*backend.scheduleListParams[0].Enabled {
t.Fatalf("list params=%#v", backend.scheduleListParams)
}
request = httptest.NewRequest( request = httptest.NewRequest(
http.MethodPost, http.MethodPost,
@@ -676,6 +685,14 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
if recorder.Code != http.StatusUnauthorized { if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String())
} }
request = httptest.NewRequest(http.MethodGet, "/admin/schedules?enabled=invalid", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid query status=%d body=%s", recorder.Code, recorder.Body.String())
}
} }
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
+18
View File
@@ -118,6 +118,20 @@ paths:
/admin/schedules: /admin/schedules:
get: get:
summary: List Rust-owned resource workflow schedules summary: List Rust-owned resource workflow schedules
parameters:
- name: id
in: query
schema:
type: string
- name: group
in: query
schema:
type: string
enum: [res, parse, i18n]
- name: enabled
in: query
schema:
type: boolean
responses: responses:
"200": "200":
description: Current schedule JSON report. description: Current schedule JSON report.
@@ -167,6 +181,10 @@ paths:
count: count:
type: integer type: integer
format: int64 format: int64
max_runs:
type: integer
format: int64
minimum: 1
clear_args: clear_args:
type: boolean type: boolean
clear_every: clear_every:
+3 -3
View File
@@ -42,7 +42,7 @@ type ControlBackend interface {
// dashboard. The JSON result remains Rust's report shape so the API does not // dashboard. The JSON result remains Rust's report shape so the API does not
// duplicate schedule state or invent a second schema. // duplicate schedule state or invent a second schema.
type ScheduleBackend interface { type ScheduleBackend interface {
ScheduleList(ctx context.Context) (json.RawMessage, error) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error)
ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
@@ -90,8 +90,8 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.CatalogRefresh(ctx, force) return r.Client.CatalogRefresh(ctx, force)
} }
func (r RPCClient) ScheduleList(ctx context.Context) (json.RawMessage, error) { func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
return r.Client.ScheduleList(ctx) return r.Client.ScheduleListFiltered(ctx, params)
} }
func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
return r.Client.ScheduleAdd(ctx, params) return r.Client.ScheduleAdd(ctx, params)
+13
View File
@@ -249,10 +249,19 @@ type ScheduleMutationParams struct {
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
} }
// ScheduleListParams filters the Rust-owned schedule store.
type ScheduleListParams struct {
ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// ScheduleRunParams selects a schedule or runs every due enabled schedule. // ScheduleRunParams selects a schedule or runs every due enabled schedule.
type ScheduleRunParams struct { type ScheduleRunParams struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Force bool `json:"force,omitempty"` Force bool `json:"force,omitempty"`
MaxRuns *uint64 `json:"max_runs,omitempty"`
} }
// Ack is returned by accepted daemon control methods. // Ack is returned by accepted daemon control methods.
@@ -454,6 +463,10 @@ func (c *Client) ScheduleList(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.list", nil) return c.rawData(ctx, "schedule.list", nil)
} }
func (c *Client) ScheduleListFiltered(ctx context.Context, params ScheduleListParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.list", params)
}
func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) { func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.add", params) return c.rawData(ctx, "schedule.add", params)
} }
+38
View File
@@ -298,6 +298,44 @@ func TestScheduleUpdateSendsMutationParams(t *testing.T) {
} }
} }
func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) {
maxRuns := uint64(2)
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "schedule.run" {
t.Fatalf("method = %s", req.Method)
}
var params ScheduleRunParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.ID != "nightly-pull" || params.Group != "res" || !params.Force ||
params.MaxRuns == nil || *params.MaxRuns != maxRuns {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-schedule-run",
Data: map[string]any{"command": "schedule-run", "executed": []any{}},
},
}
})
raw, err := client.ScheduleRun(context.Background(), ScheduleRunParams{
ID: "nightly-pull",
Group: "res",
Force: true,
MaxRuns: &maxRuns,
})
if err != nil {
t.Fatalf("ScheduleRun error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) { func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" { if req.Method != "task.status" {
+1
View File
@@ -6,6 +6,7 @@ returns JSON links for health, readiness, bootstrap, release, resources, and
OpenAPI. `GET /admin/schedules` and mutating OpenAPI. `GET /admin/schedules` and mutating
`/admin/control/schedule-{add,update,remove,run}` routes are protected by the `/admin/control/schedule-{add,update,remove,run}` routes are protected by the
configured admin token and forward the Rust-owned schedule contract. configured admin token and forward the Rust-owned schedule contract.
`GET /admin/schedules` accepts optional `id`, `group`, and `enabled` filters.
The production panel must reuse the same HTTP authentication, rate limiting, The production panel must reuse the same HTTP authentication, rate limiting,
access logging, reverse-proxy handling, and no-store dynamic response policy as access logging, reverse-proxy handling, and no-store dynamic response policy as