fix: 拆分 bat 控制面

This commit is contained in:
2026-08-03 00:43:11 +08:00
parent 2b053e247d
commit 9a5b3ba39b
10 changed files with 12083 additions and 12043 deletions
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(&params)?;
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(&params)?;
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(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchField => {
let params = unityfs_field_params_from_options(options)?;
let report = apply_unityfs_field_patch_file(&params)?;
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(())
}
+587
View File
@@ -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(&current.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