2026-05-14 16:26:49 +00:00
|
|
|
//! Generic scheduled timers: fire MCP calls or prompts at a configured instant,
|
|
|
|
|
//! with optional recurring re-arm.
|
|
|
|
|
//!
|
|
|
|
|
//! Separate from [`crate::service::timer::TimerStore`] which handles story-scoped
|
|
|
|
|
//! rate-limit retry timers. This module provides a general-purpose scheduling
|
|
|
|
|
//! primitive with explicit action types and unique IDs.
|
|
|
|
|
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-14 20:08:09 +00:00
|
|
|
use sqlx::SqlitePool;
|
2026-05-14 16:26:49 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::Mutex;
|
2026-05-14 20:08:09 +00:00
|
|
|
use tokio::sync::mpsc;
|
2026-05-14 16:26:49 +00:00
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
// ── Action ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// What to execute when a scheduled timer fires.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
|
|
|
pub enum TimerAction {
|
|
|
|
|
/// Call an MCP tool by name with JSON arguments.
|
|
|
|
|
Mcp {
|
|
|
|
|
/// MCP tool name (e.g. `"start_agent"`, `"create_bug"`).
|
|
|
|
|
method: String,
|
|
|
|
|
/// JSON arguments object for the tool call.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
args: serde_json::Value,
|
|
|
|
|
},
|
|
|
|
|
/// Broadcast a reminder text to the server log.
|
|
|
|
|
Prompt {
|
|
|
|
|
/// Free-form reminder text logged when the timer fires.
|
|
|
|
|
text: String,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Mode ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Whether the timer fires once or repeatedly.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
#[serde(tag = "mode", rename_all = "snake_case")]
|
|
|
|
|
pub enum TimerMode {
|
|
|
|
|
/// Fire once, then discard.
|
|
|
|
|
Once,
|
|
|
|
|
/// Fire, then re-arm at `fire_at + interval_secs`.
|
|
|
|
|
Recurring {
|
|
|
|
|
/// Seconds between firings.
|
|
|
|
|
interval_secs: u64,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// A single generic scheduled timer entry.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
pub struct ScheduledTimer {
|
|
|
|
|
/// Unique stable identifier (UUID v4 short form, e.g. `"tm-a3f7b9c2"`).
|
|
|
|
|
pub id: String,
|
|
|
|
|
/// Optional human-readable label.
|
|
|
|
|
pub label: Option<String>,
|
|
|
|
|
/// UTC instant when this timer should next fire.
|
|
|
|
|
pub fire_at: DateTime<Utc>,
|
|
|
|
|
/// What to execute on fire.
|
|
|
|
|
pub action: TimerAction,
|
|
|
|
|
/// One-shot or recurring.
|
|
|
|
|
pub mode: TimerMode,
|
|
|
|
|
/// UTC instant when this timer was created.
|
|
|
|
|
pub created_at: DateTime<Utc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScheduledTimer {
|
|
|
|
|
/// Generate a fresh timer ID.
|
|
|
|
|
pub fn new_id() -> String {
|
|
|
|
|
let id = Uuid::new_v4();
|
|
|
|
|
let hex = id.as_simple().to_string();
|
|
|
|
|
let prefix: String = hex.chars().take(8).collect();
|
|
|
|
|
format!("tm-{prefix}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:08:09 +00:00
|
|
|
// ── Background writer ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
enum SchedWriteCmd {
|
|
|
|
|
Upsert(ScheduledTimer),
|
|
|
|
|
Delete(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn spawn_sched_writer(pool: SqlitePool, mut rx: mpsc::UnboundedReceiver<SchedWriteCmd>) {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
while let Some(cmd) = rx.recv().await {
|
|
|
|
|
match cmd {
|
|
|
|
|
SchedWriteCmd::Upsert(t) => {
|
|
|
|
|
let action_json = match serde_json::to_string(&t.action) {
|
|
|
|
|
Ok(j) => j,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!("[scheduled-timer] Serialize action failed: {e}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let mode_json = match serde_json::to_string(&t.mode) {
|
|
|
|
|
Ok(j) => j,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!("[scheduled-timer] Serialize mode failed: {e}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let result = sqlx::query(
|
|
|
|
|
"INSERT INTO scheduled_timers \
|
|
|
|
|
(id, label, fire_at, action_json, mode_json, created_at) \
|
|
|
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
|
|
|
|
|
ON CONFLICT(id) DO UPDATE SET \
|
|
|
|
|
label = excluded.label, \
|
|
|
|
|
fire_at = excluded.fire_at, \
|
|
|
|
|
action_json = excluded.action_json, \
|
|
|
|
|
mode_json = excluded.mode_json",
|
|
|
|
|
)
|
|
|
|
|
.bind(&t.id)
|
|
|
|
|
.bind(&t.label)
|
|
|
|
|
.bind(t.fire_at.to_rfc3339())
|
|
|
|
|
.bind(&action_json)
|
|
|
|
|
.bind(&mode_json)
|
|
|
|
|
.bind(t.created_at.to_rfc3339())
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await;
|
|
|
|
|
if let Err(e) = result {
|
|
|
|
|
crate::slog!("[scheduled-timer] DB upsert failed for '{}': {e}", t.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
SchedWriteCmd::Delete(id) => {
|
|
|
|
|
let result = sqlx::query("DELETE FROM scheduled_timers WHERE id = ?1")
|
|
|
|
|
.bind(&id)
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await;
|
|
|
|
|
if let Err(e) = result {
|
|
|
|
|
crate::slog!("[scheduled-timer] DB delete failed for '{id}': {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 16:26:49 +00:00
|
|
|
// ── Store ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-14 20:08:09 +00:00
|
|
|
enum SchedPersistence {
|
|
|
|
|
Sqlite(mpsc::UnboundedSender<SchedWriteCmd>),
|
|
|
|
|
Json(PathBuf),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Persistent store for generic scheduled timers, backed by SQLite in
|
|
|
|
|
/// production and a JSON file in tests.
|
2026-05-14 16:26:49 +00:00
|
|
|
pub struct ScheduledTimerStore {
|
|
|
|
|
timers: Mutex<Vec<ScheduledTimer>>,
|
2026-05-14 20:08:09 +00:00
|
|
|
persistence: SchedPersistence,
|
2026-05-14 16:26:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScheduledTimerStore {
|
2026-05-14 20:08:09 +00:00
|
|
|
/// Load from the shared SQLite pool. This is the production constructor.
|
|
|
|
|
pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
|
|
|
|
|
let rows: Vec<(String, Option<String>, String, String, String, String)> = sqlx::query_as(
|
|
|
|
|
"SELECT id, label, fire_at, action_json, mode_json, created_at \
|
|
|
|
|
FROM scheduled_timers",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let mut timers = Vec::with_capacity(rows.len());
|
|
|
|
|
for (id, label, fire_at_str, action_json, mode_json, created_at_str) in rows {
|
|
|
|
|
let fire_at = match fire_at_str.parse::<DateTime<Utc>>() {
|
|
|
|
|
Ok(t) => t,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!("[scheduled-timer] Bad fire_at for {id}: {e}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let action: TimerAction = match serde_json::from_str(&action_json) {
|
|
|
|
|
Ok(a) => a,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!("[scheduled-timer] Bad action for {id}: {e}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let mode: TimerMode = match serde_json::from_str(&mode_json) {
|
|
|
|
|
Ok(m) => m,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!("[scheduled-timer] Bad mode for {id}: {e}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let created_at = created_at_str
|
|
|
|
|
.parse::<DateTime<Utc>>()
|
|
|
|
|
.unwrap_or_else(|_| Utc::now());
|
|
|
|
|
timers.push(ScheduledTimer {
|
|
|
|
|
id,
|
|
|
|
|
label,
|
|
|
|
|
fire_at,
|
|
|
|
|
action,
|
|
|
|
|
mode,
|
|
|
|
|
created_at,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
|
|
|
spawn_sched_writer(pool, rx);
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
timers: Mutex::new(timers),
|
|
|
|
|
persistence: SchedPersistence::Sqlite(tx),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load (or create empty) store from a JSON file path. Used by unit tests.
|
2026-05-14 16:26:49 +00:00
|
|
|
pub fn load(path: PathBuf) -> Self {
|
|
|
|
|
let timers = if path.exists() {
|
|
|
|
|
std::fs::read_to_string(&path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| serde_json::from_str::<Vec<ScheduledTimer>>(&s).ok())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
};
|
|
|
|
|
Self {
|
|
|
|
|
timers: Mutex::new(timers),
|
2026-05-14 20:08:09 +00:00
|
|
|
persistence: SchedPersistence::Json(path),
|
2026-05-14 16:26:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:08:09 +00:00
|
|
|
fn save_json(path: &Path, timers: &[ScheduledTimer]) -> Result<(), String> {
|
2026-05-14 16:26:49 +00:00
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
let content =
|
|
|
|
|
serde_json::to_string_pretty(timers).map_err(|e| format!("serialize failed: {e}"))?;
|
|
|
|
|
std::fs::write(path, content).map_err(|e| format!("write failed: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add a timer. Errors if a timer with the same ID already exists.
|
|
|
|
|
pub fn add(&self, timer: ScheduledTimer) -> Result<(), String> {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
if timers.iter().any(|t| t.id == timer.id) {
|
|
|
|
|
return Err(format!("Timer with id '{}' already exists", timer.id));
|
|
|
|
|
}
|
2026-05-14 20:08:09 +00:00
|
|
|
timers.push(timer.clone());
|
|
|
|
|
match &self.persistence {
|
|
|
|
|
SchedPersistence::Sqlite(tx) => {
|
|
|
|
|
let _ = tx.send(SchedWriteCmd::Upsert(timer));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
SchedPersistence::Json(path) => Self::save_json(path, &timers),
|
|
|
|
|
}
|
2026-05-14 16:26:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove a timer by ID. Returns `true` if one was removed.
|
|
|
|
|
pub fn remove_by_id(&self, id: &str) -> bool {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
let before = timers.len();
|
|
|
|
|
timers.retain(|t| t.id != id);
|
|
|
|
|
let removed = timers.len() < before;
|
|
|
|
|
if removed {
|
2026-05-14 20:08:09 +00:00
|
|
|
match &self.persistence {
|
|
|
|
|
SchedPersistence::Sqlite(tx) => {
|
|
|
|
|
let _ = tx.send(SchedWriteCmd::Delete(id.to_string()));
|
|
|
|
|
}
|
|
|
|
|
SchedPersistence::Json(path) => {
|
|
|
|
|
let _ = Self::save_json(path, &timers);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 16:26:49 +00:00
|
|
|
}
|
|
|
|
|
removed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return all pending timers (cloned).
|
|
|
|
|
pub fn list(&self) -> Vec<ScheduledTimer> {
|
|
|
|
|
self.timers.lock().unwrap().clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove and return all timers whose `fire_at` ≤ `now`.
|
|
|
|
|
pub fn take_due(&self, now: DateTime<Utc>) -> Vec<ScheduledTimer> {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
let mut due = Vec::new();
|
|
|
|
|
let mut remaining = Vec::new();
|
|
|
|
|
for t in timers.drain(..) {
|
|
|
|
|
if t.fire_at <= now {
|
|
|
|
|
due.push(t);
|
|
|
|
|
} else {
|
|
|
|
|
remaining.push(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
*timers = remaining;
|
|
|
|
|
if !due.is_empty() {
|
2026-05-14 20:08:09 +00:00
|
|
|
match &self.persistence {
|
|
|
|
|
SchedPersistence::Sqlite(tx) => {
|
|
|
|
|
for entry in &due {
|
|
|
|
|
let _ = tx.send(SchedWriteCmd::Delete(entry.id.clone()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
SchedPersistence::Json(path) => {
|
|
|
|
|
let _ = Self::save_json(path, &timers);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 16:26:49 +00:00
|
|
|
}
|
|
|
|
|
due
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── When parsing ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Parse a `when` string into `(fire_at, optional_interval_secs)`.
|
|
|
|
|
///
|
|
|
|
|
/// Accepted forms:
|
|
|
|
|
/// - Relative: `"in 2 hours"`, `"in 15 minutes"`, `"in 30 seconds"`,
|
|
|
|
|
/// `"2h"`, `"15m"`, `"30s"` (the `"in "` prefix is optional)
|
|
|
|
|
/// - Absolute: ISO 8601 / RFC 3339 timestamp (`"2026-05-15T10:00:00Z"`)
|
|
|
|
|
///
|
|
|
|
|
/// Returns the interval in seconds for relative durations so callers can
|
|
|
|
|
/// set up recurring re-arm intervals.
|
|
|
|
|
pub fn parse_when_str(
|
|
|
|
|
when: &str,
|
|
|
|
|
now: DateTime<Utc>,
|
|
|
|
|
) -> Result<(DateTime<Utc>, Option<u64>), String> {
|
|
|
|
|
let trimmed = when.trim();
|
|
|
|
|
|
|
|
|
|
// Strip optional "in " prefix, then attempt interval parse.
|
|
|
|
|
let candidate = trimmed
|
|
|
|
|
.strip_prefix("in ")
|
|
|
|
|
.or_else(|| trimmed.strip_prefix("In "))
|
|
|
|
|
.unwrap_or(trimmed);
|
|
|
|
|
|
|
|
|
|
if let Some(secs) = parse_interval_str(candidate) {
|
|
|
|
|
let fire_at = now + chrono::Duration::seconds(secs as i64);
|
|
|
|
|
return Ok((fire_at, Some(secs)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try ISO 8601 / RFC 3339 absolute timestamp.
|
|
|
|
|
if let Ok(dt) = trimmed.parse::<DateTime<Utc>>() {
|
|
|
|
|
return Ok((dt, None));
|
|
|
|
|
}
|
|
|
|
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
|
|
|
|
|
return Ok((dt.with_timezone(&Utc), None));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(format!(
|
|
|
|
|
"Cannot parse 'when': '{when}'. \
|
|
|
|
|
Use 'in 2 hours', '15 minutes', '2h', or an ISO 8601 timestamp."
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse an interval string like `"2h"`, `"15m"`, `"30s"`, `"2 hours"`,
|
|
|
|
|
/// `"15 minutes"`, `"30 seconds"` into a number of seconds.
|
|
|
|
|
pub fn parse_interval_str(s: &str) -> Option<u64> {
|
|
|
|
|
let s = s.trim().to_lowercase();
|
|
|
|
|
let (num_str, unit_str) = split_num_unit(&s);
|
|
|
|
|
let n: u64 = num_str.parse().ok()?;
|
|
|
|
|
if n == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
match unit_str.trim() {
|
|
|
|
|
"h" | "hr" | "hrs" | "hour" | "hours" => Some(n * 3600),
|
|
|
|
|
"m" | "min" | "mins" | "minute" | "minutes" => Some(n * 60),
|
|
|
|
|
"s" | "sec" | "secs" | "second" | "seconds" => Some(n),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Split a string like `"2hours"` or `"15 minutes"` into `("2", "hours")`.
|
|
|
|
|
fn split_num_unit(s: &str) -> (&str, &str) {
|
|
|
|
|
let idx = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
|
|
|
|
|
let (num, unit) = s.split_at(idx);
|
|
|
|
|
(num, unit.trim_start_matches(' '))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use chrono::TimeZone;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
fn fixed_now() -> DateTime<Utc> {
|
|
|
|
|
Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── parse_interval_str ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_hours_long() {
|
|
|
|
|
assert_eq!(parse_interval_str("2 hours"), Some(7200));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_hours_short() {
|
|
|
|
|
assert_eq!(parse_interval_str("2h"), Some(7200));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_minutes_long() {
|
|
|
|
|
assert_eq!(parse_interval_str("15 minutes"), Some(900));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_minutes_short() {
|
|
|
|
|
assert_eq!(parse_interval_str("15m"), Some(900));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_seconds_long() {
|
|
|
|
|
assert_eq!(parse_interval_str("30 seconds"), Some(30));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_seconds_short() {
|
|
|
|
|
assert_eq!(parse_interval_str("30s"), Some(30));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_zero_returns_none() {
|
|
|
|
|
assert_eq!(parse_interval_str("0 hours"), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_interval_unknown_unit_returns_none() {
|
|
|
|
|
assert_eq!(parse_interval_str("5 fortnights"), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── parse_when_str ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_when_relative_with_in_prefix() {
|
|
|
|
|
let now = fixed_now();
|
|
|
|
|
let (fire_at, interval) = parse_when_str("in 2 hours", now).unwrap();
|
|
|
|
|
assert_eq!(interval, Some(7200));
|
|
|
|
|
assert_eq!(fire_at, now + chrono::Duration::seconds(7200));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_when_relative_without_in_prefix() {
|
|
|
|
|
let now = fixed_now();
|
|
|
|
|
let (fire_at, interval) = parse_when_str("15 minutes", now).unwrap();
|
|
|
|
|
assert_eq!(interval, Some(900));
|
|
|
|
|
assert_eq!(fire_at, now + chrono::Duration::seconds(900));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_when_short_form() {
|
|
|
|
|
let now = fixed_now();
|
|
|
|
|
let (fire_at, interval) = parse_when_str("2h", now).unwrap();
|
|
|
|
|
assert_eq!(interval, Some(7200));
|
|
|
|
|
assert_eq!(fire_at, now + chrono::Duration::seconds(7200));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_when_iso8601() {
|
|
|
|
|
let now = fixed_now();
|
|
|
|
|
let (fire_at, interval) = parse_when_str("2026-05-15T12:00:00Z", now).unwrap();
|
|
|
|
|
assert_eq!(interval, None);
|
|
|
|
|
let expected = Utc.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
|
|
|
|
|
assert_eq!(fire_at, expected);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_when_invalid_returns_err() {
|
|
|
|
|
let now = fixed_now();
|
|
|
|
|
assert!(parse_when_str("next Tuesday", now).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── ScheduledTimerStore ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn make_timer(id: &str, fire_at: DateTime<Utc>) -> ScheduledTimer {
|
|
|
|
|
ScheduledTimer {
|
|
|
|
|
id: id.to_string(),
|
|
|
|
|
label: None,
|
|
|
|
|
fire_at,
|
|
|
|
|
action: TimerAction::Prompt {
|
|
|
|
|
text: "test".to_string(),
|
|
|
|
|
},
|
|
|
|
|
mode: TimerMode::Once,
|
|
|
|
|
created_at: Utc::now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn store_empty_on_missing_file() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn store_add_and_list() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + chrono::Duration::hours(1);
|
|
|
|
|
store.add(make_timer("tm-aabbccdd", t)).unwrap();
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].id, "tm-aabbccdd");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn store_add_duplicate_id_fails() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + chrono::Duration::hours(1);
|
|
|
|
|
store.add(make_timer("tm-aabbccdd", t)).unwrap();
|
|
|
|
|
assert!(store.add(make_timer("tm-aabbccdd", t)).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn store_remove_by_id() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + chrono::Duration::hours(1);
|
|
|
|
|
store.add(make_timer("tm-aabbccdd", t)).unwrap();
|
|
|
|
|
assert!(store.remove_by_id("tm-aabbccdd"));
|
|
|
|
|
assert!(!store.remove_by_id("tm-aabbccdd"));
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn store_persists_and_reloads() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("timers.json");
|
|
|
|
|
let t = Utc::now() + chrono::Duration::hours(2);
|
|
|
|
|
{
|
|
|
|
|
let store = ScheduledTimerStore::load(path.clone());
|
|
|
|
|
store.add(make_timer("tm-aabbccdd", t)).unwrap();
|
|
|
|
|
}
|
|
|
|
|
let store2 = ScheduledTimerStore::load(path);
|
|
|
|
|
assert_eq!(store2.list().len(), 1);
|
|
|
|
|
assert_eq!(store2.list()[0].id, "tm-aabbccdd");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn take_due_returns_only_past_entries() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let past = Utc::now() - chrono::Duration::minutes(1);
|
|
|
|
|
let future = Utc::now() + chrono::Duration::hours(1);
|
|
|
|
|
store.add(make_timer("tm-past", past)).unwrap();
|
|
|
|
|
store.add(make_timer("tm-future", future)).unwrap();
|
|
|
|
|
|
|
|
|
|
let due = store.take_due(Utc::now());
|
|
|
|
|
assert_eq!(due.len(), 1);
|
|
|
|
|
assert_eq!(due[0].id, "tm-past");
|
|
|
|
|
assert_eq!(store.list().len(), 1);
|
|
|
|
|
assert_eq!(store.list()[0].id, "tm-future");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn take_due_with_already_past_fires_immediately() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = ScheduledTimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
// Simulate server restart: timer scheduled in past (catch-up semantics)
|
|
|
|
|
let way_past = Utc::now() - chrono::Duration::hours(3);
|
|
|
|
|
store.add(make_timer("tm-catchup", way_past)).unwrap();
|
|
|
|
|
let due = store.take_due(Utc::now());
|
|
|
|
|
assert_eq!(due.len(), 1, "past timer must fire on next tick");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:08:09 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn from_pool_persists_and_reloads() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let db_path = dir.path().join("test.db");
|
|
|
|
|
let opts = sqlx::sqlite::SqliteConnectOptions::new()
|
|
|
|
|
.filename(&db_path)
|
|
|
|
|
.create_if_missing(true);
|
|
|
|
|
let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS scheduled_timers \
|
|
|
|
|
(id TEXT PRIMARY KEY, label TEXT, fire_at TEXT NOT NULL, \
|
|
|
|
|
action_json TEXT NOT NULL, mode_json TEXT NOT NULL, created_at TEXT NOT NULL)",
|
|
|
|
|
)
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let t = Utc::now() + chrono::Duration::hours(1);
|
|
|
|
|
{
|
|
|
|
|
let store = ScheduledTimerStore::from_pool(pool.clone()).await.unwrap();
|
|
|
|
|
store.add(make_timer("tm-aabbccdd", t)).unwrap();
|
|
|
|
|
// Give the background writer a moment to flush.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let store2 = ScheduledTimerStore::from_pool(pool).await.unwrap();
|
|
|
|
|
let list = store2.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].id, "tm-aabbccdd");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 16:26:49 +00:00
|
|
|
#[test]
|
|
|
|
|
fn new_id_has_tm_prefix() {
|
|
|
|
|
let id = ScheduledTimer::new_id();
|
|
|
|
|
assert!(id.starts_with("tm-"), "expected 'tm-' prefix: {id}");
|
|
|
|
|
assert_eq!(id.len(), 11, "expected 'tm-' + 8 hex chars: {id}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── TimerAction serde ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_action_mcp_round_trips() {
|
|
|
|
|
let action = TimerAction::Mcp {
|
|
|
|
|
method: "start_agent".to_string(),
|
|
|
|
|
args: serde_json::json!({ "story_id": "42_foo" }),
|
|
|
|
|
};
|
|
|
|
|
let s = serde_json::to_string(&action).unwrap();
|
|
|
|
|
let back: TimerAction = serde_json::from_str(&s).unwrap();
|
|
|
|
|
assert_eq!(action, back);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_action_prompt_round_trips() {
|
|
|
|
|
let action = TimerAction::Prompt {
|
|
|
|
|
text: "daily standup reminder".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let s = serde_json::to_string(&action).unwrap();
|
|
|
|
|
let back: TimerAction = serde_json::from_str(&s).unwrap();
|
|
|
|
|
assert_eq!(action, back);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── TimerMode serde ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_mode_once_round_trips() {
|
|
|
|
|
let mode = TimerMode::Once;
|
|
|
|
|
let s = serde_json::to_string(&mode).unwrap();
|
|
|
|
|
let back: TimerMode = serde_json::from_str(&s).unwrap();
|
|
|
|
|
assert_eq!(mode, back);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_mode_recurring_round_trips() {
|
|
|
|
|
let mode = TimerMode::Recurring {
|
|
|
|
|
interval_secs: 3600,
|
|
|
|
|
};
|
|
|
|
|
let s = serde_json::to_string(&mode).unwrap();
|
|
|
|
|
let back: TimerMode = serde_json::from_str(&s).unwrap();
|
|
|
|
|
assert_eq!(mode, back);
|
|
|
|
|
}
|
|
|
|
|
}
|