Skip to main content

agentstategraph_reminders/
manager.rs

1//! `ReminderManager` — business logic layer over a `ReminderStore`.
2//!
3//! All state transitions, schedule advancement, and permission checks
4//! live here so the store layer stays a plain CRUD surface.
5
6use std::sync::Arc;
7
8use chrono::Utc;
9
10use crate::error::ReminderError;
11use crate::store::ReminderStore;
12use crate::types::{
13    CreateReminder, ExecutionRecord, ExecutionResult, Reminder, ReminderFilter, ReminderStatus,
14};
15
16/// How many times a read-modify-write re-reads a reminder that changed under
17/// it before giving up.
18const MAX_UPDATE_ATTEMPTS: usize = 32;
19
20fn write_conflict(id: &str) -> ReminderError {
21    ReminderError::Store(format!(
22        "write conflict: reminder {id} kept changing; gave up after {MAX_UPDATE_ATTEMPTS} attempts"
23    ))
24}
25
26pub struct ReminderManager {
27    store: Arc<dyn ReminderStore>,
28}
29
30impl ReminderManager {
31    pub fn new(store: Arc<dyn ReminderStore>) -> Self {
32        Self { store }
33    }
34
35    // -----------------------------------------------------------------------
36    // Create / read
37    // -----------------------------------------------------------------------
38
39    /// Create and persist a new reminder. Returns the fully-populated record.
40    pub fn create(&self, input: CreateReminder) -> Result<Reminder, ReminderError> {
41        let reminder = input.into_reminder();
42        self.store.save(&reminder)?;
43        Ok(reminder)
44    }
45
46    /// Retrieve a reminder by ID.
47    pub fn get(&self, id: &str) -> Result<Reminder, ReminderError> {
48        self.store
49            .get(id)?
50            .ok_or_else(|| ReminderError::NotFound(id.to_string()))
51    }
52
53    // -----------------------------------------------------------------------
54    // List / remind_me
55    // -----------------------------------------------------------------------
56
57    /// Return all reminders matching `filter`.
58    pub fn list(&self, filter: &ReminderFilter) -> Result<Vec<Reminder>, ReminderError> {
59        self.store.list(filter)
60    }
61
62    /// Return all reminders that are currently actionable: status `Due` or
63    /// `AwaitingPermission`, ordered by priority then `due_at`.
64    ///
65    /// Also promotes `Pending` reminders whose `due_at <= now` to `Due`
66    /// (lazy promotion — avoids the need for a background timer).
67    pub fn remind_me(&self) -> Result<Vec<Reminder>, ReminderError> {
68        let now = Utc::now();
69
70        // Promote any pending reminders whose due_at has passed.
71        let pending = self.store.list(&ReminderFilter {
72            status: Some(ReminderStatus::Pending),
73            ..Default::default()
74        })?;
75        for read in pending {
76            if read.due_at <= now {
77                // Non-autonomous reminders require approval before they become
78                // actionable — route them to AwaitingPermission, not straight
79                // to Due. (Autonomous ones are pre-approved.)
80                let mut r = read.clone();
81                r.status = if r.autonomous {
82                    ReminderStatus::Due
83                } else {
84                    ReminderStatus::AwaitingPermission
85                };
86                // If it changed since the list (cancelled, snoozed), leave it:
87                // writing back this snapshot would revert that change.
88                self.store.update_if_unchanged(&read, &r)?;
89            }
90        }
91
92        // Also promote snoozed reminders whose snooze has expired.
93        let snoozed = self.store.list(&ReminderFilter {
94            status: Some(ReminderStatus::Snoozed),
95            ..Default::default()
96        })?;
97        for read in snoozed {
98            let wake = read.snoozed_until.unwrap_or(read.due_at);
99            if wake <= now {
100                // Same approval gate on wake: a non-autonomous reminder must be
101                // re-approved before it can run again.
102                let mut r = read.clone();
103                r.status = if r.autonomous {
104                    ReminderStatus::Due
105                } else {
106                    ReminderStatus::AwaitingPermission
107                };
108                r.snoozed_until = None;
109                self.store.update_if_unchanged(&read, &r)?;
110            }
111        }
112
113        // Now return all actionable reminders.
114        let mut due = self.store.list(&ReminderFilter {
115            status: Some(ReminderStatus::Due),
116            ..Default::default()
117        })?;
118        let mut awaiting = self.store.list(&ReminderFilter {
119            status: Some(ReminderStatus::AwaitingPermission),
120            ..Default::default()
121        })?;
122        due.append(&mut awaiting);
123        due.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.due_at.cmp(&b.due_at)));
124        Ok(due)
125    }
126
127    // -----------------------------------------------------------------------
128    // Lifecycle mutations
129    // -----------------------------------------------------------------------
130
131    /// Snooze a reminder until `until`. Valid from `Due`, `AwaitingPermission`,
132    /// or `InProgress` states.
133    pub fn snooze(
134        &self,
135        id: &str,
136        until: chrono::DateTime<Utc>,
137    ) -> Result<Reminder, ReminderError> {
138        self.modify(id, |r| {
139            if r.status.is_terminal() {
140                return Err(ReminderError::AlreadyTerminal(id.to_string(), r.status));
141            }
142            r.status = ReminderStatus::Snoozed;
143            r.snoozed_until = Some(until);
144            Ok(())
145        })
146    }
147
148    /// Cancel a reminder. Idempotent if already cancelled.
149    pub fn cancel(&self, id: &str) -> Result<Reminder, ReminderError> {
150        self.modify(id, |r| {
151            if r.status == ReminderStatus::Completed {
152                return Err(ReminderError::AlreadyTerminal(id.to_string(), r.status));
153            }
154            r.status = ReminderStatus::Cancelled;
155            Ok(())
156        })
157    }
158
159    /// Approve a non-autonomous reminder for execution, recording who approved.
160    /// Transitions `AwaitingPermission` → `Due`.
161    pub fn approve(&self, id: &str, approved_by: &str) -> Result<Reminder, ReminderError> {
162        self.modify(id, |r| {
163            match r.status {
164                ReminderStatus::AwaitingPermission => {}
165                ReminderStatus::Due => {
166                    // Already approved / promoted; record the approver but don't error.
167                }
168                s if s.is_terminal() => {
169                    return Err(ReminderError::AlreadyTerminal(id.to_string(), s));
170                }
171                s => {
172                    return Err(ReminderError::InvalidTransition {
173                        id: id.to_string(),
174                        from: s,
175                        to: ReminderStatus::Due,
176                    });
177                }
178            }
179            // Stamp the most recent pending execution record with the approver,
180            // or leave that to record_execution if none exists yet.
181            r.status = ReminderStatus::Due;
182            // Store the approved_by on the reminder directly so record_execution
183            // can pick it up without requiring the caller to pass it again.
184            // We use a sentinel in the first partial execution record if present.
185            if let Some(rec) = r.executions.last_mut() {
186                if rec.completed_at.is_none() {
187                    rec.approved_by = Some(approved_by.to_string());
188                }
189            }
190            Ok(())
191        })
192    }
193
194    /// Mark a reminder as in-progress. Transitions `Due` → `InProgress`.
195    /// Non-autonomous reminders must be approved first.
196    pub fn start(&self, id: &str, agent_id: &str) -> Result<Reminder, ReminderError> {
197        self.modify(id, |r| {
198            match r.status {
199                ReminderStatus::Due => {}
200                ReminderStatus::AwaitingPermission => {
201                    if !r.autonomous {
202                        return Err(ReminderError::RequiresApproval(id.to_string()));
203                    }
204                }
205                s if s.is_terminal() => {
206                    return Err(ReminderError::AlreadyTerminal(id.to_string(), s));
207                }
208                s => {
209                    return Err(ReminderError::InvalidTransition {
210                        id: id.to_string(),
211                        from: s,
212                        to: ReminderStatus::InProgress,
213                    });
214                }
215            }
216            // Open a partial execution record.
217            r.executions.push(crate::types::ExecutionRecord {
218                started_at: Utc::now(),
219                completed_at: None,
220                agent_id: agent_id.to_string(),
221                approved_by: None,
222                result: ExecutionResult::Deferred, // placeholder until record_execution
223                notes: Vec::new(),
224                task_id: None,
225            });
226            r.status = ReminderStatus::InProgress;
227            Ok(())
228        })
229    }
230
231    /// Complete an execution attempt, appending the `ExecutionRecord`.
232    ///
233    /// On `Success`:
234    /// - If the reminder has a repeating schedule, compute `next_due` and
235    ///   reset to `Pending`.
236    /// - Otherwise mark `Completed`.
237    ///
238    /// On non-autonomous reminders that have not yet been approved, this
239    /// method transitions to `AwaitingPermission` instead of `Due` after
240    /// schedule advancement.
241    pub fn record_execution(
242        &self,
243        id: &str,
244        record: ExecutionRecord,
245    ) -> Result<Reminder, ReminderError> {
246        self.modify(id, |r| {
247            if r.status.is_terminal() {
248                return Err(ReminderError::AlreadyTerminal(id.to_string(), r.status));
249            }
250
251            let completed_at = Utc::now();
252            let mut record = record.clone();
253            record.completed_at = Some(completed_at);
254
255            // If start() opened a partial record, update it in-place; otherwise push.
256            match r.executions.last_mut() {
257                Some(last) if last.completed_at.is_none() => {
258                    last.completed_at = record.completed_at;
259                    last.result = record.result.clone();
260                    last.notes = record.notes.clone();
261                    last.task_id = record.task_id.clone();
262                    if record.approved_by.is_some() {
263                        last.approved_by = record.approved_by.clone();
264                    }
265                }
266                _ => r.executions.push(record.clone()),
267            }
268
269            match &record.result {
270                ExecutionResult::Success => {
271                    match r.schedule.as_ref().and_then(|s| s.next_due(completed_at)) {
272                        Some(next) => {
273                            r.due_at = next;
274                            r.snoozed_until = None;
275                            r.status = if r.autonomous {
276                                ReminderStatus::Pending
277                            } else {
278                                ReminderStatus::AwaitingPermission
279                            };
280                        }
281                        None => {
282                            r.status = ReminderStatus::Completed;
283                        }
284                    }
285                }
286                ExecutionResult::Snoozed => {
287                    // Snooze was set separately via snooze(); just record.
288                    if r.status != ReminderStatus::Snoozed {
289                        r.status = ReminderStatus::Snoozed;
290                    }
291                }
292                ExecutionResult::Cancelled => {
293                    r.status = ReminderStatus::Cancelled;
294                }
295                ExecutionResult::Failed | ExecutionResult::Deferred => {
296                    // Leave status as-is (InProgress or Due); caller decides next step.
297                    if r.status == ReminderStatus::InProgress {
298                        r.status = ReminderStatus::Due;
299                    }
300                }
301            }
302            Ok(())
303        })
304    }
305
306    /// Mark a ref as stale by `ref_id`. Returns the number of refs updated.
307    pub fn mark_ref_stale(&self, id: &str, ref_id: &str) -> Result<usize, ReminderError> {
308        for _ in 0..MAX_UPDATE_ATTEMPTS {
309            let read = self.get(id)?;
310            let mut r = read.clone();
311            let mut count = 0;
312            for rf in r.refs.iter_mut() {
313                if rf.id == ref_id && !rf.stale {
314                    rf.stale = true;
315                    count += 1;
316                }
317            }
318            if count == 0 || self.store.update_if_unchanged(&read, &r)? {
319                return Ok(count);
320            }
321        }
322        Err(write_conflict(id))
323    }
324
325    /// Read-modify-write one reminder without losing a concurrent change:
326    /// read, apply `change` to a copy, and write it only if the stored record
327    /// still matches what was read; otherwise re-read and re-apply (which also
328    /// re-validates the transition). Every lifecycle method used to write back
329    /// its read unconditionally, so a concurrent cancel, snooze, or execution
330    /// record was silently reverted while both calls returned Ok.
331    fn modify<F>(&self, id: &str, mut change: F) -> Result<Reminder, ReminderError>
332    where
333        F: FnMut(&mut Reminder) -> Result<(), ReminderError>,
334    {
335        for _ in 0..MAX_UPDATE_ATTEMPTS {
336            let read = self.get(id)?;
337            let mut r = read.clone();
338            change(&mut r)?;
339            if self.store.update_if_unchanged(&read, &r)? {
340                return Ok(r);
341            }
342        }
343        Err(write_conflict(id))
344    }
345}