chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
341
crates/zclaw-kernel/src/scheduler.rs
Normal file
341
crates/zclaw-kernel/src/scheduler.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! Scheduler service for automatic trigger execution
|
||||
//!
|
||||
//! Periodically scans scheduled triggers and fires them at the appropriate time.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use chrono::{Datelike, Timelike};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::{self, Duration};
|
||||
use zclaw_types::Result;
|
||||
use crate::Kernel;
|
||||
|
||||
/// Scheduler service that runs in the background and executes scheduled triggers
|
||||
pub struct SchedulerService {
|
||||
kernel: Arc<RwLock<Option<Kernel>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
check_interval: Duration,
|
||||
}
|
||||
|
||||
impl SchedulerService {
|
||||
/// Create a new scheduler service
|
||||
pub fn new(kernel: Arc<RwLock<Option<Kernel>>>, check_interval_secs: u64) -> Self {
|
||||
Self {
|
||||
kernel,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
check_interval: Duration::from_secs(check_interval_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the scheduler loop in the background
|
||||
pub fn start(&self) {
|
||||
if self.running.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
tracing::warn!("[Scheduler] Already running, ignoring start request");
|
||||
return;
|
||||
}
|
||||
|
||||
let kernel = self.kernel.clone();
|
||||
let running = self.running.clone();
|
||||
let interval = self.check_interval;
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("[Scheduler] Starting scheduler loop with {}s interval", interval.as_secs());
|
||||
|
||||
let mut ticker = time::interval(interval);
|
||||
// First tick fires immediately — skip it
|
||||
ticker.tick().await;
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
ticker.tick().await;
|
||||
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(e) = Self::check_and_fire_scheduled_triggers(&kernel).await {
|
||||
tracing::error!("[Scheduler] Error checking triggers: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[Scheduler] Scheduler loop stopped");
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the scheduler loop
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
tracing::info!("[Scheduler] Stop requested");
|
||||
}
|
||||
|
||||
/// Check if the scheduler is running
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check all scheduled triggers and fire those that are due
|
||||
async fn check_and_fire_scheduled_triggers(
|
||||
kernel_lock: &Arc<RwLock<Option<Kernel>>>,
|
||||
) -> Result<()> {
|
||||
let kernel_read = kernel_lock.read().await;
|
||||
let kernel = match kernel_read.as_ref() {
|
||||
Some(k) => k,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Get all triggers
|
||||
let triggers = kernel.list_triggers().await;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Filter to enabled Schedule triggers
|
||||
let scheduled: Vec<_> = triggers.iter()
|
||||
.filter(|t| {
|
||||
t.config.enabled && matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })
|
||||
})
|
||||
.collect();
|
||||
|
||||
if scheduled.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!("[Scheduler] Checking {} scheduled triggers", scheduled.len());
|
||||
|
||||
// Drop the read lock before executing
|
||||
let to_execute: Vec<(String, String, String)> = scheduled.iter()
|
||||
.filter_map(|t| {
|
||||
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
|
||||
// Simple cron matching: check if we should fire now
|
||||
if Self::should_fire_cron(cron, &now) {
|
||||
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
drop(kernel_read);
|
||||
|
||||
// Execute due triggers (with write lock since execute_hand may need it)
|
||||
for (trigger_id, hand_id, cron_expr) in to_execute {
|
||||
tracing::info!(
|
||||
"[Scheduler] Firing scheduled trigger '{}' → hand '{}' (cron: {})",
|
||||
trigger_id, hand_id, cron_expr
|
||||
);
|
||||
|
||||
let kernel_read = kernel_lock.read().await;
|
||||
if let Some(kernel) = kernel_read.as_ref() {
|
||||
let trigger_source = zclaw_types::TriggerSource::Scheduled {
|
||||
trigger_id: trigger_id.clone(),
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": "schedule",
|
||||
"cron": cron_expr,
|
||||
"fired_at": now.to_rfc3339(),
|
||||
});
|
||||
|
||||
match kernel.execute_hand_with_source(&hand_id, input, trigger_source).await {
|
||||
Ok((_result, run_id)) => {
|
||||
tracing::info!(
|
||||
"[Scheduler] Successfully fired trigger '{}' → run {}",
|
||||
trigger_id, run_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[Scheduler] Failed to execute trigger '{}': {}",
|
||||
trigger_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple cron expression matcher
|
||||
///
|
||||
/// Supports basic cron format: `minute hour day month weekday`
|
||||
/// Also supports interval shorthand: `every:Ns`, `every:Nm`, `every:Nh`
|
||||
fn should_fire_cron(cron: &str, now: &chrono::DateTime<chrono::Utc>) -> bool {
|
||||
let cron = cron.trim();
|
||||
|
||||
// Handle interval shorthand: "every:30s", "every:5m", "every:1h"
|
||||
if let Some(interval_str) = cron.strip_prefix("every:") {
|
||||
return Self::check_interval_shorthand(interval_str, now);
|
||||
}
|
||||
|
||||
// Handle ISO timestamp for one-shot: "2026-03-29T10:00:00Z"
|
||||
if cron.contains('T') && cron.contains('-') {
|
||||
if let Ok(target) = chrono::DateTime::parse_from_rfc3339(cron) {
|
||||
let target_utc = target.with_timezone(&chrono::Utc);
|
||||
// Fire if within the check window (± check_interval/2, approx 30s)
|
||||
let diff = (*now - target_utc).num_seconds().abs();
|
||||
return diff <= 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard 5-field cron: minute hour day_of_month month day_of_week
|
||||
let parts: Vec<&str> = cron.split_whitespace().collect();
|
||||
if parts.len() != 5 {
|
||||
tracing::warn!("[Scheduler] Invalid cron expression (expected 5 fields): '{}'", cron);
|
||||
return false;
|
||||
}
|
||||
|
||||
let minute = now.minute() as i32;
|
||||
let hour = now.hour() as i32;
|
||||
let day = now.day() as i32;
|
||||
let month = now.month() as i32;
|
||||
let weekday = now.weekday().num_days_from_monday() as i32; // Mon=0..Sun=6
|
||||
|
||||
Self::cron_field_matches(parts[0], minute)
|
||||
&& Self::cron_field_matches(parts[1], hour)
|
||||
&& Self::cron_field_matches(parts[2], day)
|
||||
&& Self::cron_field_matches(parts[3], month)
|
||||
&& Self::cron_field_matches(parts[4], weekday)
|
||||
}
|
||||
|
||||
/// Check if a single cron field matches the current value
|
||||
fn cron_field_matches(field: &str, value: i32) -> bool {
|
||||
if field == "*" || field == "?" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle step: */N
|
||||
if let Some(step_str) = field.strip_prefix("*/") {
|
||||
if let Ok(step) = step_str.parse::<i32>() {
|
||||
if step > 0 {
|
||||
return value % step == 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle range: N-M
|
||||
if field.contains('-') {
|
||||
let range_parts: Vec<&str> = field.split('-').collect();
|
||||
if range_parts.len() == 2 {
|
||||
if let (Ok(start), Ok(end)) = (range_parts[0].parse::<i32>(), range_parts[1].parse::<i32>()) {
|
||||
return value >= start && value <= end;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle list: N,M,O
|
||||
if field.contains(',') {
|
||||
return field.split(',').any(|part| {
|
||||
part.trim().parse::<i32>().map(|p| p == value).unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Simple value
|
||||
field.parse::<i32>().map(|p| p == value).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check interval shorthand expressions
|
||||
fn check_interval_shorthand(interval: &str, now: &chrono::DateTime<chrono::Utc>) -> bool {
|
||||
let (num_str, unit) = if interval.ends_with('s') {
|
||||
(&interval[..interval.len()-1], 's')
|
||||
} else if interval.ends_with('m') {
|
||||
(&interval[..interval.len()-1], 'm')
|
||||
} else if interval.ends_with('h') {
|
||||
(&interval[..interval.len()-1], 'h')
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let num: i64 = match num_str.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if num <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let interval_secs = match unit {
|
||||
's' => num,
|
||||
'm' => num * 60,
|
||||
'h' => num * 3600,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Check if current timestamp aligns with the interval
|
||||
let timestamp = now.timestamp();
|
||||
timestamp % interval_secs == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Timelike;
|
||||
|
||||
#[test]
|
||||
fn test_cron_field_wildcard() {
|
||||
assert!(SchedulerService::cron_field_matches("*", 5));
|
||||
assert!(SchedulerService::cron_field_matches("?", 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_field_exact() {
|
||||
assert!(SchedulerService::cron_field_matches("5", 5));
|
||||
assert!(!SchedulerService::cron_field_matches("5", 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_field_step() {
|
||||
assert!(SchedulerService::cron_field_matches("*/5", 0));
|
||||
assert!(SchedulerService::cron_field_matches("*/5", 5));
|
||||
assert!(SchedulerService::cron_field_matches("*/5", 10));
|
||||
assert!(!SchedulerService::cron_field_matches("*/5", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_field_range() {
|
||||
assert!(SchedulerService::cron_field_matches("1-5", 1));
|
||||
assert!(SchedulerService::cron_field_matches("1-5", 3));
|
||||
assert!(SchedulerService::cron_field_matches("1-5", 5));
|
||||
assert!(!SchedulerService::cron_field_matches("1-5", 0));
|
||||
assert!(!SchedulerService::cron_field_matches("1-5", 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_field_list() {
|
||||
assert!(SchedulerService::cron_field_matches("1,3,5", 1));
|
||||
assert!(SchedulerService::cron_field_matches("1,3,5", 3));
|
||||
assert!(SchedulerService::cron_field_matches("1,3,5", 5));
|
||||
assert!(!SchedulerService::cron_field_matches("1,3,5", 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fire_every_minute() {
|
||||
let now = chrono::Utc::now();
|
||||
assert!(SchedulerService::should_fire_cron("every:1m", &now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fire_cron_wildcard() {
|
||||
let now = chrono::Utc::now();
|
||||
// Every minute match
|
||||
assert!(SchedulerService::should_fire_cron(
|
||||
&format!("{} * * * *", now.minute()),
|
||||
&now,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_fire_cron() {
|
||||
let now = chrono::Utc::now();
|
||||
let wrong_minute = if now.minute() < 59 { now.minute() + 1 } else { 0 };
|
||||
assert!(!SchedulerService::should_fire_cron(
|
||||
&format!("{} * * * *", wrong_minute),
|
||||
&now,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user