mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix: 拆分 bat 控制面
This commit is contained in:
@@ -8,7 +8,13 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
|
|||||||
|
|
||||||
当前已经可用的官方资源入口包括:
|
当前已经可用的官方资源入口包括:
|
||||||
|
|
||||||
- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步薄入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
||||||
|
- `infrastructure/src/bin/bat/app.rs`:CLI/env、daemon/watch、RPC dispatch、状态输出与顶层流程组合。
|
||||||
|
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
|
||||||
|
- `infrastructure/src/bin/bat/readonly_query.rs`:parse/resource/translation/localized 只读查询及 RPC 选择。
|
||||||
|
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
|
||||||
|
- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。
|
||||||
|
- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。
|
||||||
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
|
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
|
||||||
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
|
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
|
||||||
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`。
|
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`。
|
||||||
|
|||||||
@@ -246,7 +246,11 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
|
|||||||
对应实现主要在:
|
对应实现主要在:
|
||||||
|
|
||||||
- `infrastructure/src/official_update.rs`
|
- `infrastructure/src/official_update.rs`
|
||||||
- `infrastructure/src/bin/bat_official_sync.rs`
|
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
|
||||||
|
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
|
||||||
|
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
|
||||||
|
- `infrastructure/src/bin/bat/readonly_query.rs`、`translation_query.rs`(只读查询)
|
||||||
|
- `infrastructure/src/bin/bat/patch_commands.rs`(patch 命令)
|
||||||
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
|
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
|
||||||
|
|
||||||
## 4. 官方 bootstrap 与用户流程
|
## 4. 官方 bootstrap 与用户流程
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ make official-smoke
|
|||||||
|
|
||||||
- `infrastructure/examples/official_launcher_bootstrap.rs`
|
- `infrastructure/examples/official_launcher_bootstrap.rs`
|
||||||
- `infrastructure/examples/official_pull_plan.rs`
|
- `infrastructure/examples/official_pull_plan.rs`
|
||||||
- `infrastructure/src/bin/bat_official_sync.rs`
|
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`)
|
||||||
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`)
|
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`)
|
||||||
- `adapters/examples/yostar_jp_client_bootstrap.rs`
|
- `adapters/examples/yostar_jp_client_bootstrap.rs`
|
||||||
- `adapters/examples/yostar_jp_discovery.rs`
|
- `adapters/examples/yostar_jp_discovery.rs`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
|||||||
|
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
match options.command {
|
||||||
|
CliCommand::PatchApply => {
|
||||||
|
let params = patch_apply_params_from_options(options)?;
|
||||||
|
let report = apply_patch_file(¶ms)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchTextAsset => {
|
||||||
|
let params = unityfs_text_asset_params_from_options(options)?;
|
||||||
|
let report = apply_unityfs_text_asset_patch_file(¶ms)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchStringField => {
|
||||||
|
let params = unityfs_string_field_params_from_options(options)?;
|
||||||
|
let report = apply_unityfs_string_field_patch_file(¶ms)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchField => {
|
||||||
|
let params = unityfs_field_params_from_options(options)?;
|
||||||
|
let report = apply_unityfs_field_patch_file(¶ms)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("不是写入 patch 命令")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_write_patch_command(command: CliCommand) -> bool {
|
||||||
|
matches!(
|
||||||
|
command,
|
||||||
|
CliCommand::PatchApply
|
||||||
|
| CliCommand::UnityFsPatchTextAsset
|
||||||
|
| CliCommand::UnityFsPatchStringField
|
||||||
|
| CliCommand::UnityFsPatchField
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
match options.command {
|
||||||
|
CliCommand::PatchApply => {
|
||||||
|
let _ = patch_apply_params_from_options(options)?;
|
||||||
|
reject_unityfs_write_options(options, "patch-apply")?;
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchTextAsset => {
|
||||||
|
let _ = unityfs_text_asset_params_from_options(options)?;
|
||||||
|
reject_patch_apply_options(options, "unityfs-patch-text-asset")?;
|
||||||
|
if options.unityfs_field_path.is_some()
|
||||||
|
|| options.unityfs_replacement_text.is_some()
|
||||||
|
|| options.unityfs_expected_value.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchStringField => {
|
||||||
|
let _ = unityfs_string_field_params_from_options(options)?;
|
||||||
|
reject_patch_apply_options(options, "unityfs-patch-string-field")?;
|
||||||
|
if options.unityfs_expected_name.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unityfs-patch-string-field 不接受 --expected-name"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::UnityFsPatchField => {
|
||||||
|
let _ = unityfs_field_params_from_options(options)?;
|
||||||
|
reject_patch_apply_options(options, "unityfs-patch-field")?;
|
||||||
|
if options.unityfs_expected_name.is_some()
|
||||||
|
|| options.unityfs_replacement_text.is_some()
|
||||||
|
|| options.unityfs_expected_value.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result<PatchApplyParams> {
|
||||||
|
Ok(PatchApplyParams {
|
||||||
|
kind: require_cli_option(options.patch_kind, "--patch-kind")?,
|
||||||
|
source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?,
|
||||||
|
patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?,
|
||||||
|
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unityfs_text_asset_params_from_options(
|
||||||
|
options: &CliOptions,
|
||||||
|
) -> anyhow::Result<UnityFsTextAssetPatchParams> {
|
||||||
|
Ok(UnityFsTextAssetPatchParams {
|
||||||
|
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||||
|
serialized_file_path: require_cli_option(
|
||||||
|
options.unityfs_serialized_file_path.clone(),
|
||||||
|
"--serialized-file",
|
||||||
|
)?,
|
||||||
|
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||||
|
replacement_path: require_cli_option(
|
||||||
|
options.unityfs_replacement_path.clone(),
|
||||||
|
"--replacement-file",
|
||||||
|
)?,
|
||||||
|
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||||
|
expected_name: options.unityfs_expected_name.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unityfs_string_field_params_from_options(
|
||||||
|
options: &CliOptions,
|
||||||
|
) -> anyhow::Result<UnityFsStringFieldPatchParams> {
|
||||||
|
let has_replacement_text = options.unityfs_replacement_text.is_some();
|
||||||
|
let has_replacement_path = options.unityfs_replacement_path.is_some();
|
||||||
|
if has_replacement_text == has_replacement_path {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(UnityFsStringFieldPatchParams {
|
||||||
|
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||||
|
serialized_file_path: require_cli_option(
|
||||||
|
options.unityfs_serialized_file_path.clone(),
|
||||||
|
"--serialized-file",
|
||||||
|
)?,
|
||||||
|
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||||
|
field_path: require_cli_option(
|
||||||
|
options.unityfs_field_path.clone(),
|
||||||
|
"--field-path/--string-field-path",
|
||||||
|
)?,
|
||||||
|
replacement_text: options.unityfs_replacement_text.clone(),
|
||||||
|
replacement_path: options.unityfs_replacement_path.clone(),
|
||||||
|
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||||
|
expected_value: options.unityfs_expected_value.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unityfs_field_params_from_options(
|
||||||
|
options: &CliOptions,
|
||||||
|
) -> anyhow::Result<UnityFsFieldPatchParams> {
|
||||||
|
if options.unityfs_replacement_path.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(UnityFsFieldPatchParams {
|
||||||
|
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||||
|
serialized_file_path: require_cli_option(
|
||||||
|
options.unityfs_serialized_file_path.clone(),
|
||||||
|
"--serialized-file",
|
||||||
|
)?,
|
||||||
|
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||||
|
field_path: require_cli_option(
|
||||||
|
options.unityfs_field_path.clone(),
|
||||||
|
"--field-path/--string-field-path",
|
||||||
|
)?,
|
||||||
|
replacement: require_cli_option(
|
||||||
|
options.unityfs_replacement_value.clone(),
|
||||||
|
"--replacement-json",
|
||||||
|
)?,
|
||||||
|
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||||
|
expected_value: options.unityfs_expected_semantic_value.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_cli_option<T>(value: Option<T>, name: &str) -> anyhow::Result<T> {
|
||||||
|
value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
|
||||||
|
if options.patch_kind.is_some()
|
||||||
|
|| options.patch_source_path.is_some()
|
||||||
|
|| options.patch_patch_path.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
|
||||||
|
if options.unityfs_bundle_path.is_some()
|
||||||
|
|| options.unityfs_serialized_file_path.is_some()
|
||||||
|
|| options.unityfs_path_id.is_some()
|
||||||
|
|| options.unityfs_field_path.is_some()
|
||||||
|
|| options.unityfs_replacement_path.is_some()
|
||||||
|
|| options.unityfs_replacement_text.is_some()
|
||||||
|
|| options.unityfs_expected_name.is_some()
|
||||||
|
|| options.unityfs_expected_value.is_some()
|
||||||
|
|| options.unityfs_replacement_value.is_some()
|
||||||
|
|| options.unityfs_expected_semantic_value.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
use super::*;
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run_readonly_query_command_with_rpc(
|
||||||
|
options: &CliOptions,
|
||||||
|
rpc_available: impl Fn(&Path) -> bool,
|
||||||
|
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let method = readonly_query_rpc_method(options.command)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?;
|
||||||
|
if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) {
|
||||||
|
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||||
|
let report = rpc_call(
|
||||||
|
&options.state_dir,
|
||||||
|
method,
|
||||||
|
readonly_query_rpc_params(options),
|
||||||
|
)?;
|
||||||
|
print_json_value(options.output_format, &report)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = build_readonly_query_report(options, method)?;
|
||||||
|
print_json_value(options.output_format, &report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
|
||||||
|
match command {
|
||||||
|
CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS),
|
||||||
|
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
|
||||||
|
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
|
||||||
|
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
|
||||||
|
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
|
||||||
|
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
|
||||||
|
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value> {
|
||||||
|
let mut params = serde_json::Map::new();
|
||||||
|
match options.command {
|
||||||
|
CliCommand::ResourceIndex
|
||||||
|
| CliCommand::ParseTextUnits
|
||||||
|
| CliCommand::ParseErrors
|
||||||
|
| CliCommand::TranslationTasks => {
|
||||||
|
params.insert(
|
||||||
|
"offset".to_string(),
|
||||||
|
serde_json::json!(options.query_offset),
|
||||||
|
);
|
||||||
|
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
match options.command {
|
||||||
|
CliCommand::ResourceIndex => {
|
||||||
|
if let Some(resource_type) = options.query_resource_type {
|
||||||
|
params.insert(
|
||||||
|
"resource_type".to_string(),
|
||||||
|
serde_json::json!(resource_type_rpc_label(resource_type)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(hash) = options.query_hash.as_ref() {
|
||||||
|
params.insert("hash".to_string(), serde_json::json!(hash));
|
||||||
|
}
|
||||||
|
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||||
|
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||||
|
}
|
||||||
|
if let Some(release_id) = options.query_official_release_id.as_ref() {
|
||||||
|
params.insert(
|
||||||
|
"official_release_id".to_string(),
|
||||||
|
serde_json::json!(release_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(platform) = options.query_platform.as_ref() {
|
||||||
|
params.insert("platform".to_string(), serde_json::json!(platform));
|
||||||
|
}
|
||||||
|
if let Some(destination) = options.query_destination.as_ref() {
|
||||||
|
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||||
|
}
|
||||||
|
if let Some(bundle_path) = options.query_bundle_path.as_ref() {
|
||||||
|
params.insert("bundle_path".to_string(), serde_json::json!(bundle_path));
|
||||||
|
}
|
||||||
|
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||||
|
params.insert(
|
||||||
|
"archive_entry".to_string(),
|
||||||
|
serde_json::json!(archive_entry),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(parse_status) = options.query_parse_status.as_ref() {
|
||||||
|
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
|
||||||
|
}
|
||||||
|
if let Some(format) = options.query_format.as_ref() {
|
||||||
|
params.insert("text_unit_format".to_string(), serde_json::json!(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
|
||||||
|
if let Some(destination) = options.query_destination.as_ref() {
|
||||||
|
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||||
|
}
|
||||||
|
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||||
|
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||||
|
}
|
||||||
|
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||||
|
params.insert(
|
||||||
|
"archive_entry".to_string(),
|
||||||
|
serde_json::json!(archive_entry),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(path_id) = options.query_path_id {
|
||||||
|
params.insert("path_id".to_string(), serde_json::json!(path_id));
|
||||||
|
}
|
||||||
|
if let Some(class_id) = options.query_class_id {
|
||||||
|
params.insert("class_id".to_string(), serde_json::json!(class_id));
|
||||||
|
}
|
||||||
|
if let Some(field_path) = options.query_field_path.as_ref() {
|
||||||
|
params.insert("field_path".to_string(), serde_json::json!(field_path));
|
||||||
|
}
|
||||||
|
if let Some(format) = options.query_format.as_ref() {
|
||||||
|
params.insert("format".to_string(), serde_json::json!(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::TranslationTasks => {
|
||||||
|
if let Some(task_id) = options.query_task_id.as_ref() {
|
||||||
|
params.insert("task_id".to_string(), serde_json::json!(task_id));
|
||||||
|
}
|
||||||
|
if let Some(release_id) = options.query_official_release_id.as_ref() {
|
||||||
|
params.insert(
|
||||||
|
"official_release_id".to_string(),
|
||||||
|
serde_json::json!(release_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(destination) = options.query_destination.as_ref() {
|
||||||
|
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||||
|
}
|
||||||
|
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||||
|
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||||
|
}
|
||||||
|
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||||
|
params.insert(
|
||||||
|
"archive_entry".to_string(),
|
||||||
|
serde_json::json!(archive_entry),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(status) = options.query_task_status.as_ref() {
|
||||||
|
params.insert("status".to_string(), serde_json::json!(status));
|
||||||
|
}
|
||||||
|
if let Some(status) = options.query_worker_status.as_ref() {
|
||||||
|
params.insert("worker_status".to_string(), serde_json::json!(status));
|
||||||
|
}
|
||||||
|
if let Some(parse_status) = options.query_parse_status.as_ref() {
|
||||||
|
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
|
||||||
|
}
|
||||||
|
if let Some(format) = options.query_format.as_ref() {
|
||||||
|
params.insert("text_unit_format".to_string(), serde_json::json!(format));
|
||||||
|
}
|
||||||
|
if let Some(has_reason) = options.query_has_reason {
|
||||||
|
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
|
||||||
|
}
|
||||||
|
if let Some(has_failure_reason) = options.query_has_failure_reason {
|
||||||
|
params.insert(
|
||||||
|
"has_failure_reason".to_string(),
|
||||||
|
serde_json::json!(has_failure_reason),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(serde_json::Value::Object(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readonly_query_requires_local_config(options: &CliOptions) -> bool {
|
||||||
|
matches!(options.command, CliCommand::ResourceIndex)
|
||||||
|
&& options.config.import_resource_repository_path.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_readonly_query_report(
|
||||||
|
options: &CliOptions,
|
||||||
|
method: &str,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
match method {
|
||||||
|
RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir),
|
||||||
|
RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report(
|
||||||
|
&options.state_dir,
|
||||||
|
textunit_query_from_options(options),
|
||||||
|
options.query_offset,
|
||||||
|
options.query_limit,
|
||||||
|
),
|
||||||
|
RPC_METHOD_PARSE_ERRORS => build_parse_errors_report(
|
||||||
|
&options.state_dir,
|
||||||
|
textunit_query_from_options(options),
|
||||||
|
options.query_offset,
|
||||||
|
options.query_limit,
|
||||||
|
),
|
||||||
|
RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report(
|
||||||
|
&options.state_dir,
|
||||||
|
translation_task_query_from_options(options),
|
||||||
|
options.query_offset,
|
||||||
|
options.query_limit,
|
||||||
|
),
|
||||||
|
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
|
||||||
|
RPC_METHOD_LOCALIZED_STATUS => {
|
||||||
|
build_localized_status_report(&options.state_dir, &options.config)
|
||||||
|
}
|
||||||
|
RPC_METHOD_RESOURCE_INDEX => build_resource_index_report(
|
||||||
|
&options.state_dir,
|
||||||
|
&options.config,
|
||||||
|
resource_index_query_from_options(options),
|
||||||
|
options.query_offset,
|
||||||
|
options.query_limit,
|
||||||
|
),
|
||||||
|
_ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let has_resource_index_only_filter = options.query_resource_type.is_some()
|
||||||
|
|| options.query_hash.is_some()
|
||||||
|
|| options.query_platform.is_some()
|
||||||
|
|| options.query_bundle_path.is_some();
|
||||||
|
let has_parse_object_filter = options.query_path_id.is_some()
|
||||||
|
|| options.query_class_id.is_some()
|
||||||
|
|| options.query_field_path.is_some();
|
||||||
|
let has_translation_task_filter = options.query_task_id.is_some()
|
||||||
|
|| options.query_task_status.is_some()
|
||||||
|
|| options.query_worker_status.is_some()
|
||||||
|
|| options.query_has_reason.is_some()
|
||||||
|
|| options.query_has_failure_reason.is_some();
|
||||||
|
|
||||||
|
match options.command {
|
||||||
|
CliCommand::ResourceIndex => {
|
||||||
|
if has_parse_object_filter || has_translation_task_filter {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
|
||||||
|
if has_resource_index_only_filter
|
||||||
|
|| has_translation_task_filter
|
||||||
|
|| options.query_official_release_id.is_some()
|
||||||
|
|| options.query_parse_status.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::TranslationTasks => {
|
||||||
|
if has_resource_index_only_filter || has_parse_object_filter {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index;--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::TranslationHandoff if options.query_option_explicit => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) const MAX_RETAINED_TASKS: usize = 64;
|
||||||
|
/// 每个任务保留的进度日志行数上限。
|
||||||
|
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
|
||||||
|
|
||||||
|
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(super) enum TaskKind {
|
||||||
|
Sync,
|
||||||
|
Verify,
|
||||||
|
Repair,
|
||||||
|
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskKind {
|
||||||
|
pub(super) fn method(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||||
|
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||||
|
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
||||||
|
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 由 daemon 基准配置派生该任务的实际同步配置。
|
||||||
|
pub(super) fn build_config(
|
||||||
|
self,
|
||||||
|
base: &OfficialUpdateConfig,
|
||||||
|
force: bool,
|
||||||
|
) -> OfficialUpdateConfig {
|
||||||
|
let mut config = base.clone();
|
||||||
|
match self {
|
||||||
|
Self::Sync => {
|
||||||
|
config.dry_run = false;
|
||||||
|
config.force = config.force || force;
|
||||||
|
}
|
||||||
|
Self::Verify => {
|
||||||
|
config.dry_run = true;
|
||||||
|
config.plan = true;
|
||||||
|
config.audit_local = true;
|
||||||
|
config.repair = false;
|
||||||
|
config.force = false;
|
||||||
|
}
|
||||||
|
Self::Repair => {
|
||||||
|
config.dry_run = false;
|
||||||
|
config.audit_local = true;
|
||||||
|
config.repair = true;
|
||||||
|
config.force = false;
|
||||||
|
}
|
||||||
|
Self::Refresh => {
|
||||||
|
config.dry_run = true;
|
||||||
|
config.plan = true;
|
||||||
|
config.audit_local = false;
|
||||||
|
config.repair = false;
|
||||||
|
config.force = force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求取消任务的结果。
|
||||||
|
pub(super) enum CancelOutcome {
|
||||||
|
Requested,
|
||||||
|
AlreadyFinished,
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个任务的可轮询记录。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub(super) struct TaskRecord {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) kind: &'static str,
|
||||||
|
/// `queued` | `running` | `succeeded` | `failed` | `cancelled`。
|
||||||
|
pub(super) status: &'static str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) stage: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) message: Option<String>,
|
||||||
|
pub(super) created_at: u64,
|
||||||
|
pub(super) updated_at: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) started_at: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) finished_at: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) error: Option<ApiError>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) result: Option<serde_json::Value>,
|
||||||
|
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
|
||||||
|
#[serde(skip)]
|
||||||
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
|
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
|
||||||
|
#[serde(skip)]
|
||||||
|
pub(super) log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskRecord {
|
||||||
|
pub(super) fn is_finished(&self) -> bool {
|
||||||
|
matches!(self.status, "succeeded" | "failed" | "cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TaskStore {
|
||||||
|
tasks: HashMap<String, TaskRecord>,
|
||||||
|
order: Vec<String>,
|
||||||
|
seq: u64,
|
||||||
|
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
|
||||||
|
persist_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。
|
||||||
|
pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json";
|
||||||
|
/// 任务历史文件结构版本。
|
||||||
|
pub(super) const TASKS_FILE_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub(super) struct PersistedTaskFile {
|
||||||
|
pub(super) version: u32,
|
||||||
|
/// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。
|
||||||
|
pub(super) seq: u64,
|
||||||
|
pub(super) tasks: Vec<PersistedTaskRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub(super) struct PersistedTaskRecord {
|
||||||
|
id: String,
|
||||||
|
kind: String,
|
||||||
|
pub(super) status: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
stage: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
message: Option<String>,
|
||||||
|
created_at: u64,
|
||||||
|
updated_at: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
started_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
finished_at: Option<u64>,
|
||||||
|
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<serde_json::Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。
|
||||||
|
fn task_kind_static(kind: &str) -> Option<&'static str> {
|
||||||
|
match kind {
|
||||||
|
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
||||||
|
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
|
||||||
|
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
|
||||||
|
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。
|
||||||
|
fn task_status_static(status: &str) -> Option<&'static str> {
|
||||||
|
match status {
|
||||||
|
"queued" => Some("queued"),
|
||||||
|
"running" => Some("running"),
|
||||||
|
"succeeded" => Some("succeeded"),
|
||||||
|
"failed" => Some("failed"),
|
||||||
|
"cancelled" => Some("cancelled"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedTaskRecord {
|
||||||
|
fn from_record(record: &TaskRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
id: record.id.clone(),
|
||||||
|
kind: record.kind.to_string(),
|
||||||
|
status: record.status.to_string(),
|
||||||
|
stage: record.stage.clone(),
|
||||||
|
message: record.message.clone(),
|
||||||
|
created_at: record.created_at,
|
||||||
|
updated_at: record.updated_at,
|
||||||
|
started_at: record.started_at,
|
||||||
|
finished_at: record.finished_at,
|
||||||
|
error: record
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|error| serde_json::to_value(error).ok()),
|
||||||
|
result: record.result.clone(),
|
||||||
|
log: record.log.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。
|
||||||
|
fn into_record(self) -> Option<TaskRecord> {
|
||||||
|
let kind = task_kind_static(&self.kind)?;
|
||||||
|
let status = task_status_static(&self.status)?;
|
||||||
|
// 错误从序列化形态还原:code 经码表反查(未登记回退 internal),
|
||||||
|
// location 固定为任务执行器(当前全部任务错误的唯一来源)。
|
||||||
|
let error = self.error.as_ref().map(|value| {
|
||||||
|
let code = value
|
||||||
|
.get("code")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.and_then(ErrorCode::from_id)
|
||||||
|
.unwrap_or(ErrorCode::INTERNAL);
|
||||||
|
let message = value
|
||||||
|
.get("message")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("<持久化错误信息缺失>")
|
||||||
|
.to_string();
|
||||||
|
ApiError::new(code, "task.executor", message)
|
||||||
|
});
|
||||||
|
Some(TaskRecord {
|
||||||
|
id: self.id,
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
stage: self.stage,
|
||||||
|
message: self.message,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
started_at: self.started_at,
|
||||||
|
finished_at: self.finished_at,
|
||||||
|
error,
|
||||||
|
result: self.result,
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
log: self.log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。
|
||||||
|
fn load_persisted_tasks(path: &Path) -> Result<Option<PersistedTaskFile>, String> {
|
||||||
|
let Some(bytes) = read_file_no_symlink(path, "任务历史")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let file: PersistedTaskFile = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?;
|
||||||
|
if file.version != TASKS_FILE_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"不支持的任务历史版本 {},文件 {}",
|
||||||
|
file.version,
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。
|
||||||
|
///
|
||||||
|
/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct TaskRegistry {
|
||||||
|
inner: Arc<Mutex<TaskStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskRegistry {
|
||||||
|
/// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(TaskStore {
|
||||||
|
tasks: HashMap::new(),
|
||||||
|
order: Vec::new(),
|
||||||
|
seq: 0,
|
||||||
|
persist_path: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 state dir 恢复任务历史并启用持久化。
|
||||||
|
///
|
||||||
|
/// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`);
|
||||||
|
/// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从
|
||||||
|
/// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。
|
||||||
|
pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) {
|
||||||
|
let path = state_dir.join(TASKS_FILE_NAME);
|
||||||
|
let now = unix_seconds_now();
|
||||||
|
let mut seq = 0;
|
||||||
|
let mut tasks = HashMap::new();
|
||||||
|
let mut order = Vec::new();
|
||||||
|
let summary = match load_persisted_tasks(&path) {
|
||||||
|
Ok(None) => "无历史任务文件,从空任务历史开始".to_string(),
|
||||||
|
Ok(Some(file)) => {
|
||||||
|
seq = file.seq;
|
||||||
|
let total = file.tasks.len();
|
||||||
|
let mut interrupted = 0usize;
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
for persisted in file.tasks {
|
||||||
|
let Some(mut record) = persisted.into_record() else {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !record.is_finished() {
|
||||||
|
interrupted += 1;
|
||||||
|
record.status = "failed";
|
||||||
|
record.finished_at = Some(now);
|
||||||
|
record.updated_at = now;
|
||||||
|
record.error = Some(ApiError::new(
|
||||||
|
ErrorCode::TASK_INTERRUPTED,
|
||||||
|
"task.executor",
|
||||||
|
"daemon 停止/重启导致任务中断",
|
||||||
|
));
|
||||||
|
record
|
||||||
|
.log
|
||||||
|
.push("[daemon] 任务因 daemon 停止/重启而中断".to_string());
|
||||||
|
}
|
||||||
|
if tasks.insert(record.id.clone(), record.clone()).is_none() {
|
||||||
|
order.push(record.id);
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)")
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
// 保留损坏文件供诊断(改名而非覆盖),从空历史开始。
|
||||||
|
let corrupt = path.with_extension("json.corrupt");
|
||||||
|
if fs::rename(&path, &corrupt).is_ok() {
|
||||||
|
format!(
|
||||||
|
"任务历史不可用({error});原文件已改名保留为 {}",
|
||||||
|
corrupt.display()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("任务历史不可用({error});且无法改名保留原文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let registry = Self {
|
||||||
|
inner: Arc::new(Mutex::new(TaskStore {
|
||||||
|
tasks,
|
||||||
|
order,
|
||||||
|
seq,
|
||||||
|
persist_path: Some(path),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
// 把中断标记(或空历史)立即写回,保证文件与内存视图一致。
|
||||||
|
registry.lock().persist();
|
||||||
|
(registry, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 queued 任务并返回 task_id。
|
||||||
|
pub(super) fn create(&self, kind: TaskKind) -> String {
|
||||||
|
let now = unix_seconds_now();
|
||||||
|
let mut store = self.lock();
|
||||||
|
store.seq += 1;
|
||||||
|
let id = format!("task-{}-{}", std::process::id(), store.seq);
|
||||||
|
let record = TaskRecord {
|
||||||
|
id: id.clone(),
|
||||||
|
kind: kind.method(),
|
||||||
|
status: "queued",
|
||||||
|
stage: None,
|
||||||
|
message: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
error: None,
|
||||||
|
result: None,
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
log: Vec::new(),
|
||||||
|
};
|
||||||
|
store.tasks.insert(id.clone(), record);
|
||||||
|
store.order.push(id.clone());
|
||||||
|
store.prune();
|
||||||
|
store.persist();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update<F: FnOnce(&mut TaskRecord)>(&self, id: &str, update: F) {
|
||||||
|
let mut store = self.lock();
|
||||||
|
let mut status_changed = false;
|
||||||
|
if let Some(record) = store.tasks.get_mut(id) {
|
||||||
|
let previous_status = record.status;
|
||||||
|
update(record);
|
||||||
|
record.updated_at = unix_seconds_now();
|
||||||
|
status_changed = record.status != previous_status;
|
||||||
|
}
|
||||||
|
// 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准,
|
||||||
|
// 随下一次转换一起写入(避免每个进度事件一次磁盘写)。
|
||||||
|
if status_changed {
|
||||||
|
store.persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get(&self, id: &str) -> Option<TaskRecord> {
|
||||||
|
self.lock().tasks.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
|
||||||
|
pub(super) fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
|
||||||
|
self.lock()
|
||||||
|
.tasks
|
||||||
|
.get(id)
|
||||||
|
.map(|record| Arc::clone(&record.cancel))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加一行进度日志,超出上限时丢弃最旧的。
|
||||||
|
pub(super) fn append_log(&self, id: &str, line: String) {
|
||||||
|
let mut store = self.lock();
|
||||||
|
if let Some(record) = store.tasks.get_mut(id) {
|
||||||
|
record.log.push(line);
|
||||||
|
if record.log.len() > MAX_TASK_LOG_LINES {
|
||||||
|
let overflow = record.log.len() - MAX_TASK_LOG_LINES;
|
||||||
|
record.log.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回任务的进度日志。
|
||||||
|
pub(super) fn logs(&self, id: &str) -> Option<Vec<String>> {
|
||||||
|
self.lock().tasks.get(id).map(|record| record.log.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。
|
||||||
|
pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome {
|
||||||
|
let store = self.lock();
|
||||||
|
match store.tasks.get(id) {
|
||||||
|
None => CancelOutcome::NotFound,
|
||||||
|
Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished,
|
||||||
|
Some(record) => {
|
||||||
|
record.cancel.store(true, Ordering::Relaxed);
|
||||||
|
CancelOutcome::Requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回全部任务,最新创建的在前。
|
||||||
|
pub(super) fn list(&self) -> Vec<TaskRecord> {
|
||||||
|
let store = self.lock();
|
||||||
|
store
|
||||||
|
.order
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(|id| store.tasks.get(id).cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskStore {
|
||||||
|
/// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。
|
||||||
|
///
|
||||||
|
/// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志),
|
||||||
|
/// 不让持久化故障拖垮任务执行本身。
|
||||||
|
fn persist(&self) {
|
||||||
|
let Some(path) = &self.persist_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let file = PersistedTaskFile {
|
||||||
|
version: TASKS_FILE_VERSION,
|
||||||
|
seq: self.seq,
|
||||||
|
tasks: self
|
||||||
|
.order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| self.tasks.get(id))
|
||||||
|
.map(PersistedTaskRecord::from_record)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
match serde_json::to_vec_pretty(&file) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
|
||||||
|
{
|
||||||
|
eprintln!("[daemon] 任务历史落盘失败:{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。
|
||||||
|
fn prune(&mut self) {
|
||||||
|
while self.order.len() > MAX_RETAINED_TASKS {
|
||||||
|
let Some(position) = self.order.iter().position(|id| {
|
||||||
|
self.tasks
|
||||||
|
.get(id)
|
||||||
|
.map(TaskRecord::is_finished)
|
||||||
|
.unwrap_or(true)
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let id = self.order.remove(position);
|
||||||
|
self.tasks.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
|
||||||
|
pub(super) struct TaskJob {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) config: OfficialUpdateConfig,
|
||||||
|
/// 与任务记录共享的取消标志。
|
||||||
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct DaemonTaskContext {
|
||||||
|
pub(super) registry: TaskRegistry,
|
||||||
|
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||||
|
pub(super) base_config: OfficialUpdateConfig,
|
||||||
|
pub(super) restart_controller: DaemonRestartController,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
|
||||||
|
|
||||||
|
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
|
||||||
|
///
|
||||||
|
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
|
||||||
|
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
|
||||||
|
pub(super) fn run_task_worker(
|
||||||
|
receiver: mpsc::Receiver<TaskJob>,
|
||||||
|
registry: TaskRegistry,
|
||||||
|
sync_lock: Arc<Mutex<()>>,
|
||||||
|
control: DaemonControl,
|
||||||
|
) {
|
||||||
|
let service = OfficialUpdateService::new();
|
||||||
|
for job in receiver {
|
||||||
|
registry.update(&job.id, |record| {
|
||||||
|
record.status = "running";
|
||||||
|
record.started_at = Some(unix_seconds_now());
|
||||||
|
});
|
||||||
|
|
||||||
|
let cancel = Arc::clone(&job.cancel);
|
||||||
|
let run_result = {
|
||||||
|
let _sync_guard = sync_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
|
let progress_registry = registry.clone();
|
||||||
|
let progress_id = job.id.clone();
|
||||||
|
let cancel_check = Arc::clone(&cancel);
|
||||||
|
let stop_control = Arc::clone(&control);
|
||||||
|
service.run_with_progress_and_cancellation(
|
||||||
|
&job.config,
|
||||||
|
|event| {
|
||||||
|
progress_registry
|
||||||
|
.append_log(&progress_id, format!("[{}] {}", event.stage, event.message));
|
||||||
|
progress_registry.update(&progress_id, |record| {
|
||||||
|
record.stage = Some(event.stage.to_string());
|
||||||
|
record.message = Some(event.message.clone());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
cancel_check.load(Ordering::Relaxed)
|
||||||
|
|| daemon_control_stop_requested(Some(&stop_control))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match run_result {
|
||||||
|
Ok(report) => registry.update(&job.id, |record| {
|
||||||
|
record.status = "succeeded";
|
||||||
|
record.finished_at = Some(unix_seconds_now());
|
||||||
|
record.result = serde_json::to_value(&report).ok();
|
||||||
|
}),
|
||||||
|
Err(error) => {
|
||||||
|
let cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
|
||||||
|
let code = error
|
||||||
|
.downcast_ref::<bat_infrastructure::DownloadError>()
|
||||||
|
.map(bat_infrastructure::DownloadError::code)
|
||||||
|
.unwrap_or(ErrorCode::INTERNAL);
|
||||||
|
registry.update(&job.id, |record| {
|
||||||
|
record.finished_at = Some(unix_seconds_now());
|
||||||
|
if cancelled {
|
||||||
|
record.status = "cancelled";
|
||||||
|
record.error = Some(ApiError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
|
"task.executor",
|
||||||
|
"任务已取消",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
record.status = "failed";
|
||||||
|
record.error =
|
||||||
|
Some(ApiError::new(code, "task.executor", error.to_string()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn build_translation_tasks_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
query: OfficialTextUnitTaskQuery,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||||
|
let current = version_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.current_completed_version.as_ref());
|
||||||
|
let Some(record) = current else {
|
||||||
|
return Ok(serde_json::json!({ "available": false }));
|
||||||
|
};
|
||||||
|
let task_queue_path = record
|
||||||
|
.resource_root
|
||||||
|
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
|
||||||
|
let task_repository_path =
|
||||||
|
SqliteTranslationTaskRepository::repository_path(&record.resource_root);
|
||||||
|
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
else {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"current_version_id": record.id,
|
||||||
|
"resource_root": record.resource_root,
|
||||||
|
"textunit_task_queue_path": task_queue_path,
|
||||||
|
"task_repository_path": task_repository_path,
|
||||||
|
"task_repository_available": false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let task_repository_available =
|
||||||
|
sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?;
|
||||||
|
let (total_entries, entries) = if task_repository_available {
|
||||||
|
query_translation_task_repository(&task_repository_path, &query, offset, limit)?
|
||||||
|
} else {
|
||||||
|
let mut queue_query = query.clone();
|
||||||
|
queue_query.task_status = None;
|
||||||
|
queue_query.has_failure_reason = None;
|
||||||
|
let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query);
|
||||||
|
let persisted = matches
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|task| {
|
||||||
|
bat_infrastructure::PersistedTranslationTask::from_queued_task(
|
||||||
|
task,
|
||||||
|
queue.generated_unix_seconds,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.filter(|task| {
|
||||||
|
query
|
||||||
|
.task_status
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|status| task.task_status.as_str() == status)
|
||||||
|
})
|
||||||
|
.filter(|task| {
|
||||||
|
query
|
||||||
|
.has_failure_reason
|
||||||
|
.is_none_or(|has_reason| task.failure_reason.is_some() == has_reason)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let total_entries = persisted.len();
|
||||||
|
let entries = persisted
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(total_entries as u64, entries)
|
||||||
|
};
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"current_version_id": record.id,
|
||||||
|
"resource_root": record.resource_root,
|
||||||
|
"textunit_task_queue_path": task_queue_path,
|
||||||
|
"task_repository_path": task_repository_path,
|
||||||
|
"task_repository_available": task_repository_available,
|
||||||
|
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
|
||||||
|
"summary": queue.summary,
|
||||||
|
"total_entries": total_entries,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"query": translation_task_query_json(&query),
|
||||||
|
"entries": entries,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_translation_handoff_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||||
|
let current = version_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.current_completed_version.as_ref());
|
||||||
|
let Some(record) = current else {
|
||||||
|
return Ok(serde_json::json!({ "available": false }));
|
||||||
|
};
|
||||||
|
let resource_root = &record.resource_root;
|
||||||
|
let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
|
||||||
|
let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE);
|
||||||
|
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
|
||||||
|
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
else {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"current_version_id": record.id,
|
||||||
|
"resource_root": resource_root,
|
||||||
|
"textunit_task_queue_path": task_queue_path,
|
||||||
|
"translation_handoff_path": handoff_path,
|
||||||
|
"task_repository_path": repository_path,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let task_repository_available =
|
||||||
|
sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?;
|
||||||
|
let tasks = if task_repository_available {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime.block_on(async {
|
||||||
|
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.list(&OfficialTextUnitTaskQuery::default())
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
queue
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|task| {
|
||||||
|
bat_infrastructure::PersistedTranslationTask::from_queued_task(
|
||||||
|
task,
|
||||||
|
queue.generated_unix_seconds,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks);
|
||||||
|
let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"current_version_id": record.id,
|
||||||
|
"resource_root": resource_root,
|
||||||
|
"textunit_task_queue_path": task_queue_path,
|
||||||
|
"translation_handoff_path": handoff_path,
|
||||||
|
"translation_handoff_file_available": handoff_file_available,
|
||||||
|
"task_repository_path": repository_path,
|
||||||
|
"task_repository_available": task_repository_available,
|
||||||
|
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
|
||||||
|
"handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION,
|
||||||
|
"handoff": handoff,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_translation_task_status_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let task_id = rpc_string_param(params, "task_id")
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
||||||
|
let status_label = rpc_string_param(params, "status")
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?;
|
||||||
|
let status = TranslationTaskStatus::parse(status_label)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?;
|
||||||
|
let failure_reason = rpc_string_param(params, "failure_reason")
|
||||||
|
.or_else(|| rpc_string_param(params, "reason"))
|
||||||
|
.map(str::to_string);
|
||||||
|
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
|
||||||
|
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||||
|
let current = version_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.current_completed_version.as_ref())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?;
|
||||||
|
let repository_path = SqliteTranslationTaskRepository::repository_path(¤t.resource_root);
|
||||||
|
if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"翻译任务状态数据库不存在:{}",
|
||||||
|
repository_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let task = runtime.block_on(async {
|
||||||
|
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.update_status(task_id, status, failure_reason, provider_run_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"current_version_id": current.id,
|
||||||
|
"task_repository_path": repository_path,
|
||||||
|
"entry": task,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"destination": query.destination.clone(),
|
||||||
|
"path_pattern": query.path_pattern.clone(),
|
||||||
|
"archive_entry": query.archive_entry.clone(),
|
||||||
|
"path_id": query.path_id,
|
||||||
|
"class_id": query.class_id,
|
||||||
|
"field_path": query.field_path.clone(),
|
||||||
|
"format": query.format.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"task_id": query.task_id.clone(),
|
||||||
|
"official_release_id": query.official_release_id.clone(),
|
||||||
|
"destination": query.destination.clone(),
|
||||||
|
"path_pattern": query.path_pattern.clone(),
|
||||||
|
"archive_entry": query.archive_entry.clone(),
|
||||||
|
"status": query.status.clone(),
|
||||||
|
"task_status": query.task_status.clone(),
|
||||||
|
"parse_status": query.parse_status.clone(),
|
||||||
|
"text_unit_format": query.text_unit_format.clone(),
|
||||||
|
"has_reason": query.has_reason,
|
||||||
|
"has_failure_reason": query.has_failure_reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user