feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+2927
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "task-pilot-backend"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
[dependencies]
axum = "0.8.9"
chrono = { version = "0.4.45", features = ["serde"] }
chrono-tz = "0.10.4"
croner = "4.0.1"
futures-util = { version = "0.3.34", features = ["std"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
thiserror = "2.0.21"
tokio = { version = "1.53.3", features = ["full"] }
tokio-stream = { version = "0.1.19", features = ["sync"] }
tokio-util = "0.7.20"
tower-http = { version = "0.7.1", features = ["cors", "fs", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8"
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.3.0"
uuid = { version = "1.28.0", features = ["v4"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] }
[dev-dependencies]
http-body-util = "0.1.5"
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
CHECK (concurrency_policy = 'skip'),
retry_max_attempts INTEGER NOT NULL DEFAULT 0
CHECK (retry_max_attempts >= 0),
retry_delay_secs INTEGER NOT NULL DEFAULT 0
CHECK (retry_delay_secs >= 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
+201
View File
@@ -0,0 +1,201 @@
use axum::{
Json, Router,
extract::State,
http::{HeaderValue, Method, header},
middleware,
response::IntoResponse,
routing::any,
routing::get,
};
use serde::Serialize;
use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use crate::{
cron::{self, CronPreviewRequest, CronPreviewResponse},
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
routes::{
__path_cancel_run, __path_download_run_logs, __path_execution_events, __path_get_run,
__path_get_run_logs, __path_list_task_runs, __path_run_task,
},
},
security,
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
},
routes::{
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
__path_get_task, __path_list_tasks, __path_update_task,
},
},
};
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthResponse {
/// Current service status.
pub status: String,
/// Stable service identifier.
pub service: String,
/// Backend package version.
pub version: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SessionResponse {
/// Random per-process token required for mutating browser requests.
pub token: String,
}
#[utoipa::path(
get,
path = "/api/v1/health",
tag = "system",
responses(
(status = 200, description = "TaskPilot API is healthy", body = HealthResponse)
)
)]
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_owned(),
service: "task-pilot-backend".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
})
}
#[utoipa::path(
get,
path = "/api/v1/session",
tag = "system",
responses(
(status = 200, description = "Local API session token", body = SessionResponse)
)
)]
pub async fn session(State(state): State<AppState>) -> Json<SessionResponse> {
Json(SessionResponse {
token: state.auth_token().to_owned(),
})
}
#[utoipa::path(
post,
path = "/api/v1/cron/preview",
tag = "system",
request_body = CronPreviewRequest,
responses(
(status = 200, description = "Upcoming cron occurrences", body = CronPreviewResponse),
(status = 400, description = "Invalid cron preview request", body = ErrorResponse)
)
)]
pub async fn preview_cron(
ApiJson(request): ApiJson<CronPreviewRequest>,
) -> ApiResult<Json<CronPreviewResponse>> {
if request.count == 0 || request.count > 10 {
return Err(ApiError::validation("count", "预览次数必须在 1 到 10 之间"));
}
let preview = cron::preview_in_timezone(
&request.expression,
&request.timezone,
chrono::Utc::now(),
request.count,
)
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
Ok(Json(preview))
}
#[derive(OpenApi)]
#[openapi(
info(
title = "TaskPilot API",
version = env!("CARGO_PKG_VERSION"),
description = "Local task scheduling and process management API."
),
paths(
health,
session,
preview_cron,
list_tasks,
create_task,
get_task,
update_task,
delete_task,
enable_task,
disable_task,
run_task,
list_task_runs,
get_run,
get_run_logs,
cancel_run,
download_run_logs,
execution_events
),
components(schemas(
HealthResponse,
SessionResponse,
CronPreviewRequest,
CronPreviewResponse,
ErrorResponse,
TaskActionType,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
TaskResponse,
TaskListResponse,
TaskRunResponse,
TaskRunListResponse,
LogChunkResponse
))
)]
pub struct ApiDoc;
pub fn openapi() -> utoipa::openapi::OpenApi {
ApiDoc::openapi()
}
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
Json(openapi())
}
pub fn router(state: AppState) -> Router {
let allowed_origins = [
HeaderValue::from_static("http://127.0.0.1:5173"),
HeaderValue::from_static("http://localhost:5173"),
];
let cors = CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]);
let api = Router::new()
.route("/api/v1/health", get(health))
.route("/api/v1/session", get(session))
.route("/api/v1/cron/preview", axum::routing::post(preview_cron))
.route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router())
.merge(crate::runs::routes::router())
.route("/api/{*path}", any(api_not_found))
.layer(cors)
.route_layer(middleware::from_fn_with_state(
state.clone(),
security::api_guard,
));
api.with_state(state)
}
async fn api_not_found() -> impl IntoResponse {
ApiError::route_not_found("API 路由不存在")
}
+16
View File
@@ -0,0 +1,16 @@
use std::{env, error::Error, fs, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
let json = serde_json::to_string_pretty(&task_pilot_backend::api::openapi())?;
if let Some(path) = env::args().nth(1) {
if let Some(parent) = PathBuf::from(&path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{json}\n"))?;
} else {
println!("{json}");
}
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
use std::str::FromStr;
use chrono::{DateTime, Local, Utc};
use chrono_tz::Tz;
use croner::Cron;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use utoipa::ToSchema;
const MAX_PREVIEW_RUNS: usize = 100;
#[derive(Debug, Error)]
pub enum CronPreviewError {
#[error("invalid cron expression: {0}")]
InvalidExpression(String),
#[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")]
TooManyRuns,
#[error("cron expression has no future occurrence")]
NoFutureOccurrence,
#[error("invalid IANA timezone: {0}")]
InvalidTimezone(String),
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CronPreviewRequest {
pub expression: String,
pub timezone: String,
pub count: usize,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CronPreviewResponse {
pub timezone: String,
pub runs: Vec<String>,
}
/// Calculates the next occurrences of a cron expression in local time.
pub fn next_occurrences(
expression: &str,
from: DateTime<Local>,
count: usize,
) -> Result<Vec<DateTime<Local>>, CronPreviewError> {
if count > MAX_PREVIEW_RUNS {
return Err(CronPreviewError::TooManyRuns);
}
let cron = Cron::from_str(expression)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
let mut runs = Vec::with_capacity(count);
let mut cursor = from;
for _ in 0..count {
let next = cron
.find_next_occurrence(&cursor, false)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
runs.push(next);
cursor = next;
}
Ok(runs)
}
pub fn preview_in_timezone(
expression: &str,
timezone: &str,
from: DateTime<Utc>,
count: usize,
) -> Result<CronPreviewResponse, CronPreviewError> {
if count > MAX_PREVIEW_RUNS {
return Err(CronPreviewError::TooManyRuns);
}
let timezone: Tz = timezone
.parse()
.map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?;
let cron = Cron::from_str(expression)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
let mut cursor = from.with_timezone(&timezone);
let mut runs = Vec::with_capacity(count);
for _ in 0..count {
let next = cron
.find_next_occurrence(&cursor, false)
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string());
cursor = next;
}
Ok(CronPreviewResponse {
timezone: timezone.name().to_owned(),
runs,
})
}
#[cfg(test)]
mod tests {
use chrono::{Datelike, TimeZone, Weekday};
use super::*;
fn local_time(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> DateTime<Local> {
Local
.with_ymd_and_hms(year, month, day, hour, minute, second)
.single()
.expect("test timestamp should be unambiguous")
}
#[test]
fn parses_five_field_expression() {
let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1)
.expect("five field expression should parse");
assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]);
}
#[test]
fn parses_six_field_expression_with_seconds() {
let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2)
.expect("six field expression should parse");
assert_eq!(
runs,
vec![
local_time(2026, 1, 1, 12, 0, 15),
local_time(2026, 1, 1, 12, 0, 30)
]
);
}
#[test]
fn supports_hourly_alias() {
let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1)
.expect("hourly alias should parse");
assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]);
}
#[test]
fn rejects_zero_step() {
let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1)
.expect_err("zero step should be rejected");
assert!(matches!(error, CronPreviewError::InvalidExpression(_)));
}
#[test]
fn uses_posix_or_semantics_for_day_of_month_and_weekday() {
let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1)
.expect("POSIX cron expression should parse");
assert_eq!(runs[0].day(), 5);
assert_eq!(runs[0].weekday(), Weekday::Mon);
}
#[test]
fn supports_explicit_day_and_weekday_and_semantics() {
let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1)
.expect("AND semantics should parse");
assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0));
}
#[test]
fn previews_runs_in_iana_timezone() {
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap();
assert_eq!(preview.timezone, "Asia/Shanghai");
assert_eq!(
preview.runs,
vec![
"2026-01-02 02:00:00".to_owned(),
"2026-01-03 02:00:00".to_owned()
]
);
}
}
+29
View File
@@ -0,0 +1,29 @@
use std::{error::Error, str::FromStr};
use sqlx::{
SqlitePool,
migrate::MigrateError,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.foreign_keys(true);
let max_connections = if database_url == "sqlite::memory:" {
1
} else {
5
};
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.connect_with(options)
.await?;
migrate(&pool).await?;
Ok(pool)
}
pub async fn migrate(pool: &SqlitePool) -> Result<(), MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}
+222
View File
@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{FromRequest, FromRequestParts, Query, Request, rejection::JsonRejection},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Serialize, de::DeserializeOwned};
use utoipa::ToSchema;
pub type ApiResult<T> = Result<T, ApiError>;
pub struct ApiJson<T>(pub T);
impl<S, T> FromRequest<S> for ApiJson<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = ApiError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
Json::<T>::from_request(request, state)
.await
.map(|Json(value)| Self(value))
.map_err(json_rejection)
}
}
pub struct ApiQuery<T>(pub T);
impl<S, T> FromRequestParts<S> for ApiQuery<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
Query::<T>::from_request_parts(parts, state)
.await
.map(|Query(value)| Self(value))
.map_err(|error| {
ApiError::bad_request("INVALID_QUERY", format!("查询参数无效: {error}"))
})
}
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
code: &'static str,
message: String,
field: Option<String>,
details: BTreeMap<String, String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
pub details: BTreeMap<String, String>,
}
impl ApiError {
pub fn bad_request(code: &'static str, message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code,
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code: "VALIDATION_ERROR",
message: message.into(),
field: Some(field.into()),
details: BTreeMap::new(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "TASK_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "RUN_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn route_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_not_cancellable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "RUN_NOT_CANCELLABLE",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn run_log_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "RUN_LOG_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: "FORBIDDEN",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "TASK_ID_CONFLICT",
message: message.into(),
field: Some("id".to_owned()),
details: BTreeMap::new(),
}
}
pub fn unsupported(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNPROCESSABLE_ENTITY,
code: "ACTION_NOT_IMPLEMENTED",
message: message.into(),
field: Some("action.type".to_owned()),
details: BTreeMap::new(),
}
}
pub fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "internal API error");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "INTERNAL_ERROR",
message: "服务器处理请求时发生内部错误".to_owned(),
field: None,
details: BTreeMap::new(),
}
}
}
fn json_rejection(error: JsonRejection) -> ApiError {
ApiError::bad_request(
"INVALID_JSON",
format!("请求体不是有效的任务 JSON: {error}"),
)
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = ErrorResponse {
code: self.code.to_owned(),
message: self.message,
field: self.field,
details: self.details,
};
(self.status, Json(body)).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(error: sqlx::Error) -> Self {
Self::internal(error)
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
Self::internal(error)
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
+40
View File
@@ -0,0 +1,40 @@
pub mod api;
pub mod cron;
pub mod database;
pub mod error;
pub mod process_job;
pub mod runs;
pub mod scheduler;
pub mod security;
pub mod state;
pub mod tasks;
use axum::Router;
use sqlx::SqlitePool;
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
pub async fn app(
pool: SqlitePool,
logs_dir: std::path::PathBuf,
) -> crate::error::ApiResult<Router> {
let state = state::AppState::start(pool, logs_dir).await?;
Ok(router(state))
}
pub fn router(state: state::AppState) -> Router {
api::router(state).layer(TraceLayer::new_for_http())
}
pub fn router_with_static(state: state::AppState, static_dir: std::path::PathBuf) -> Router {
let index = static_dir.join("index.html");
let router = if index.is_file() {
api::router(state)
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)))
} else {
api::router(state)
};
router.layer(TraceLayer::new_for_http())
}
+77
View File
@@ -0,0 +1,77 @@
use std::{
env,
error::Error,
fs,
net::SocketAddr,
path::{Path, PathBuf},
};
use tokio::net::TcpListener;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
init_tracing();
let port = env::var("TASK_PILOT_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(3000);
let address = SocketAddr::from(([127, 0, 0, 1], port));
let database_url = database_url()?;
let pool = task_pilot_backend::database::connect(&database_url).await?;
let logs_dir = env::var("TASK_PILOT_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new("logs").to_path_buf());
fs::create_dir_all(&logs_dir)?;
let state = task_pilot_backend::state::AppState::start(pool.clone(), logs_dir).await?;
let static_dir = env::var("TASK_PILOT_STATIC_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new("frontend/dist").to_path_buf());
let listener = TcpListener::bind(address).await?;
tracing::info!(%address, database_url, "TaskPilot backend listening");
axum::serve(
listener,
task_pilot_backend::router_with_static(state.clone(), static_dir),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
state.shutdown();
pool.close().await;
Ok(())
}
fn database_url() -> Result<String, Box<dyn Error>> {
if let Ok(url) = env::var("TASK_PILOT_DATABASE_URL") {
return Ok(url);
}
let data_dir = Path::new("data");
fs::create_dir_all(data_dir)?;
Ok(format!(
"sqlite://{}",
data_dir
.join("task_pilot.db")
.to_string_lossy()
.replace('\\', "/")
))
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "task_pilot_backend=debug,tower_http=debug".into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
}
async fn shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::error!(%error, "failed to listen for shutdown signal");
}
}
+109
View File
@@ -0,0 +1,109 @@
#[cfg(windows)]
mod platform {
use std::{
ffi::c_void,
io,
mem::{size_of, zeroed},
ptr,
};
use tokio::process::Child;
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
},
};
pub struct ProcessJob {
handle: HANDLE,
}
// Windows job handles can be used across threads. This wrapper owns exactly one
// handle and closes it once in Drop.
unsafe impl Send for ProcessJob {}
unsafe impl Sync for ProcessJob {}
impl ProcessJob {
pub fn new() -> io::Result<Self> {
let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
if handle.is_null() {
return Err(io::Error::last_os_error());
}
let job = Self { handle };
job.enable_kill_on_close()?;
Ok(job)
}
pub fn assign(&self, child: &Child) -> io::Result<()> {
let process_handle = child
.raw_handle()
.ok_or_else(|| io::Error::other("process handle is unavailable"))?;
let result = unsafe { AssignProcessToJobObject(self.handle, process_handle.cast()) };
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn terminate(&self) -> io::Result<()> {
let result = unsafe { TerminateJobObject(self.handle, 1) };
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn enable_kill_on_close(&self) -> io::Result<()> {
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let result = unsafe {
SetInformationJobObject(
self.handle,
JobObjectExtendedLimitInformation,
(&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if result == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
impl Drop for ProcessJob {
fn drop(&mut self) {
unsafe {
CloseHandle(self.handle);
}
}
}
}
#[cfg(not(windows))]
mod platform {
use std::io;
use tokio::process::Child;
pub struct ProcessJob;
impl ProcessJob {
pub fn new() -> io::Result<Self> {
Ok(Self)
}
pub fn assign(&self, _child: &Child) -> io::Result<()> {
Ok(())
}
pub fn terminate(&self) -> io::Result<()> {
Ok(())
}
}
}
pub use platform::ProcessJob;
+962
View File
@@ -0,0 +1,962 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Stdio,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{Instant, SystemTime},
};
use chrono::Utc;
use futures_util::StreamExt;
use reqwest::Method;
use serde::Serialize;
use sqlx::SqlitePool;
use tokio::{
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex as AsyncMutex, broadcast},
time::{Duration, sleep, timeout},
};
use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
process_job::ProcessJob,
runs::{
model::{RunStatus, RunTrigger, TaskRunResponse},
repository,
},
tasks::model::{HttpMethod, TaskAction, TaskResponse},
};
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
const TERMINATION_WAIT_SECS: u64 = 5;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DEFAULT_RETENTION_DAYS: u64 = 30;
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct LogRetentionConfig {
pub retention_days: u64,
pub max_total_bytes: u64,
}
impl Default for LogRetentionConfig {
fn default() -> Self {
Self {
retention_days: DEFAULT_RETENTION_DAYS,
max_total_bytes: DEFAULT_MAX_TOTAL_LOG_BYTES,
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum ExecutionEvent {
Run {
run: TaskRunResponse,
},
Log {
run_id: i64,
offset: u64,
next_offset: u64,
content: String,
},
}
impl ExecutionEvent {
pub const fn event_name(&self) -> &'static str {
match self {
Self::Run { .. } => "run",
Self::Log { .. } => "log",
}
}
}
#[derive(Clone)]
pub struct TaskExecutor {
inner: Arc<ExecutorInner>,
}
struct ExecutorInner {
pool: SqlitePool,
logs_dir: PathBuf,
shutdown: CancellationToken,
running: Mutex<HashMap<String, RunningHandle>>,
events: broadcast::Sender<ExecutionEvent>,
http: reqwest::Client,
log_config: LogRetentionConfig,
cleanup_lock: AsyncMutex<()>,
}
struct RunningHandle {
run_id: Option<i64>,
cancellation: CancellationToken,
}
#[derive(Clone)]
struct LogSink {
file: Arc<AsyncMutex<tokio::fs::File>>,
size: Arc<AtomicU64>,
truncated: Arc<AtomicBool>,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Arc<Vec<String>>,
}
struct ScriptOutcome {
status: RunStatus,
exit_code: Option<i32>,
error_message: Option<String>,
}
enum ProcessEnd {
Exited(std::process::ExitStatus),
TimedOut,
Cancelled,
}
impl TaskExecutor {
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
let (events, _) = broadcast::channel(512);
Self {
inner: Arc::new(ExecutorInner {
pool,
logs_dir,
shutdown: CancellationToken::new(),
running: Mutex::new(HashMap::new()),
events,
http: reqwest::Client::builder()
.user_agent("TaskPilot/0.1")
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.unwrap_or_default(),
log_config,
cleanup_lock: AsyncMutex::new(()),
}),
}
}
pub async fn trigger(
&self,
task: &TaskResponse,
trigger: RunTrigger,
) -> ApiResult<TaskRunResponse> {
let cancellation = CancellationToken::new();
let already_running = {
let mut running = lock_running(&self.inner.running);
if running.contains_key(&task.id) {
true
} else {
running.insert(
task.id.clone(),
RunningHandle {
run_id: None,
cancellation: cancellation.clone(),
},
);
false
}
};
if already_running {
let run = repository::create_skipped(
&self.inner.pool,
&task.id,
trigger,
"同一任务已有运行实例,本次触发已跳过",
)
.await?;
self.emit_run(&run);
return Ok(run);
}
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
Ok(run) => run,
Err(error) => {
lock_running(&self.inner.running).remove(&task.id);
return Err(error);
}
};
if let Some(handle) = lock_running(&self.inner.running).get_mut(&task.id) {
handle.run_id = Some(run.id);
}
self.emit_run(&run);
let executor = self.clone();
let task = task.clone();
tokio::spawn(async move {
executor.execute_run(task, run.id, cancellation).await;
});
Ok(run)
}
pub fn cancel_task(&self, task_id: &str) -> bool {
let mut running = lock_running(&self.inner.running);
if let Some(handle) = running.remove(task_id) {
handle.cancellation.cancel();
true
} else {
false
}
}
pub fn cancel_run(&self, run_id: i64) -> bool {
let mut running = lock_running(&self.inner.running);
let task_id = running
.iter()
.find_map(|(task_id, handle)| (handle.run_id == Some(run_id)).then(|| task_id.clone()));
if let Some(task_id) = task_id
&& let Some(handle) = running.remove(&task_id)
{
handle.cancellation.cancel();
return true;
}
false
}
pub fn subscribe(&self) -> broadcast::Receiver<ExecutionEvent> {
self.inner.events.subscribe()
}
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
self.inner
.logs_dir
.join(task_id)
.join(format!("{run_id}.log"))
}
pub fn shutdown(&self) {
self.inner.shutdown.cancel();
let running = lock_running(&self.inner.running);
for handle in running.values() {
handle.cancellation.cancel();
}
}
pub async fn cleanup_logs(&self) -> std::io::Result<()> {
let _guard = self.inner.cleanup_lock.lock().await;
let logs_dir = self.inner.logs_dir.clone();
let config = self.inner.log_config.clone();
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
.await
.map_err(std::io::Error::other)?
}
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
let started_at = Utc::now();
let started = Instant::now();
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
tracing::error!(run_id, error = %error, "failed to mark task run as running");
lock_running(&self.inner.running).remove(&task.id);
return;
}
self.emit_run_by_id(run_id).await;
let outcome = self.run_action(&task, run_id, &cancellation).await;
let finished_at = Utc::now();
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(error) = repository::complete(
&self.inner.pool,
run_id,
outcome.status,
finished_at,
duration_ms,
outcome.exit_code,
outcome.error_message.as_deref(),
)
.await
{
tracing::error!(run_id, error = %error, "failed to complete task run");
}
self.emit_run_by_id(run_id).await;
lock_running(&self.inner.running).remove(&task.id);
if let Err(error) = self.cleanup_logs().await {
tracing::warn!(%error, "failed to clean task logs");
}
}
async fn run_action(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
) -> ScriptOutcome {
match &task.action {
TaskAction::Script {
working_dir,
interpreter,
script,
args,
env,
} => {
let mut command = Command::new(interpreter);
command
.arg(script)
.args(args)
.envs(env)
.current_dir(working_dir);
self.run_command(task, run_id, cancellation, command).await
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
let mut command = Command::new(executable);
command.args(args).envs(env);
if let Some(working_dir) = working_dir {
command.current_dir(working_dir);
}
self.run_command(task, run_id, cancellation, command).await
}
TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await,
}
}
async fn run_command(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
mut command: Command,
) -> ScriptOutcome {
let log_path = self.log_path(&task.id, run_id);
let secrets = sensitive_values(&task.action);
let sink = match LogSink::create(
&log_path,
task,
run_id,
self.inner.events.clone(),
secrets,
)
.await
{
Ok(sink) => sink,
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
};
let job = match ProcessJob::new() {
Ok(job) => job,
Err(error) => {
sink.system(&format!("无法创建 Windows Job Object: {error}"))
.await;
return failed_outcome(format!("无法创建 Windows Job Object: {error}"));
}
};
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
{
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
sink.system(&format!("启动进程失败: {error}")).await;
return failed_outcome(format!("启动进程失败: {error}"));
}
};
if let Err(error) = job.assign(&child) {
let _ = child.kill().await;
sink.system(&format!("关联 Job Object 失败: {error}")).await;
return failed_outcome(format!("关联 Windows Job Object 失败: {error}"));
}
let stdout_task = child
.stdout
.take()
.map(|stdout| spawn_log_reader(stdout, "stdout", sink.clone()));
let stderr_task = child
.stderr
.take()
.map(|stderr| spawn_log_reader(stderr, "stderr", sink.clone()));
let process_end = tokio::select! {
status = child.wait() => {
match status {
Ok(status) => ProcessEnd::Exited(status),
Err(error) => {
sink.system(&format!("等待进程失败: {error}")).await;
return failed_outcome(format!("等待进程失败: {error}"));
}
}
}
_ = sleep(Duration::from_secs(task.timeout_secs)) => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate timed out job");
}
wait_after_termination(&mut child).await;
ProcessEnd::TimedOut
}
_ = cancellation.cancelled() => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate cancelled job");
}
wait_after_termination(&mut child).await;
ProcessEnd::Cancelled
}
_ = self.inner.shutdown.cancelled() => {
if let Err(error) = job.terminate() {
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate job during shutdown");
}
wait_after_termination(&mut child).await;
ProcessEnd::Cancelled
}
};
if let Some(stdout_task) = stdout_task
&& let Err(error) = stdout_task.await
{
tracing::warn!(task_id = %task.id, run_id, %error, "stdout capture task failed");
}
if let Some(stderr_task) = stderr_task
&& let Err(error) = stderr_task.await
{
tracing::warn!(task_id = %task.id, run_id, %error, "stderr capture task failed");
}
match process_end {
ProcessEnd::Exited(status) => {
let exit_code = status.code();
if status.success() {
sink.system("任务执行成功").await;
ScriptOutcome {
status: RunStatus::Succeeded,
exit_code,
error_message: None,
}
} else {
let message = format!(
"进程退出码为 {}",
exit_code.map_or_else(|| "unknown".to_owned(), |code| code.to_string())
);
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Failed,
exit_code,
error_message: Some(message),
}
}
}
ProcessEnd::TimedOut => {
let message = format!("任务执行超过 {} 秒,已终止进程树", task.timeout_secs);
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::TimedOut,
exit_code: None,
error_message: Some(message),
}
}
ProcessEnd::Cancelled => {
let message = "任务已取消".to_owned();
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some(message),
}
}
}
}
async fn run_http(
&self,
task: &TaskResponse,
run_id: i64,
cancellation: &CancellationToken,
) -> ScriptOutcome {
let TaskAction::Http {
url,
method,
headers,
body,
expected_status,
} = &task.action
else {
return failed_outcome("invalid HTTP action payload");
};
let log_path = self.log_path(&task.id, run_id);
let sink = match LogSink::create(
&log_path,
task,
run_id,
self.inner.events.clone(),
sensitive_values(&task.action),
)
.await
{
Ok(sink) => sink,
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
};
let method = match method {
HttpMethod::Get => Method::GET,
HttpMethod::Post => Method::POST,
};
let mut request = self
.inner
.http
.request(method.clone(), url)
.timeout(Duration::from_secs(task.timeout_secs));
for (name, value) in headers {
request = request.header(name, value);
}
if let Some(body) = body {
request = request.body(body.clone());
}
sink.http(&format!("请求 {method} {url}")).await;
let response = tokio::select! {
response = request.send() => {
match response {
Ok(response) => response,
Err(error) => {
let message = if error.is_timeout() {
format!("HTTP 请求超过 {} 秒", task.timeout_secs)
} else {
format!("HTTP 请求失败: {error}")
};
sink.system(&message).await;
return ScriptOutcome {
status: if error.is_timeout() {
RunStatus::TimedOut
} else {
RunStatus::Failed
},
exit_code: None,
error_message: Some(message),
};
}
}
}
_ = cancellation.cancelled() => {
sink.system("HTTP 请求已取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 请求已取消".to_owned()),
};
}
_ = self.inner.shutdown.cancelled() => {
sink.system("HTTP 请求因服务关闭而取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 请求因服务关闭而取消".to_owned()),
};
}
};
let http_status = response.status();
sink.http(&format!("响应状态 {http_status}")).await;
let mut response_stream = response.bytes_stream();
let mut response_body = Vec::new();
let mut body_truncated = false;
loop {
let next = tokio::select! {
chunk = response_stream.next() => chunk,
_ = cancellation.cancelled() => {
sink.system("读取 HTTP 响应已取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("读取 HTTP 响应已取消".to_owned()),
};
}
_ = self.inner.shutdown.cancelled() => {
sink.system("HTTP 响应读取因服务关闭而取消").await;
return ScriptOutcome {
status: RunStatus::Cancelled,
exit_code: None,
error_message: Some("HTTP 响应读取因服务关闭而取消".to_owned()),
};
}
};
match next {
Some(Ok(chunk)) => {
let remaining = MAX_HTTP_LOG_BODY_BYTES.saturating_sub(response_body.len());
let copy_length = remaining.min(chunk.len());
response_body.extend_from_slice(&chunk[..copy_length]);
if chunk.len() > copy_length {
body_truncated = true;
break;
}
}
Some(Err(error)) => {
let message = if error.is_timeout() {
format!("读取 HTTP 响应超过 {} 秒", task.timeout_secs)
} else {
format!("读取 HTTP 响应失败: {error}")
};
sink.system(&message).await;
return ScriptOutcome {
status: if error.is_timeout() {
RunStatus::TimedOut
} else {
RunStatus::Failed
},
exit_code: None,
error_message: Some(message),
};
}
None => break,
}
}
let body_text = String::from_utf8_lossy(&response_body);
if !body_text.is_empty() {
sink.http(&body_text).await;
}
if body_truncated {
sink.system(&format!(
"HTTP 响应体超过 {MAX_HTTP_LOG_BODY_BYTES} 字节,日志仅保留前部内容"
))
.await;
}
let expected = if expected_status.is_empty() {
http_status.is_success()
} else {
expected_status.contains(&http_status.as_u16())
};
if expected {
sink.system("HTTP 任务执行成功").await;
ScriptOutcome {
status: RunStatus::Succeeded,
exit_code: Some(i32::from(http_status.as_u16())),
error_message: None,
}
} else {
let message = format!("HTTP 状态码 {http_status} 不在期望范围内");
sink.system(&message).await;
ScriptOutcome {
status: RunStatus::Failed,
exit_code: Some(i32::from(http_status.as_u16())),
error_message: Some(message),
}
}
}
fn emit_run(&self, run: &TaskRunResponse) {
let _ = self
.inner
.events
.send(ExecutionEvent::Run { run: run.clone() });
}
async fn emit_run_by_id(&self, run_id: i64) {
match repository::get(&self.inner.pool, run_id).await {
Ok(Some(run)) => self.emit_run(&run),
Ok(None) => {}
Err(error) => {
tracing::warn!(run_id, error = %error, "failed to load run for SSE event");
}
}
}
}
impl LogSink {
async fn create(
path: &Path,
task: &TaskResponse,
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Vec<String>,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let header = format!(
"[system] TaskPilot run {run_id} for task {} started at {}\n",
task.id,
Utc::now().to_rfc3339()
);
tokio::fs::write(path, header.as_bytes()).await?;
let file = tokio::fs::OpenOptions::new()
.append(true)
.open(path)
.await?;
let mut secrets = secrets;
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
Ok(Self {
file: Arc::new(AsyncMutex::new(file)),
size: Arc::new(AtomicU64::new(header.len() as u64)),
truncated: Arc::new(AtomicBool::new(false)),
run_id,
events,
secrets: Arc::new(secrets),
})
}
async fn append(&self, text: &str) {
let redacted = redact_text(text, &self.secrets);
let bytes = redacted.as_bytes();
let current = self.size.load(Ordering::Relaxed);
if current + bytes.len() as u64 > MAX_LOG_BYTES {
if !self.truncated.swap(true, Ordering::Relaxed) {
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
let mut file = self.file.lock().await;
let _ = file.write_all(notice.as_bytes()).await;
}
return;
}
let mut file = self.file.lock().await;
if file.write_all(bytes).await.is_err() {
return;
}
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
let _ = self.events.send(ExecutionEvent::Log {
run_id: self.run_id,
offset: current,
next_offset: next,
content: redacted,
});
}
async fn system(&self, message: &str) {
self.append(&format!("[system] {message}\n")).await;
}
async fn http(&self, message: &str) {
self.append(&format!("[http] {message}\n")).await;
}
}
fn lock_running(
running: &Mutex<HashMap<String, RunningHandle>>,
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
running
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn spawn_log_reader<R>(
reader: R,
stream: &'static str,
sink: LogSink,
) -> tokio::task::JoinHandle<()>
where
R: AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line);
sink.append(&format!("[{stream}] {text}")).await;
}
Err(error) => {
tracing::warn!(stream, %error, "failed to read task output");
break;
}
}
}
})
}
async fn wait_after_termination(child: &mut Child) {
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
.await
.is_err()
{
let _ = child.kill().await;
}
}
fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
.iter()
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
.map(|(_, value)| value.clone())
.collect(),
TaskAction::Http { headers, .. } => headers
.iter()
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
.map(|(_, value)| value.clone())
.collect(),
}
}
fn is_sensitive_name(name: &str) -> bool {
let name = name.to_ascii_uppercase();
[
"SECRET",
"TOKEN",
"PASSWORD",
"PASS",
"API_KEY",
"PRIVATE_KEY",
"CREDENTIAL",
"AUTHORIZATION",
"AUTH",
]
.iter()
.any(|marker| name.contains(marker))
}
fn redact_text(text: &str, secrets: &[String]) -> String {
secrets.iter().fold(text.to_owned(), |current, secret| {
current.replace(secret, "[REDACTED]")
})
}
fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
ScriptOutcome {
status: RunStatus::Failed,
exit_code: None,
error_message: Some(message.into()),
}
}
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
if !root.exists() {
return Ok(());
}
let mut files = Vec::new();
collect_log_files(root, &mut files)?;
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
let cutoff = SystemTime::now().checked_sub(retention);
if let Some(cutoff) = cutoff {
files.retain(|(path, metadata)| {
let expired = metadata
.modified()
.map(|modified| modified < cutoff)
.unwrap_or(false);
if expired {
let _ = std::fs::remove_file(path);
false
} else {
true
}
});
}
files.sort_by_key(|(_, metadata)| metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH));
let mut total = files
.iter()
.map(|(_, metadata)| metadata.len())
.sum::<u64>();
for (path, metadata) in files {
if total <= config.max_total_bytes {
break;
}
if std::fs::remove_file(&path).is_ok() {
total = total.saturating_sub(metadata.len());
}
}
remove_empty_directories(root)?;
Ok(())
}
fn collect_log_files(
directory: &Path,
files: &mut Vec<(PathBuf, std::fs::Metadata)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_log_files(&entry.path(), files)?;
} else if file_type.is_file() {
files.push((entry.path(), entry.metadata()?));
}
}
Ok(())
}
fn remove_empty_directories(root: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let path = entry.path();
remove_empty_directories(&path)?;
if std::fs::read_dir(&path)?.next().is_none() {
let _ = std::fs::remove_dir(path);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_statuses_are_identified() {
assert!(!RunStatus::Queued.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(RunStatus::Succeeded.is_terminal());
assert!(RunStatus::Failed.is_terminal());
assert!(RunStatus::TimedOut.is_terminal());
assert!(RunStatus::Cancelled.is_terminal());
assert!(RunStatus::Skipped.is_terminal());
}
#[test]
fn redacts_sensitive_values() {
let secrets = vec!["super-secret".to_owned(), "token-value".to_owned()];
let output = redact_text("found super-secret and token-value", &secrets);
assert_eq!(output, "found [REDACTED] and [REDACTED]");
}
#[test]
fn identifies_sensitive_environment_names() {
assert!(is_sensitive_name("API_TOKEN"));
assert!(is_sensitive_name("DB_PASSWORD"));
assert!(is_sensitive_name("Authorization"));
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
}
#[test]
fn cleanup_enforces_total_log_capacity() {
let temp = tempfile::TempDir::new().unwrap();
for index in 0..6 {
std::fs::write(temp.path().join(format!("run-{index}.log")), vec![b'x'; 4]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
}
cleanup_logs_sync(
temp.path(),
&LogRetentionConfig {
retention_days: 30,
max_total_bytes: 10,
},
)
.unwrap();
let total = std::fs::read_dir(temp.path())
.unwrap()
.map(|entry| entry.unwrap().metadata().unwrap().len())
.sum::<u64>();
assert!(total <= 10);
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod executor;
pub mod model;
pub mod repository;
pub mod routes;
+98
View File
@@ -0,0 +1,98 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunTrigger {
Schedule,
Manual,
Retry,
}
impl RunTrigger {
pub const fn as_str(self) -> &'static str {
match self {
Self::Schedule => "schedule",
Self::Manual => "manual",
Self::Retry => "retry",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Queued,
Running,
Succeeded,
Failed,
TimedOut,
Cancelled,
Skipped,
}
impl RunStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::TimedOut => "timed_out",
Self::Cancelled => "cancelled",
Self::Skipped => "skipped",
}
}
pub const fn is_terminal(self) -> bool {
matches!(
self,
Self::Succeeded | Self::Failed | Self::TimedOut | Self::Cancelled | Self::Skipped
)
}
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TaskRunResponse {
pub id: i64,
pub task_id: String,
pub trigger: RunTrigger,
pub status: RunStatus,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
pub exit_code: Option<i32>,
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct TaskRunListResponse {
pub items: Vec<TaskRunResponse>,
pub page: u32,
pub page_size: u32,
pub total: u64,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct RunListQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub status: Option<RunStatus>,
pub trigger: Option<RunTrigger>,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct LogChunkQuery {
pub offset: Option<u64>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct LogChunkResponse {
pub content: String,
pub next_offset: u64,
pub complete: bool,
}
+257
View File
@@ -0,0 +1,257 @@
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<DateTime<Utc>>,
finished_at: Option<DateTime<Utc>>,
duration_ms: Option<i64>,
exit_code: Option<i64>,
error_message: Option<String>,
created_at: DateTime<Utc>,
}
impl TryFrom<TaskRunRow> for TaskRunResponse {
type Error = ApiError;
fn try_from(row: TaskRunRow) -> Result<Self, Self::Error> {
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<TaskRunResponse> {
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<TaskRunResponse> {
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,
})
}
pub async fn mark_running(
pool: &SqlitePool,
run_id: i64,
started_at: DateTime<Utc>,
) -> ApiResult<()> {
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(())
}
pub async fn complete(
pool: &SqlitePool,
run_id: i64,
status: RunStatus,
finished_at: DateTime<Utc>,
duration_ms: u64,
exit_code: Option<i32>,
error_message: Option<&str>,
) -> ApiResult<()> {
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(())
}
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
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<TaskRunListResponse> {
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::<ApiResult<Vec<_>>>()?;
Ok(TaskRunListResponse {
items,
page,
page_size,
total: u64::try_from(total).map_err(ApiError::internal)?,
})
}
fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
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<RunStatus> {
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}"))),
}
}
+265
View File
@@ -0,0 +1,265 @@
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use futures_util::{StreamExt, stream};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_stream::wrappers::BroadcastStream;
use crate::{
error::{ApiError, ApiQuery, ApiResult},
runs::{
model::{
LogChunkQuery, LogChunkResponse, RunListQuery, RunTrigger, TaskRunListResponse,
TaskRunResponse,
},
repository,
},
state::AppState,
tasks::service,
};
const MAX_LOG_CHUNK_BYTES: u64 = 64 * 1024;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/tasks/{id}/run", post(run_task))
.route("/api/v1/tasks/{id}/runs", get(list_task_runs))
.route("/api/v1/runs/{run_id}", get(get_run))
.route("/api/v1/runs/{run_id}/logs", get(get_run_logs))
.route(
"/api/v1/runs/{run_id}/logs/download",
get(download_run_logs),
)
.route("/api/v1/runs/{run_id}/cancel", post(cancel_run))
.route("/api/v1/events", get(execution_events))
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/run",
tag = "runs",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 202, description = "Manual run accepted", body = TaskRunResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse),
(status = 422, description = "Action executor not implemented", body = crate::error::ErrorResponse)
)
)]
pub async fn run_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
let task = service::get(&state.pool, &id).await?;
let run = state.executor.trigger(&task, RunTrigger::Manual).await?;
Ok((StatusCode::ACCEPTED, Json(run)))
}
#[utoipa::path(
get,
path = "/api/v1/tasks/{id}/runs",
tag = "runs",
params(
("id" = String, Path, description = "Stable task identifier"),
RunListQuery
),
responses(
(status = 200, description = "Task run history", body = TaskRunListResponse)
)
)]
pub async fn list_task_runs(
State(state): State<AppState>,
Path(id): Path<String>,
ApiQuery(query): ApiQuery<RunListQuery>,
) -> ApiResult<Json<TaskRunListResponse>> {
Ok(Json(
repository::list_for_task(&state.pool, &id, query).await?,
))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 200, description = "Task run detail", body = TaskRunResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_run(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<Json<TaskRunResponse>> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
Ok(Json(run))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}/logs",
tag = "runs",
params(
("run_id" = i64, Path, description = "Run identifier"),
LogChunkQuery
),
responses(
(status = 200, description = "Incremental task run log", body = LogChunkResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_run_logs(
State(state): State<AppState>,
Path(run_id): Path<i64>,
ApiQuery(query): ApiQuery<LogChunkQuery>,
) -> ApiResult<Json<LogChunkResponse>> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
let offset = query.offset.unwrap_or(0);
let path = state.executor.log_path(&run.task_id, run.id);
let mut file = match tokio::fs::File::open(&path).await {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Json(LogChunkResponse {
content: String::new(),
next_offset: offset,
complete: run.status.is_terminal(),
}));
}
Err(error) => return Err(ApiError::internal(error)),
};
let length = file.metadata().await.map_err(ApiError::internal)?.len();
if offset > length {
return Err(ApiError::validation(
"offset",
"日志读取偏移不能大于当前日志长度",
));
}
file.seek(std::io::SeekFrom::Start(offset))
.await
.map_err(ApiError::internal)?;
let mut bytes = Vec::new();
file.take(MAX_LOG_CHUNK_BYTES)
.read_to_end(&mut bytes)
.await
.map_err(ApiError::internal)?;
Ok(Json(LogChunkResponse {
content: String::from_utf8_lossy(&bytes).into_owned(),
next_offset: offset + bytes.len() as u64,
complete: run.status.is_terminal(),
}))
}
#[utoipa::path(
post,
path = "/api/v1/runs/{run_id}/cancel",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 202, description = "Cancellation requested", body = TaskRunResponse),
(status = 404, description = "Run not found", body = crate::error::ErrorResponse),
(status = 409, description = "Run is not cancellable", body = crate::error::ErrorResponse)
)
)]
pub async fn cancel_run(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
if run.status.is_terminal() {
return Err(ApiError::run_not_cancellable("运行已经结束,无法取消"));
}
if !state.executor.cancel_run(run_id) {
return Err(ApiError::run_not_cancellable(
"运行不在当前实例的可取消状态中",
));
}
Ok((StatusCode::ACCEPTED, Json(run)))
}
#[utoipa::path(
get,
path = "/api/v1/runs/{run_id}/logs/download",
tag = "runs",
params(("run_id" = i64, Path, description = "Run identifier")),
responses(
(status = 200, description = "Plain text task log", body = String, content_type = "text/plain"),
(status = 404, description = "Run or log not found", body = crate::error::ErrorResponse)
)
)]
pub async fn download_run_logs(
State(state): State<AppState>,
Path(run_id): Path<i64>,
) -> ApiResult<Response> {
let run = repository::get(&state.pool, run_id)
.await?
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
let path = state.executor.log_path(&run.task_id, run.id);
let content = tokio::fs::read(&path).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ApiError::run_log_not_found(format!("运行记录 {run_id} 还没有日志文件"))
} else {
ApiError::internal(error)
}
})?;
let mut response = content.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"run-{run_id}.log\"")
.parse::<HeaderValue>()
.map_err(ApiError::internal)?,
);
Ok(response)
}
#[utoipa::path(
get,
path = "/api/v1/events",
tag = "runs",
responses(
(status = 200, description = "Run status and log events", body = String, content_type = "text/event-stream")
)
)]
pub async fn execution_events(
State(state): State<AppState>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
let receiver = state.executor.subscribe();
let events = BroadcastStream::new(receiver).filter_map(|message| async move {
match message {
Ok(event) => {
let event_name = event.event_name();
let data = serde_json::to_string(&event).ok()?;
Some(Ok(Event::default().event(event_name).data(data)))
}
Err(error) => {
tracing::warn!(%error, "SSE subscriber lagged");
Some(Ok(Event::default()
.event("lagged")
.data("{\"reason\":\"event stream lagged\"}")))
}
}
});
let events =
stream::once(async { Ok(Event::default().event("ready").data("{\"status\":\"ok\"}")) })
.chain(events);
Sse::new(events).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)))
}
+205
View File
@@ -0,0 +1,205 @@
use std::{
collections::HashMap,
str::FromStr,
sync::{Arc, Mutex, MutexGuard},
};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
runs::{executor::TaskExecutor, model::RunTrigger},
tasks::{model::TaskAction, model::TaskResponse, repository},
};
struct ScheduledTask {
cancellation: CancellationToken,
handle: JoinHandle<()>,
}
struct SchedulerInner {
executor: TaskExecutor,
shutdown: CancellationToken,
jobs: Mutex<HashMap<String, ScheduledTask>>,
}
#[derive(Clone)]
pub struct Scheduler {
inner: Arc<SchedulerInner>,
}
impl Scheduler {
pub async fn start(pool: SqlitePool, executor: TaskExecutor) -> ApiResult<Self> {
let scheduler = Self {
inner: Arc::new(SchedulerInner {
executor,
shutdown: CancellationToken::new(),
jobs: Mutex::new(HashMap::new()),
}),
};
for task in repository::list_enabled_script_tasks(&pool).await? {
scheduler.refresh(task);
}
Ok(scheduler)
}
pub fn refresh(&self, task: TaskResponse) {
self.cancel(&task.id);
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
return;
}
let cancellation = CancellationToken::new();
let task_for_loop = task.clone();
let executor = self.inner.executor.clone();
let shutdown = self.inner.shutdown.clone();
let cancellation_for_loop = cancellation.clone();
let handle = tokio::spawn(async move {
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
});
lock_jobs(&self.inner.jobs).insert(
task.id,
ScheduledTask {
cancellation,
handle,
},
);
}
pub fn cancel(&self, task_id: &str) {
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
job.cancellation.cancel();
job.handle.abort();
}
}
pub fn is_scheduled(&self, task_id: &str) -> bool {
lock_jobs(&self.inner.jobs).contains_key(task_id)
}
pub fn shutdown(&self) {
self.inner.shutdown.cancel();
let mut jobs = lock_jobs(&self.inner.jobs);
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
jobs.clear();
}
}
impl Drop for SchedulerInner {
fn drop(&mut self) {
self.shutdown.cancel();
let jobs = self
.jobs
.get_mut()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for job in jobs.values() {
job.cancellation.cancel();
job.handle.abort();
}
}
}
async fn schedule_loop(
task: TaskResponse,
executor: TaskExecutor,
cancellation: CancellationToken,
shutdown: CancellationToken,
) {
loop {
let now = Utc::now();
let next = match next_occurrence(&task, now) {
Ok(next) => next,
Err(error) => {
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
return;
}
};
let delay = (next - now).to_std().unwrap_or_default();
tokio::select! {
_ = cancellation.cancelled() => return,
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(delay) => {
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
}
}
}
}
}
fn next_occurrence(task: &TaskResponse, now: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
let timezone = Tz::from_str(&task.timezone)
.map_err(|_| format!("invalid task timezone: {}", task.timezone))?;
let cron = Cron::from_str(&task.cron).map_err(|error| format!("invalid task cron: {error}"))?;
let local_now = now.with_timezone(&timezone);
let next = cron
.find_next_occurrence(&local_now, false)
.map_err(|error| format!("failed to calculate next occurrence: {error}"))?;
Ok(next.with_timezone(&Utc))
}
fn lock_jobs(
jobs: &Mutex<HashMap<String, ScheduledTask>>,
) -> MutexGuard<'_, HashMap<String, ScheduledTask>> {
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
use super::*;
fn task(cron: &str, timezone: &str) -> TaskResponse {
TaskResponse {
id: "schedule_test".to_owned(),
name: "Schedule test".to_owned(),
cron: cron.to_owned(),
timezone: timezone.to_owned(),
enabled: true,
timeout_secs: 30,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy::default(),
action: TaskAction::Script {
working_dir: ".".to_owned(),
interpreter: "cmd.exe".to_owned(),
script: "/C".to_owned(),
args: vec!["exit".to_owned(), "0".to_owned()],
env: Default::default(),
},
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn calculates_next_occurrence_in_task_timezone() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let next = next_occurrence(&task("0 0 9 * * *", "Asia/Shanghai"), now).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap());
assert_eq!(next.hour(), 1);
}
#[test]
fn rejects_unknown_timezone() {
let now = Utc::now();
let error = next_occurrence(&task("0 * * * * *", "Mars/Olympus"), now).unwrap_err();
assert!(error.contains("timezone"));
}
}
+75
View File
@@ -0,0 +1,75 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, Method, header},
middleware::Next,
response::Response,
};
use crate::{
error::{ApiError, ApiResult},
state::AppState,
};
pub const AUTH_HEADER: &str = "x-taskpilot-token";
pub async fn api_guard(
State(state): State<AppState>,
request: Request,
next: Next,
) -> ApiResult<Response> {
let headers = request.headers();
if let Some(origin) = headers.get(header::ORIGIN) {
let origin = origin.to_str().unwrap_or_default();
if !is_local_origin(origin) {
return Err(ApiError::forbidden("请求 Origin 不在本地允许列表中"));
}
}
if !matches!(
*request.method(),
Method::GET | Method::HEAD | Method::OPTIONS
) && !has_valid_token(headers, state.auth_token())
{
return Err(ApiError::forbidden("缺少有效的本地管理令牌"));
}
Ok(next.run(request).await)
}
fn has_valid_token(headers: &HeaderMap, expected: &str) -> bool {
headers
.get(AUTH_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
fn is_local_origin(origin: &str) -> bool {
url::Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_local_origins_only() {
assert!(is_local_origin("http://127.0.0.1:5173"));
assert!(is_local_origin("http://localhost:3000"));
assert!(!is_local_origin("http://127.0.0.1.example.com"));
assert!(!is_local_origin("https://example.com"));
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::{env, path::PathBuf};
use sqlx::SqlitePool;
use crate::{
error::{ApiError, ApiResult},
runs::executor::{LogRetentionConfig, TaskExecutor},
scheduler::Scheduler,
};
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
pub scheduler: Scheduler,
pub executor: TaskExecutor,
auth_token: String,
}
impl AppState {
pub async fn start(pool: SqlitePool, logs_dir: PathBuf) -> ApiResult<Self> {
tokio::fs::create_dir_all(&logs_dir)
.await
.map_err(ApiError::internal)?;
let mut log_config = LogRetentionConfig::default();
if let Ok(value) = env::var("TASK_PILOT_LOG_RETENTION_DAYS")
&& let Ok(days) = value.parse::<u64>()
{
log_config.retention_days = days;
}
if let Ok(value) = env::var("TASK_PILOT_LOG_MAX_TOTAL_MB")
&& let Ok(megabytes) = value.parse::<u64>()
{
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
}
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
executor.cleanup_logs().await.map_err(ApiError::internal)?;
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
Ok(Self {
pool,
scheduler,
executor,
auth_token: uuid::Uuid::new_v4().simple().to_string(),
})
}
pub fn shutdown(&self) {
self.scheduler.shutdown();
self.executor.shutdown();
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub(crate) mod repository;
pub mod routes;
pub mod service;
+176
View File
@@ -0,0 +1,176 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::IntoParams;
use utoipa::ToSchema;
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskActionType {
Script,
Http,
Process,
}
impl TaskActionType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Script => "script",
Self::Http => "http",
Self::Process => "process",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum HttpMethod {
#[serde(rename = "GET")]
Get,
#[serde(rename = "POST")]
Post,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyPolicy {
#[default]
Skip,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_attempts: u32,
#[serde(default)]
pub delay_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskAction {
Script {
working_dir: String,
interpreter: String,
script: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
},
Http {
url: String,
method: HttpMethod,
#[serde(default)]
headers: BTreeMap<String, String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
expected_status: Vec<u16>,
},
Process {
executable: String,
#[serde(default)]
working_dir: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
},
}
impl TaskAction {
pub const fn action_type(&self) -> TaskActionType {
match self {
Self::Script { .. } => TaskActionType::Script,
Self::Http { .. } => TaskActionType::Http,
Self::Process { .. } => TaskActionType::Process,
}
}
}
fn default_timezone() -> String {
DEFAULT_TIMEZONE.to_owned()
}
const fn default_enabled() -> bool {
true
}
const fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreateTaskRequest {
pub id: String,
pub name: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateTaskRequest {
pub name: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TaskResponse {
pub id: String,
pub name: String,
pub cron: String,
pub timezone: String,
pub enabled: bool,
pub timeout_secs: u64,
pub concurrency_policy: ConcurrencyPolicy,
pub retry_policy: RetryPolicy,
pub action: TaskAction,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct TaskListResponse {
pub items: Vec<TaskResponse>,
pub page: u32,
pub page_size: u32,
pub total: u64,
}
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListTasksQuery {
pub q: Option<String>,
pub enabled: Option<bool>,
pub action_type: Option<TaskActionType>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
+284
View File
@@ -0,0 +1,284 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
";
pub struct ListFilters {
pub q: Option<String>,
pub enabled: Option<bool>,
pub action_type: Option<TaskActionType>,
pub page: u32,
pub page_size: u32,
}
#[derive(Debug, FromRow)]
struct TaskRow {
id: String,
name: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
"task {} action type does not match action payload",
row.id
)));
}
Ok(Self {
id: row.id,
name: row.name,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}
pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(now)
.execute(pool)
.await;
if let Err(error) = result {
if is_unique_violation(&error) {
return Err(ApiError::conflict(format!(
"任务标识 {} 已存在",
request.id
)));
}
return Err(error.into());
}
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
})
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
row.map(TaskResponse::try_from).transpose()
}
pub async fn update(
pool: &SqlitePool,
id: &str,
request: &UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found(format!("任务 {id} 不存在")));
}
get(pool, id)
.await?
.ok_or_else(|| ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<bool> {
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<bool> {
let result = sqlx::query("UPDATE tasks SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(Utc::now())
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskListResponse> {
let mut count_builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM tasks");
apply_filters(&mut count_builder, filters);
let total: i64 = count_builder.build_query_scalar().fetch_one(pool).await?;
let offset = i64::from(filters.page.saturating_sub(1)) * i64::from(filters.page_size);
let mut list_builder = QueryBuilder::<Sqlite>::new(format!("SELECT {TASK_COLUMNS} FROM tasks"));
apply_filters(&mut list_builder, filters);
list_builder
.push(" ORDER BY updated_at DESC, id ASC LIMIT ")
.push_bind(i64::from(filters.page_size))
.push(" OFFSET ")
.push_bind(offset);
let rows = list_builder
.build_query_as::<TaskRow>()
.fetch_all(pool)
.await?;
let items = rows
.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()?;
Ok(TaskListResponse {
items,
page: filters.page,
page_size: filters.page_size,
total: u64::try_from(total).map_err(ApiError::internal)?,
})
}
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE enabled = 1 AND action_type = 'script'
ORDER BY updated_at DESC, id ASC",
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()
}
fn apply_filters(builder: &mut QueryBuilder<Sqlite>, filters: &ListFilters) {
builder.push(" WHERE 1 = 1");
if let Some(search) = &filters.q {
let pattern = format!(
"%{}%",
search
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
builder
.push(" AND (id LIKE ")
.push_bind(pattern.clone())
.push(" ESCAPE '\\' OR name LIKE ")
.push_bind(pattern)
.push(" ESCAPE '\\')");
}
if let Some(enabled) = filters.enabled {
builder.push(" AND enabled = ").push_bind(enabled);
}
if let Some(action_type) = filters.action_type {
builder
.push(" AND action_type = ")
.push_bind(action_type.as_str());
}
}
fn is_unique_violation(error: &sqlx::Error) -> bool {
matches!(error, sqlx::Error::Database(database_error) if database_error.is_unique_violation())
}
+167
View File
@@ -0,0 +1,167 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use crate::{
error::{ApiJson, ApiQuery, ApiResult},
state::AppState,
tasks::{
model::{
CreateTaskRequest, ListTasksQuery, TaskListResponse, TaskResponse, UpdateTaskRequest,
},
service,
},
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/tasks", get(list_tasks).post(create_task))
.route(
"/api/v1/tasks/{id}",
get(get_task).put(update_task).delete(delete_task),
)
.route(
"/api/v1/tasks/{id}/enable",
axum::routing::post(enable_task),
)
.route(
"/api/v1/tasks/{id}/disable",
axum::routing::post(disable_task),
)
}
#[utoipa::path(
get,
path = "/api/v1/tasks",
tag = "tasks",
params(ListTasksQuery),
responses(
(status = 200, description = "Paginated task list", body = TaskListResponse)
)
)]
pub async fn list_tasks(
State(state): State<AppState>,
ApiQuery(query): ApiQuery<ListTasksQuery>,
) -> ApiResult<Json<TaskListResponse>> {
Ok(Json(service::list(&state.pool, query).await?))
}
#[utoipa::path(
post,
path = "/api/v1/tasks",
tag = "tasks",
request_body = CreateTaskRequest,
responses(
(status = 201, description = "Task created", body = TaskResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 409, description = "Task ID already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn create_task(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreateTaskRequest>,
) -> ApiResult<(StatusCode, Json<TaskResponse>)> {
let task = service::create(&state.pool, request).await?;
state.scheduler.refresh(task.clone());
Ok((StatusCode::CREATED, Json(task)))
}
#[utoipa::path(
get,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task detail", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
Ok(Json(service::get(&state.pool, &id).await?))
}
#[utoipa::path(
put,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
request_body = UpdateTaskRequest,
responses(
(status = 200, description = "Task updated", body = TaskResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn update_task(
State(state): State<AppState>,
Path(id): Path<String>,
ApiJson(request): ApiJson<UpdateTaskRequest>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::update(&state.pool, &id, request).await?;
state.scheduler.refresh(task.clone());
Ok(Json(task))
}
#[utoipa::path(
delete,
path = "/api/v1/tasks/{id}",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 204, description = "Task deleted"),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
state.scheduler.cancel(&id);
state.executor.cancel_task(&id);
service::delete(&state.pool, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/enable",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task enabled", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn enable_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::set_enabled(&state.pool, &id, true).await?;
state.scheduler.refresh(task.clone());
Ok(Json(task))
}
#[utoipa::path(
post,
path = "/api/v1/tasks/{id}/disable",
tag = "tasks",
params(("id" = String, Path, description = "Stable task identifier")),
responses(
(status = 200, description = "Task disabled", body = TaskResponse),
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
)
)]
pub async fn disable_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<TaskResponse>> {
let task = service::set_enabled(&state.pool, &id, false).await?;
state.scheduler.cancel(&id);
Ok(Json(task))
}
+369
View File
@@ -0,0 +1,369 @@
use std::{collections::BTreeMap, str::FromStr};
use axum::http::{HeaderName, HeaderValue};
use chrono::Utc;
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use url::Url;
use crate::{error::ApiResult, tasks::model::*};
use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
const MAX_ENV_ENTRIES: usize = 128;
const MAX_ENV_KEY_LEN: usize = 128;
const MAX_ENV_VALUE_LEN: usize = 8_192;
const MAX_HTTP_BODY_LEN: usize = 1_048_576;
const DEFAULT_PAGE_SIZE: u32 = 20;
const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_task_id(&request.id)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::create(pool, &request).await
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<TaskResponse> {
repository::get(pool, id)
.await?
.ok_or_else(|| crate::error::ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn update(
pool: &SqlitePool,
id: &str,
mut request: UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::update(pool, id, &request).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
if repository::delete(pool, id).await? {
Ok(())
} else {
Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)))
}
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<TaskResponse> {
if !repository::set_enabled(pool, id, enabled).await? {
return Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)));
}
get(pool, id).await
}
pub async fn list(pool: &SqlitePool, query: ListTasksQuery) -> ApiResult<TaskListResponse> {
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 filters = ListFilters {
q: query
.q
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
enabled: query.enabled,
action_type: query.action_type,
page,
page_size,
};
repository::list(pool, &filters).await
}
fn validate_task_id(id: &str) -> ApiResult<()> {
if id.is_empty() || id.len() > MAX_TASK_ID_LEN {
return Err(crate::error::ApiError::validation(
"id",
format!("任务标识长度必须在 1 到 {MAX_TASK_ID_LEN} 个字符之间"),
));
}
if !id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return Err(crate::error::ApiError::validation(
"id",
"任务标识只能包含 ASCII 字母、数字、下划线和连字符",
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
"name",
format!("任务名称长度必须在 1 到 {MAX_TASK_NAME_LEN} 个字符之间"),
));
}
if timeout_secs == 0 || timeout_secs > MAX_TIMEOUT_SECS {
return Err(crate::error::ApiError::validation(
"timeout_secs",
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
})?;
let cron = Cron::from_str(cron_expression).map_err(|error| {
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
})?;
cron.find_next_occurrence(&Utc::now(), false)
.map_err(|error| {
crate::error::ApiError::validation(
"cron",
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
)
})?;
Ok(())
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
interpreter,
script,
args,
env,
} => {
validate_required("action.working_dir", working_dir)?;
validate_required("action.interpreter", interpreter)?;
validate_required("action.script", script)?;
validate_args(args)?;
validate_env(env)?;
}
TaskAction::Http {
url,
headers,
body,
expected_status,
..
} => {
let parsed_url = Url::parse(url).map_err(|error| {
crate::error::ApiError::validation("action.url", format!("URL 无效: {error}"))
})?;
if !matches!(parsed_url.scheme(), "http" | "https") || parsed_url.host_str().is_none() {
return Err(crate::error::ApiError::validation(
"action.url",
"URL 必须使用 http 或 https 协议并包含主机名",
));
}
validate_headers(headers)?;
if body
.as_ref()
.is_some_and(|value| value.len() > MAX_HTTP_BODY_LEN)
{
return Err(crate::error::ApiError::validation(
"action.body",
"HTTP Body 不能超过 1 MiB",
));
}
if expected_status
.iter()
.any(|status| !(100..=599).contains(status))
{
return Err(crate::error::ApiError::validation(
"action.expected_status",
"期望状态码必须在 100 到 599 之间",
));
}
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
validate_required("action.executable", executable)?;
if let Some(working_dir) = working_dir {
validate_required("action.working_dir", working_dir)?;
}
validate_args(args)?;
validate_env(env)?;
}
}
Ok(())
}
fn validate_required(field: &str, value: &str) -> ApiResult<()> {
if value.trim().is_empty() {
Err(crate::error::ApiError::validation(field, "该字段不能为空"))
} else {
Ok(())
}
}
fn validate_args(args: &[String]) -> ApiResult<()> {
if args.len() > MAX_ARGS {
return Err(crate::error::ApiError::validation(
"action.args",
format!("参数数量不能超过 {MAX_ARGS} 个"),
));
}
if args.iter().any(|argument| argument.len() > MAX_ARG_LEN) {
return Err(crate::error::ApiError::validation(
"action.args",
format!("单个参数不能超过 {MAX_ARG_LEN} 个字符"),
));
}
Ok(())
}
fn validate_env(env: &BTreeMap<String, String>) -> ApiResult<()> {
if env.len() > MAX_ENV_ENTRIES {
return Err(crate::error::ApiError::validation(
"action.env",
format!("环境变量数量不能超过 {MAX_ENV_ENTRIES} 个"),
));
}
for (key, value) in env {
if key.is_empty()
|| key.len() > MAX_ENV_KEY_LEN
|| key.contains(['=', '\0'])
|| value.len() > MAX_ENV_VALUE_LEN
|| value.contains('\0')
{
return Err(crate::error::ApiError::validation(
"action.env",
"环境变量名称或值无效",
));
}
}
Ok(())
}
fn validate_headers(headers: &BTreeMap<String, String>) -> ApiResult<()> {
for (name, value) in headers {
HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 名称无效")
})?;
HeaderValue::from_str(value).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 值无效")
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use axum::response::IntoResponse;
use super::*;
fn script_action() -> TaskAction {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
env: BTreeMap::new(),
}
}
#[test]
fn accepts_valid_task_fields() {
validate_task_id("db_backup_daily").expect("task id should be valid");
validate_common(
"每日数据库备份",
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
}
#[test]
fn rejects_invalid_cron() {
let error = validate_common(
"Invalid cron",
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
assert_eq!(error.into_response().status(), 400);
}
#[test]
fn rejects_empty_script_path() {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
env: BTreeMap::new(),
};
validate_action(&action).expect_err("empty script path should fail");
}
}
+74
View File
@@ -0,0 +1,74 @@
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::Value;
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
#[tokio::test]
async fn health_endpoint_returns_service_metadata() {
let (app, _temp_dir) = test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/health")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "task-pilot-backend");
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn openapi_endpoint_describes_health_api() {
let (app, _temp_dir) = test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/openapi.json")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
assert_eq!(json["info"]["title"], "TaskPilot API");
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
}
+650
View File
@@ -0,0 +1,650 @@
use std::time::Duration;
use axum::{
Json, Router,
body::Body,
http::{HeaderMap, Method, Request, StatusCode, header},
response::Response,
routing::{get, post},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use task_pilot_backend::database;
use task_pilot_backend::state::AppState;
use tempfile::TempDir;
use tokio::time::sleep;
use tokio::{net::TcpListener, task::JoinHandle};
use tower::ServiceExt;
use task_pilot_backend::runs::executor::ExecutionEvent;
struct TestApp {
router: Router,
state: AppState,
_temp_dir: TempDir,
}
async fn test_app() -> TestApp {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let state = AppState::start(pool, temp_dir.path().join("logs"))
.await
.expect("app state should start");
let router = task_pilot_backend::router(state.clone());
TestApp {
router,
state,
_temp_dir: temp_dir,
}
}
async fn send_json(router: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
None
} else {
Some(fetch_api_token(router).await)
};
let body = body
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
.unwrap_or_else(Body::empty);
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = builder.body(body).expect("request should build");
router
.clone()
.oneshot(request)
.await
.expect("router should respond")
}
async fn fetch_api_token(router: &Router) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/api/v1/session")
.body(Body::empty())
.expect("session request should build");
let response = router
.clone()
.oneshot(request)
.await
.expect("session endpoint should respond");
assert_eq!(response.status(), StatusCode::OK);
response_json(response).await["token"]
.as_str()
.expect("session token should be a string")
.to_owned()
}
async fn response_json(response: Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
serde_json::from_slice(&body).expect("response should be JSON")
}
async fn connect_file(path: &std::path::Path) -> SqlitePool {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("file database should open");
database::migrate(&pool)
.await
.expect("migrations should run");
pool
}
fn script_task(
id: &str,
working_dir: &str,
interpreter: &str,
script: &str,
args: Vec<&str>,
cron: &str,
timeout_secs: u64,
) -> Value {
json!({
"id": id,
"name": id,
"cron": cron,
"timeout_secs": timeout_secs,
"action": {
"type": "script",
"working_dir": working_dir,
"interpreter": interpreter,
"script": script,
"args": args,
"env": {}
}
})
}
fn http_task(url: &str) -> Value {
json!({
"id": "http_task",
"name": "HTTP task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "http",
"url": url,
"method": "POST",
"headers": {
"X-Test": "yes"
},
"body": "{\"ping\":true}",
"expected_status": [202]
}
})
}
async fn spawn_http_target() -> (String, JoinHandle<()>) {
async fn check(headers: HeaderMap, body: String) -> StatusCode {
if headers.get("x-test").and_then(|value| value.to_str().ok()) == Some("yes")
&& body.contains("\"ping\":true")
{
StatusCode::ACCEPTED
} else {
StatusCode::BAD_REQUEST
}
}
let app = Router::new()
.route("/check", post(check))
.route("/health", get(|| async { Json(json!({"status":"ok"})) }));
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("HTTP test listener should bind");
let address = listener
.local_addr()
.expect("listener address should exist");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{address}"), handle)
}
async fn wait_for_run(router: &Router, run_id: i64) -> Value {
for _ in 0..100 {
let response =
send_json(router, Method::GET, &format!("/api/v1/runs/{run_id}"), None).await;
assert_eq!(response.status(), StatusCode::OK);
let run = response_json(response).await;
if matches!(
run["status"].as_str(),
Some("succeeded" | "failed" | "timed_out" | "cancelled" | "skipped")
) {
return run;
}
sleep(Duration::from_millis(100)).await;
}
panic!("run {run_id} did not reach a terminal state");
}
#[tokio::test]
async fn manual_script_run_captures_exit_code_and_logs() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"echo_task",
&working_dir,
"cmd.exe",
"/C",
vec!["echo", "hello-from-task"],
"0 0 2 * * *",
5,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let run_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/echo_task/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let run_id = run["id"].as_i64().expect("run id should be an integer");
assert_eq!(run["status"], "queued");
let completed = wait_for_run(&app.router, run_id).await;
assert_eq!(completed["status"], "succeeded");
assert_eq!(completed["exit_code"], 0);
assert_eq!(completed["trigger"], "manual");
let log_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs"),
None,
)
.await;
assert_eq!(log_response.status(), StatusCode::OK);
let log = response_json(log_response).await;
assert_eq!(log["complete"], true);
assert!(log["content"].as_str().unwrap().contains("hello-from-task"));
let filtered_response = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/echo_task/runs?status=succeeded&trigger=manual",
None,
)
.await;
let filtered = response_json(filtered_response).await;
assert_eq!(filtered["total"], 1);
let download_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs/download"),
None,
)
.await;
assert_eq!(download_response.status(), StatusCode::OK);
assert!(
download_response
.headers()
.get(header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("attachment")
);
app.state.shutdown();
}
#[tokio::test]
async fn http_and_process_executors_run_through_the_api() {
let app = test_app().await;
let (http_base, http_handle) = spawn_http_target().await;
let create_http = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(http_task(&format!("{http_base}/check"))),
)
.await;
assert_eq!(create_http.status(), StatusCode::CREATED);
let http_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/http_task/run",
None,
)
.await;
assert_eq!(http_run.status(), StatusCode::ACCEPTED);
let http_run_id = response_json(http_run).await["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, http_run_id).await["status"],
"succeeded"
);
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let process_task = json!({
"id": "process_task",
"name": "Process task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "process",
"executable": "cmd.exe",
"working_dir": working_dir,
"args": ["/C", "echo", "process-output"],
"env": {}
}
});
let create_process = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(process_task),
)
.await;
assert_eq!(create_process.status(), StatusCode::CREATED);
let process_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/process_task/run",
None,
)
.await;
let process_run_id = response_json(process_run).await["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, process_run_id).await["status"],
"succeeded"
);
let process_log = response_json(
send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{process_run_id}/logs"),
None,
)
.await,
)
.await;
assert!(
process_log["content"]
.as_str()
.unwrap()
.contains("process-output")
);
http_handle.abort();
app.state.shutdown();
}
#[tokio::test]
async fn running_task_can_be_cancelled_and_sensitive_output_is_redacted() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = json!({
"id": "cancel_task",
"name": "Cancel task",
"cron": "0 0 2 * * *",
"timeout_secs": 30,
"action": {
"type": "script",
"working_dir": working_dir,
"interpreter": "cmd.exe",
"script": "/C",
"args": ["ping", "-t", "127.0.0.1"],
"env": {
"API_TOKEN": "super-secret-token"
}
}
});
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let mut events = app.state.executor.subscribe();
let run_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/cancel_task/run",
None,
)
.await;
let run_id = response_json(run_response).await["id"].as_i64().unwrap();
let cancel_response = send_json(
&app.router,
Method::POST,
&format!("/api/v1/runs/{run_id}/cancel"),
None,
)
.await;
assert_eq!(cancel_response.status(), StatusCode::ACCEPTED);
assert_eq!(
wait_for_run(&app.router, run_id).await["status"],
"cancelled"
);
let mut saw_run_event = false;
let mut saw_log_event = false;
for _ in 0..20 {
let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
.await
.expect("SSE event should arrive")
.expect("SSE channel should stay open");
match event {
ExecutionEvent::Run { run } if run.id == run_id => saw_run_event = true,
ExecutionEvent::Log {
run_id: event_run_id,
..
} if event_run_id == run_id => saw_log_event = true,
_ => {}
}
if saw_run_event && saw_log_event {
break;
}
}
assert!(saw_run_event);
assert!(saw_log_event);
let terminal_cancel = send_json(
&app.router,
Method::POST,
&format!("/api/v1/runs/{run_id}/cancel"),
None,
)
.await;
assert_eq!(terminal_cancel.status(), StatusCode::CONFLICT);
let output_task = json!({
"id": "redaction_task",
"name": "Redaction task",
"cron": "0 0 2 * * *",
"timeout_secs": 5,
"action": {
"type": "script",
"working_dir": app._temp_dir.path().to_string_lossy(),
"interpreter": "cmd.exe",
"script": "/C",
"args": ["echo", "%API_TOKEN%"],
"env": {
"API_TOKEN": "super-secret-token"
}
}
});
let create_output = send_json(
&app.router,
Method::POST,
"/api/v1/tasks",
Some(output_task),
)
.await;
assert_eq!(create_output.status(), StatusCode::CREATED);
let output_run = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/redaction_task/run",
None,
)
.await;
let output_run_id = response_json(output_run).await["id"].as_i64().unwrap();
wait_for_run(&app.router, output_run_id).await;
let output_log = response_json(
send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{output_run_id}/logs"),
None,
)
.await,
)
.await;
let content = output_log["content"].as_str().unwrap();
assert!(content.contains("[REDACTED]"));
assert!(!content.contains("super-secret-token"));
app.state.shutdown();
}
#[tokio::test]
async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
let app = test_app().await;
let marker_path = app._temp_dir.path().join("child-survived.txt");
let marker = marker_path.to_string_lossy().replace('\'', "''");
let script_path = app._temp_dir.path().join("timeout-test.ps1");
let script = format!(
"$marker = '{marker}'\n\
$child = \"Start-Sleep -Seconds 3; Set-Content -LiteralPath '{marker}' -Value 'alive'\"\n\
Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile', '-Command', $child -WindowStyle Hidden\n\
Start-Sleep -Seconds 30\n"
);
std::fs::write(&script_path, script).expect("test script should be written");
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"timeout_task",
&working_dir,
"powershell.exe",
"-NoProfile",
vec!["-File", &script_path.to_string_lossy()],
"0 0 2 * * *",
1,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let first_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/timeout_task/run",
None,
)
.await;
assert_eq!(first_response.status(), StatusCode::ACCEPTED);
let first_run = response_json(first_response).await;
let first_run_id = first_run["id"].as_i64().unwrap();
let second_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/timeout_task/run",
None,
)
.await;
assert_eq!(second_response.status(), StatusCode::ACCEPTED);
let second_run = response_json(second_response).await;
assert_eq!(second_run["status"], "skipped");
let completed = wait_for_run(&app.router, first_run_id).await;
assert_eq!(completed["status"], "timed_out");
assert!(
completed["error_message"]
.as_str()
.unwrap()
.contains("已终止进程树")
);
sleep(Duration::from_secs(3)).await;
assert!(
!marker_path.exists(),
"child process survived after job termination"
);
app.state.shutdown();
}
#[tokio::test]
async fn scheduler_runs_tasks_and_tracks_enable_state() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"scheduled_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"*/1 * * * * *",
5,
);
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
let mut scheduled_run = None;
for _ in 0..50 {
let response = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/scheduled_task/runs",
None,
)
.await;
let runs = response_json(response).await;
if let Some(item) = runs["items"]
.as_array()
.and_then(|items| items.first())
.filter(|item| item["trigger"] == "schedule")
{
scheduled_run = Some(item.clone());
break;
}
sleep(Duration::from_millis(100)).await;
}
let scheduled_run = scheduled_run.expect("scheduler should trigger a run");
let run_id = scheduled_run["id"].as_i64().unwrap();
assert_eq!(
wait_for_run(&app.router, run_id).await["status"],
"succeeded"
);
let disable_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/scheduled_task/disable",
None,
)
.await;
assert_eq!(disable_response.status(), StatusCode::OK);
assert!(!app.state.scheduler.is_scheduled("scheduled_task"));
let enable_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/scheduled_task/enable",
None,
)
.await;
assert_eq!(enable_response.status(), StatusCode::OK);
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
app.state.shutdown();
}
#[tokio::test]
async fn scheduler_restores_enabled_tasks_after_restart() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let database_path = temp_dir.path().join("restart.db");
let first_pool = connect_file(&database_path).await;
let first_state = AppState::start(first_pool.clone(), temp_dir.path().join("logs-first"))
.await
.expect("first app state should start");
let first_router = task_pilot_backend::router(first_state.clone());
let working_dir = temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"restored_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let create_response = send_json(&first_router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
first_state.shutdown();
drop(first_router);
drop(first_state);
first_pool.close().await;
let second_pool = connect_file(&database_path).await;
let second_state = AppState::start(second_pool.clone(), temp_dir.path().join("logs-second"))
.await
.expect("second app state should start");
assert!(second_state.scheduler.is_scheduled("restored_task"));
second_state.shutdown();
second_pool.close().await;
}
+57
View File
@@ -0,0 +1,57 @@
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use http_body_util::BodyExt;
use task_pilot_backend::state::AppState;
use tempfile::TempDir;
use tower::ServiceExt;
#[tokio::test]
async fn serves_spa_and_keeps_api_404_as_json() {
let temp = TempDir::new().unwrap();
let static_dir = temp.path().join("web");
std::fs::create_dir_all(&static_dir).unwrap();
std::fs::write(
static_dir.join("index.html"),
"<!doctype html><title>TaskPilot SPA</title>",
)
.unwrap();
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.unwrap();
let state = AppState::start(pool, temp.path().join("logs"))
.await
.unwrap();
let app = task_pilot_backend::router_with_static(state.clone(), static_dir);
let page = app
.clone()
.oneshot(
Request::builder()
.uri("/tasks/example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(page.status(), StatusCode::OK);
let page_body = page.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&page_body).contains("TaskPilot SPA"));
let api_not_found = app
.oneshot(
Request::builder()
.uri("/api/v1/does-not-exist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(api_not_found.status(), StatusCode::NOT_FOUND);
assert_eq!(
api_not_found.headers().get(header::CONTENT_TYPE),
Some(&header::HeaderValue::from_static("application/json"))
);
state.shutdown();
}
+354
View File
@@ -0,0 +1,354 @@
use axum::{
Router,
body::Body,
http::{Method, Request, StatusCode, header},
response::Response,
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use task_pilot_backend::database;
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn connect_file(path: &std::path::Path) -> SqlitePool {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("file database should open");
database::migrate(&pool)
.await
.expect("migrations should run");
pool
}
async fn send_json(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
None
} else {
Some(fetch_api_token(app).await)
};
let body = body
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
.unwrap_or_else(Body::empty);
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = builder.body(body).expect("request should build");
app.clone()
.oneshot(request)
.await
.expect("router should respond")
}
async fn fetch_api_token(app: &Router) -> String {
let request = Request::builder()
.method(Method::GET)
.uri("/api/v1/session")
.body(Body::empty())
.expect("session request should build");
let response = app
.clone()
.oneshot(request)
.await
.expect("session endpoint should respond");
assert_eq!(response.status(), StatusCode::OK);
response_json(response).await["token"]
.as_str()
.expect("session token should be a string")
.to_owned()
}
async fn response_json(response: Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
serde_json::from_slice(&body).expect("response should be JSON")
}
fn script_task(id: &str, name: &str) -> Value {
json!({
"id": id,
"name": name,
"cron": "0 0 2 * * *",
"enabled": true,
"timeout_secs": 300,
"action": {
"type": "script",
"working_dir": "D:\\Projects\\sync",
"interpreter": "python.exe",
"script": "main.py",
"args": ["--mode", "full"],
"env": {
"PYTHONUNBUFFERED": "1"
}
}
})
}
#[tokio::test]
async fn task_crud_flow_works_end_to_end() {
let (app, _temp_dir) = test_app().await;
let create_response = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("sync_data", "Data sync")),
)
.await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let created = response_json(create_response).await;
assert_eq!(created["id"], "sync_data");
assert_eq!(created["timezone"], "Asia/Shanghai");
assert_eq!(created["action"]["type"], "script");
let get_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
assert_eq!(get_response.status(), StatusCode::OK);
assert_eq!(response_json(get_response).await["name"], "Data sync");
let list_response = send_json(
&app,
Method::GET,
"/api/v1/tasks?q=sync&enabled=true&action_type=script&page=1&page_size=10",
None,
)
.await;
assert_eq!(list_response.status(), StatusCode::OK);
let listed = response_json(list_response).await;
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["id"], "sync_data");
let update_response = send_json(
&app,
Method::PUT,
"/api/v1/tasks/sync_data",
Some(json!({
"name": "Updated data sync",
"cron": "*/30 * * * * *",
"enabled": false,
"timeout_secs": 120,
"action": {
"type": "http",
"url": "https://example.com/health",
"method": "GET",
"headers": {},
"expected_status": [200]
}
})),
)
.await;
assert_eq!(update_response.status(), StatusCode::OK);
let updated = response_json(update_response).await;
assert_eq!(updated["name"], "Updated data sync");
assert_eq!(updated["enabled"], false);
assert_eq!(updated["action"]["type"], "http");
let delete_response = send_json(&app, Method::DELETE, "/api/v1/tasks/sync_data", None).await;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let missing_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
let missing = response_json(missing_response).await;
assert_eq!(missing["code"], "TASK_NOT_FOUND");
}
#[tokio::test]
async fn duplicate_task_id_returns_conflict() {
let (app, _temp_dir) = test_app().await;
let first = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("duplicate", "First")),
)
.await;
assert_eq!(first.status(), StatusCode::CREATED);
let second = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task("duplicate", "Second")),
)
.await;
assert_eq!(second.status(), StatusCode::CONFLICT);
let body = response_json(second).await;
assert_eq!(body["code"], "TASK_ID_CONFLICT");
assert_eq!(body["field"], "id");
}
#[tokio::test]
async fn invalid_cron_returns_structured_validation_error() {
let (app, _temp_dir) = test_app().await;
let mut request = script_task("invalid_cron", "Invalid cron");
request["cron"] = Value::String("*/0 * * * * *".to_owned());
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(request)).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["code"], "VALIDATION_ERROR");
assert_eq!(body["field"], "cron");
}
#[tokio::test]
async fn malformed_json_and_query_return_structured_errors() {
let (app, _temp_dir) = test_app().await;
let token = fetch_api_token(&app).await;
let malformed_request = Request::builder()
.method(Method::POST)
.uri("/api/v1/tasks")
.header(header::CONTENT_TYPE, "application/json")
.header("x-taskpilot-token", token)
.body(Body::from("{not-json"))
.expect("request should build");
let malformed_response = app
.clone()
.oneshot(malformed_request)
.await
.expect("router should respond");
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response_json(malformed_response).await["code"],
"INVALID_JSON"
);
let invalid_query = send_json(&app, Method::GET, "/api/v1/tasks?enabled=maybe", None).await;
assert_eq!(invalid_query.status(), StatusCode::BAD_REQUEST);
assert_eq!(response_json(invalid_query).await["code"], "INVALID_QUERY");
}
#[tokio::test]
async fn rejects_cross_origin_and_missing_management_token() {
let (app, _temp_dir) = test_app().await;
let cross_origin = Request::builder()
.method(Method::GET)
.uri("/api/v1/tasks")
.header(header::ORIGIN, "https://evil.example")
.body(Body::empty())
.expect("request should build");
let response = app
.clone()
.oneshot(cross_origin)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let missing_token = Request::builder()
.method(Method::POST)
.uri("/api/v1/tasks")
.header(header::ORIGIN, "http://127.0.0.1:5173")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&script_task("blocked_task", "Blocked"))
.expect("JSON should serialize"),
))
.expect("request should build");
let response = app
.oneshot(missing_token)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response_json(response).await["code"], "FORBIDDEN");
}
#[tokio::test]
async fn list_supports_pagination_and_escaped_search() {
let (app, _temp_dir) = test_app().await;
for (id, name) in [
("sync_one", "Sync one"),
("sync_two", "Sync two"),
("other", "100% complete"),
] {
let response = send_json(
&app,
Method::POST,
"/api/v1/tasks",
Some(script_task(id, name)),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
}
let first_page = send_json(
&app,
Method::GET,
"/api/v1/tasks?q=sync&page=1&page_size=1",
None,
)
.await;
let first_page = response_json(first_page).await;
assert_eq!(first_page["page"], 1);
assert_eq!(first_page["page_size"], 1);
assert_eq!(first_page["total"], 2);
assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1));
let escaped = send_json(&app, Method::GET, "/api/v1/tasks?q=%25", None).await;
let escaped = response_json(escaped).await;
assert_eq!(escaped["total"], 1);
assert_eq!(escaped["items"][0]["id"], "other");
}
#[tokio::test]
async fn tasks_survive_database_reconnect() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let database_path = temp_dir.path().join("task_pilot_test.db");
let first_pool = connect_file(&database_path).await;
let first_app = task_pilot_backend::app(first_pool.clone(), temp_dir.path().join("logs-first"))
.await
.expect("first app should start");
let create_response = send_json(
&first_app,
Method::POST,
"/api/v1/tasks",
Some(script_task("persistent_task", "Persistent task")),
)
.await;
assert_eq!(create_response.status(), StatusCode::CREATED);
first_pool.close().await;
drop(first_app);
let second_pool = connect_file(&database_path).await;
let second_app =
task_pilot_backend::app(second_pool.clone(), temp_dir.path().join("logs-second"))
.await
.expect("second app should start");
let get_response = send_json(
&second_app,
Method::GET,
"/api/v1/tasks/persistent_task",
None,
)
.await;
assert_eq!(get_response.status(), StatusCode::OK);
assert_eq!(response_json(get_response).await["name"], "Persistent task");
second_pool.close().await;
}