1use crate::{OrchestratorError, PromptRequest, SessionId};
65use chrono::{Local, Timelike};
66use serde::{Deserialize, Serialize};
67use std::collections::HashMap;
68use std::sync::Arc;
69use tokio::sync::mpsc;
70use tokio::sync::RwLock;
71use tokio::task::JoinHandle;
72use tracing::{debug, error, info, warn};
73use uuid::Uuid;
74
75#[derive(Debug, Clone, PartialEq, Eq)]
81enum CronField {
82 Any,
84 Every(u32),
86 Exact(u32),
88}
89
90impl CronField {
91 fn parse(token: &str, max: u32) -> Result<Self, String> {
97 if token == "*" {
98 return Ok(Self::Any);
99 }
100 if let Some(rest) = token.strip_prefix("*/") {
101 let n: u32 = rest
102 .parse()
103 .map_err(|_| format!("invalid step value in '{token}'"))?;
104 if n == 0 || n > max {
105 return Err(format!("step value {n} out of range (1..={max})"));
106 }
107 return Ok(Self::Every(n));
108 }
109 let v: u32 = token
110 .parse()
111 .map_err(|_| format!("invalid numeric value '{token}'"))?;
112 if v > max {
113 return Err(format!("value {v} out of range (0..={max})"));
114 }
115 Ok(Self::Exact(v))
116 }
117
118 fn matches(&self, value: u32) -> bool {
120 match self {
121 Self::Any => true,
122 Self::Every(n) => value % n == 0,
123 Self::Exact(v) => *v == value,
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CronExpression {
137 minute: CronField,
138 hour: CronField,
139 raw: String,
141}
142
143impl CronExpression {
144 pub fn parse(expr: &str) -> Result<Self, OrchestratorError> {
161 let parts: Vec<&str> = expr.split_whitespace().collect();
162 if parts.len() != 2 {
163 return Err(OrchestratorError::ConfigError(format!(
164 "cron expression must have exactly 2 fields (minute hour), got '{expr}'"
165 )));
166 }
167 let minute = CronField::parse(parts[0], 59).map_err(|e| {
168 OrchestratorError::ConfigError(format!("minute field: {e}"))
169 })?;
170 let hour = CronField::parse(parts[1], 23).map_err(|e| {
171 OrchestratorError::ConfigError(format!("hour field: {e}"))
172 })?;
173 Ok(Self {
174 minute,
175 hour,
176 raw: expr.to_string(),
177 })
178 }
179
180 pub fn matches(&self, hour: u32, minute: u32) -> bool {
186 self.hour.matches(hour) && self.minute.matches(minute)
187 }
188
189 pub fn as_str(&self) -> &str {
195 &self.raw
196 }
197}
198
199impl std::fmt::Display for CronExpression {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.write_str(&self.raw)
202 }
203}
204
205impl Serialize for CronExpression {
206 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
207 s.serialize_str(&self.raw)
208 }
209}
210
211impl<'de> Deserialize<'de> for CronExpression {
212 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
213 let s = String::deserialize(d)?;
214 CronExpression::parse(&s).map_err(serde::de::Error::custom)
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct ScheduledPrompt {
229 pub id: String,
231 pub name: String,
233 pub schedule: CronExpression,
235 pub prompt_template: String,
237 pub session_id: Option<String>,
239 pub enabled: bool,
241 #[serde(default)]
243 pub metadata: HashMap<String, String>,
244}
245
246impl ScheduledPrompt {
247 pub fn new(
264 name: impl Into<String>,
265 cron_expr: &str,
266 prompt_template: impl Into<String>,
267 ) -> Result<Self, OrchestratorError> {
268 let schedule = CronExpression::parse(cron_expr)?;
269 Ok(Self {
270 id: Uuid::new_v4().to_string(),
271 name: name.into(),
272 schedule,
273 prompt_template: prompt_template.into(),
274 session_id: None,
275 enabled: true,
276 metadata: HashMap::new(),
277 })
278 }
279
280 #[must_use]
286 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
287 self.session_id = Some(session_id.into());
288 self
289 }
290
291 #[must_use]
297 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
298 self.metadata.insert(key.into(), value.into());
299 self
300 }
301
302 #[must_use]
308 pub fn disabled(mut self) -> Self {
309 self.enabled = false;
310 self
311 }
312}
313
314pub struct Scheduler {
333 prompts: RwLock<Vec<ScheduledPrompt>>,
334 tx: mpsc::Sender<PromptRequest>,
335}
336
337impl Scheduler {
338 pub fn new(tx: mpsc::Sender<PromptRequest>) -> Self {
344 Self {
345 prompts: RwLock::new(Vec::new()),
346 tx,
347 }
348 }
349
350 pub async fn add(&self, prompt: ScheduledPrompt) -> Result<String, OrchestratorError> {
365 let id = prompt.id.clone();
366 info!(
367 id = %id,
368 name = %prompt.name,
369 schedule = %prompt.schedule,
370 enabled = prompt.enabled,
371 "registered scheduled prompt"
372 );
373 self.prompts.write().await.push(prompt);
374 Ok(id)
375 }
376
377 pub async fn remove(&self, id: &str) -> bool {
386 let mut prompts = self.prompts.write().await;
387 let before = prompts.len();
388 prompts.retain(|p| p.id != id);
389 let removed = prompts.len() < before;
390 if removed {
391 info!(id = %id, "removed scheduled prompt");
392 } else {
393 warn!(id = %id, "remove_scheduled_prompt: id not found");
394 }
395 removed
396 }
397
398 pub async fn list(&self) -> Vec<ScheduledPrompt> {
404 self.prompts.read().await.clone()
405 }
406
407 pub async fn set_enabled(&self, id: &str, enabled: bool) -> bool {
415 let mut prompts = self.prompts.write().await;
416 if let Some(p) = prompts.iter_mut().find(|p| p.id == id) {
417 p.enabled = enabled;
418 info!(id = %id, enabled = enabled, "updated scheduled prompt state");
419 true
420 } else {
421 false
422 }
423 }
424
425 pub fn spawn(self: &Arc<Self>) -> JoinHandle<()> {
436 let scheduler = Arc::clone(self);
437 tokio::spawn(async move {
438 info!("Scheduler started");
439 loop {
440 let now = Local::now();
442 let secs_into_minute = now.second() as u64;
443 let nanos_remaining = now.nanosecond() as u64;
444 let sleep_ms = (60 - secs_into_minute) * 1000
445 + if nanos_remaining > 0 { 1 } else { 0 };
446
447 debug!(sleep_ms = sleep_ms, "scheduler sleeping until next minute");
448 tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await;
449
450 let fire_time = Local::now();
452 let hour = fire_time.hour();
453 let minute = fire_time.minute();
454
455 debug!(hour = hour, minute = minute, "scheduler tick");
456
457 let prompts = scheduler.prompts.read().await;
458 for prompt in prompts.iter() {
459 if !prompt.enabled {
460 continue;
461 }
462 if !prompt.schedule.matches(hour, minute) {
463 continue;
464 }
465 let session = prompt
466 .session_id
467 .clone()
468 .unwrap_or_else(|| Uuid::new_v4().to_string());
469 let request_id = Uuid::new_v4().to_string();
470 let req = PromptRequest {
471 session: SessionId::new(session),
472 request_id: request_id.clone(),
473 input: prompt.prompt_template.clone(),
474 meta: prompt.metadata.clone(),
475 deadline: None,
476 };
477 match scheduler.tx.try_send(req) {
478 Ok(_) => {
479 info!(
480 scheduled_prompt = %prompt.name,
481 request_id = %request_id,
482 "submitted scheduled prompt"
483 );
484 }
485 Err(mpsc::error::TrySendError::Full(_)) => {
486 warn!(
487 scheduled_prompt = %prompt.name,
488 "pipeline input channel full; skipping scheduled prompt"
489 );
490 }
491 Err(mpsc::error::TrySendError::Closed(_)) => {
492 error!("pipeline input channel closed; stopping scheduler");
493 return;
494 }
495 }
496 }
497 }
498 })
499 }
500}
501
502#[derive(Clone)]
521pub struct SchedulerState {
522 pub scheduler: Arc<Scheduler>,
523}
524
525impl SchedulerState {
526 pub fn new(scheduler: Arc<Scheduler>) -> Self {
532 Self { scheduler }
533 }
534}
535
536#[cfg(feature = "web-api")]
542#[derive(Debug, Deserialize)]
543pub struct CreateScheduleRequest {
544 pub name: String,
546 pub schedule: String,
548 pub prompt_template: String,
550 #[serde(default)]
552 pub session_id: Option<String>,
553 #[serde(default)]
555 pub metadata: HashMap<String, String>,
556 #[serde(default = "default_enabled")]
558 pub enabled: bool,
559}
560
561#[cfg(feature = "web-api")]
562fn default_enabled() -> bool {
563 true
564}
565
566#[cfg(feature = "web-api")]
568#[derive(Debug, Serialize)]
569pub struct ScheduleResponse {
570 pub id: String,
571 pub name: String,
572 pub schedule: String,
573 pub enabled: bool,
574 pub prompt_preview: String,
575}
576
577#[cfg(feature = "web-api")]
578impl From<&ScheduledPrompt> for ScheduleResponse {
579 fn from(p: &ScheduledPrompt) -> Self {
580 Self {
581 id: p.id.clone(),
582 name: p.name.clone(),
583 schedule: p.schedule.to_string(),
584 enabled: p.enabled,
585 prompt_preview: p.prompt_template.chars().take(80).collect(),
586 }
587 }
588}
589
590#[cfg(feature = "web-api")]
611pub fn scheduler_routes(state: SchedulerState) -> axum::Router {
612 use axum::routing::{delete, get, patch, post};
613
614 axum::Router::new()
615 .route("/api/v1/schedule", post(create_schedule_handler))
616 .route("/api/v1/schedule", get(list_schedules_handler))
617 .route("/api/v1/schedule/:id", delete(delete_schedule_handler))
618 .route("/api/v1/schedule/:id/enable", patch(enable_schedule_handler))
619 .route(
620 "/api/v1/schedule/:id/disable",
621 patch(disable_schedule_handler),
622 )
623 .with_state(state)
624}
625
626#[cfg(feature = "web-api")]
628async fn create_schedule_handler(
629 axum::extract::State(state): axum::extract::State<SchedulerState>,
630 axum::Json(body): axum::Json<CreateScheduleRequest>,
631) -> axum::response::Response {
632 use axum::{
633 http::StatusCode,
634 response::IntoResponse,
635 Json,
636 };
637
638 let mut prompt = match ScheduledPrompt::new(&body.name, &body.schedule, &body.prompt_template) {
639 Ok(p) => p,
640 Err(e) => {
641 return (
642 StatusCode::UNPROCESSABLE_ENTITY,
643 Json(serde_json::json!({"error": e.to_string()})),
644 )
645 .into_response();
646 }
647 };
648
649 prompt.session_id = body.session_id;
650 prompt.metadata = body.metadata;
651 if !body.enabled {
652 prompt.enabled = false;
653 }
654
655 let resp = ScheduleResponse::from(&prompt);
656
657 match state.scheduler.add(prompt).await {
658 Ok(_) => (StatusCode::CREATED, Json(resp)).into_response(),
659 Err(e) => (
660 StatusCode::INTERNAL_SERVER_ERROR,
661 Json(serde_json::json!({"error": e.to_string()})),
662 )
663 .into_response(),
664 }
665}
666
667#[cfg(feature = "web-api")]
669async fn list_schedules_handler(
670 axum::extract::State(state): axum::extract::State<SchedulerState>,
671) -> axum::Json<Vec<ScheduleResponse>> {
672 let prompts = state.scheduler.list().await;
673 axum::Json(prompts.iter().map(ScheduleResponse::from).collect())
674}
675
676#[cfg(feature = "web-api")]
678async fn delete_schedule_handler(
679 axum::extract::State(state): axum::extract::State<SchedulerState>,
680 axum::extract::Path(id): axum::extract::Path<String>,
681) -> axum::response::Response {
682 use axum::{http::StatusCode, response::IntoResponse, Json};
683
684 if state.scheduler.remove(&id).await {
685 (StatusCode::OK, Json(serde_json::json!({"deleted": id}))).into_response()
686 } else {
687 (
688 StatusCode::NOT_FOUND,
689 Json(serde_json::json!({"error": "scheduled prompt not found", "id": id})),
690 )
691 .into_response()
692 }
693}
694
695#[cfg(feature = "web-api")]
697async fn enable_schedule_handler(
698 axum::extract::State(state): axum::extract::State<SchedulerState>,
699 axum::extract::Path(id): axum::extract::Path<String>,
700) -> axum::response::Response {
701 toggle_enabled(state, id, true).await
702}
703
704#[cfg(feature = "web-api")]
706async fn disable_schedule_handler(
707 axum::extract::State(state): axum::extract::State<SchedulerState>,
708 axum::extract::Path(id): axum::extract::Path<String>,
709) -> axum::response::Response {
710 toggle_enabled(state, id, false).await
711}
712
713#[cfg(feature = "web-api")]
714async fn toggle_enabled(
715 state: SchedulerState,
716 id: String,
717 enabled: bool,
718) -> axum::response::Response {
719 use axum::{http::StatusCode, response::IntoResponse, Json};
720
721 if state.scheduler.set_enabled(&id, enabled).await {
722 (
723 StatusCode::OK,
724 Json(serde_json::json!({"id": id, "enabled": enabled})),
725 )
726 .into_response()
727 } else {
728 (
729 StatusCode::NOT_FOUND,
730 Json(serde_json::json!({"error": "scheduled prompt not found", "id": id})),
731 )
732 .into_response()
733 }
734}
735
736#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn cron_field_parse_any() {
746 let f = CronField::parse("*", 59).expect("should parse");
747 assert_eq!(f, CronField::Any);
748 assert!(f.matches(0));
749 assert!(f.matches(30));
750 assert!(f.matches(59));
751 }
752
753 #[test]
754 fn cron_field_parse_every() {
755 let f = CronField::parse("*/5", 59).expect("should parse");
756 assert_eq!(f, CronField::Every(5));
757 assert!(f.matches(0));
758 assert!(f.matches(5));
759 assert!(f.matches(15));
760 assert!(!f.matches(7));
761 }
762
763 #[test]
764 fn cron_field_parse_exact() {
765 let f = CronField::parse("30", 59).expect("should parse");
766 assert_eq!(f, CronField::Exact(30));
767 assert!(f.matches(30));
768 assert!(!f.matches(31));
769 }
770
771 #[test]
772 fn cron_field_invalid_step_zero() {
773 assert!(CronField::parse("*/0", 59).is_err());
774 }
775
776 #[test]
777 fn cron_field_out_of_range() {
778 assert!(CronField::parse("60", 59).is_err());
779 }
780
781 #[test]
782 fn cron_expression_parse_valid() {
783 let expr = CronExpression::parse("*/5 *").expect("valid");
784 assert!(expr.matches(0, 0));
785 assert!(expr.matches(0, 5));
786 assert!(!expr.matches(0, 3));
787 }
788
789 #[test]
790 fn cron_expression_parse_exact_hour_minute() {
791 let expr = CronExpression::parse("30 9").expect("valid");
792 assert!(expr.matches(9, 30));
793 assert!(!expr.matches(9, 31));
794 assert!(!expr.matches(10, 30));
795 }
796
797 #[test]
798 fn cron_expression_every_minute() {
799 let expr = CronExpression::parse("* *").expect("valid");
800 assert!(expr.matches(0, 0));
801 assert!(expr.matches(23, 59));
802 }
803
804 #[test]
805 fn cron_expression_wrong_field_count() {
806 assert!(CronExpression::parse("*").is_err());
807 assert!(CronExpression::parse("* * *").is_err());
808 }
809
810 #[test]
811 fn scheduled_prompt_new() {
812 let sp = ScheduledPrompt::new("test", "*/10 *", "hello").expect("valid");
813 assert!(sp.enabled);
814 assert_eq!(sp.name, "test");
815 assert_eq!(sp.prompt_template, "hello");
816 assert!(!sp.id.is_empty());
817 }
818
819 #[test]
820 fn scheduled_prompt_builders() {
821 let sp = ScheduledPrompt::new("x", "0 9", "ping")
822 .expect("valid")
823 .with_session("my-session")
824 .with_metadata("env", "prod")
825 .disabled();
826 assert_eq!(sp.session_id.as_deref(), Some("my-session"));
827 assert_eq!(sp.metadata.get("env").map(String::as_str), Some("prod"));
828 assert!(!sp.enabled);
829 }
830
831 #[tokio::test]
832 async fn scheduler_add_list_remove() {
833 let (tx, _rx) = mpsc::channel(16);
834 let scheduler = Arc::new(Scheduler::new(tx));
835
836 let sp = ScheduledPrompt::new("test", "* *", "hello").expect("valid");
837 let id = scheduler.add(sp).await.expect("added");
838
839 let list = scheduler.list().await;
840 assert_eq!(list.len(), 1);
841 assert_eq!(list[0].id, id);
842
843 let removed = scheduler.remove(&id).await;
844 assert!(removed);
845
846 assert!(scheduler.list().await.is_empty());
847 }
848
849 #[tokio::test]
850 async fn scheduler_set_enabled() {
851 let (tx, _rx) = mpsc::channel(16);
852 let scheduler = Arc::new(Scheduler::new(tx));
853
854 let sp = ScheduledPrompt::new("test", "* *", "hello").expect("valid");
855 let id = scheduler.add(sp).await.expect("added");
856
857 assert!(scheduler.set_enabled(&id, false).await);
858 let list = scheduler.list().await;
859 assert!(!list[0].enabled);
860
861 assert!(scheduler.set_enabled(&id, true).await);
862 let list = scheduler.list().await;
863 assert!(list[0].enabled);
864 }
865
866 #[test]
867 fn cron_expression_serialises_to_string() {
868 let expr = CronExpression::parse("*/15 8").expect("valid");
869 let json = serde_json::to_string(&expr).expect("serialise");
870 assert_eq!(json, "\"*/15 8\"");
871 }
872
873 #[test]
874 fn cron_expression_deserialises_from_string() {
875 let expr: CronExpression = serde_json::from_str("\"0 9\"").expect("deserialise");
876 assert!(expr.matches(9, 0));
877 }
878}