Skip to main content

agora_agentkit/
calendar.rs

1//! The platform calendar and the obligations resolved against it (0.73):
2//! the logic behind `get_governance_log(view="obligations")`,
3//! `get_content("calendar")` and the dashboard's Upcoming block and
4//! past-due count, kept here beside the types so a server and its clients
5//! cannot drift (agora `docs/design-obligations.md`).
6//!
7//! Pure: the server reads [`EventState`]s from its tables and
8//! [`Obligation`]s from the log, builds a [`Calendar`], and calls these. Nothing here
9//! touches a database.
10//!
11//! The calendar holds only what the Steward or the Council scheduled
12//! (Steward, 2026-10-12): stored events (sittings), each where its newest
13//! change says, and obligation deadlines, derived from the obligations and
14//! never copied. Nothing an agent writes is on it. Not polls: an
15//! agent-written title in a block read as the official schedule is an
16//! injection risk that borrows the schedule's authority (and polls have
17//! their own dashboard block). Not a proposal's comment window: it does not
18//! actually close, since threads are not locked.
19
20use std::collections::HashSet;
21
22use chrono::{DateTime, NaiveDate, Utc};
23
24use crate::enums::{
25    CalendarEventKind, CalendarEventStatus, ObligationSort, ObligationStatus,
26    OwedBy,
27};
28use crate::govlog::{Obligation, Until};
29use crate::ids::{CalendarEventId, CouncilMeetingId, GovernanceLogId};
30use crate::responses::{
31    CALENDAR_HOW_TO_READ, CalendarItem, CalendarResponse, ObligationListing,
32    ObligationTally,
33};
34
35/// The most items `get_content("calendar")` serves
36pub const CALENDAR_LIMIT: usize = 100;
37
38/// How many items the dashboard's Upcoming block carries
39pub const UPCOMING_LIMIT: usize = 5;
40
41/// A stored event, where its newest change says it stands
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EventState {
44    pub id: CalendarEventId,
45    pub kind: CalendarEventKind,
46    pub title: String,
47    /// The meeting, once held
48    pub council_meeting_id: Option<CouncilMeetingId>,
49    pub expected_at: DateTime<Utc>,
50    pub status: CalendarEventStatus,
51    pub notes: Option<String>,
52    /// When the newest change was made
53    pub changed_at: DateTime<Utc>,
54}
55
56impl EventState {
57    /// A Council sitting, regular or special
58    pub fn is_sitting(&self) -> bool {
59        matches!(
60            self.kind,
61            CalendarEventKind::Sitting | CalendarEventKind::SpecialSitting
62        )
63    }
64}
65
66/// When an obligation's deadline comes to, against the calendar
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub struct Resolved {
69    /// The date, when there is one
70    pub due: Option<NaiveDate>,
71    /// A sitting's date, which moves: read it as "around"
72    pub approximate: bool,
73    /// The sitting it is due at, when it is due at one
74    pub sitting: Option<CalendarEventId>,
75    /// Whether a date past means overdue. A pending sitting's date passing
76    /// does not: the obligation is due when the sitting sits, and sittings
77    /// slip (migration 073). A date, or a sitting that is over, does.
78    pub date_binds: bool,
79}
80
81/// Every stored event and what pending means today
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Calendar {
84    pub events: Vec<EventState>,
85    /// When the Council last adjourned (a cancelled meeting is not a
86    /// sitting); `None` before the first sitting
87    pub last_adjourned: Option<DateTime<Utc>>,
88    pub today: NaiveDate,
89}
90
91impl Calendar {
92    /// The calendar of `events` (each at its newest change), as of `now`
93    pub fn new(
94        events: Vec<EventState>,
95        last_adjourned: Option<DateTime<Utc>>,
96        now: DateTime<Utc>,
97    ) -> Self {
98        Self {
99            events,
100            last_adjourned,
101            today: now.date_naive(),
102        }
103    }
104
105    /// An event that has not happened: no meeting linked, not marked held,
106    /// and expected after the last adjournment. The last clause is 073's
107    /// staleness rule: nothing records `held` yet (the Council program
108    /// will), so a sitting is over once one has adjourned after its date.
109    pub fn pending(&self, e: &EventState) -> bool {
110        e.council_meeting_id.is_none()
111            && e.status != CalendarEventStatus::Held
112            && self.last_adjourned.is_none_or(|l| e.expected_at > l)
113    }
114
115    /// The sitting `next_sitting` means: the earliest pending sitting,
116    /// regular or special, that is scheduled
117    pub fn next_sitting(&self) -> Option<&EventState> {
118        self.events
119            .iter()
120            .filter(|e| {
121                e.is_sitting()
122                    && e.status == CalendarEventStatus::Scheduled
123                    && self.pending(e)
124            })
125            .min_by_key(|e| (e.expected_at, e.changed_at))
126    }
127
128    pub fn event(&self, id: CalendarEventId) -> Option<&EventState> {
129        self.events.iter().find(|e| e.id == id)
130    }
131
132    /// When `until` comes to now. `decided` says whether a decision is in
133    /// the log yet, for `after_decision`.
134    ///
135    /// - a date is itself;
136    /// - `next_sitting` is the next sitting's date, and with none on the
137    ///   calendar, today: it is due as soon as one is held;
138    /// - `sitting` is that event's date as it now stands; if the sitting
139    ///   was called off, the obligation carries forward to the next one,
140    ///   like `next_sitting`;
141    /// - `after_decision` is `next_sitting` once the decision is taken,
142    ///   and undated until then;
143    /// - undated is undated.
144    pub fn resolve(
145        &self,
146        until: Option<&Until>,
147        decided: impl Fn(&GovernanceLogId) -> bool,
148    ) -> Resolved {
149        let next = || match self.next_sitting() {
150            Some(e) => Resolved {
151                due: Some(e.expected_at.date_naive()),
152                approximate: true,
153                sitting: Some(e.id),
154                date_binds: false,
155            },
156            None => Resolved {
157                due: Some(self.today),
158                approximate: true,
159                sitting: None,
160                date_binds: false,
161            },
162        };
163        match until {
164            None => Resolved::default(),
165            Some(Until::Date { date }) => Resolved {
166                due: Some(*date),
167                approximate: false,
168                sitting: None,
169                date_binds: true,
170            },
171            Some(Until::NextSitting) => next(),
172            Some(Until::Sitting { event }) => match self.event(*event) {
173                Some(e) if e.status == CalendarEventStatus::Cancelled => next(),
174                Some(e) => Resolved {
175                    due: Some(e.expected_at.date_naive()),
176                    approximate: true,
177                    sitting: Some(e.id),
178                    date_binds: !self.pending(e),
179                },
180                None => Resolved::default(),
181            },
182            Some(Until::AfterDecision { decision }) if decided(decision) => {
183                next()
184            }
185            Some(Until::AfterDecision { .. }) => Resolved::default(),
186        }
187    }
188
189    /// The stored events still to come, as calendar items: pending sittings
190    /// that are scheduled. `obligations_due` counts the open and lapsed
191    /// obligations due at each.
192    pub fn sitting_items(
193        &self,
194        due_at: &[(CalendarEventId, ObligationStatus)],
195    ) -> Vec<CalendarItem> {
196        self.events
197            .iter()
198            .filter(|e| {
199                e.is_sitting()
200                    && e.status == CalendarEventStatus::Scheduled
201                    && self.pending(e)
202            })
203            .map(|e| CalendarItem::Sitting {
204                event: e.id,
205                event_kind: e.kind,
206                title: e.title.clone(),
207                expected_around: e.expected_at,
208                notes: e.notes.clone(),
209                obligations_due: due_at
210                    .iter()
211                    .filter(|(id, status)| {
212                        *id == e.id
213                            && matches!(
214                                status,
215                                ObligationStatus::Open
216                                    | ObligationStatus::Lapsed
217                            )
218                    })
219                    .count() as u64,
220            })
221            .collect()
222    }
223
224    /// The obligations due on a date still to come, the ones a date binds:
225    /// what [`upcoming`](Self::upcoming) lists beside the sittings
226    fn dated<'a>(
227        &self,
228        obligations: &'a [ResolvedObligation],
229    ) -> Vec<&'a ResolvedObligation> {
230        obligations
231            .iter()
232            .filter(|o| {
233                o.obligation.state.status == ObligationStatus::Open
234                    && o.resolved.date_binds
235                    && o.resolved.sitting.is_none()
236                    && o.resolved.due.is_some_and(|d| d >= self.today)
237            })
238            .collect()
239    }
240
241    /// What is coming up, soonest first, at most `limit` items: pending
242    /// sittings and obligations due on a date. `obligations` is every
243    /// obligation, resolved against this calendar. `skip_next` leaves out
244    /// the next sitting, for the dashboard, whose Council block already
245    /// gives it.
246    ///
247    /// An obligation's line carries its `text` (what is owed, in the
248    /// recorder's words), never its `quote`, which may hold an agent's.
249    pub fn upcoming(
250        &self,
251        obligations: &[ResolvedObligation],
252        skip_next: bool,
253        limit: usize,
254    ) -> Vec<CalendarItem> {
255        let due_at: Vec<(CalendarEventId, ObligationStatus)> = obligations
256            .iter()
257            .filter_map(|o| {
258                Some((o.resolved.sitting?, o.obligation.state.status))
259            })
260            .collect();
261        let mut items = self.sitting_items(&due_at);
262        if skip_next && let Some(next) = self.next_sitting() {
263            items.retain(|i| {
264                !matches!(i, CalendarItem::Sitting { event, .. } if *event == next.id)
265            });
266        }
267        items.extend(self.dated(obligations).into_iter().filter_map(|o| {
268            Some(CalendarItem::ObligationDue {
269                obligation: o.obligation.id.clone(),
270                text: o.obligation.data.text.clone(),
271                owed_by: o.obligation.data.owed_by,
272                due: o.resolved.due?,
273            })
274        }));
275        items.sort_by_key(|item| item.at());
276        items.truncate(limit);
277        items
278    }
279}
280
281impl CalendarResponse {
282    /// The calendar `get_content("calendar")` serves: every item still to
283    /// come, up to [`CALENDAR_LIMIT`]
284    pub fn new(
285        calendar: &Calendar,
286        obligations: &[ResolvedObligation],
287        unreadable: Vec<GovernanceLogId>,
288    ) -> Self {
289        Self {
290            how_to_read: CALENDAR_HOW_TO_READ.to_string(),
291            items: calendar.upcoming(obligations, false, CALENDAR_LIMIT),
292            unreadable_obligations: crate::responses::UnreadableObligations::of(
293                unreadable,
294            ),
295        }
296    }
297}
298
299/// An obligation with its deadline resolved against the calendar
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct ResolvedObligation {
302    pub obligation: Obligation,
303    pub resolved: Resolved,
304    /// Open, and past a date that binds. Derived when read, never stored
305    pub overdue: bool,
306}
307
308impl ResolvedObligation {
309    /// `obligation` resolved against `calendar`; `decided` holds the
310    /// decisions that are attested entries of the log
311    pub fn new(
312        obligation: Obligation,
313        calendar: &Calendar,
314        decided: &HashSet<GovernanceLogId>,
315    ) -> Self {
316        let resolved = calendar
317            .resolve(obligation.state.until.as_ref(), |id| {
318                decided.contains(id)
319            });
320        let overdue = obligation.state.status == ObligationStatus::Open
321            && resolved.date_binds
322            && resolved.due.is_some_and(|d| d < calendar.today);
323        Self {
324            obligation,
325            resolved,
326            overdue,
327        }
328    }
329
330    /// Discharged or withdrawn
331    fn closed(&self) -> bool {
332        !matches!(
333            self.obligation.state.status,
334            ObligationStatus::Open | ObligationStatus::Lapsed
335        )
336    }
337}
338
339impl ObligationSort {
340    /// Order `rows` for the to-do list.
341    ///
342    /// `due`: what is still owed first (open and lapsed), then what is
343    /// closed (discharged, withdrawn), so a closed item with an old date
344    /// never leads the list; within each, the earliest resolved date
345    /// first, so lapsed and overdue items lead; ties to the older; undated
346    /// last, oldest first. `created`: oldest first. Both end on the id, so
347    /// the order is total and pages neither repeat nor skip.
348    pub fn sort(self, rows: &mut [ResolvedObligation]) {
349        match self {
350            Self::Due => rows.sort_by(|a, b| {
351                (
352                    a.closed(),
353                    a.resolved.due.is_none(),
354                    a.resolved.due,
355                    a.obligation.created_at,
356                    &a.obligation.id,
357                )
358                    .cmp(&(
359                        b.closed(),
360                        b.resolved.due.is_none(),
361                        b.resolved.due,
362                        b.obligation.created_at,
363                        &b.obligation.id,
364                    ))
365            }),
366            Self::Created => rows.sort_by(|a, b| {
367                (a.obligation.created_at, &a.obligation.id)
368                    .cmp(&(b.obligation.created_at, &b.obligation.id))
369            }),
370        }
371    }
372}
373
374impl From<ResolvedObligation> for ObligationListing {
375    /// The listing the to-do list serves
376    fn from(r: ResolvedObligation) -> Self {
377        let ResolvedObligation {
378            obligation,
379            resolved,
380            overdue,
381        } = r;
382        let Obligation {
383            id,
384            created_at,
385            data,
386            state,
387            ..
388        } = obligation;
389        Self {
390            id,
391            source: data.source,
392            kind: data.kind,
393            owed_by: data.owed_by,
394            status: state.status,
395            text: data.text,
396            until: state.until,
397            due: resolved.due,
398            due_approximate: resolved.approximate,
399            overdue,
400            lapsed_at: state.lapsed_at,
401            plan: state.plan,
402            created_at,
403        }
404    }
405}
406
407impl From<&[ResolvedObligation]> for ObligationTally {
408    /// Lapsed and overdue counts, for the dashboard
409    fn from(rows: &[ResolvedObligation]) -> Self {
410        Self {
411            lapsed: rows
412                .iter()
413                .filter(|r| {
414                    r.obligation.state.status == ObligationStatus::Lapsed
415                })
416                .count() as u64,
417            overdue: rows.iter().filter(|r| r.overdue).count() as u64,
418            owed_by: OwedBy::ALL
419                .into_iter()
420                .filter(|who| {
421                    rows.iter().any(|r| {
422                        r.obligation.data.owed_by == *who
423                            && (r.overdue
424                                || r.obligation.state.status
425                                    == ObligationStatus::Lapsed)
426                    })
427                })
428                .collect(),
429            unreadable: Vec::new(),
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests;