feat: 加固运行期可靠性并补齐调度与观测接口

- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态
- 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected
- 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule
- SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB
- 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
This commit is contained in:
2026-10-26 00:38:16 +08:00
parent 773196d689
commit 815258c19b
19 changed files with 990 additions and 93 deletions
+148 -2
View File
@@ -573,7 +573,12 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
&working_dir,
"powershell.exe",
"-NoProfile",
vec!["-File", &script_path.to_string_lossy()],
vec![
"-ExecutionPolicy",
"Bypass",
"-File",
&script_path.to_string_lossy(),
],
"0 0 2 * * *",
1,
);
@@ -603,7 +608,10 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
assert_eq!(second_run["status"], "skipped");
let completed = wait_for_run(&app.router, first_run_id).await;
assert_eq!(completed["status"], "timed_out");
assert_eq!(
completed["status"], "timed_out",
"unexpected run: {completed}"
);
assert!(
completed["error_message"]
.as_str()
@@ -821,3 +829,141 @@ async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
);
app.state.shutdown();
}
#[tokio::test]
async fn stats_endpoint_aggregates_task_counts() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let script = script_task(
"stats_script",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let http = http_task("http://127.0.0.1:9/never");
for payload in [script.clone(), http.clone()] {
let response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(payload)).await;
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = send_json(&app.router, Method::GET, "/api/v1/stats", None).await;
assert_eq!(response.status(), StatusCode::OK);
let stats = response_json(response).await;
assert_eq!(stats["total"], 2);
assert_eq!(stats["enabled"], 2);
assert_eq!(stats["script"], 1);
assert_eq!(stats["http"], 1);
assert_eq!(stats["process"], 0);
assert_eq!(stats["running"], 0);
app.state.shutdown();
}
#[tokio::test]
async fn schedule_endpoint_reports_next_run_for_enabled_tasks() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"scheduled_stats",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"*/30 * * * * *",
5,
);
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
let response = send_json(&app.router, Method::GET, "/api/v1/schedule", None).await;
assert_eq!(response.status(), StatusCode::OK);
let schedule = response_json(response).await;
let entries = schedule.as_array().expect("schedule should be an array");
let entry = entries
.iter()
.find(|item| item["task_id"] == "scheduled_stats")
.expect("enabled task should appear in the schedule");
assert!(
entry["next_run_at"].is_string(),
"next_run_at should be computed for a registered task"
);
app.state.shutdown();
}
#[tokio::test]
async fn http_tasks_are_registered_for_scheduling() {
let app = test_app().await;
let task = http_task("http://127.0.0.1:9/never");
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
assert!(
app.state.scheduler.is_scheduled("http_task"),
"HTTP tasks must take part in scheduled runs"
);
let disabled = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/http_task/disable",
None,
)
.await;
assert_eq!(disabled.status(), StatusCode::OK);
assert!(!app.state.scheduler.is_scheduled("http_task"));
app.state.shutdown();
}
#[tokio::test]
async fn startup_marks_runs_interrupted_by_restart_as_cancelled() {
let app = test_app().await;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
let task = script_task(
"interrupted_task",
&working_dir,
"cmd.exe",
"/C",
vec!["exit", "0"],
"0 0 2 * * *",
5,
);
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(created.status(), StatusCode::CREATED);
// 模拟上一次进程被强杀后遗留的运行记录。
sqlx::query(
"INSERT INTO task_runs (task_id, trigger_type, status, created_at)
VALUES ('interrupted_task', 'manual', 'running', ?)",
)
.bind(chrono::Utc::now())
.execute(&app.state.pool)
.await
.expect("stale run should be inserted");
let reconciled = task_pilot_backend::runs::repository::fail_interrupted_runs(&app.state.pool)
.await
.expect("reconciliation should run");
assert_eq!(reconciled, 1);
let runs = send_json(
&app.router,
Method::GET,
"/api/v1/tasks/interrupted_task/runs",
None,
)
.await;
let body = response_json(runs).await;
let item = &body["items"][0];
assert_eq!(item["status"], "cancelled");
assert!(
item["error_message"]
.as_str()
.unwrap_or_default()
.contains("重启")
);
app.state.shutdown();
}