755 lines
23 KiB
Rust
755 lines
23 KiB
Rust
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
use std::time::{Duration, SystemTime};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
use tauri::{Emitter, Runtime};
|
||
|
||
use crate::mod_translation;
|
||
use crate::mod_translation::analyze::{AnalysisSummary, JarInspection};
|
||
use crate::mod_translation::error::TranslateErrorCode;
|
||
use crate::mod_translation::repair::{RepairActivity, RepairEmitter};
|
||
use crate::mod_translation::translate::{
|
||
PreparedTranslationWorkspace, TranslationReport, TranslationSample,
|
||
};
|
||
|
||
const WORKSPACE_SUBDIR: &str = "mod-translation";
|
||
const TASK_EVENT: &str = "mod-translation-task-event";
|
||
const ANALYSIS_TTL: Duration = Duration::from_secs(30 * 60);
|
||
const MAX_SNAPSHOT_EVENTS: usize = 400;
|
||
|
||
struct TaskHandle {
|
||
cancel: Arc<AtomicBool>,
|
||
sequence: AtomicU64,
|
||
event_gate: Mutex<()>,
|
||
snapshot: Mutex<TaskSnapshot>,
|
||
lock_keys: Vec<String>,
|
||
}
|
||
|
||
struct PreparedAnalysis {
|
||
created_at: SystemTime,
|
||
input_path: PathBuf,
|
||
input_hash: String,
|
||
workspace: PathBuf,
|
||
inspection: JarInspection,
|
||
}
|
||
|
||
static TASKS: OnceLock<Mutex<HashMap<String, Arc<TaskHandle>>>> =
|
||
OnceLock::new();
|
||
static ACTIVE_LOCKS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||
static ANALYSES: OnceLock<Mutex<HashMap<String, PreparedAnalysis>>> =
|
||
OnceLock::new();
|
||
|
||
fn tasks() -> &'static Mutex<HashMap<String, Arc<TaskHandle>>> {
|
||
TASKS.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
fn active_locks() -> &'static Mutex<HashMap<String, String>> {
|
||
ACTIVE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
fn analyses() -> &'static Mutex<HashMap<String, PreparedAnalysis>> {
|
||
ANALYSES.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||
tauri::plugin::Builder::new("mod-translation")
|
||
.invoke_handler(tauri::generate_handler![
|
||
mod_translation_analyze,
|
||
mod_translation_translate,
|
||
mod_translation_cancel,
|
||
mod_translation_list_tasks,
|
||
mod_translation_get_task,
|
||
mod_translation_dismiss_task,
|
||
])
|
||
.build()
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ModTranslationOptions {
|
||
#[serde(default = "default_batch_size")]
|
||
pub batch_size: usize,
|
||
#[serde(default = "default_deep_batch_size")]
|
||
pub deep_batch_size: usize,
|
||
#[serde(default)]
|
||
pub generate_mod_name: bool,
|
||
#[serde(default = "default_repair_enabled")]
|
||
pub repair_enabled: bool,
|
||
#[serde(default)]
|
||
pub class_text_enabled: bool,
|
||
#[serde(default = "default_class_batch")]
|
||
pub max_class_batch: usize,
|
||
}
|
||
|
||
impl Default for ModTranslationOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
batch_size: default_batch_size(),
|
||
deep_batch_size: default_deep_batch_size(),
|
||
generate_mod_name: false,
|
||
repair_enabled: default_repair_enabled(),
|
||
class_text_enabled: false,
|
||
max_class_batch: default_class_batch(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_batch_size() -> usize {
|
||
40
|
||
}
|
||
|
||
fn default_deep_batch_size() -> usize {
|
||
24
|
||
}
|
||
|
||
fn default_repair_enabled() -> bool {
|
||
true
|
||
}
|
||
|
||
fn default_class_batch() -> usize {
|
||
16
|
||
}
|
||
|
||
impl From<ModTranslationOptions>
|
||
for mod_translation::translate::TranslateOptions
|
||
{
|
||
fn from(value: ModTranslationOptions) -> Self {
|
||
Self {
|
||
batch_size: value.batch_size.clamp(10, 80),
|
||
deep_batch_size: value.deep_batch_size.clamp(4, 40),
|
||
generate_mod_name: value.generate_mod_name,
|
||
repair_enabled: value.repair_enabled,
|
||
class_text_enabled: value.class_text_enabled,
|
||
max_class_batch: value.max_class_batch.clamp(4, 20),
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ModTranslationAnalysis {
|
||
pub analysis_id: String,
|
||
pub input_hash: String,
|
||
#[serde(flatten)]
|
||
pub summary: AnalysisSummary,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TaskFailure {
|
||
pub code: String,
|
||
pub message: String,
|
||
pub details: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ModTranslationTaskEvent {
|
||
pub event_id: String,
|
||
pub task_id: String,
|
||
pub sequence: u64,
|
||
pub occurred_at: String,
|
||
pub event_type: String,
|
||
pub status: String,
|
||
pub progress: Option<TranslationSample>,
|
||
pub activity: Option<RepairActivity>,
|
||
pub report: Option<TranslationReport>,
|
||
pub error: Option<TaskFailure>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TaskSnapshot {
|
||
pub task_id: String,
|
||
pub input_path: PathBuf,
|
||
pub output_path: PathBuf,
|
||
pub input_hash: String,
|
||
pub started_at: String,
|
||
pub updated_at: String,
|
||
pub status: String,
|
||
pub sequence: u64,
|
||
pub progress: Option<TranslationSample>,
|
||
pub activities: Vec<RepairActivity>,
|
||
pub report: Option<TranslationReport>,
|
||
pub error: Option<TaskFailure>,
|
||
pub events: Vec<ModTranslationTaskEvent>,
|
||
}
|
||
|
||
async fn workspace_root() -> crate::api::Result<PathBuf> {
|
||
let state = theseus::State::get().await?;
|
||
Ok(state.directories.caches_dir().join(WORKSPACE_SUBDIR))
|
||
}
|
||
|
||
fn to_command_error(error: mod_translation::error::TranslateError) -> String {
|
||
tracing::error!(code = ?error.code, message = %error.message, "mod translation command failed");
|
||
error.user_message()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn mod_translation_analyze(
|
||
input_path: String,
|
||
) -> Result<ModTranslationAnalysis, String> {
|
||
let root = workspace_root().await.map_err(|error| error.to_string())?;
|
||
let input = PathBuf::from(&input_path);
|
||
if !input.is_file() {
|
||
return Err("找不到输入 JAR 文件".to_string());
|
||
}
|
||
let input_for_analysis = input.clone();
|
||
let (directory, inspection, input_hash) =
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let input_hash =
|
||
mod_translation::analyze::input_file_hash(&input_for_analysis)
|
||
.map_err(to_command_error)?;
|
||
let (directory, inspection) =
|
||
mod_translation::analyze::extract_and_inspect(
|
||
&input_for_analysis,
|
||
&root,
|
||
)
|
||
.map_err(to_command_error)?;
|
||
Ok::<_, String>((directory, inspection, input_hash))
|
||
})
|
||
.await
|
||
.map_err(|error| error.to_string())??;
|
||
let analysis_id = format!("ANALYSIS-{}", uuid::Uuid::new_v4());
|
||
let summary = mod_translation::translate::analysis_summary(&inspection);
|
||
let mut registry = analyses()
|
||
.lock()
|
||
.map_err(|_| "分析注册表不可用".to_string())?;
|
||
prune_analyses(&mut registry);
|
||
registry.insert(
|
||
analysis_id.clone(),
|
||
PreparedAnalysis {
|
||
created_at: SystemTime::now(),
|
||
input_path: input,
|
||
input_hash: input_hash.clone(),
|
||
workspace: directory,
|
||
inspection,
|
||
},
|
||
);
|
||
Ok(ModTranslationAnalysis {
|
||
analysis_id,
|
||
input_hash,
|
||
summary,
|
||
})
|
||
}
|
||
|
||
async fn validate_ai_config(
|
||
provider_id: &str,
|
||
model_id: &str,
|
||
) -> Result<(), String> {
|
||
let state = theseus::ai::get_state()
|
||
.await
|
||
.map_err(|error| error.to_string())?;
|
||
if !state.settings.enabled {
|
||
return Err(format!(
|
||
"{}: 请在 AI 设置中启用 AI 功能后再试",
|
||
TranslateErrorCode::AiDisabled.as_str()
|
||
));
|
||
}
|
||
let Some(provider) = state
|
||
.providers
|
||
.iter()
|
||
.find(|provider| provider.provider_id == provider_id)
|
||
else {
|
||
return Err(format!(
|
||
"{}: 未找到已配置的 AI 提供商 {provider_id},请先在 AI 设置中添加",
|
||
TranslateErrorCode::AiProviderDisabled.as_str()
|
||
));
|
||
};
|
||
if !provider.enabled {
|
||
return Err(format!(
|
||
"{}: AI 提供商 {provider_id} 当前已禁用,请在 AI 设置中启用",
|
||
TranslateErrorCode::AiProviderDisabled.as_str()
|
||
));
|
||
}
|
||
if model_id.trim().is_empty()
|
||
|| !provider.models.iter().any(|model| model.id == model_id)
|
||
{
|
||
return Err(format!(
|
||
"{}: 请先在翻译设置或 AI 设置中选择模型",
|
||
TranslateErrorCode::AiModelNotSelected.as_str()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn mod_translation_translate<R: Runtime>(
|
||
app: tauri::AppHandle<R>,
|
||
input_path: String,
|
||
output_path: String,
|
||
provider_id: String,
|
||
model_id: String,
|
||
analysis_id: Option<String>,
|
||
input_hash: Option<String>,
|
||
options: Option<ModTranslationOptions>,
|
||
) -> Result<TaskSnapshot, String> {
|
||
validate_ai_config(&provider_id, &model_id).await?;
|
||
let input = PathBuf::from(&input_path);
|
||
let output = PathBuf::from(&output_path);
|
||
if !input.is_file() {
|
||
return Err("找不到输入 JAR 文件".to_string());
|
||
}
|
||
if !mod_translation::jar::is_clean_absolute_path(&output) {
|
||
return Err("输出路径必须是合法的绝对路径".to_string());
|
||
}
|
||
if output.exists() {
|
||
return Err(format!("输出文件已存在:{}", output.display()));
|
||
}
|
||
let actual_hash = {
|
||
let input = input.clone();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
mod_translation::analyze::input_file_hash(&input)
|
||
})
|
||
.await
|
||
.map_err(|error| error.to_string())?
|
||
.map_err(to_command_error)?
|
||
};
|
||
let root = workspace_root().await.map_err(|error| error.to_string())?;
|
||
let task_id = new_task_id();
|
||
let lock_keys = vec![
|
||
format!("output:{}", normalized_path_lock_key(&output)),
|
||
format!("checkpoint:{actual_hash}"),
|
||
];
|
||
claim_locks(&task_id, &lock_keys)?;
|
||
let prepared = match take_prepared_analysis(
|
||
analysis_id.as_deref(),
|
||
input_hash.as_deref(),
|
||
&actual_hash,
|
||
&input,
|
||
) {
|
||
Ok(prepared) => prepared,
|
||
Err(error) => {
|
||
release_lock_keys(&task_id, &lock_keys);
|
||
return Err(error);
|
||
}
|
||
};
|
||
let now = iso_now();
|
||
let snapshot = TaskSnapshot {
|
||
task_id: task_id.clone(),
|
||
input_path: input.clone(),
|
||
output_path: output.clone(),
|
||
input_hash: actual_hash,
|
||
started_at: now.clone(),
|
||
updated_at: now,
|
||
status: "running".to_string(),
|
||
sequence: 0,
|
||
progress: None,
|
||
activities: Vec::new(),
|
||
report: None,
|
||
error: None,
|
||
events: Vec::new(),
|
||
};
|
||
let handle = Arc::new(TaskHandle {
|
||
cancel: Arc::new(AtomicBool::new(false)),
|
||
sequence: AtomicU64::new(0),
|
||
event_gate: Mutex::new(()),
|
||
snapshot: Mutex::new(snapshot.clone()),
|
||
lock_keys,
|
||
});
|
||
match tasks().lock() {
|
||
Ok(mut registry) => {
|
||
registry.insert(task_id.clone(), handle.clone());
|
||
}
|
||
Err(_) => {
|
||
release_lock_keys(&task_id, &handle.lock_keys);
|
||
if let Some(prepared) = &prepared {
|
||
let _ = std::fs::remove_dir_all(&prepared.workspace);
|
||
}
|
||
return Err("任务注册表不可用".to_string());
|
||
}
|
||
}
|
||
|
||
let mut translate_options: mod_translation::translate::TranslateOptions =
|
||
options.unwrap_or_default().into();
|
||
translate_options.provider_id = provider_id;
|
||
translate_options.model_id = model_id;
|
||
let emitter = make_progress_emitter(app.clone(), handle.clone());
|
||
let repair_emitter = make_repair_emitter(app.clone(), handle.clone());
|
||
let cancel = handle.cancel.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
let result = mod_translation::translate::run_translation_task(
|
||
task_id.clone(),
|
||
input,
|
||
output,
|
||
translate_options,
|
||
root,
|
||
prepared,
|
||
cancel,
|
||
emitter,
|
||
repair_emitter,
|
||
)
|
||
.await;
|
||
if let Err(error) = result {
|
||
ensure_terminal_error(&app, &handle, &error);
|
||
}
|
||
release_locks(&handle);
|
||
});
|
||
Ok(snapshot)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn mod_translation_cancel(task_id: String) -> Result<(), String> {
|
||
if let Some(handle) = tasks()
|
||
.lock()
|
||
.map_err(|_| "任务注册表不可用".to_string())?
|
||
.get(&task_id)
|
||
{
|
||
handle.cancel.store(true, Ordering::Relaxed);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn mod_translation_list_tasks() -> Result<Vec<TaskSnapshot>, String> {
|
||
let registry =
|
||
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
|
||
let mut snapshots = registry
|
||
.values()
|
||
.filter_map(|handle| {
|
||
handle.snapshot.lock().ok().map(|snapshot| snapshot.clone())
|
||
})
|
||
.collect::<Vec<_>>();
|
||
snapshots.sort_by(|left, right| right.started_at.cmp(&left.started_at));
|
||
Ok(snapshots)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn mod_translation_get_task(
|
||
task_id: String,
|
||
) -> Result<Option<TaskSnapshot>, String> {
|
||
let registry =
|
||
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
|
||
Ok(registry.get(&task_id).and_then(|handle| {
|
||
handle.snapshot.lock().ok().map(|snapshot| snapshot.clone())
|
||
}))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn mod_translation_dismiss_task(task_id: String) -> Result<(), String> {
|
||
let mut registry =
|
||
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
|
||
let Some(handle) = registry.get(&task_id) else {
|
||
return Ok(());
|
||
};
|
||
let running = handle
|
||
.snapshot
|
||
.lock()
|
||
.map_err(|_| "任务快照不可用".to_string())?
|
||
.status
|
||
== "running";
|
||
if running {
|
||
return Err("运行中的任务不能移除,请先取消".to_string());
|
||
}
|
||
registry.remove(&task_id);
|
||
Ok(())
|
||
}
|
||
|
||
fn make_progress_emitter<R: Runtime>(
|
||
app: tauri::AppHandle<R>,
|
||
handle: Arc<TaskHandle>,
|
||
) -> mod_translation::translate::EventEmitter {
|
||
Arc::new(move |progress: TranslationSample| {
|
||
let report = progress.report.as_deref().and_then(|value| {
|
||
serde_json::from_str::<TranslationReport>(value).ok()
|
||
});
|
||
let error = if progress.finished && !progress.ok {
|
||
Some(parse_failure(
|
||
progress.report.as_deref().unwrap_or(&progress.message),
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
emit_task_event(
|
||
&app,
|
||
&handle,
|
||
"progress",
|
||
Some(progress),
|
||
None,
|
||
report,
|
||
error,
|
||
);
|
||
})
|
||
}
|
||
|
||
fn make_repair_emitter<R: Runtime>(
|
||
app: tauri::AppHandle<R>,
|
||
handle: Arc<TaskHandle>,
|
||
) -> RepairEmitter {
|
||
Arc::new(move |activity: RepairActivity| {
|
||
emit_task_event(
|
||
&app,
|
||
&handle,
|
||
"activity",
|
||
None,
|
||
Some(activity),
|
||
None,
|
||
None,
|
||
);
|
||
})
|
||
}
|
||
|
||
fn emit_task_event<R: Runtime>(
|
||
app: &tauri::AppHandle<R>,
|
||
handle: &Arc<TaskHandle>,
|
||
event_type: &str,
|
||
progress: Option<TranslationSample>,
|
||
activity: Option<RepairActivity>,
|
||
report: Option<TranslationReport>,
|
||
error: Option<TaskFailure>,
|
||
) {
|
||
let Ok(_event_guard) = handle.event_gate.lock() else {
|
||
return;
|
||
};
|
||
let event = {
|
||
let Ok(mut snapshot) = handle.snapshot.lock() else {
|
||
return;
|
||
};
|
||
let sequence = handle.sequence.fetch_add(1, Ordering::SeqCst) + 1;
|
||
let occurred_at = iso_now();
|
||
if let Some(progress) = &progress {
|
||
snapshot.progress = Some(progress.clone());
|
||
if progress.finished {
|
||
snapshot.status =
|
||
if progress.ok { "completed" } else { "failed" }
|
||
.to_string();
|
||
}
|
||
}
|
||
if let Some(activity) = &activity {
|
||
snapshot.activities.push(activity.clone());
|
||
if snapshot.activities.len() > MAX_SNAPSHOT_EVENTS {
|
||
snapshot.activities.remove(0);
|
||
}
|
||
}
|
||
if report.is_some() {
|
||
snapshot.report = report.clone();
|
||
}
|
||
if error.is_some() {
|
||
snapshot.error = error.clone();
|
||
snapshot.status = "failed".to_string();
|
||
}
|
||
snapshot.sequence = sequence;
|
||
snapshot.updated_at = occurred_at.clone();
|
||
let event = ModTranslationTaskEvent {
|
||
event_id: format!("EVENT-{}", uuid::Uuid::new_v4()),
|
||
task_id: snapshot.task_id.clone(),
|
||
sequence,
|
||
occurred_at,
|
||
event_type: event_type.to_string(),
|
||
status: snapshot.status.clone(),
|
||
progress,
|
||
activity,
|
||
report,
|
||
error,
|
||
};
|
||
snapshot.events.push(event.clone());
|
||
if snapshot.events.len() > MAX_SNAPSHOT_EVENTS {
|
||
snapshot.events.remove(0);
|
||
}
|
||
event
|
||
};
|
||
if let Err(error) = app.emit(TASK_EVENT, event) {
|
||
tracing::warn!(error = %error, "mod translation task event emit failed");
|
||
}
|
||
}
|
||
|
||
fn ensure_terminal_error<R: Runtime>(
|
||
app: &tauri::AppHandle<R>,
|
||
handle: &Arc<TaskHandle>,
|
||
error: &mod_translation::error::TranslateError,
|
||
) {
|
||
let already_terminal = handle
|
||
.snapshot
|
||
.lock()
|
||
.map(|snapshot| {
|
||
snapshot.status != "running" && snapshot.error.is_some()
|
||
})
|
||
.unwrap_or(false);
|
||
if already_terminal {
|
||
return;
|
||
}
|
||
emit_task_event(
|
||
app,
|
||
handle,
|
||
"finished",
|
||
None,
|
||
None,
|
||
None,
|
||
Some(TaskFailure {
|
||
code: error.code.as_str().to_string(),
|
||
message: error.message.clone(),
|
||
details: None,
|
||
}),
|
||
);
|
||
}
|
||
|
||
fn parse_failure(value: &str) -> TaskFailure {
|
||
let (code, message) = value
|
||
.split_once(": ")
|
||
.map(|(code, message)| (code.to_string(), message.to_string()))
|
||
.unwrap_or_else(|| ("UNKNOWN".to_string(), value.to_string()));
|
||
let details = message
|
||
.rsplit_once(';')
|
||
.and_then(|(_, json)| serde_json::from_str::<Value>(json).ok());
|
||
TaskFailure {
|
||
code,
|
||
message,
|
||
details,
|
||
}
|
||
}
|
||
|
||
fn take_prepared_analysis(
|
||
analysis_id: Option<&str>,
|
||
requested_hash: Option<&str>,
|
||
actual_hash: &str,
|
||
input_path: &PathBuf,
|
||
) -> Result<Option<PreparedTranslationWorkspace>, String> {
|
||
let Some(analysis_id) = analysis_id else {
|
||
return Ok(None);
|
||
};
|
||
let mut registry = analyses()
|
||
.lock()
|
||
.map_err(|_| "分析注册表不可用".to_string())?;
|
||
prune_analyses(&mut registry);
|
||
let Some(prepared) = registry.remove(analysis_id) else {
|
||
return Ok(None);
|
||
};
|
||
let matches = requested_hash == Some(prepared.input_hash.as_str())
|
||
&& prepared.input_hash == actual_hash
|
||
&& prepared.input_path == *input_path
|
||
&& prepared.workspace.is_dir();
|
||
if !matches {
|
||
let _ = std::fs::remove_dir_all(prepared.workspace);
|
||
return Ok(None);
|
||
}
|
||
Ok(Some(PreparedTranslationWorkspace {
|
||
workspace: prepared.workspace,
|
||
inspection: prepared.inspection,
|
||
input_hash: prepared.input_hash,
|
||
}))
|
||
}
|
||
|
||
fn prune_analyses(registry: &mut HashMap<String, PreparedAnalysis>) {
|
||
let now = SystemTime::now();
|
||
let expired = registry
|
||
.iter()
|
||
.filter_map(|(id, prepared)| {
|
||
now.duration_since(prepared.created_at)
|
||
.ok()
|
||
.filter(|age| *age > ANALYSIS_TTL)
|
||
.map(|_| id.clone())
|
||
})
|
||
.collect::<Vec<_>>();
|
||
for id in expired {
|
||
if let Some(prepared) = registry.remove(&id) {
|
||
let _ = std::fs::remove_dir_all(prepared.workspace);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn claim_locks(task_id: &str, keys: &[String]) -> Result<(), String> {
|
||
let mut locks = active_locks()
|
||
.lock()
|
||
.map_err(|_| "任务锁不可用".to_string())?;
|
||
if let Some(key) = keys.iter().find(|key| locks.contains_key(*key)) {
|
||
return Err(format!("该输出或检查点已有运行中的任务:{key}"));
|
||
}
|
||
for key in keys {
|
||
locks.insert(key.clone(), task_id.to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalized_path_lock_key(path: &std::path::Path) -> String {
|
||
let normalized = path.to_string_lossy().replace('\\', "/");
|
||
if cfg!(windows) {
|
||
normalized.to_lowercase()
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn release_locks(handle: &TaskHandle) {
|
||
let task_id = handle
|
||
.snapshot
|
||
.lock()
|
||
.ok()
|
||
.map(|snapshot| snapshot.task_id.clone())
|
||
.unwrap_or_default();
|
||
release_lock_keys(&task_id, &handle.lock_keys);
|
||
}
|
||
|
||
fn release_lock_keys(task_id: &str, keys: &[String]) {
|
||
if let Ok(mut locks) = active_locks().lock() {
|
||
for key in keys {
|
||
if locks.get(key).is_some_and(|owner| owner == task_id) {
|
||
locks.remove(key);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn new_task_id() -> String {
|
||
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
|
||
format!("TASK-{timestamp}-{}", uuid::Uuid::new_v4().simple())
|
||
}
|
||
|
||
fn iso_now() -> String {
|
||
chrono::Utc::now().to_rfc3339()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn handle(task_id: &str, lock_keys: Vec<String>) -> TaskHandle {
|
||
let now = iso_now();
|
||
TaskHandle {
|
||
cancel: Arc::new(AtomicBool::new(false)),
|
||
sequence: AtomicU64::new(0),
|
||
event_gate: Mutex::new(()),
|
||
snapshot: Mutex::new(TaskSnapshot {
|
||
task_id: task_id.to_string(),
|
||
input_path: PathBuf::from("C:/mods/demo.jar"),
|
||
output_path: PathBuf::from("C:/mods/demo-zh_cn.jar"),
|
||
input_hash: "hash".to_string(),
|
||
started_at: now.clone(),
|
||
updated_at: now,
|
||
status: "running".to_string(),
|
||
sequence: 0,
|
||
progress: None,
|
||
activities: Vec::new(),
|
||
report: None,
|
||
error: None,
|
||
events: Vec::new(),
|
||
}),
|
||
lock_keys,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_output_or_checkpoint_locks_are_rejected_until_release() {
|
||
let key = format!("output:test-{}", uuid::Uuid::new_v4());
|
||
claim_locks("TASK-a", std::slice::from_ref(&key)).unwrap();
|
||
assert!(claim_locks("TASK-b", std::slice::from_ref(&key)).is_err());
|
||
let first = handle("TASK-a", vec![key.clone()]);
|
||
release_locks(&first);
|
||
claim_locks("TASK-b", std::slice::from_ref(&key)).unwrap();
|
||
let second = handle("TASK-b", vec![key]);
|
||
release_locks(&second);
|
||
}
|
||
|
||
#[test]
|
||
fn task_failures_preserve_the_backend_error_code() {
|
||
let failure =
|
||
parse_failure("UNSUPPORTED_RESOURCE: assets/demo/data.txt");
|
||
assert_eq!(failure.code, "UNSUPPORTED_RESOURCE");
|
||
assert_eq!(failure.message, "assets/demo/data.txt");
|
||
}
|
||
}
|