//! Timer service — deferred agent start via one-shot timers. //! //! Provides [`TimerStore`] for persisting timers to `.huskies/timers.json` //! and command parsing / handling for the `timer` bot command. //! Due timers are fired by the unified background tick loop in `main`. //! //! Follows service-module conventions: //! - `mod.rs` (this file) — public API, typed [`Error`], orchestration //! - `io.rs` — the ONLY place that performs side effects (filesystem, clock, spawn) //! - `parse.rs` — pure: command parsing, time display formatting //! - `persist.rs` — pure: serialisation/deserialisation of `timers.json` //! - `schedule.rs` — pure: next-fire-time calculation given a reference instant pub(super) mod io; pub(super) mod parse; pub(super) mod persist; pub(super) mod schedule; pub use io::{TimerStore, next_occurrence_of_hhmm, spawn_rate_limit_auto_scheduler, tick_once}; pub use parse::{TimerCommand, extract_timer_command}; pub use persist::TimerEntry; use std::path::Path; // ── Error type ──────────────────────────────────────────────────────────────── /// Typed errors returned by `service::timer` operations. /// /// HTTP handlers and bot commands may map these to user-facing messages: /// - [`Error::Parse`] → "Invalid time format" /// - [`Error::DuplicateSchedule`] → "Timer already scheduled" /// - [`Error::NoSuchSchedule`] → "No timer found" /// - [`Error::Io`] → "Internal error saving timer" #[derive(Debug)] #[allow(dead_code)] pub enum Error { /// The supplied `HH:MM` string could not be parsed or is out of range. Parse(String), /// A timer already exists for the given story ID. DuplicateSchedule(String), /// No timer exists for the given story ID. NoSuchSchedule(String), /// A filesystem read or write operation failed. Io(String), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Parse(msg) => write!(f, "Parse error: {msg}"), Self::DuplicateSchedule(id) => { write!(f, "Timer already exists for story '{id}'") } Self::NoSuchSchedule(id) => write!(f, "No timer found for story '{id}'"), Self::Io(msg) => write!(f, "I/O error: {msg}"), } } } // ── Typed public API ────────────────────────────────────────────────────────── /// Schedule a new timer for `story_id` to fire at the next `HH:MM` occurrence. #[allow(dead_code)] /// /// Returns the scheduled UTC instant on success. /// /// # Errors /// - [`Error::DuplicateSchedule`] if a timer already exists for `story_id`. /// - [`Error::Parse`] if `hhmm` is not a valid `HH:MM` string. /// - [`Error::Io`] if persisting the timer to disk fails. pub fn schedule_timer( store: &TimerStore, story_id: &str, hhmm: &str, timezone: Option<&str>, ) -> Result, Error> { if store.list().iter().any(|t| t.story_id == story_id) { return Err(Error::DuplicateSchedule(story_id.to_string())); } let scheduled_at = next_occurrence_of_hhmm(hhmm, timezone) .ok_or_else(|| Error::Parse(format!("invalid HH:MM: '{hhmm}'")))?; store .add(story_id.to_string(), scheduled_at) .map_err(Error::Io)?; Ok(scheduled_at) } /// Cancel an existing timer for `story_id`. #[allow(dead_code)] /// /// # Errors /// - [`Error::NoSuchSchedule`] if no timer exists for `story_id`. pub fn cancel_timer(store: &TimerStore, story_id: &str) -> Result<(), Error> { if store.remove(story_id) { Ok(()) } else { Err(Error::NoSuchSchedule(story_id.to_string())) } } // ── Command handler ──────────────────────────────────────────────────────────── /// Handle a parsed `timer` command. Returns a markdown-formatted response. pub async fn handle_timer_command( cmd: TimerCommand, store: &TimerStore, project_root: &Path, ) -> String { // Load the configured timezone (if any) from project.toml. let config_tz: Option = crate::config::ProjectConfig::load(project_root) .ok() .and_then(|c| c.timezone); let tz_str: Option<&str> = config_tz.as_deref(); match cmd { TimerCommand::Schedule { story_number_or_id, hhmm, } => { let story_id = match io::resolve_story_id(&story_number_or_id, project_root) { Some(id) => id, None => { return format!("No story with number or ID **{story_number_or_id}** found."); } }; // The story must be in backlog or current. When the timer fires, // backlog stories are moved to current automatically. // Check CRDT state first, then fall back to filesystem. let in_valid_stage = if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) { use crate::pipeline_state::Stage; matches!(item.stage, Stage::Backlog | Stage::Coding) } else { let work_dir = project_root.join(".huskies").join("work"); work_dir .join("1_backlog") .join(format!("{story_id}.md")) .exists() || work_dir .join("2_current") .join(format!("{story_id}.md")) .exists() }; if !in_valid_stage { return format!("Story **{story_id}** is not in backlog or current."); } let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) { Some(t) => t, None => { return format!("Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`)."); } }; match store.add(story_id.clone(), scheduled_at) { Ok(()) => { let (display_time, tz_label) = parse::format_in_timezone(scheduled_at, tz_str); format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).") } Err(e) => format!("Failed to save timer: {e}"), } } TimerCommand::List => { let timers = store.list(); if timers.is_empty() { return "No pending timers.".to_string(); } let mut lines = vec!["**Pending timers:**".to_string()]; for t in &timers { let (display_time, _) = parse::format_in_timezone(t.scheduled_at, tz_str); lines.push(format!("- **{}** → {}", t.story_id, display_time)); } lines.join("\n") } TimerCommand::Cancel { story_number_or_id } => { let story_id = io::resolve_story_id(&story_number_or_id, project_root) .unwrap_or(story_number_or_id.clone()); if store.remove(&story_id) { format!("Timer for **{story_id}** cancelled.") } else { format!("No timer found for **{story_id}**.") } } TimerCommand::BadArgs => "Usage:\n\ - `timer ` — schedule deferred start\n\ - `timer list` — show pending timers\n\ - `timer cancel ` — remove a timer" .to_string(), } } // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use chrono::{Duration, Utc}; use tempfile::TempDir; // ── Error Display ───────────────────────────────────────────────────────── #[test] fn error_parse_display() { let e = Error::Parse("bad value".to_string()); assert!(e.to_string().contains("Parse error")); assert!(e.to_string().contains("bad value")); } #[test] fn error_duplicate_schedule_display() { let e = Error::DuplicateSchedule("421_story_foo".to_string()); assert!(e.to_string().contains("already exists")); assert!(e.to_string().contains("421_story_foo")); } #[test] fn error_no_such_schedule_display() { let e = Error::NoSuchSchedule("421_story_foo".to_string()); assert!(e.to_string().contains("No timer found")); assert!(e.to_string().contains("421_story_foo")); } #[test] fn error_io_display() { let e = Error::Io("disk full".to_string()); assert!(e.to_string().contains("I/O error")); assert!(e.to_string().contains("disk full")); } // ── schedule_timer ───────────────────────────────────────────────────── #[test] fn schedule_timer_returns_duplicate_when_already_exists() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let t = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), t).unwrap(); let result = schedule_timer(&store, "421_story_foo", "14:30", None); assert!( matches!(result, Err(Error::DuplicateSchedule(_))), "expected DuplicateSchedule: {result:?}" ); } #[test] fn schedule_timer_returns_parse_error_for_bad_hhmm() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = schedule_timer(&store, "421_story_foo", "99:99", None); assert!( matches!(result, Err(Error::Parse(_))), "expected Parse error: {result:?}" ); } // ── cancel_timer ─────────────────────────────────────────────────────── #[test] fn cancel_timer_returns_no_such_when_missing() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = cancel_timer(&store, "421_story_foo"); assert!( matches!(result, Err(Error::NoSuchSchedule(_))), "expected NoSuchSchedule: {result:?}" ); } #[test] fn cancel_timer_succeeds_when_exists() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let t = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), t).unwrap(); assert!(cancel_timer(&store, "421_story_foo").is_ok()); assert!(store.list().is_empty()); } // ── handle_timer_command ──────────────────────────────────────────── #[tokio::test] async fn handle_list_empty() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await; assert!(result.contains("No pending timers"), "unexpected: {result}"); } #[tokio::test] async fn handle_cancel_not_found() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Cancel { story_number_or_id: "421_story_foo".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("No timer found"), "unexpected: {result}"); } #[tokio::test] async fn handle_schedule_story_not_in_backlog_or_current() { let dir = TempDir::new().unwrap(); // Ensure CRDT content store is initialised so the DB-first lookup works. crate::db::ensure_content_store(); // No story written — "9950_story_timer_neg" should not be found. let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "9950_story_timer_neg".to_string(), hhmm: "14:30".to_string(), }, &store, dir.path(), ) .await; assert!( result.contains("not in backlog or current"), "unexpected: {result}" ); } #[tokio::test] async fn handle_schedule_accepts_backlog_story() { let dir = TempDir::new().unwrap(); let backlog_dir = dir.path().join(".huskies/work/1_backlog"); std::fs::create_dir_all(&backlog_dir).unwrap(); std::fs::write( backlog_dir.join("421_story_foo.md"), "---\nname: Foo\n---\n", ) .unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "14:30".to_string(), }, &store, dir.path(), ) .await; assert!( result.contains("Timer set"), "backlog story should be accepted: {result}" ); } #[tokio::test] async fn handle_schedule_success() { let dir = TempDir::new().unwrap(); let current_dir = dir.path().join(".huskies/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "23:59".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("Timer set for"), "unexpected: {result}"); assert_eq!(store.list().len(), 1); } #[tokio::test] async fn handle_schedule_invalid_time() { let dir = TempDir::new().unwrap(); let current_dir = dir.path().join(".huskies/work/2_current"); std::fs::create_dir_all(¤t_dir).unwrap(); std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let result = handle_timer_command( TimerCommand::Schedule { story_number_or_id: "421_story_foo".to_string(), hhmm: "99:00".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("Invalid time"), "unexpected: {result}"); } #[tokio::test] async fn handle_cancel_existing_timer() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let future = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), future).unwrap(); let result = handle_timer_command( TimerCommand::Cancel { story_number_or_id: "421_story_foo".to_string(), }, &store, dir.path(), ) .await; assert!(result.contains("cancelled"), "unexpected: {result}"); assert!(store.list().is_empty()); } #[tokio::test] async fn handle_list_with_entries() { let dir = TempDir::new().unwrap(); let store = TimerStore::load(dir.path().join("timers.json")); let future = Utc::now() + Duration::hours(1); store.add("421_story_foo".to_string(), future).unwrap(); let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await; assert!(result.contains("421_story_foo"), "unexpected: {result}"); assert!(result.contains("Pending timers"), "unexpected: {result}"); } // ── firing a timer for a backlog story moves it to current ─────────── /// When a timer fires for a story in backlog, the tick loop calls /// `move_story_to_current` before `start_agent`. This test exercises /// that exact sequence (minus the agent pool) to prove the story ends /// up in `2_current/` after firing. #[test] fn fired_timer_for_backlog_story_moves_to_current() { use std::fs; let dir = TempDir::new().unwrap(); let root = dir.path(); let backlog = root.join(".huskies/work/1_backlog"); let current = root.join(".huskies/work/2_current"); fs::create_dir_all(&backlog).unwrap(); fs::create_dir_all(¤t).unwrap(); let content = "---\nname: Foo\n---\n"; fs::write(backlog.join("421_story_foo.md"), content).unwrap(); crate::db::ensure_content_store(); crate::db::write_content(crate::db::ContentKey::Story("421_story_foo"), content); // Add a past timer so take_due returns it immediately. let store = TimerStore::load(root.join("timers.json")); let past = Utc::now() - Duration::seconds(1); store.add("421_story_foo".to_string(), past).unwrap(); // Drain due timers — same as the tick loop does. let due = store.take_due(Utc::now()); assert_eq!(due.len(), 1, "expected one fired timer"); // Apply the move-to-current step the tick loop performs. for entry in &due { crate::agents::lifecycle::move_story_to_current(&entry.story_id) .expect("move_story_to_current should succeed for backlog story"); } // Story must still be accessible in the content store after the move. assert!( crate::db::read_content(crate::db::ContentKey::Story("421_story_foo")).is_some(), "story should be in the content store after timer fires" ); // Timer was consumed. assert!( store.list().is_empty(), "fired timer should be removed from store" ); } }