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
+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 路由不存在")
}