Skip to main content

agentstategraph_reminders/
types.rs

1//! Core data types for the reminders substrate.
2
3use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7// ---------------------------------------------------------------------------
8// Priority
9// ---------------------------------------------------------------------------
10
11/// Reminder urgency. Lower number = higher urgency (1 is most urgent).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Priority {
15    Critical = 1,
16    High = 2,
17    Medium = 3,
18    Low = 4,
19    Minimal = 5,
20}
21
22impl Default for Priority {
23    fn default() -> Self {
24        Self::Medium
25    }
26}
27
28impl Priority {
29    pub fn as_u8(self) -> u8 {
30        self as u8
31    }
32}
33
34// ---------------------------------------------------------------------------
35// ReminderStatus
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ReminderStatus {
41    /// Scheduled but not yet due.
42    Pending,
43    /// `due_at` has passed; ready to be acted on.
44    Due,
45    /// Not autonomous — waiting for explicit user approval before execution.
46    AwaitingPermission,
47    /// Agent has begun working on this reminder.
48    InProgress,
49    /// Successfully executed (or acknowledged).
50    Completed,
51    /// Deferred to a later time via `snooze()`.
52    Snoozed,
53    /// Explicitly cancelled; will never fire again.
54    Cancelled,
55}
56
57impl ReminderStatus {
58    /// Returns `true` if the reminder is in a terminal state.
59    pub fn is_terminal(self) -> bool {
60        matches!(self, Self::Completed | Self::Cancelled)
61    }
62
63    /// Returns `true` if the reminder is actionable right now.
64    pub fn is_actionable(self) -> bool {
65        matches!(
66            self,
67            Self::Due | Self::AwaitingPermission | Self::InProgress
68        )
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Schedule
74// ---------------------------------------------------------------------------
75
76/// Recurrence schedule for a repeating reminder.
77///
78/// When `record_execution` completes, the manager computes the next `due_at`
79/// from the schedule and resets status to `Pending`.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum Schedule {
83    /// Fire once (no recurrence). This is the default when no schedule is set.
84    Once,
85    /// Repeat every N seconds after each execution.
86    Interval { every_seconds: u64 },
87    /// Repeat daily at a fixed UTC time.
88    Daily { time: NaiveTime },
89    /// Repeat weekly on a specific day at a fixed UTC time.
90    Weekly { day: Weekday, time: NaiveTime },
91}
92
93impl Schedule {
94    /// Compute the next due time given the time the current execution
95    /// completed. Returns `None` for `Once` (no recurrence).
96    pub fn next_due(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
97        use chrono::Duration;
98        match self {
99            Schedule::Once => None,
100            Schedule::Interval { every_seconds } => {
101                Some(after + Duration::seconds(*every_seconds as i64))
102            }
103            Schedule::Daily { time } => {
104                let candidate = after.date_naive().and_time(*time).and_utc();
105                Some(if candidate > after {
106                    candidate
107                } else {
108                    (after.date_naive() + chrono::Days::new(1))
109                        .and_time(*time)
110                        .and_utc()
111                })
112            }
113            Schedule::Weekly { day, time } => {
114                // Walk forward day-by-day until we hit the target weekday.
115                let mut date = after.date_naive() + chrono::Days::new(1);
116                for _ in 0..7 {
117                    if date.weekday() == *day {
118                        let candidate = date.and_time(*time).and_utc();
119                        return Some(candidate);
120                    }
121                    date = date + chrono::Days::new(1);
122                }
123                None // unreachable
124            }
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Soft references
131// ---------------------------------------------------------------------------
132
133/// The kind of object a `ReminderRef` points to.
134#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum RefKind {
137    /// A git-style branch in the stategraph.
138    Branch,
139    /// A memory entry (key in the memories system).
140    Memory,
141    /// A plan record.
142    Plan,
143    /// A task record.
144    Task,
145    /// Any `/_path` in the stategraph state tree.
146    StatePath,
147    /// An external resource (URL, file path, etc.).
148    External { scheme: String },
149}
150
151/// A soft, advisory reference to another object.
152///
153/// Refs are contextual metadata — a stale ref does not invalidate the
154/// reminder. The `label` is captured at creation so the agent retains
155/// a human-readable hint even if the underlying object is renamed or
156/// deleted.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ReminderRef {
159    pub kind: RefKind,
160    /// Stable identifier (branch name, memory key, task ID, path, URL…).
161    pub id: String,
162    /// Human-readable label captured at creation time. Survives renames.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub label: Option<String>,
165    /// Set lazily when the referenced object can no longer be resolved.
166    #[serde(default)]
167    pub stale: bool,
168}
169
170impl ReminderRef {
171    pub fn branch(id: impl Into<String>, label: impl Into<String>) -> Self {
172        Self {
173            kind: RefKind::Branch,
174            id: id.into(),
175            label: Some(label.into()),
176            stale: false,
177        }
178    }
179
180    pub fn memory(id: impl Into<String>) -> Self {
181        Self {
182            kind: RefKind::Memory,
183            id: id.into(),
184            label: None,
185            stale: false,
186        }
187    }
188
189    pub fn plan(id: impl Into<String>, label: impl Into<String>) -> Self {
190        Self {
191            kind: RefKind::Plan,
192            id: id.into(),
193            label: Some(label.into()),
194            stale: false,
195        }
196    }
197
198    pub fn task(id: impl Into<String>, label: impl Into<String>) -> Self {
199        Self {
200            kind: RefKind::Task,
201            id: id.into(),
202            label: Some(label.into()),
203            stale: false,
204        }
205    }
206
207    pub fn state_path(path: impl Into<String>) -> Self {
208        Self {
209            kind: RefKind::StatePath,
210            id: path.into(),
211            label: None,
212            stale: false,
213        }
214    }
215
216    pub fn external(url: impl Into<String>, scheme: impl Into<String>) -> Self {
217        Self {
218            kind: RefKind::External {
219                scheme: scheme.into(),
220            },
221            id: url.into(),
222            label: None,
223            stale: false,
224        }
225    }
226}
227
228// ---------------------------------------------------------------------------
229// ExecutionRecord
230// ---------------------------------------------------------------------------
231
232/// Outcome of a single reminder execution attempt.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ExecutionResult {
236    /// Reminder was fully acted on.
237    Success,
238    /// Execution failed; see `notes`.
239    Failed,
240    /// Agent decided to defer without a specific new time.
241    Deferred,
242    /// Agent snoozed during execution (new `due_at` was set).
243    Snoozed,
244    /// Reminder was cancelled during execution.
245    Cancelled,
246}
247
248/// Audit record of one execution of a reminder.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ExecutionRecord {
251    pub started_at: DateTime<Utc>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub completed_at: Option<DateTime<Utc>>,
254    pub agent_id: String,
255    /// Set when `autonomous: false` and user approved execution.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub approved_by: Option<String>,
258    pub result: ExecutionResult,
259    #[serde(default)]
260    pub notes: Vec<String>,
261    /// Task ID created for this execution, if the agent created one.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub task_id: Option<String>,
264}
265
266// ---------------------------------------------------------------------------
267// Reminder
268// ---------------------------------------------------------------------------
269
270/// A scheduled reminder for an agent or user.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct Reminder {
273    pub id: String,
274    pub title: String,
275    /// Full instructions for the agent at execution time.
276    pub instructions: String,
277    /// Optional specific commands/tool calls to run (agent interprets these).
278    #[serde(default)]
279    pub commands: Vec<String>,
280    /// Soft references to contextual objects (branches, memories, plans, tasks).
281    #[serde(default)]
282    pub refs: Vec<ReminderRef>,
283    pub priority: Priority,
284    pub due_at: DateTime<Utc>,
285    /// Recurrence schedule. `None` means fire once.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub schedule: Option<Schedule>,
288    /// `true`: agent may execute without asking the user first.
289    /// `false`: agent must surface to user and call `approve()` before acting.
290    pub autonomous: bool,
291    pub created_by: String,
292    pub created_at: DateTime<Utc>,
293    pub status: ReminderStatus,
294    /// Set when the reminder was snoozed; the original `due_at` is preserved
295    /// as the first entry in `executions` if snooze happened mid-execution.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub snoozed_until: Option<DateTime<Utc>>,
298    /// Full audit trail of every execution attempt.
299    #[serde(default)]
300    pub executions: Vec<ExecutionRecord>,
301    #[serde(default)]
302    pub tags: Vec<String>,
303}
304
305// ---------------------------------------------------------------------------
306// Input / filter types
307// ---------------------------------------------------------------------------
308
309/// Input for creating a new reminder.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct CreateReminder {
312    pub title: String,
313    pub instructions: String,
314    #[serde(default)]
315    pub commands: Vec<String>,
316    #[serde(default)]
317    pub refs: Vec<ReminderRef>,
318    #[serde(default)]
319    pub priority: Priority,
320    pub due_at: DateTime<Utc>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub schedule: Option<Schedule>,
323    #[serde(default = "default_true")]
324    pub autonomous: bool,
325    pub created_by: String,
326    #[serde(default)]
327    pub tags: Vec<String>,
328}
329
330fn default_true() -> bool {
331    true
332}
333
334impl CreateReminder {
335    pub fn new(
336        title: impl Into<String>,
337        instructions: impl Into<String>,
338        due_at: DateTime<Utc>,
339        created_by: impl Into<String>,
340    ) -> Self {
341        Self {
342            title: title.into(),
343            instructions: instructions.into(),
344            commands: Vec::new(),
345            refs: Vec::new(),
346            priority: Priority::default(),
347            due_at,
348            schedule: None,
349            autonomous: true,
350            created_by: created_by.into(),
351            tags: Vec::new(),
352        }
353    }
354
355    pub fn with_priority(mut self, p: Priority) -> Self {
356        self.priority = p;
357        self
358    }
359    pub fn with_schedule(mut self, s: Schedule) -> Self {
360        self.schedule = Some(s);
361        self
362    }
363    pub fn with_autonomous(mut self, a: bool) -> Self {
364        self.autonomous = a;
365        self
366    }
367    pub fn with_commands(mut self, cmds: Vec<String>) -> Self {
368        self.commands = cmds;
369        self
370    }
371    pub fn with_refs(mut self, refs: Vec<ReminderRef>) -> Self {
372        self.refs = refs;
373        self
374    }
375    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
376        self.tags = tags;
377        self
378    }
379
380    /// Build a `Reminder` from this input, generating a new ID.
381    pub fn into_reminder(self) -> Reminder {
382        let now = Utc::now();
383        Reminder {
384            id: Uuid::new_v4().to_string(),
385            title: self.title,
386            instructions: self.instructions,
387            commands: self.commands,
388            refs: self.refs,
389            priority: self.priority,
390            due_at: self.due_at,
391            schedule: self.schedule,
392            autonomous: self.autonomous,
393            created_by: self.created_by,
394            created_at: now,
395            status: ReminderStatus::Pending,
396            snoozed_until: None,
397            executions: Vec::new(),
398            tags: self.tags,
399        }
400    }
401}
402
403/// Filters for `ReminderStore::list`.
404#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405pub struct ReminderFilter {
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub status: Option<ReminderStatus>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub priority_at_most: Option<Priority>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub created_by: Option<String>,
412    /// Return only reminders due at or before this time.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub due_before: Option<DateTime<Utc>>,
415    /// Return only reminders that have a ref with this `id`.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub ref_id: Option<String>,
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub tags: Vec<String>,
420}
421
422impl ReminderFilter {
423    pub fn matches(&self, r: &Reminder) -> bool {
424        if let Some(s) = self.status {
425            if r.status != s {
426                return false;
427            }
428        }
429        if let Some(p) = self.priority_at_most {
430            if r.priority > p {
431                return false;
432            }
433        }
434        if let Some(ref cb) = self.created_by {
435            if &r.created_by != cb {
436                return false;
437            }
438        }
439        if let Some(due) = self.due_before {
440            if r.due_at > due {
441                return false;
442            }
443        }
444        if let Some(ref rid) = self.ref_id {
445            if !r.refs.iter().any(|rf| &rf.id == rid) {
446                return false;
447            }
448        }
449        for tag in &self.tags {
450            if !r.tags.contains(tag) {
451                return false;
452            }
453        }
454        true
455    }
456}
457
458// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use chrono::Duration;
466
467    fn due_in(secs: i64) -> DateTime<Utc> {
468        Utc::now() + Duration::seconds(secs)
469    }
470
471    fn past(secs: i64) -> DateTime<Utc> {
472        Utc::now() - Duration::seconds(secs)
473    }
474
475    // --- Priority ---
476
477    #[test]
478    fn priority_ordering() {
479        assert!(Priority::Critical < Priority::High);
480        assert!(Priority::High < Priority::Medium);
481        assert!(Priority::Medium < Priority::Low);
482        assert!(Priority::Low < Priority::Minimal);
483    }
484
485    #[test]
486    fn priority_default_is_medium() {
487        assert_eq!(Priority::default(), Priority::Medium);
488    }
489
490    #[test]
491    fn priority_roundtrips_json() {
492        for p in [
493            Priority::Critical,
494            Priority::High,
495            Priority::Medium,
496            Priority::Low,
497            Priority::Minimal,
498        ] {
499            let j = serde_json::to_value(p).unwrap();
500            let back: Priority = serde_json::from_value(j).unwrap();
501            assert_eq!(p, back);
502        }
503    }
504
505    // --- ReminderStatus ---
506
507    #[test]
508    fn terminal_statuses() {
509        assert!(ReminderStatus::Completed.is_terminal());
510        assert!(ReminderStatus::Cancelled.is_terminal());
511        assert!(!ReminderStatus::Pending.is_terminal());
512        assert!(!ReminderStatus::Due.is_terminal());
513    }
514
515    #[test]
516    fn actionable_statuses() {
517        assert!(ReminderStatus::Due.is_actionable());
518        assert!(ReminderStatus::AwaitingPermission.is_actionable());
519        assert!(ReminderStatus::InProgress.is_actionable());
520        assert!(!ReminderStatus::Pending.is_actionable());
521        assert!(!ReminderStatus::Snoozed.is_actionable());
522    }
523
524    // --- Schedule::next_due ---
525
526    #[test]
527    fn schedule_once_returns_none() {
528        let now = Utc::now();
529        assert!(Schedule::Once.next_due(now).is_none());
530    }
531
532    #[test]
533    fn schedule_interval_advances_by_given_seconds() {
534        let now = Utc::now();
535        let next = Schedule::Interval {
536            every_seconds: 3600,
537        }
538        .next_due(now)
539        .unwrap();
540        let diff = (next - now).num_seconds();
541        assert_eq!(diff, 3600);
542    }
543
544    #[test]
545    fn schedule_daily_advances_to_next_occurrence() {
546        let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
547        let now = Utc::now();
548        let next = Schedule::Daily { time }.next_due(now).unwrap();
549        assert!(next > now);
550        assert!(next <= now + Duration::days(2));
551        assert_eq!(next.time(), time);
552    }
553
554    #[test]
555    fn schedule_weekly_lands_on_correct_day() {
556        let time = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
557        let now = Utc::now();
558        let next = Schedule::Weekly {
559            day: Weekday::Mon,
560            time,
561        }
562        .next_due(now)
563        .unwrap();
564        assert!(next > now);
565        assert_eq!(next.weekday(), Weekday::Mon);
566    }
567
568    // --- ReminderRef builders ---
569
570    #[test]
571    fn ref_builders_set_kind_and_stale_false() {
572        let b = ReminderRef::branch("feature/x", "Feature X cleanup");
573        assert_eq!(b.kind, RefKind::Branch);
574        assert!(!b.stale);
575        assert_eq!(b.label.as_deref(), Some("Feature X cleanup"));
576
577        let t = ReminderRef::task("t-042", "Deploy task");
578        assert_eq!(t.kind, RefKind::Task);
579        assert!(!t.stale);
580
581        let sp = ReminderRef::state_path("/_sessions/dev-server");
582        assert_eq!(sp.kind, RefKind::StatePath);
583        assert!(sp.label.is_none());
584    }
585
586    #[test]
587    fn ref_stale_can_be_set() {
588        let mut r = ReminderRef::memory("mem-001");
589        assert!(!r.stale);
590        r.stale = true;
591        assert!(r.stale);
592    }
593
594    #[test]
595    fn ref_roundtrips_json() {
596        let r = ReminderRef::branch("main", "main branch");
597        let j = serde_json::to_value(&r).unwrap();
598        let back: ReminderRef = serde_json::from_value(j).unwrap();
599        assert_eq!(r, back);
600    }
601
602    // --- CreateReminder / into_reminder ---
603
604    #[test]
605    fn create_reminder_builder_defaults() {
606        let cr = CreateReminder::new("title", "do the thing", due_in(3600), "agent/test");
607        assert!(cr.autonomous);
608        assert_eq!(cr.priority, Priority::Medium);
609        assert!(cr.schedule.is_none());
610        assert!(cr.refs.is_empty());
611        assert!(cr.tags.is_empty());
612    }
613
614    #[test]
615    fn into_reminder_generates_id_and_pending_status() {
616        let due = due_in(3600);
617        let r = CreateReminder::new("test", "instructions", due, "agent/a").into_reminder();
618        assert!(!r.id.is_empty());
619        assert_eq!(r.status, ReminderStatus::Pending);
620        assert_eq!(r.due_at, due);
621        assert!(r.executions.is_empty());
622    }
623
624    #[test]
625    fn create_reminder_builder_chain() {
626        let due = due_in(100);
627        let r = CreateReminder::new("t", "i", due, "a")
628            .with_priority(Priority::Critical)
629            .with_autonomous(false)
630            .with_tags(vec!["cleanup".into()])
631            .with_schedule(Schedule::Interval {
632                every_seconds: 86400,
633            })
634            .with_refs(vec![ReminderRef::branch("feat/x", "Feature X")])
635            .into_reminder();
636
637        assert_eq!(r.priority, Priority::Critical);
638        assert!(!r.autonomous);
639        assert_eq!(r.tags, vec!["cleanup"]);
640        assert!(matches!(r.schedule, Some(Schedule::Interval { .. })));
641        assert_eq!(r.refs.len(), 1);
642    }
643
644    // --- ReminderFilter::matches ---
645
646    fn make_reminder(
647        status: ReminderStatus,
648        priority: Priority,
649        due_at: DateTime<Utc>,
650    ) -> Reminder {
651        let mut r = CreateReminder::new("t", "i", due_at, "agent/a")
652            .with_priority(priority)
653            .into_reminder();
654        r.status = status;
655        r
656    }
657
658    #[test]
659    fn filter_by_status() {
660        let r = make_reminder(ReminderStatus::Due, Priority::Medium, past(1));
661        assert!(
662            ReminderFilter {
663                status: Some(ReminderStatus::Due),
664                ..Default::default()
665            }
666            .matches(&r)
667        );
668        assert!(
669            !ReminderFilter {
670                status: Some(ReminderStatus::Pending),
671                ..Default::default()
672            }
673            .matches(&r)
674        );
675    }
676
677    #[test]
678    fn filter_by_priority_at_most() {
679        let r = make_reminder(ReminderStatus::Due, Priority::High, past(1));
680        // High (2) <= Medium (3) → matches
681        assert!(
682            ReminderFilter {
683                priority_at_most: Some(Priority::Medium),
684                ..Default::default()
685            }
686            .matches(&r)
687        );
688        // High (2) > Critical (1) → no match
689        assert!(
690            !ReminderFilter {
691                priority_at_most: Some(Priority::Critical),
692                ..Default::default()
693            }
694            .matches(&r)
695        );
696    }
697
698    #[test]
699    fn filter_by_due_before() {
700        let now = Utc::now();
701        let r = make_reminder(
702            ReminderStatus::Pending,
703            Priority::Medium,
704            now + Duration::hours(2),
705        );
706        assert!(
707            !ReminderFilter {
708                due_before: Some(now + Duration::hours(1)),
709                ..Default::default()
710            }
711            .matches(&r)
712        );
713        assert!(
714            ReminderFilter {
715                due_before: Some(now + Duration::hours(3)),
716                ..Default::default()
717            }
718            .matches(&r)
719        );
720    }
721
722    #[test]
723    fn filter_by_ref_id() {
724        let mut r = make_reminder(ReminderStatus::Pending, Priority::Medium, due_in(100));
725        r.refs
726            .push(ReminderRef::branch("feature/web-demo", "web demo"));
727        assert!(
728            ReminderFilter {
729                ref_id: Some("feature/web-demo".into()),
730                ..Default::default()
731            }
732            .matches(&r)
733        );
734        assert!(
735            !ReminderFilter {
736                ref_id: Some("other-branch".into()),
737                ..Default::default()
738            }
739            .matches(&r)
740        );
741    }
742
743    #[test]
744    fn filter_by_tags_all_must_match() {
745        let mut r = make_reminder(ReminderStatus::Pending, Priority::Medium, due_in(100));
746        r.tags = vec!["cleanup".into(), "infra".into()];
747        assert!(
748            ReminderFilter {
749                tags: vec!["cleanup".into()],
750                ..Default::default()
751            }
752            .matches(&r)
753        );
754        assert!(
755            ReminderFilter {
756                tags: vec!["cleanup".into(), "infra".into()],
757                ..Default::default()
758            }
759            .matches(&r)
760        );
761        assert!(
762            !ReminderFilter {
763                tags: vec!["cleanup".into(), "missing".into()],
764                ..Default::default()
765            }
766            .matches(&r)
767        );
768    }
769
770    #[test]
771    fn empty_filter_matches_everything() {
772        let r = make_reminder(ReminderStatus::Due, Priority::Critical, past(1));
773        assert!(ReminderFilter::default().matches(&r));
774    }
775}