mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(bat): 补全工作流校验与调度过滤
新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。 Refs #43
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 计划 ID;add 必填,update/remove 用于定位。 |
|
| `id` | string | 计划 ID;add 必填,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`。每次执行前先持久化下一次状态,执行后
|
||||||
再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。
|
再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。
|
||||||
|
|||||||
@@ -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 离线状态仓储范围内。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|||||||
@@ -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 => {
|
||||||
request_id,
|
let params = request
|
||||||
RPC_METHOD_SCHEDULE_LIST,
|
.params
|
||||||
schedule_commands::schedule_list_report(state_dir),
|
.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,
|
||||||
|
RPC_METHOD_SCHEDULE_LIST,
|
||||||
|
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");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 版本号
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -590,12 +590,14 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
|
|||||||
|
|
||||||
type scheduleBackend struct {
|
type scheduleBackend struct {
|
||||||
*controlBackend
|
*controlBackend
|
||||||
scheduleCalls []string
|
scheduleCalls []string
|
||||||
scheduleRaw json.RawMessage
|
scheduleListParams []backendrpc.ScheduleListParams
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
Force bool `json:"force,omitempty"`
|
Group string `json:"group,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¶ms); 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" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user