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}