//! Scheduled prompts: persistent sqlite queue of `(fire_at, targets, //! body)` rows that the worker fans out as broker `Message`s to each //! target's inbox at fire time. Recurring schedules carry //! `interval_seconds` and re-arm `next_fire_at` on delivery; //! one-shots are reaped. //! //! Schema + retention: `docs/persistence.md::/var/lib/hyperhive/broker.sqlite` //! (the `scheduled_prompts` / `scheduled_prompt_targets` table bullets). //! Submit paths (operator-direct vs `ApprovalKind::SchedulePrompt`, //! plus why even agent-self schedules go through approval): //! `docs/approvals.md::Scheduled prompts (submit paths)`. //! Catch-up clamp on resume + per-target tombstoning: //! `docs/approvals.md::Scheduled prompt worker (catch-up clamp)`. use std::path::Path; use std::sync::Mutex; use anyhow::{Context, Result, bail}; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; const SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS scheduled_prompts ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, body TEXT NOT NULL, interval_seconds INTEGER, next_fire_at_unix INTEGER NOT NULL, created_at_unix INTEGER NOT NULL, source TEXT NOT NULL, cancelled_at_unix INTEGER, description TEXT ); CREATE INDEX IF NOT EXISTS idx_scheduled_due ON scheduled_prompts (next_fire_at_unix) WHERE cancelled_at_unix IS NULL; CREATE TABLE IF NOT EXISTS scheduled_prompt_targets ( schedule_id INTEGER NOT NULL, target TEXT NOT NULL, cancelled_at_unix INTEGER, last_fired_at_unix INTEGER, last_result TEXT, PRIMARY KEY (schedule_id, target), FOREIGN KEY (schedule_id) REFERENCES scheduled_prompts(id) ON DELETE CASCADE ); "; /// One scheduled-prompt row + its current target set. Returned by /// `list` / `get`; the per-target last-result blob is suitable for /// rendering on the dashboard without a second query. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schedule { pub id: i64, /// `"operator"` or an agent name — drives cancel-permission /// checks. For approval-sourced rows this is the requesting /// agent (the `Approval.agent` at submit time). pub owner: String, pub body: String, /// `None` = one-shot, deleted after first fire. /// `Some(n)` = recurring every `n` seconds. pub interval_seconds: Option, pub next_fire_at_unix: i64, pub created_at_unix: i64, pub source: ScheduleSource, /// Set when the *entire* schedule was cancelled (all targets /// flipped, or operator cancel-all). Worker reaps these on the /// next pass. pub cancelled_at_unix: Option, pub description: Option, pub targets: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduleTarget { pub target: String, pub cancelled_at_unix: Option, pub last_fired_at_unix: Option, pub last_result: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ScheduleSource { Operator, Approval { id: i64 }, } impl ScheduleSource { fn to_db_string(&self) -> String { match self { ScheduleSource::Operator => "operator".to_owned(), ScheduleSource::Approval { id } => format!("approval:{id}"), } } fn from_db_string(s: &str) -> Self { if let Some(rest) = s.strip_prefix("approval:") && let Ok(id) = rest.parse::() { return ScheduleSource::Approval { id }; } // Unknown / "operator" / corrupt → operator (best the row // can do without an unparseable-source variant). ScheduleSource::Operator } } /// Submission payload — everything the caller knows at insert time. /// Used by both the operator-direct path and the approval-flow /// path; the latter sets `source = Approval { id }`. #[derive(Debug, Clone)] pub struct NewSchedule { pub owner: String, pub targets: Vec, pub body: String, pub first_fire_at_unix: i64, pub interval_seconds: Option, pub description: Option, pub source: ScheduleSource, } /// Partial-update payload for `ScheduledPrompts::update`. /// Every field is `Option<_>`; `None` keeps the existing value. /// The doubly-wrapped `Option>` on `interval_seconds` /// is intentional: outer `None` = "don't touch", outer /// `Some(None)` = "set to NULL (toggle to one-shot)", outer /// `Some(Some(n))` = "set to n seconds." Same encoding as JSON /// "missing key" vs "explicit null" — the dashboard surface /// preserves the distinction. /// /// Target add/remove: `targets_add` / `targets_remove` /// run inside the same transaction as the scalar field updates so /// "save my changes" is atomic. Remove delegates to the same /// cancel-targets path used by `cancel_targets` (tombstoning, preserves /// audit, auto-cancels parent when no active targets remain). Add uses /// `INSERT OR REPLACE` so re-adding a previously-cancelled target /// drops the tombstone and starts fresh (operator intent on re-add = /// "this target is active again"; prior history was already visible /// at cancel time). #[derive(Debug, Clone, Default)] #[allow( clippy::option_option, reason = "double-Option carries three-state PATCH semantics: outer None = \ leave alone, Some(None) = clear, Some(Some(v)) = set. \ collapsing to a single Option would lose the 'clear' state" )] pub struct UpdateSchedule { pub body: Option, pub description: Option>, pub interval_seconds: Option>, pub next_fire_at_unix: Option, pub targets_add: Option>, pub targets_remove: Option>, } pub struct ScheduledPrompts { conn: Mutex, } impl ScheduledPrompts { pub fn open(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| { format!("create scheduled_prompts db parent {}", parent.display()) })?; } let conn = Connection::open(path) .with_context(|| format!("open scheduled_prompts db {}", path.display()))?; // Required for ON DELETE CASCADE to actually fire — sqlite // ships with FKs disabled per connection by default. conn.execute_batch("PRAGMA foreign_keys = ON;") .context("enable foreign keys")?; conn.execute_batch(SCHEMA) .context("apply scheduled_prompts schema")?; Ok(Self { conn: Mutex::new(conn), }) } /// Insert a new schedule. Returns the new id. Empty `targets` is /// rejected — a schedule with no recipients would silently /// never fan out, masking caller bugs. pub fn submit(&self, new: &NewSchedule) -> Result { if new.targets.is_empty() { bail!("schedule must have at least one target"); } let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "INSERT INTO scheduled_prompts (owner, body, interval_seconds, next_fire_at_unix, created_at_unix, source, description) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ &new.owner, &new.body, new.interval_seconds.map(i64::try_from).and_then(Result::ok), new.first_fire_at_unix, now_unix(), new.source.to_db_string(), &new.description, ], )?; let id = tx.last_insert_rowid(); for target in &new.targets { tx.execute( "INSERT INTO scheduled_prompt_targets (schedule_id, target) VALUES (?1, ?2)", params![id, target], )?; } tx.commit()?; Ok(id) } /// Fetch a single schedule by id (with its target rows). /// `Ok(None)` for a non-existent / already-reaped id. pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); let row = conn .query_row( "SELECT id, owner, body, interval_seconds, next_fire_at_unix, created_at_unix, source, cancelled_at_unix, description FROM scheduled_prompts WHERE id = ?1", params![id], row_to_schedule_header, ) .optional()?; let Some(mut s) = row else { return Ok(None); }; s.targets = load_targets(&conn, id)?; Ok(Some(s)) } /// Every active (non-globally-cancelled) schedule in insert /// order. Used by the dashboard list view + the cancel-auth /// check (the latter only needs the header but `list()` is the /// shared hot path). pub fn list(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, owner, body, interval_seconds, next_fire_at_unix, created_at_unix, source, cancelled_at_unix, description FROM scheduled_prompts ORDER BY next_fire_at_unix ASC, id ASC", )?; let rows = stmt.query_map([], row_to_schedule_header)?; let mut out = Vec::new(); for row in rows { let mut s = row?; s.targets = load_targets(&conn, s.id)?; out.push(s); } Ok(out) } /// Pop the set of active rows that are due (`next_fire_at_unix <= now`) /// up to `limit`. Read-only — the worker calls `mark_fired` /// after each successful fan-out so the rows reappear on the /// next tick when re-armed. pub fn due(&self, now: i64, limit: u64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, owner, body, interval_seconds, next_fire_at_unix, created_at_unix, source, cancelled_at_unix, description FROM scheduled_prompts WHERE cancelled_at_unix IS NULL AND next_fire_at_unix <= ?1 ORDER BY next_fire_at_unix ASC, id ASC LIMIT ?2", )?; let rows = stmt.query_map(params![now, limit], row_to_schedule_header)?; let mut out = Vec::new(); for row in rows { let mut s = row?; s.targets = load_targets(&conn, s.id)?; out.push(s); } Ok(out) } /// Record a per-target fan-out result. `result` is "ok" or a /// short error string; surfaces on the dashboard last-result /// column. No-op for a target row that's already cancelled. pub fn record_target_result( &self, schedule_id: i64, target: &str, fired_at_unix: i64, result: &str, ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE scheduled_prompt_targets SET last_fired_at_unix = ?1, last_result = ?2 WHERE schedule_id = ?3 AND target = ?4 AND cancelled_at_unix IS NULL", params![fired_at_unix, result, schedule_id, target], )?; Ok(()) } /// Advance a recurring schedule's `next_fire_at` to the smallest /// multiple-of-interval > `from`. Returns the count of skipped /// cycles (≥ 0); the worker stamps that into the per-row /// `last_result` so operators see "caught up from N missed". /// /// For one-shots (`interval_seconds IS NULL`) this is a no-op /// at the SQL level; callers should `delete` them after fan-out /// instead. pub fn rearm(&self, id: i64, from_unix: i64) -> Result { let conn = self.conn.lock().unwrap(); let row: Option<(Option, i64)> = conn .query_row( "SELECT interval_seconds, next_fire_at_unix FROM scheduled_prompts WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; let Some((interval, current_next)) = row else { return Ok(0); }; let Some(interval) = interval.filter(|&i| i > 0) else { return Ok(0); }; // Smallest multiple-of-interval > `from_unix`. Catch-up // semantics: when from_unix > current_next, every step in // between gets counted as a skipped cycle. let mut next = current_next + interval; let mut skipped: u64 = 0; while next <= from_unix { next += interval; skipped += 1; } conn.execute( "UPDATE scheduled_prompts SET next_fire_at_unix = ?1 WHERE id = ?2", params![next, id], )?; Ok(skipped) } /// Partial-update an existing schedule's mutable fields. /// Every scalar field is `Option<_>`; `None` means "leave the /// existing value alone", `Some(_)` means "set it to this." /// Refuses cancelled rows (no point editing a tombstone — /// operator can just submit a new schedule). Refuses /// `interval_seconds = Some(0)` — same rule as submit-time /// validation. /// /// Targets are mutable via `targets_remove` + `targets_add`. /// Both are processed in the same /// transaction, with **removes before adds** so a single PATCH /// can swap a target without ever leaving the schedule /// target-less mid-tx. Remove writes a tombstone via /// `COALESCE(cancelled_at_unix, ?1)` so it's idempotent. Add /// uses `INSERT OR REPLACE` so re-adding a previously-removed /// target drops the tombstone and resets per-target history /// (fresh start — option C from the iris design thread). If /// the post-tx active-target count is zero the parent schedule /// is auto-cancelled — the worker would otherwise spin forever /// firing nothing. pub fn update(&self, id: i64, patch: UpdateSchedule) -> Result<()> { // Pre-flight: row must exist and not be cancelled. let mut conn = self.conn.lock().unwrap(); let cancelled: Option = conn .query_row( "SELECT cancelled_at_unix FROM scheduled_prompts WHERE id = ?1", params![id], |row| row.get(0), ) .optional()? .ok_or_else(|| anyhow::anyhow!("schedule {id} not found"))?; if cancelled.is_some() { bail!("schedule {id} is cancelled — submit a new one"); } if let Some(Some(0)) = patch.interval_seconds { bail!("interval_seconds must be > 0 (use None for one-shot)"); } // Single transaction so a partial failure can't leave a // half-updated row. let tx = conn.transaction()?; if let Some(body) = patch.body { tx.execute( "UPDATE scheduled_prompts SET body = ?1 WHERE id = ?2", params![body, id], )?; } if let Some(description) = patch.description { tx.execute( "UPDATE scheduled_prompts SET description = ?1 WHERE id = ?2", params![description, id], )?; } if let Some(interval) = patch.interval_seconds { // `Some(Some(n))` = set to n. `Some(None)` = clear (one-shot). // `None` is filtered out by the outer `if let`. let value: Option = interval.map(i64::try_from).and_then(Result::ok); tx.execute( "UPDATE scheduled_prompts SET interval_seconds = ?1 WHERE id = ?2", params![value, id], )?; } if let Some(next) = patch.next_fire_at_unix { tx.execute( "UPDATE scheduled_prompts SET next_fire_at_unix = ?1 WHERE id = ?2", params![next, id], )?; } // Target removals first, then adds. Order matters: an // operator who sends `{add: ["alice"], remove: ["alice"]}` // probably typo'd, but treating add-after-remove as the // surviving intent means a clean re-arm rather than an // orphaned tombstone. Plus removing-then-adding is the // natural way to "restart history" on one target without // a separate flow. let now = now_unix(); if let Some(remove) = patch.targets_remove.as_deref() { for target in remove { tx.execute( "UPDATE scheduled_prompt_targets SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE schedule_id = ?2 AND target = ?3", params![now, id, target], )?; } } if let Some(add) = patch.targets_add.as_deref() { for target in add { // INSERT OR REPLACE drops any prior row (cancelled // tombstone or otherwise) and inserts a fresh one // with empty history. Operator intent on re-add = // "this target is active again, fresh start." tx.execute( "INSERT OR REPLACE INTO scheduled_prompt_targets (schedule_id, target, cancelled_at_unix, last_fired_at_unix, last_result) VALUES (?1, ?2, NULL, NULL, NULL)", params![id, target], )?; } } // If the remove set drained the last active target (and // the add set didn't bring any back), auto-cancel the // parent so the worker stops scanning it. Mirrors // `cancel_targets`'s behaviour for the same edge case. if patch.targets_remove.is_some() || patch.targets_add.is_some() { let active_targets: i64 = tx.query_row( "SELECT COUNT(*) FROM scheduled_prompt_targets WHERE schedule_id = ?1 AND cancelled_at_unix IS NULL", params![id], |row| row.get(0), )?; if active_targets == 0 { tx.execute( "UPDATE scheduled_prompts SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE id = ?2", params![now, id], )?; } } tx.commit()?; Ok(()) } /// Delete a one-shot row after its single fire. Cascades the /// target rows via the FK constraint. Idempotent on a missing /// id. pub fn delete(&self, id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM scheduled_prompts WHERE id = ?1", params![id])?; Ok(()) } /// Cancel the entire schedule (all targets flipped + the parent /// row marked cancelled). Idempotent; safe to call on an /// already-cancelled row. pub fn cancel_all(&self, id: i64) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let now = now_unix(); let tx = conn.transaction()?; tx.execute( "UPDATE scheduled_prompts SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE id = ?2", params![now, id], )?; tx.execute( "UPDATE scheduled_prompt_targets SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE schedule_id = ?2", params![now, id], )?; tx.commit()?; Ok(()) } /// Cancel a subset of a schedule's targets. When the last /// active target is cancelled, the parent row is auto-cancelled /// too (so the worker reaps it). Unknown targets in the list /// are silently skipped. pub fn cancel_targets(&self, id: i64, targets: &[String]) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let now = now_unix(); let tx = conn.transaction()?; for target in targets { tx.execute( "UPDATE scheduled_prompt_targets SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE schedule_id = ?2 AND target = ?3", params![now, id, target], )?; } // If every target on this schedule is now cancelled, flip // the parent so the worker stops scanning it. let active_targets: i64 = tx.query_row( "SELECT COUNT(*) FROM scheduled_prompt_targets WHERE schedule_id = ?1 AND cancelled_at_unix IS NULL", params![id], |row| row.get(0), )?; if active_targets == 0 { tx.execute( "UPDATE scheduled_prompts SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) WHERE id = ?2", params![now, id], )?; } tx.commit()?; Ok(()) } /// Reap cancelled rows older than `older_than_unix`. Returns /// the number of rows deleted. Called from the worker on each /// tick so cancellations clear out of the dashboard without an /// extra periodic vacuum task. pub fn reap_cancelled(&self, older_than_unix: i64) -> Result { let conn = self.conn.lock().unwrap(); let n = conn.execute( "DELETE FROM scheduled_prompts WHERE cancelled_at_unix IS NOT NULL AND cancelled_at_unix <= ?1", params![older_than_unix], )?; Ok(n) } } fn row_to_schedule_header(row: &rusqlite::Row) -> rusqlite::Result { let interval: Option = row.get(3)?; let source_str: String = row.get(6)?; Ok(Schedule { id: row.get(0)?, owner: row.get(1)?, body: row.get(2)?, interval_seconds: interval.and_then(|i| u64::try_from(i).ok()), next_fire_at_unix: row.get(4)?, created_at_unix: row.get(5)?, source: ScheduleSource::from_db_string(&source_str), cancelled_at_unix: row.get(7)?, description: row.get(8)?, targets: Vec::new(), }) } fn load_targets(conn: &Connection, schedule_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT target, cancelled_at_unix, last_fired_at_unix, last_result FROM scheduled_prompt_targets WHERE schedule_id = ?1 ORDER BY target ASC", )?; let rows = stmt.query_map(params![schedule_id], |row| { Ok(ScheduleTarget { target: row.get(0)?, cancelled_at_unix: row.get(1)?, last_fired_at_unix: row.get(2)?, last_result: row.get(3)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn open() -> (TempDir, ScheduledPrompts) { let dir = TempDir::new().expect("tempdir"); let path = dir.path().join("schedules.sqlite"); let db = ScheduledPrompts::open(&path).expect("open"); (dir, db) } fn submit_one_shot(db: &ScheduledPrompts, fire_at: i64, targets: &[&str]) -> i64 { db.submit(&NewSchedule { owner: "operator".into(), targets: targets.iter().map(|t| (*t).to_owned()).collect(), body: "wake".into(), first_fire_at_unix: fire_at, interval_seconds: None, description: None, source: ScheduleSource::Operator, }) .expect("submit") } #[test] fn submit_and_get_round_trips_targets() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.id, id); assert_eq!(s.targets.len(), 2); assert_eq!(s.targets[0].target, "alice"); assert_eq!(s.targets[1].target, "bob"); assert!(s.targets.iter().all(|t| t.cancelled_at_unix.is_none())); } #[test] fn submit_rejects_empty_targets() { let (_dir, db) = open(); let err = db .submit(&NewSchedule { owner: "operator".into(), targets: Vec::new(), body: "wake".into(), first_fire_at_unix: 100, interval_seconds: None, description: None, source: ScheduleSource::Operator, }) .unwrap_err(); assert!(format!("{err:#}").contains("at least one target")); } #[test] fn due_returns_only_past_active_rows() { let (_dir, db) = open(); let _future = submit_one_shot(&db, 1000, &["alice"]); let past = submit_one_shot(&db, 50, &["alice"]); let cancelled_past = submit_one_shot(&db, 50, &["bob"]); db.cancel_all(cancelled_past).expect("cancel"); let due = db.due(100, 10).expect("due"); assert_eq!(due.len(), 1); assert_eq!(due[0].id, past); } #[test] fn rearm_handles_catch_up() { let (_dir, db) = open(); // Recurring every 60s, last fire at t=100. let id = db .submit(&NewSchedule { owner: "operator".into(), targets: vec!["alice".into()], body: "wake".into(), first_fire_at_unix: 100, interval_seconds: Some(60), description: None, source: ScheduleSource::Operator, }) .expect("submit"); // Worker comes back at t=400 — 5 missed cycles (160, 220, // 280, 340, 400) → next should be 460, skipped = 5. let skipped = db.rearm(id, 400).expect("rearm"); assert_eq!(skipped, 5); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.next_fire_at_unix, 460); } #[test] fn rearm_advances_one_step_when_caught_up() { let (_dir, db) = open(); let id = db .submit(&NewSchedule { owner: "operator".into(), targets: vec!["alice".into()], body: "wake".into(), first_fire_at_unix: 100, interval_seconds: Some(60), description: None, source: ScheduleSource::Operator, }) .expect("submit"); // Worker fires right at t=100 — single advance to t=160. let skipped = db.rearm(id, 100).expect("rearm"); assert_eq!(skipped, 0); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.next_fire_at_unix, 160); } #[test] fn rearm_is_a_no_op_for_one_shots() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); let skipped = db.rearm(id, 1000).expect("rearm"); assert_eq!(skipped, 0); // next_fire_at unchanged — one-shots are reaped via delete(). let s = db.get(id).expect("get").expect("present"); assert_eq!(s.next_fire_at_unix, 100); } #[test] fn cancel_targets_auto_cancels_parent_when_last_drops() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); db.cancel_targets(id, &["alice".to_owned()]) .expect("cancel"); let s = db.get(id).expect("get").expect("present"); // Parent still active (bob remains). assert!(s.cancelled_at_unix.is_none()); db.cancel_targets(id, &["bob".to_owned()]).expect("cancel"); let s = db.get(id).expect("get").expect("present"); // Parent auto-cancels once every target is gone. assert!(s.cancelled_at_unix.is_some()); assert!(s.targets.iter().all(|t| t.cancelled_at_unix.is_some())); } #[test] fn cancel_all_flips_parent_and_targets() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); db.cancel_all(id).expect("cancel"); let s = db.get(id).expect("get").expect("present"); assert!(s.cancelled_at_unix.is_some()); assert!(s.targets.iter().all(|t| t.cancelled_at_unix.is_some())); } #[test] fn delete_cascades_targets() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); db.delete(id).expect("delete"); assert!(db.get(id).expect("get").is_none()); } #[test] fn record_target_result_skips_cancelled_targets() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); db.cancel_targets(id, &["alice".to_owned()]) .expect("cancel"); db.record_target_result(id, "alice", 200, "ok") .expect("record alice"); db.record_target_result(id, "bob", 200, "ok") .expect("record bob"); let s = db.get(id).expect("get").expect("present"); let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); let bob = s.targets.iter().find(|t| t.target == "bob").unwrap(); // Cancelled targets do NOT get last-result writes. assert!(alice.last_fired_at_unix.is_none()); assert!(alice.last_result.is_none()); assert_eq!(bob.last_fired_at_unix, Some(200)); assert_eq!(bob.last_result.as_deref(), Some("ok")); } #[test] fn update_partial_only_touches_set_fields() { let (_dir, db) = open(); let id = db .submit(&NewSchedule { owner: "operator".into(), targets: vec!["alice".into()], body: "old body".into(), first_fire_at_unix: 100, interval_seconds: Some(60), description: Some("old desc".into()), source: ScheduleSource::Operator, }) .expect("submit"); // Only change body — everything else must stay. db.update( id, UpdateSchedule { body: Some("new body".into()), ..Default::default() }, ) .expect("update body"); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.body, "new body"); assert_eq!(s.description.as_deref(), Some("old desc")); assert_eq!(s.interval_seconds, Some(60)); assert_eq!(s.next_fire_at_unix, 100); } #[test] fn update_interval_toggle_recurring_to_one_shot() { let (_dir, db) = open(); let id = db .submit(&NewSchedule { owner: "operator".into(), targets: vec!["alice".into()], body: "x".into(), first_fire_at_unix: 100, interval_seconds: Some(60), description: None, source: ScheduleSource::Operator, }) .expect("submit"); db.update( id, UpdateSchedule { interval_seconds: Some(None), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.interval_seconds, None); } #[test] fn update_interval_toggle_one_shot_to_recurring() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); db.update( id, UpdateSchedule { interval_seconds: Some(Some(3600)), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.interval_seconds, Some(3600)); } #[test] fn update_rejects_cancelled_row() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); db.cancel_all(id).expect("cancel"); let err = db .update( id, UpdateSchedule { body: Some("nope".into()), ..Default::default() }, ) .unwrap_err(); assert!(format!("{err:#}").contains("cancelled")); } #[test] fn update_rejects_zero_interval() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); let err = db .update( id, UpdateSchedule { interval_seconds: Some(Some(0)), ..Default::default() }, ) .unwrap_err(); assert!(format!("{err:#}").contains("interval_seconds")); } #[test] fn update_clears_description() { let (_dir, db) = open(); let id = db .submit(&NewSchedule { owner: "operator".into(), targets: vec!["alice".into()], body: "x".into(), first_fire_at_unix: 100, interval_seconds: None, description: Some("first".into()), source: ScheduleSource::Operator, }) .expect("submit"); db.update( id, UpdateSchedule { description: Some(None), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); assert!(s.description.is_none()); } #[test] fn update_adds_new_targets() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); db.update( id, UpdateSchedule { targets_add: Some(vec!["bob".into(), "carol".into()]), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); let names: Vec<_> = s.targets.iter().map(|t| t.target.as_str()).collect(); assert!(names.contains(&"alice")); assert!(names.contains(&"bob")); assert!(names.contains(&"carol")); } #[test] fn update_removes_targets_via_cancel_tombstone() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); db.update( id, UpdateSchedule { targets_remove: Some(vec!["alice".into()]), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); let bob = s.targets.iter().find(|t| t.target == "bob").unwrap(); // alice tombstoned, bob still active, parent still active. assert!(alice.cancelled_at_unix.is_some()); assert!(bob.cancelled_at_unix.is_none()); assert!(s.cancelled_at_unix.is_none()); } #[test] fn update_removing_last_target_auto_cancels_parent() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); db.update( id, UpdateSchedule { targets_remove: Some(vec!["alice".into()]), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); assert!(s.cancelled_at_unix.is_some()); } #[test] fn update_re_add_resurrects_cancelled_target_fresh() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice", "bob"]); // Record some history on alice, then cancel her. db.record_target_result(id, "alice", 50, "ok") .expect("record"); db.update( id, UpdateSchedule { targets_remove: Some(vec!["alice".into()]), ..Default::default() }, ) .expect("cancel alice"); // Re-add alice — should drop the tombstone + prior history. db.update( id, UpdateSchedule { targets_add: Some(vec!["alice".into()]), ..Default::default() }, ) .expect("re-add alice"); let s = db.get(id).expect("get").expect("present"); let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); assert!(alice.cancelled_at_unix.is_none(), "fresh row, no tombstone"); assert!(alice.last_fired_at_unix.is_none(), "history reset"); assert!(alice.last_result.is_none(), "history reset"); } #[test] fn update_add_and_remove_in_one_transaction() { let (_dir, db) = open(); let id = submit_one_shot(&db, 100, &["alice"]); db.update( id, UpdateSchedule { targets_remove: Some(vec!["alice".into()]), targets_add: Some(vec!["bob".into()]), ..Default::default() }, ) .expect("update"); let s = db.get(id).expect("get").expect("present"); let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); let bob = s.targets.iter().find(|t| t.target == "bob").unwrap(); assert!(alice.cancelled_at_unix.is_some()); assert!(bob.cancelled_at_unix.is_none()); // Bob arrived AFTER alice was tombstoned in the same tx — // active-target count > 0, so parent stays alive. assert!(s.cancelled_at_unix.is_none()); } #[test] fn approval_source_round_trips() { let (_dir, db) = open(); let id = db .submit(&NewSchedule { owner: "manager".into(), targets: vec!["alice".into()], body: "wake".into(), first_fire_at_unix: 100, interval_seconds: None, description: None, source: ScheduleSource::Approval { id: 42 }, }) .expect("submit"); let s = db.get(id).expect("get").expect("present"); assert_eq!(s.source, ScheduleSource::Approval { id: 42 }); } #[test] fn reap_cancelled_removes_old_rows_only() { let (_dir, db) = open(); let stale = submit_one_shot(&db, 100, &["alice"]); let recent = submit_one_shot(&db, 100, &["bob"]); db.cancel_all(stale).expect("cancel stale"); // Manually backdate the stale row. { let conn = db.conn.lock().unwrap(); conn.execute( "UPDATE scheduled_prompts SET cancelled_at_unix = ?1 WHERE id = ?2", params![10_i64, stale], ) .expect("backdate"); } db.cancel_all(recent).expect("cancel recent"); let n = db.reap_cancelled(100).expect("reap"); assert_eq!(n, 1); assert!(db.get(stale).expect("get stale").is_none()); assert!(db.get(recent).expect("get recent").is_some()); } }