use chrono::{DateTime, Utc}; use sqlx::{FromRow, SqlitePool}; use crate::{ error::{ApiError, ApiResult}, runs::model::{RunListQuery, RunStatus, RunTrigger, TaskRunListResponse, TaskRunResponse}, }; const DEFAULT_PAGE_SIZE: u32 = 20; const MAX_PAGE_SIZE: u32 = 100; #[derive(Debug, FromRow)] struct TaskRunRow { id: i64, task_id: String, trigger_type: String, status: String, started_at: Option>, finished_at: Option>, duration_ms: Option, exit_code: Option, error_message: Option, created_at: DateTime, } impl TryFrom for TaskRunResponse { type Error = ApiError; fn try_from(row: TaskRunRow) -> Result { Ok(Self { id: row.id, task_id: row.task_id, trigger: parse_trigger(&row.trigger_type)?, status: parse_status(&row.status)?, started_at: row.started_at, finished_at: row.finished_at, duration_ms: row .duration_ms .map(u64::try_from) .transpose() .map_err(ApiError::internal)?, exit_code: row .exit_code .map(i32::try_from) .transpose() .map_err(ApiError::internal)?, error_message: row.error_message, created_at: row.created_at, }) } } pub async fn create_queued( pool: &SqlitePool, task_id: &str, trigger: RunTrigger, ) -> ApiResult { let now = Utc::now(); let result = sqlx::query( "INSERT INTO task_runs ( task_id, trigger_type, status, created_at ) VALUES (?, ?, ?, ?)", ) .bind(task_id) .bind(trigger.as_str()) .bind(RunStatus::Queued.as_str()) .bind(now) .execute(pool) .await?; Ok(TaskRunResponse { id: result.last_insert_rowid(), task_id: task_id.to_owned(), trigger, status: RunStatus::Queued, started_at: None, finished_at: None, duration_ms: None, exit_code: None, error_message: None, created_at: now, }) } pub async fn create_skipped( pool: &SqlitePool, task_id: &str, trigger: RunTrigger, message: &str, ) -> ApiResult { let now = Utc::now(); let result = sqlx::query( "INSERT INTO task_runs ( task_id, trigger_type, status, started_at, finished_at, duration_ms, error_message, created_at ) VALUES (?, ?, ?, ?, ?, 0, ?, ?)", ) .bind(task_id) .bind(trigger.as_str()) .bind(RunStatus::Skipped.as_str()) .bind(now) .bind(now) .bind(message) .bind(now) .execute(pool) .await?; Ok(TaskRunResponse { id: result.last_insert_rowid(), task_id: task_id.to_owned(), trigger, status: RunStatus::Skipped, started_at: Some(now), finished_at: Some(now), duration_ms: Some(0), exit_code: None, error_message: Some(message.to_owned()), created_at: now, }) } /// 返回是否真的更新到了运行记录;记录被删除(例如任务被删)时返回 false。 pub async fn mark_running( pool: &SqlitePool, run_id: i64, started_at: DateTime, ) -> ApiResult { let result = sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?") .bind(RunStatus::Running.as_str()) .bind(started_at) .bind(run_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// 返回是否真的更新到了运行记录。 pub async fn complete( pool: &SqlitePool, run_id: i64, status: RunStatus, finished_at: DateTime, duration_ms: u64, exit_code: Option, error_message: Option<&str>, ) -> ApiResult { let result = sqlx::query( "UPDATE task_runs SET status = ?, finished_at = ?, duration_ms = ?, exit_code = ?, error_message = ? WHERE id = ?", ) .bind(status.as_str()) .bind(finished_at) .bind(duration_ms as i64) .bind(exit_code.map(i64::from)) .bind(error_message) .bind(run_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// 启动时收敛上一次进程遗留下来的非终态运行,避免界面永远显示"运行中"。 pub async fn fail_interrupted_runs(pool: &SqlitePool) -> ApiResult { let result = sqlx::query( "UPDATE task_runs SET status = ?, finished_at = ?, error_message = ? WHERE status IN ('queued', 'running')", ) .bind(RunStatus::Cancelled.as_str()) .bind(Utc::now()) .bind("TaskPilot 进程重启,运行被中断") .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult> { let row = sqlx::query_as::<_, TaskRunRow>( "SELECT id, task_id, trigger_type, status, started_at, finished_at, duration_ms, exit_code, error_message, created_at FROM task_runs WHERE id = ?", ) .bind(run_id) .fetch_optional(pool) .await?; row.map(TaskRunResponse::try_from).transpose() } pub async fn list_for_task( pool: &SqlitePool, task_id: &str, query: RunListQuery, ) -> ApiResult { let page = query.page.unwrap_or(1).max(1); let page_size = query .page_size .unwrap_or(DEFAULT_PAGE_SIZE) .clamp(1, MAX_PAGE_SIZE); let offset = i64::from(page - 1) * i64::from(page_size); let status = query.status.map(RunStatus::as_str); let trigger = query.trigger.map(RunTrigger::as_str); let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM task_runs WHERE task_id = ? AND (? IS NULL OR status = ?) AND (? IS NULL OR trigger_type = ?)", ) .bind(task_id) .bind(status) .bind(status) .bind(trigger) .bind(trigger) .fetch_one(pool) .await?; let rows = sqlx::query_as::<_, TaskRunRow>( "SELECT id, task_id, trigger_type, status, started_at, finished_at, duration_ms, exit_code, error_message, created_at FROM task_runs WHERE task_id = ? AND (? IS NULL OR status = ?) AND (? IS NULL OR trigger_type = ?) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", ) .bind(task_id) .bind(status) .bind(status) .bind(trigger) .bind(trigger) .bind(i64::from(page_size)) .bind(offset) .fetch_all(pool) .await?; let items = rows .into_iter() .map(TaskRunResponse::try_from) .collect::>>()?; Ok(TaskRunListResponse { items, page, page_size, total: u64::try_from(total).map_err(ApiError::internal)?, }) } fn parse_trigger(value: &str) -> ApiResult { match value { "schedule" => Ok(RunTrigger::Schedule), "manual" => Ok(RunTrigger::Manual), "retry" => Ok(RunTrigger::Retry), _ => Err(ApiError::internal(format!("unknown run trigger: {value}"))), } } fn parse_status(value: &str) -> ApiResult { match value { "queued" => Ok(RunStatus::Queued), "running" => Ok(RunStatus::Running), "succeeded" => Ok(RunStatus::Succeeded), "failed" => Ok(RunStatus::Failed), "timed_out" => Ok(RunStatus::TimedOut), "cancelled" => Ok(RunStatus::Cancelled), "skipped" => Ok(RunStatus::Skipped), _ => Err(ApiError::internal(format!("unknown run status: {value}"))), } }