diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 6d69ad7..0a8a1fc 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -22,7 +22,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `/official-launcher-bootstrap.pending.json` 作为维护期证据。下载执行保持顺序处理;manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。 7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy ` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。 -8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 +8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;`resource.state` / `catalog.status` / `parse.status` / `localized.status` 会返回 `status` 与稳定 `status_code`(如 `official.up_to_date`、`official.published`、`parse.completed`、`translation.queued_offline`、`localized.published`、`distribution.ready`),供 `bat-api` 等读侧判断阶段、终态和重试属性;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`,up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index f180a3e..8fada1f 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -118,6 +118,15 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 `text_unit_error_count` 等字段。旧索引库会通过 `metadata_json` 迁移列得到 默认空 metadata。 +`resource.state`、`catalog.status`、`parse.status` 和 `localized.status` +都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code`。`status_code` +使用命名空间格式,例如 `official.up_to_date`、`official.published`、 +`parse.completed`、`translation.queued_offline`、`localized.published` 和 +`distribution.ready`。这些状态码描述资源/解析/翻译 handoff/汉化/分发生命周期; +失败原因仍使用 `BAT-ERR-*` 错误码,二者不混用。响应还会包含 +`status_phase`、`status_terminal` 和 `status_retryable`,供 `bat-api` 等读侧 +决定展示、重试或 readiness。 + 官方资源完整新版本发布后,Rust 侧会先比较上一完整 release 与当前 release 的 download manifest,并在当前 release 根目录写出: @@ -151,7 +160,9 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 `textunit_index_available=true`、`textunit_index_path` 和 `textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含 `textunit_queue_available=true`、`textunit_task_queue_path` 和 -`textunit_task_summary`。 +`textunit_task_summary`。当 TextUnit 队列存在且有离线任务时, +`translation_status_code=translation.queued_offline`;真实 Crowdin worker +尚未接入时不会返回翻译完成状态。 `parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有 `official-textunit-index.json` 时返回 `ok=true` 且 `data.available=false`。 @@ -202,6 +213,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化 | `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 | 只读查询在没有可用版本时返回 `ok=true` 且 `data.available=false`。 +`catalog.status` 可用时会返回 `status_code=official.published`,并用 +`distribution_status_code=distribution.ready` 表示该官方 release 可被读侧分发; +不可用时对应 `official.unavailable` / `distribution.blocked`。 ### task diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index ade8ee3..3ce9045 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -230,6 +230,7 @@ - `cmd/bat-api`:组织 Rust `bat` 已发布 release 的启动前资源入口,并只读分发官方 CDN host/path 形态资源。 - **拉取归属 Rust `bat`**;`bat-api` 不做下载器。 - 发现经 `bat.sock`:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`。 +- `bat-api` 通过 RPC 读取的 `resource.state`、`catalog.status`、`parse.status` 和 `localized.status` 会获得短状态 `status` 与稳定生命周期状态码 `status_code`;错误原因仍以 `BAT-ERR-*` 为准。 - 生产与 Rust `bat` 同环境运行,资源根来自 RPC 返回的 `resource_root`;`--resource-root` 仅用于 fixture 或应急只读诊断。 - `.env` 配置端口 / public base / RPC socket / RPC 刷新周期;预留 database/redis。 - `/v1/bootstrap` 返回 RPC 健康、release 摘要、server-info URL、client-patch base 和改写后的 Addressables root。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 8911943..9d58fa1 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -65,6 +65,7 @@ | H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | | I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 | | J | 索引以 **manifest + 磁盘 Present/size** 为准 | +| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 | ### 进程配置 diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 3eda3f3..482b079 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -15,11 +15,11 @@ use bat_infrastructure::{ OfficialServerInfoSource, OfficialTextUnitQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, - PatchApplyParams, PatchApplyReport, SqliteResourceRepository, UnityFsFieldPatchParams, - UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, - LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR, - LOCALIZED_VERSION_STATE_FILE, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, - PRIVATE_FILE_MODE, + PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository, + UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, + UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, + LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, OFFICIAL_PARSE_CACHE_FILE, + OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -649,6 +649,8 @@ struct DaemonStatusFile { #[serde(default)] current_stage: Option, #[serde(default)] + status_code: Option, + #[serde(default)] current_message: Option, #[serde(default)] download_progress: Option, @@ -2450,9 +2452,55 @@ fn read_daemon_resource_state( Ok((status_file, version_state)) } +fn flow_status_fields( + code: ReleaseFlowStatusCode, +) -> (&'static str, &'static str, &'static str, bool, bool) { + ( + code.status(), + code.as_str(), + code.phase(), + code.terminal(), + code.retryable(), + ) +} + fn build_resource_state_report(state_dir: &Path) -> anyhow::Result { let (status_file, version_state) = read_daemon_resource_state(state_dir)?; + let flow_status_code = status_file + .as_ref() + .and_then(|status| status.status_code.as_deref()) + .and_then(ReleaseFlowStatusCode::from_str) + .or_else(|| { + if version_state + .as_ref() + .and_then(|state| state.in_progress_version.as_ref()) + .is_some() + { + Some(ReleaseFlowStatusCode::OfficialDownloading) + } else if version_state + .as_ref() + .is_some_and(|state| !state.failed_versions.is_empty()) + { + Some(ReleaseFlowStatusCode::OfficialFailed) + } else if version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .is_some() + { + Some(ReleaseFlowStatusCode::OfficialPublished) + } else { + Some(ReleaseFlowStatusCode::OfficialUnavailable) + } + }) + .unwrap_or(ReleaseFlowStatusCode::OfficialUnavailable); + let (status, status_code, status_phase, status_terminal, status_retryable) = + flow_status_fields(flow_status_code); Ok(serde_json::json!({ + "status": status, + "status_code": status_code, + "status_phase": status_phase, + "status_terminal": status_terminal, + "status_retryable": status_retryable, "resource_output_root": status_file .as_ref() .map(|status| status.resource_output_root.clone()), @@ -2477,15 +2525,39 @@ fn build_catalog_status_report(state_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result 0 { + ReleaseFlowStatusCode::ParseCompletedWithErrors + } else { + ReleaseFlowStatusCode::ParseCompleted + }; + let queue_summary = textunit_queue.as_ref().map(|queue| &queue.summary); + let translation_status_code = + if queue_summary.is_some_and(|summary| summary.queued_task_count > 0) { + ReleaseFlowStatusCode::TranslationQueuedOffline + } else { + ReleaseFlowStatusCode::TranslationUnavailable + }; + let (status, status_code, status_phase, status_terminal, status_retryable) = + flow_status_fields(parse_status_code); + let (translation_status, translation_status_code, _, _, _) = + flow_status_fields(translation_status_code); Ok(serde_json::json!({ "available": true, + "status": status, + "status_code": status_code, + "status_phase": status_phase, + "status_terminal": status_terminal, + "status_retryable": status_retryable, + "translation_status": translation_status, + "translation_status_code": translation_status_code, "current_version_id": record.id, "resource_root": record.resource_root, "cache_path": cache_path, @@ -2880,11 +2999,19 @@ fn build_localized_status_report( let mut patch_file_count = None; let mut patch_text_asset_operation_count = None; let mut rollback_previous_current_target = None; + let mut flow_status_code = if official_version_id.is_some() { + ReleaseFlowStatusCode::LocalizedPending + } else { + ReleaseFlowStatusCode::LocalizedBlockedOfficial + }; if let Some(localized_state) = state.as_ref() { matches_current_official_release = official_version_id .as_deref() .is_some_and(|id| localized_state.official_release_id == id); + if official_version_id.is_some() && !matches_current_official_release { + flow_status_code = ReleaseFlowStatusCode::LocalizedStale; + } if localized_state.status == "localized" && matches_current_official_release { if let Some(release_id) = localized_state.current_release_id.as_deref() { let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id); @@ -2907,15 +3034,22 @@ fn build_localized_status_report( && patch_manifest_matches_release { status = "localized"; + flow_status_code = ReleaseFlowStatusCode::LocalizedPublished; published_version_path = Some(candidate); } } } } + let (_, status_code, status_phase, status_terminal, status_retryable) = + flow_status_fields(flow_status_code); Ok(serde_json::json!({ "available": state.is_some(), "status": status, + "status_code": status_code, + "status_phase": status_phase, + "status_terminal": status_terminal, + "status_retryable": status_retryable, "official_current_version_id": official_version_id, "localized_output_root": localized_root, "state_path": state_path, @@ -3314,6 +3448,7 @@ struct DaemonStatusReport { last_error: Option, next_retry_seconds: Option, current_stage: Option, + status_code: Option, current_message: Option, download_progress: Option, version_state_path: Option, @@ -3462,6 +3597,7 @@ fn start_daemon_with_args( last_error: None, next_retry_seconds: None, current_stage: None, + status_code: None, current_message: None, download_progress: None, pending_scheduled_force: false, @@ -3691,6 +3827,9 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result anyhow::Result<()> { print_title("官方资源同步"); print_field("状态", self.update_status.as_str()); + print_field("状态码", self.status_code.as_str()); print_field("应用版本", &self.app_version); print_optional_field("Bundle 版本", self.bundle_version.as_deref()); print_field("连接组", &self.connection_group); @@ -5692,6 +5832,7 @@ fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> any last_error: None, next_retry_seconds: None, current_stage: None, + status_code: None, current_message: None, download_progress: None, pending_scheduled_force: false, @@ -5701,6 +5842,21 @@ fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> any status.pid = std::process::id(); status.state = update.state.to_string(); status.updated_unix_seconds = unix_seconds_now(); + status.status_code = match update.state { + "running" => Some(ReleaseFlowStatusCode::OfficialChecking.as_str().to_string()), + "waiting" => Some( + ReleaseFlowStatusCode::OfficialWaitingForResources + .as_str() + .to_string(), + ), + "error" => Some(ReleaseFlowStatusCode::OfficialFailed.as_str().to_string()), + "sleeping" => update + .last_update_status + .as_deref() + .map(ReleaseFlowStatusCode::from_update_status) + .map(|code| code.as_str().to_string()), + _ => None, + }; status.last_update_status = update.last_update_status; status.last_error = update.last_error; status.next_retry_seconds = update.next_retry_seconds; @@ -5728,6 +5884,7 @@ fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> a status.pid = std::process::id(); status.updated_unix_seconds = unix_seconds_now(); status.current_stage = Some(event.stage.to_string()); + status.status_code = Some(event.status_code.as_str().to_string()); status.current_message = Some(event.message.clone()); status.download_progress = match (event.download_index, event.download_total) { (Some(index), Some(total)) => Some(DaemonDownloadProgress { @@ -6269,6 +6426,8 @@ impl RotatingStructuredLogger { "level": "info", "stage": event.stage, "stage_label": localized_stage(event.stage), + "status_code": event.status_code.as_str(), + "status_phase": event.status_code.phase(), "message": event.message.as_str(), "download": event.download_index.map(|index| serde_json::json!({ "index": index, @@ -9788,6 +9947,7 @@ mod tests { last_error: None, next_retry_seconds: Some(60), current_stage: None, + status_code: Some(ReleaseFlowStatusCode::OfficialUpToDate.as_str().to_string()), current_message: None, download_progress: None, pending_scheduled_force: false, @@ -9860,6 +10020,29 @@ mod tests { current_dir } + #[test] + fn dispatch_resource_state_reports_status_code_for_bat_api() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + + let envelope = dispatch_rpc_method( + &rpc_request("resource.state", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-state-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["status"], "up_to_date"); + assert_eq!(value["data"]["status_code"], "official.up_to_date"); + assert_eq!(value["data"]["status_phase"], "official_sync"); + assert_eq!(value["data"]["status_terminal"], true); + assert_eq!(value["data"]["last_update_status"], "up_to_date"); + } + #[test] fn dispatch_catalog_status_unavailable_without_state() { let temp = tempfile::TempDir::new().unwrap(); @@ -9873,6 +10056,12 @@ mod tests { let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["status"], "unavailable"); + assert_eq!(value["data"]["status_code"], "official.unavailable"); + assert_eq!( + value["data"]["distribution_status_code"], + "distribution.blocked" + ); } #[test] @@ -9893,6 +10082,12 @@ mod tests { assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["bundle_version"], "bundle-b2"); + assert_eq!(value["data"]["status"], "published"); + assert_eq!(value["data"]["status_code"], "official.published"); + assert_eq!( + value["data"]["distribution_status_code"], + "distribution.ready" + ); assert_eq!(value["data"]["version"]["id"], "v-current"); assert_eq!(value["data"]["endpoint_count"], 0); assert_eq!(value["data"]["connection_group_name"], "Prod-Audit"); @@ -9938,6 +10133,12 @@ mod tests { let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "completed"); + assert_eq!(value["data"]["status_code"], "parse.completed"); + assert_eq!( + value["data"]["translation_status_code"], + "translation.queued_offline" + ); assert_eq!(value["data"]["current_version_id"], "v-current"); assert_eq!(value["data"]["previous_version_id"], "v-previous"); assert_eq!(value["data"]["previous_snapshot_missing"], false); @@ -10475,6 +10676,12 @@ mod tests { let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["status"], "pending"); + assert_eq!(value["data"]["status_code"], "parse.pending"); + assert_eq!( + value["data"]["translation_status_code"], + "translation.unavailable" + ); assert_eq!(value["data"]["current_version_id"], "v-current"); } @@ -10543,6 +10750,7 @@ mod tests { assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["status"], "localized"); + assert_eq!(value["data"]["status_code"], "localized.published"); assert_eq!(value["data"]["official_current_version_id"], "v-current"); assert_eq!(value["data"]["matches_current_official_release"], true); assert_eq!(value["data"]["current_points_to_published_version"], true); @@ -10601,6 +10809,7 @@ mod tests { assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["status"], "not_localized"); + assert_eq!(value["data"]["status_code"], "localized.stale"); assert_eq!(value["data"]["matches_current_official_release"], false); assert_eq!( value["data"]["published_version_path"], diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 5005699..8c855ef 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -26,6 +26,7 @@ pub mod official_textunit_queue; pub mod official_update; pub mod patch_ops; pub mod path_security; +pub mod release_flow; pub mod resources; mod zip_validation; @@ -121,6 +122,7 @@ pub use path_security::{ open_append_file, read_file_no_symlink, set_file_mode, validate_output_root, validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE, }; +pub use release_flow::ReleaseFlowStatusCode; pub use resources::{InMemoryResourceRepository, SqliteResourceRepository}; /// Infrastructure 版本号 diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index b5435d2..1d66479 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -28,6 +28,7 @@ use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE, }; +use crate::release_flow::ReleaseFlowStatusCode; use crate::{ build_official_pull_plan_for_platform_inventory, build_official_sync_plan, changed_endpoint_urls, default_official_platforms, DownloadError, @@ -227,6 +228,16 @@ impl OfficialUpdateStatus { Self::Downloaded => "downloaded", } } + + /// Returns the stable cross-module flow status code. + pub const fn flow_status_code(self) -> ReleaseFlowStatusCode { + match self { + Self::UpToDate => ReleaseFlowStatusCode::OfficialUpToDate, + Self::WouldDownload => ReleaseFlowStatusCode::OfficialUpdateAvailable, + Self::WaitingForOfficialResources => ReleaseFlowStatusCode::OfficialWaitingForResources, + Self::Downloaded => ReleaseFlowStatusCode::OfficialPublished, + } + } } /// Publication state for localized resources associated with an official release. @@ -247,6 +258,15 @@ impl LocalizedReleaseStatus { Self::Localized => "localized", } } + + /// Returns the stable status code for an official release that is known + /// to match the currently selected localized state. + pub const fn flow_status_code(self) -> ReleaseFlowStatusCode { + match self { + Self::NotLocalized => ReleaseFlowStatusCode::LocalizedPending, + Self::Localized => ReleaseFlowStatusCode::LocalizedPublished, + } + } } /// Snapshot of the official JP update state observed at a point in time. @@ -712,6 +732,8 @@ impl OfficialVerificationSummary { pub struct OfficialUpdateReport { /// Final update status. pub update_status: OfficialUpdateStatus, + /// Stable cross-module flow status code. + pub status_code: ReleaseFlowStatusCode, /// Selected connection group. pub connection_group: String, /// Selected app version. @@ -842,6 +864,8 @@ pub struct OfficialUpdateReport { pub struct OfficialUpdateProgress { /// Stable progress stage label. pub stage: &'static str, + /// Stable cross-module flow status code derived from `stage`. + pub status_code: ReleaseFlowStatusCode, /// Human-readable status line. pub message: String, /// One-based download index when the event represents URL download work. @@ -877,6 +901,7 @@ impl OfficialUpdateProgress { pub fn new(stage: &'static str, message: impl Into) -> Self { Self { stage, + status_code: ReleaseFlowStatusCode::from_progress_stage(stage), message: message.into(), download_index: None, download_total: None, @@ -1579,6 +1604,11 @@ impl OfficialUpdateService { } else { OfficialUpdateStatus::UpToDate }, + status_code: if should_download { + ReleaseFlowStatusCode::OfficialUpdateAvailable + } else { + ReleaseFlowStatusCode::OfficialUpToDate + }, connection_group: current_snapshot.connection_group_name.clone(), app_version: current_snapshot.app_version.clone(), bundle_version: current_snapshot.bundle_version.clone(), @@ -1942,6 +1972,7 @@ impl OfficialUpdateService { }; report.update_status = OfficialUpdateStatus::Downloaded; + report.status_code = report.update_status.flow_status_code(); report.active_resource_root = published_version_path.clone(); report.published_version_path = Some(published_version_path.clone()); report.snapshot_path = final_snapshot_path.clone(); @@ -2404,6 +2435,7 @@ fn waiting_for_official_resources_report( let localized_info = localized_release_info_for(config, Some(active_release_id.as_str())); OfficialUpdateReport { update_status: OfficialUpdateStatus::WaitingForOfficialResources, + status_code: ReleaseFlowStatusCode::OfficialWaitingForResources, connection_group: base_snapshot.connection_group_name.clone(), app_version: base_snapshot.app_version.clone(), bundle_version: base_snapshot.bundle_version.clone(), diff --git a/infrastructure/src/release_flow.rs b/infrastructure/src/release_flow.rs new file mode 100644 index 0000000..35cfd18 --- /dev/null +++ b/infrastructure/src/release_flow.rs @@ -0,0 +1,295 @@ +//! Stable status codes for the official-resource to localized-release flow. +//! +//! The codes describe observable lifecycle state. They are deliberately +//! separate from `BAT-ERR-*`: an error code explains why an operation failed, +//! while a flow status code explains what a caller can do next. + +use serde::{Deserialize, Serialize}; + +/// Stable status code shared by Rust reports and read-only RPC data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReleaseFlowStatusCode { + /// No published official release is currently available. + #[serde(rename = "official.unavailable")] + OfficialUnavailable, + /// The producer is discovering remote and local official-resource state. + #[serde(rename = "official.checking")] + OfficialChecking, + /// The producer determined that a new or repaired official release is needed. + #[serde(rename = "official.update_available")] + OfficialUpdateAvailable, + /// Official resources are being downloaded or reused into staging. + #[serde(rename = "official.downloading")] + OfficialDownloading, + /// Downloaded official resources are being verified. + #[serde(rename = "official.validating")] + OfficialValidating, + /// A verified official release is being staged or atomically published. + #[serde(rename = "official.publishing")] + OfficialPublishing, + /// A verified official release has been published. + #[serde(rename = "official.published")] + OfficialPublished, + /// The published official release already matches the observed remote state. + #[serde(rename = "official.up_to_date")] + OfficialUpToDate, + /// Launcher/server-info has advanced before required CDN resources are readable. + #[serde(rename = "official.waiting_for_resources")] + OfficialWaitingForResources, + /// Official-resource production failed before a publishable state was reached. + #[serde(rename = "official.failed")] + OfficialFailed, + /// Parsing is blocked because there is no published official release. + #[serde(rename = "parse.blocked_official")] + ParseBlockedOfficial, + /// A published official release exists but parse cache is not present yet. + #[serde(rename = "parse.pending")] + ParsePending, + /// Parse cache or TextUnit index generation is running. + #[serde(rename = "parse.running")] + ParseRunning, + /// Parse cache and TextUnit indexes are present without recorded parse failures. + #[serde(rename = "parse.completed")] + ParseCompleted, + /// Parse cache exists but contains parser or extraction failures. + #[serde(rename = "parse.completed_with_errors")] + ParseCompletedWithErrors, + /// Translation worker integration is not available for the observed release. + #[serde(rename = "translation.unavailable")] + TranslationUnavailable, + /// Translation handoff files are being prepared from the official change set. + #[serde(rename = "translation.handoff_preparing")] + TranslationHandoffPreparing, + /// Translation tasks have been queued to local offline handoff files. + #[serde(rename = "translation.queued_offline")] + TranslationQueuedOffline, + /// Localized publication is blocked because there is no official release. + #[serde(rename = "localized.blocked_official")] + LocalizedBlockedOfficial, + /// The current official release has no matching localized publication yet. + #[serde(rename = "localized.pending")] + LocalizedPending, + /// A localized state exists but it does not match the current official release. + #[serde(rename = "localized.stale")] + LocalizedStale, + /// A localized release is published and matches the current official release. + #[serde(rename = "localized.published")] + LocalizedPublished, + /// Distribution cannot serve a usable release for the observed channel. + #[serde(rename = "distribution.blocked")] + DistributionBlocked, + /// Distribution can serve the published release. + #[serde(rename = "distribution.ready")] + DistributionReady, +} + +impl ReleaseFlowStatusCode { + /// Returns the stable wire label. + pub const fn as_str(self) -> &'static str { + match self { + Self::OfficialUnavailable => "official.unavailable", + Self::OfficialChecking => "official.checking", + Self::OfficialUpdateAvailable => "official.update_available", + Self::OfficialDownloading => "official.downloading", + Self::OfficialValidating => "official.validating", + Self::OfficialPublishing => "official.publishing", + Self::OfficialPublished => "official.published", + Self::OfficialUpToDate => "official.up_to_date", + Self::OfficialWaitingForResources => "official.waiting_for_resources", + Self::OfficialFailed => "official.failed", + Self::ParseBlockedOfficial => "parse.blocked_official", + Self::ParsePending => "parse.pending", + Self::ParseRunning => "parse.running", + Self::ParseCompleted => "parse.completed", + Self::ParseCompletedWithErrors => "parse.completed_with_errors", + Self::TranslationUnavailable => "translation.unavailable", + Self::TranslationHandoffPreparing => "translation.handoff_preparing", + Self::TranslationQueuedOffline => "translation.queued_offline", + Self::LocalizedBlockedOfficial => "localized.blocked_official", + Self::LocalizedPending => "localized.pending", + Self::LocalizedStale => "localized.stale", + Self::LocalizedPublished => "localized.published", + Self::DistributionBlocked => "distribution.blocked", + Self::DistributionReady => "distribution.ready", + } + } + + /// Returns the short status value used alongside `status_code` in RPC + /// payloads. This keeps existing human-facing labels independent from the + /// namespaced wire code. + pub const fn status(self) -> &'static str { + match self { + Self::OfficialUnavailable => "unavailable", + Self::OfficialChecking => "checking", + Self::OfficialUpdateAvailable => "update_available", + Self::OfficialDownloading => "downloading", + Self::OfficialValidating => "validating", + Self::OfficialPublishing => "publishing", + Self::OfficialPublished => "published", + Self::OfficialUpToDate => "up_to_date", + Self::OfficialWaitingForResources => "waiting_for_resources", + Self::OfficialFailed => "failed", + Self::ParseBlockedOfficial => "blocked_official", + Self::ParsePending => "pending", + Self::ParseRunning => "running", + Self::ParseCompleted => "completed", + Self::ParseCompletedWithErrors => "completed_with_errors", + Self::TranslationUnavailable => "unavailable", + Self::TranslationHandoffPreparing => "handoff_preparing", + Self::TranslationQueuedOffline => "queued_offline", + Self::LocalizedBlockedOfficial => "blocked_official", + Self::LocalizedPending => "pending", + Self::LocalizedStale => "stale", + Self::LocalizedPublished => "published", + Self::DistributionBlocked => "blocked", + Self::DistributionReady => "ready", + } + } + + /// Parses a status code read from a persisted daemon/RPC snapshot. + pub fn from_str(code: &str) -> Option { + Some(match code { + "official.unavailable" => Self::OfficialUnavailable, + "official.checking" => Self::OfficialChecking, + "official.update_available" => Self::OfficialUpdateAvailable, + "official.downloading" => Self::OfficialDownloading, + "official.validating" => Self::OfficialValidating, + "official.publishing" => Self::OfficialPublishing, + "official.published" => Self::OfficialPublished, + "official.up_to_date" => Self::OfficialUpToDate, + "official.waiting_for_resources" => Self::OfficialWaitingForResources, + "official.failed" => Self::OfficialFailed, + "parse.blocked_official" => Self::ParseBlockedOfficial, + "parse.pending" => Self::ParsePending, + "parse.running" => Self::ParseRunning, + "parse.completed" => Self::ParseCompleted, + "parse.completed_with_errors" => Self::ParseCompletedWithErrors, + "translation.unavailable" => Self::TranslationUnavailable, + "translation.handoff_preparing" => Self::TranslationHandoffPreparing, + "translation.queued_offline" => Self::TranslationQueuedOffline, + "localized.blocked_official" => Self::LocalizedBlockedOfficial, + "localized.pending" => Self::LocalizedPending, + "localized.stale" => Self::LocalizedStale, + "localized.published" => Self::LocalizedPublished, + "distribution.blocked" => Self::DistributionBlocked, + "distribution.ready" => Self::DistributionReady, + _ => return None, + }) + } + + /// Returns the broad flow phase represented by this code. + pub const fn phase(self) -> &'static str { + match self { + Self::OfficialUnavailable + | Self::OfficialChecking + | Self::OfficialUpdateAvailable + | Self::OfficialDownloading + | Self::OfficialValidating + | Self::OfficialPublishing + | Self::OfficialPublished + | Self::OfficialUpToDate + | Self::OfficialWaitingForResources + | Self::OfficialFailed => "official_sync", + Self::ParseBlockedOfficial + | Self::ParsePending + | Self::ParseRunning + | Self::ParseCompleted + | Self::ParseCompletedWithErrors => "parse", + Self::TranslationUnavailable + | Self::TranslationHandoffPreparing + | Self::TranslationQueuedOffline => "translation", + Self::LocalizedBlockedOfficial + | Self::LocalizedPending + | Self::LocalizedStale + | Self::LocalizedPublished => "localized_publish", + Self::DistributionBlocked | Self::DistributionReady => "distribution", + } + } + + /// Returns whether the state is stable for the current observation. + pub const fn terminal(self) -> bool { + !matches!( + self, + Self::OfficialChecking + | Self::OfficialDownloading + | Self::OfficialValidating + | Self::OfficialPublishing + | Self::ParseRunning + | Self::TranslationHandoffPreparing + ) + } + + /// Returns whether the producer may retry the operation automatically. + pub const fn retryable(self) -> bool { + matches!( + self, + Self::OfficialWaitingForResources + | Self::OfficialFailed + | Self::ParsePending + | Self::ParseCompletedWithErrors + ) + } + + /// Maps an existing official update result to the stable flow code. + pub fn from_update_status(status: &str) -> Self { + match status { + "would_download" => Self::OfficialUpdateAvailable, + "waiting_for_official_resources" => Self::OfficialWaitingForResources, + "downloaded" => Self::OfficialPublished, + "up_to_date" => Self::OfficialUpToDate, + _ => Self::OfficialFailed, + } + } + + /// Maps an existing progress stage to the stable flow code. + pub fn from_progress_stage(stage: &str) -> Self { + match stage { + "download" => Self::OfficialDownloading, + "audit" | "snapshot" => Self::OfficialValidating, + "publish" | "launcher-bootstrap" => Self::OfficialPublishing, + "parse" => Self::ParseRunning, + "changes" => Self::TranslationHandoffPreparing, + "finish" => Self::OfficialPublished, + _ => Self::OfficialChecking, + } + } +} + +#[cfg(test)] +mod tests { + use super::ReleaseFlowStatusCode; + + #[test] + fn status_codes_are_stable_and_namespaced() { + let codes = [ + ReleaseFlowStatusCode::OfficialUnavailable, + ReleaseFlowStatusCode::OfficialChecking, + ReleaseFlowStatusCode::OfficialUpdateAvailable, + ReleaseFlowStatusCode::OfficialDownloading, + ReleaseFlowStatusCode::OfficialValidating, + ReleaseFlowStatusCode::OfficialPublishing, + ReleaseFlowStatusCode::OfficialPublished, + ReleaseFlowStatusCode::OfficialUpToDate, + ReleaseFlowStatusCode::OfficialWaitingForResources, + ReleaseFlowStatusCode::OfficialFailed, + ReleaseFlowStatusCode::ParseBlockedOfficial, + ReleaseFlowStatusCode::ParsePending, + ReleaseFlowStatusCode::ParseRunning, + ReleaseFlowStatusCode::ParseCompleted, + ReleaseFlowStatusCode::ParseCompletedWithErrors, + ReleaseFlowStatusCode::TranslationUnavailable, + ReleaseFlowStatusCode::TranslationHandoffPreparing, + ReleaseFlowStatusCode::TranslationQueuedOffline, + ReleaseFlowStatusCode::LocalizedBlockedOfficial, + ReleaseFlowStatusCode::LocalizedPending, + ReleaseFlowStatusCode::LocalizedStale, + ReleaseFlowStatusCode::LocalizedPublished, + ReleaseFlowStatusCode::DistributionBlocked, + ReleaseFlowStatusCode::DistributionReady, + ]; + let labels = codes.iter().map(|code| code.as_str()).collect::>(); + let unique = labels.iter().collect::>(); + assert_eq!(labels.len(), unique.len()); + assert!(labels.iter().all(|label| label.contains('.'))); + } +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 775ecaa..651d3ff 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -558,11 +558,15 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { b := uint64(info.Size()) _ = bytes catalogObj := map[string]any{ - "available": true, - "app_version": "1.70.0", - "bundle_version": "s8tloc7lo3", - "connection_group_name": "Prod", - "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "available": true, + "status": "published", + "status_code": "official.published", + "distribution_status": "ready", + "distribution_status_code": "distribution.ready", + "app_version": "1.70.0", + "bundle_version": "s8tloc7lo3", + "connection_group_name": "Prod", + "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", "version": map[string]any{ "id": "v1", "resource_root": root, @@ -603,6 +607,13 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { if result.DoctorHealthy == nil || !*result.DoctorHealthy { t.Fatal("doctor healthy expected") } + summary := result.Index.Summary() + if summary.Snapshot.Status != "published" || summary.Snapshot.StatusCode != "official.published" { + t.Fatalf("snapshot status=%q code=%q", summary.Snapshot.Status, summary.Snapshot.StatusCode) + } + if summary.Snapshot.DistributionStatusCode != "distribution.ready" { + t.Fatalf("distribution status code=%q", summary.Snapshot.DistributionStatusCode) + } } func TestLoadEnvFileDoesNotOverride(t *testing.T) { diff --git a/internal/api/release_index.go b/internal/api/release_index.go index 4e6b250..8f0aab7 100644 --- a/internal/api/release_index.go +++ b/internal/api/release_index.go @@ -22,14 +22,18 @@ type ResourceEntry struct { // SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status. type SnapshotSummary struct { - AppVersion string `json:"app_version,omitempty"` - BundleVersion string `json:"bundle_version,omitempty"` - ConnectionGroupName string `json:"connection_group_name,omitempty"` - AddressablesRoot string `json:"addressables_root,omitempty"` - VersionID string `json:"version_id,omitempty"` - CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"` - LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"` - GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"` + Status string `json:"status,omitempty"` + StatusCode string `json:"status_code,omitempty"` + DistributionStatus string `json:"distribution_status,omitempty"` + DistributionStatusCode string `json:"distribution_status_code,omitempty"` + AppVersion string `json:"app_version,omitempty"` + BundleVersion string `json:"bundle_version,omitempty"` + ConnectionGroupName string `json:"connection_group_name,omitempty"` + AddressablesRoot string `json:"addressables_root,omitempty"` + VersionID string `json:"version_id,omitempty"` + CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"` + GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"` } // LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot. diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 1774559..4ab47b3 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -195,14 +195,18 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { return nil, "", false } var payload struct { - Available bool `json:"available"` - AppVersion string `json:"app_version"` - BundleVersion string `json:"bundle_version"` - ConnectionGroupName string `json:"connection_group_name"` - AddressablesRoot string `json:"addressables_root"` - LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"` - GameMainConfig *GameMainConfigSummary `json:"game_main_config"` - Version *struct { + Available bool `json:"available"` + Status string `json:"status"` + StatusCode string `json:"status_code"` + DistributionStatus string `json:"distribution_status"` + DistributionStatusCode string `json:"distribution_status_code"` + AppVersion string `json:"app_version"` + BundleVersion string `json:"bundle_version"` + ConnectionGroupName string `json:"connection_group_name"` + AddressablesRoot string `json:"addressables_root"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"` + GameMainConfig *GameMainConfigSummary `json:"game_main_config"` + Version *struct { ID string `json:"id"` CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"` ResourceRoot string `json:"resource_root"` @@ -212,12 +216,16 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { return nil, "", false } snap := &SnapshotSummary{ - AppVersion: payload.AppVersion, - BundleVersion: payload.BundleVersion, - ConnectionGroupName: payload.ConnectionGroupName, - AddressablesRoot: payload.AddressablesRoot, - LauncherMetadata: payload.LauncherMetadata, - GameMainConfig: payload.GameMainConfig, + Status: payload.Status, + StatusCode: payload.StatusCode, + DistributionStatus: payload.DistributionStatus, + DistributionStatusCode: payload.DistributionStatusCode, + AppVersion: payload.AppVersion, + BundleVersion: payload.BundleVersion, + ConnectionGroupName: payload.ConnectionGroupName, + AddressablesRoot: payload.AddressablesRoot, + LauncherMetadata: payload.LauncherMetadata, + GameMainConfig: payload.GameMainConfig, } root := "" if payload.Version != nil { diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 071c4ad..0d79035 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -265,6 +265,7 @@ type LogsReport struct { type DaemonStatusReport struct { Status string `json:"status"` + StatusCode *string `json:"status_code,omitempty"` Message string `json:"message"` Running bool `json:"running"` PID *int `json:"pid,omitempty"` @@ -277,6 +278,11 @@ type DaemonStatusReport struct { } type ResourceState struct { + Status string `json:"status,omitempty"` + StatusCode string `json:"status_code,omitempty"` + StatusPhase string `json:"status_phase,omitempty"` + StatusTerminal bool `json:"status_terminal,omitempty"` + StatusRetryable bool `json:"status_retryable,omitempty"` ResourceOutputRoot *string `json:"resource_output_root,omitempty"` VersionState json.RawMessage `json:"version_state,omitempty"` LastUpdateStatus *string `json:"last_update_status,omitempty"`