Skip to main content

okbot_testkit/
memory_store.rs

1//! An in-memory [`ConversationStore`].
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use jiff::{SignedDuration, Timestamp};
8use okbot_core::{
9    AgentRunRecord, AutomationRun, ChannelId, Clock, ConversationKey, ConversationStore, Fire,
10    FireState, NewMessage, PAIRING_MAX_FAILURES, PeerTaskRecord, PeerTaskState, RoleGrant,
11    SealedEntry, StoreError, StoredMessage, ToolCallRecord, Usage, UserId, async_trait,
12};
13
14#[derive(Default)]
15struct State {
16    seen: HashMap<(ChannelId, String), Timestamp>,
17    messages: Vec<(u64, NewMessage)>,
18    reset_after: HashMap<ConversationKey, u64>,
19    sessions: HashMap<ConversationKey, (String, String)>,
20    runs: Vec<AgentRunRecord>,
21    next: u64,
22    owners: Vec<(ChannelId, UserId)>,
23    /// code → (expires, failures)
24    codes: HashMap<String, (Timestamp, u32)>,
25    fires: HashMap<(String, Timestamp), Fire>,
26    direct: HashMap<(ChannelId, UserId), ConversationKey>,
27    tool_calls: Vec<ToolCallRecord>,
28    grants: HashMap<(String, String), RoleGrant>,
29    /// (kind, server, subject) → (sealed, updated)
30    sealed: HashMap<(String, String, String), (Vec<u8>, Timestamp)>,
31    /// (automation, item) → seen
32    items: HashMap<(String, String), Timestamp>,
33    automation_runs: Vec<AutomationRun>,
34    /// (inbound, id) → task
35    peer_tasks: HashMap<(bool, String), PeerTaskRecord>,
36}
37
38/// A [`ConversationStore`] kept in memory, for runtime tests.
39pub struct MemoryStore {
40    state: Mutex<State>,
41    clock: Arc<dyn Clock>,
42}
43
44impl std::fmt::Debug for MemoryStore {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("MemoryStore").finish_non_exhaustive()
47    }
48}
49
50impl MemoryStore {
51    /// An empty store reading time from `clock`.
52    pub fn new(clock: Arc<dyn Clock>) -> Self {
53        Self {
54            state: Mutex::new(State::default()),
55            clock,
56        }
57    }
58
59    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
60        self.state
61            .lock()
62            .unwrap_or_else(std::sync::PoisonError::into_inner)
63    }
64
65    /// Recorded agent runs, oldest first.
66    pub fn runs(&self) -> Vec<AgentRunRecord> {
67        self.lock().runs.clone()
68    }
69
70    /// Recorded tool calls, oldest first.
71    pub fn recorded_tool_calls(&self) -> Vec<ToolCallRecord> {
72        self.lock().tool_calls.clone()
73    }
74}
75
76fn signed(d: Duration) -> SignedDuration {
77    SignedDuration::try_from(d).unwrap_or(SignedDuration::MAX)
78}
79
80#[async_trait]
81impl ConversationStore for MemoryStore {
82    async fn mark_seen(
83        &self,
84        channel: &ChannelId,
85        event_id: &str,
86        ttl: Duration,
87    ) -> Result<bool, StoreError> {
88        let now = self.clock.now();
89        let mut state = self.lock();
90        let key = (channel.clone(), event_id.to_string());
91        if state.seen.get(&key).is_some_and(|expires| *expires > now) {
92            return Ok(false);
93        }
94        state
95            .seen
96            .insert(key, now.saturating_add(signed(ttl)).unwrap_or(now));
97        Ok(true)
98    }
99
100    async fn append(&self, message: NewMessage) -> Result<(), StoreError> {
101        let mut state = self.lock();
102        state.next += 1;
103        let id = state.next;
104        state.messages.push((id, message));
105        Ok(())
106    }
107
108    async fn recent(
109        &self,
110        conversation: &ConversationKey,
111        limit: usize,
112    ) -> Result<Vec<StoredMessage>, StoreError> {
113        let state = self.lock();
114        let after = state.reset_after.get(conversation).copied().unwrap_or(0);
115        let mut out: Vec<StoredMessage> = state
116            .messages
117            .iter()
118            .rev()
119            .filter(|(id, m)| *id > after && m.conversation == *conversation)
120            .take(limit)
121            .map(|(_, m)| StoredMessage {
122                role: m.role,
123                author_name: m.author_name.clone(),
124                text: m.text.clone(),
125                at: m.at,
126            })
127            .collect();
128        out.reverse();
129        Ok(out)
130    }
131
132    async fn reset(&self, conversation: &ConversationKey) -> Result<(), StoreError> {
133        let mut state = self.lock();
134        let last = state.next;
135        state.reset_after.insert(conversation.clone(), last);
136        state.sessions.remove(conversation);
137        Ok(())
138    }
139
140    async fn session(
141        &self,
142        conversation: &ConversationKey,
143    ) -> Result<Option<(String, String)>, StoreError> {
144        Ok(self.lock().sessions.get(conversation).cloned())
145    }
146
147    async fn set_session(
148        &self,
149        conversation: &ConversationKey,
150        backend: &str,
151        session: &str,
152    ) -> Result<(), StoreError> {
153        self.lock().sessions.insert(
154            conversation.clone(),
155            (backend.to_string(), session.to_string()),
156        );
157        Ok(())
158    }
159
160    async fn owners(&self) -> Result<Vec<(ChannelId, UserId)>, StoreError> {
161        let mut owners = self.lock().owners.clone();
162        owners.sort();
163        Ok(owners)
164    }
165
166    async fn remove_owner(&self, channel: &ChannelId, user: &UserId) -> Result<bool, StoreError> {
167        let mut state = self.lock();
168        let before = state.owners.len();
169        state.owners.retain(|(c, u)| c != channel || u != user);
170        Ok(state.owners.len() != before)
171    }
172
173    async fn create_pairing_code(&self, code: &str, ttl: Duration) -> Result<(), StoreError> {
174        let expires = self.clock.now() + signed(ttl);
175        self.lock().codes.insert(code.to_string(), (expires, 0));
176        Ok(())
177    }
178
179    async fn redeem_pairing_code(
180        &self,
181        code: &str,
182        channel: &ChannelId,
183        user: &UserId,
184    ) -> Result<bool, StoreError> {
185        let now = self.clock.now();
186        let mut state = self.lock();
187        state.codes.retain(|_, (expires, _)| *expires > now);
188        if state.codes.remove(code).is_some() {
189            let account = (channel.clone(), user.clone());
190            if !state.owners.contains(&account) {
191                state.owners.push(account);
192            }
193            return Ok(true);
194        }
195        for (_, failures) in state.codes.values_mut() {
196            *failures += 1;
197        }
198        state
199            .codes
200            .retain(|_, (_, failures)| *failures < PAIRING_MAX_FAILURES);
201        Ok(false)
202    }
203
204    async fn record_run(&self, run: AgentRunRecord) -> Result<(), StoreError> {
205        self.lock().runs.push(run);
206        Ok(())
207    }
208
209    async fn claim_fire(&self, task_id: &str, fire_at: Timestamp) -> Result<Fire, StoreError> {
210        let now = self.clock.now();
211        Ok(self
212            .lock()
213            .fires
214            .entry((task_id.to_string(), fire_at))
215            .or_insert_with(|| Fire {
216                task_id: task_id.to_string(),
217                fire_at,
218                state: FireState::Sending,
219                attempts: 0,
220                updated_at: now,
221            })
222            .clone())
223    }
224
225    async fn set_fire(
226        &self,
227        task_id: &str,
228        fire_at: Timestamp,
229        state: FireState,
230        attempts: u32,
231    ) -> Result<(), StoreError> {
232        let fire = Fire {
233            task_id: task_id.to_string(),
234            fire_at,
235            state,
236            attempts,
237            updated_at: self.clock.now(),
238        };
239        self.lock()
240            .fires
241            .insert((task_id.to_string(), fire_at), fire);
242        Ok(())
243    }
244
245    async fn recent_fires(&self, limit: usize) -> Result<Vec<Fire>, StoreError> {
246        let mut fires: Vec<Fire> = self.lock().fires.values().cloned().collect();
247        fires.sort_by_key(|f| std::cmp::Reverse((f.updated_at, f.fire_at)));
248        fires.truncate(limit);
249        Ok(fires)
250    }
251
252    async fn set_direct_chat(
253        &self,
254        channel: &ChannelId,
255        user: &UserId,
256        conversation: &ConversationKey,
257    ) -> Result<(), StoreError> {
258        self.lock()
259            .direct
260            .insert((channel.clone(), user.clone()), conversation.clone());
261        Ok(())
262    }
263
264    async fn direct_chat(
265        &self,
266        channel: &ChannelId,
267        user: &UserId,
268    ) -> Result<Option<ConversationKey>, StoreError> {
269        Ok(self
270            .lock()
271            .direct
272            .get(&(channel.clone(), user.clone()))
273            .cloned())
274    }
275
276    async fn usage_since(&self, person: &str, since: Timestamp) -> Result<Usage, StoreError> {
277        let state = self.lock();
278        let mut usage = Usage::default();
279        for run in state
280            .runs
281            .iter()
282            .filter(|r| r.person.as_deref() == Some(person) && r.started_at >= since)
283        {
284            usage.turns += 1;
285            usage.tokens += run.usage.map_or(0, |u| u.input + u.output);
286        }
287        Ok(usage)
288    }
289
290    async fn record_tool_call(&self, call: ToolCallRecord) -> Result<(), StoreError> {
291        self.lock().tool_calls.push(call);
292        Ok(())
293    }
294
295    async fn tool_calls(
296        &self,
297        since: Timestamp,
298        limit: usize,
299    ) -> Result<Vec<ToolCallRecord>, StoreError> {
300        Ok(self
301            .lock()
302            .tool_calls
303            .iter()
304            .rev()
305            .filter(|c| c.at >= since)
306            .take(limit)
307            .cloned()
308            .collect())
309    }
310
311    async fn grant_role(&self, grant: RoleGrant) -> Result<(), StoreError> {
312        self.lock()
313            .grants
314            .insert((grant.role.clone(), grant.account.clone()), grant);
315        Ok(())
316    }
317
318    async fn revoke_role(&self, role: &str, account: &str) -> Result<bool, StoreError> {
319        let now = self.clock.now();
320        Ok(self
321            .lock()
322            .grants
323            .remove(&(role.to_string(), account.to_string()))
324            .is_some_and(|g| g.expires_at > now))
325    }
326
327    async fn role_grants(&self, now: Timestamp) -> Result<Vec<RoleGrant>, StoreError> {
328        let mut grants: Vec<RoleGrant> = self
329            .lock()
330            .grants
331            .values()
332            .filter(|g| g.expires_at > now)
333            .cloned()
334            .collect();
335        grants.sort_by(|a, b| (&a.role, &a.account).cmp(&(&b.role, &b.account)));
336        Ok(grants)
337    }
338
339    async fn purge_before(&self, before: Timestamp) -> Result<u64, StoreError> {
340        let now = self.clock.now();
341        let mut state = self.lock();
342        let count = |s: &State| {
343            s.messages.len()
344                + s.runs.len()
345                + s.seen.len()
346                + s.fires.len()
347                + s.tool_calls.len()
348                + s.grants.len()
349                + s.items.len()
350                + s.automation_runs.len()
351                + s.peer_tasks.len()
352        };
353        let start = count(&state);
354        state.messages.retain(|(_, m)| m.at >= before);
355        state.runs.retain(|r| r.started_at >= before);
356        state.tool_calls.retain(|c| c.at >= before);
357        state.grants.retain(|_, g| g.expires_at > now);
358        state.items.retain(|_, seen| *seen >= before);
359        state.automation_runs.retain(|r| r.at >= before);
360        state.peer_tasks.retain(|_, t| t.updated_at >= before);
361        state.seen.retain(|_, expires| *expires > now);
362        state
363            .fires
364            .retain(|_, f| f.updated_at >= before || f.state == FireState::Sending);
365        let end = count(&state);
366        Ok((start - end) as u64)
367    }
368
369    async fn sealed(
370        &self,
371        kind: &str,
372        server: &str,
373        subject: &str,
374    ) -> Result<Option<Vec<u8>>, StoreError> {
375        let key = (kind.to_string(), server.to_string(), subject.to_string());
376        Ok(self.lock().sealed.get(&key).map(|(v, _)| v.clone()))
377    }
378
379    async fn set_sealed(
380        &self,
381        kind: &str,
382        server: &str,
383        subject: &str,
384        sealed: Vec<u8>,
385    ) -> Result<(), StoreError> {
386        let key = (kind.to_string(), server.to_string(), subject.to_string());
387        let now = self.clock.now();
388        self.lock().sealed.insert(key, (sealed, now));
389        Ok(())
390    }
391
392    async fn delete_sealed(
393        &self,
394        kind: &str,
395        server: &str,
396        subject: &str,
397    ) -> Result<bool, StoreError> {
398        let key = (kind.to_string(), server.to_string(), subject.to_string());
399        Ok(self.lock().sealed.remove(&key).is_some())
400    }
401
402    async fn sealed_entries(&self, kind: &str) -> Result<Vec<SealedEntry>, StoreError> {
403        let mut out: Vec<SealedEntry> = self
404            .lock()
405            .sealed
406            .iter()
407            .filter(|((k, _, _), _)| k == kind)
408            .map(|((k, server, subject), (_, at))| SealedEntry {
409                kind: k.clone(),
410                server: server.clone(),
411                subject: subject.clone(),
412                updated_at: *at,
413            })
414            .collect();
415        out.sort_by(|a, b| (&a.server, &a.subject).cmp(&(&b.server, &b.subject)));
416        Ok(out)
417    }
418
419    async fn claim_items(
420        &self,
421        automation: &str,
422        items: Vec<String>,
423    ) -> Result<Vec<String>, StoreError> {
424        let now = self.clock.now();
425        let mut state = self.lock();
426        let mut new = Vec::new();
427        for item in items {
428            let fresh = state
429                .items
430                .insert((automation.to_string(), item.clone()), now)
431                .is_none();
432            if fresh {
433                new.push(item);
434            }
435        }
436        Ok(new)
437    }
438
439    async fn release_items(&self, automation: &str, items: Vec<String>) -> Result<(), StoreError> {
440        let mut state = self.lock();
441        for item in items {
442            state.items.remove(&(automation.to_string(), item));
443        }
444        Ok(())
445    }
446
447    async fn record_automation_run(&self, run: AutomationRun) -> Result<(), StoreError> {
448        self.lock().automation_runs.push(run);
449        Ok(())
450    }
451
452    async fn automation_runs(
453        &self,
454        automation: Option<&str>,
455        since: Timestamp,
456        limit: usize,
457    ) -> Result<Vec<AutomationRun>, StoreError> {
458        Ok(self
459            .lock()
460            .automation_runs
461            .iter()
462            .rev()
463            .filter(|r| r.at >= since && automation.is_none_or(|a| r.automation == a))
464            .take(limit)
465            .cloned()
466            .collect())
467    }
468
469    async fn put_peer_task(&self, task: PeerTaskRecord) -> Result<(), StoreError> {
470        let mut state = self.lock();
471        let key = (task.inbound, task.id.clone());
472        let duplicate = state.peer_tasks.values().any(|t| {
473            t.inbound == task.inbound
474                && t.team == task.team
475                && t.peer == task.peer
476                && t.message_id == task.message_id
477                && t.id != task.id
478        });
479        if duplicate {
480            return Err(StoreError::Backend("duplicate peer message".into()));
481        }
482        match state.peer_tasks.get_mut(&key) {
483            Some(old) => {
484                old.state = task.state;
485                old.conversation = task.conversation;
486                old.text = task.text;
487                old.tokens = task.tokens;
488                old.updated_at = task.updated_at;
489            }
490            None => {
491                state.peer_tasks.insert(key, task);
492            }
493        }
494        Ok(())
495    }
496
497    async fn peer_task(
498        &self,
499        inbound: bool,
500        id: &str,
501    ) -> Result<Option<PeerTaskRecord>, StoreError> {
502        Ok(self
503            .lock()
504            .peer_tasks
505            .get(&(inbound, id.to_string()))
506            .cloned())
507    }
508
509    async fn peer_task_by_message(
510        &self,
511        inbound: bool,
512        team: &str,
513        peer: &str,
514        message_id: &str,
515    ) -> Result<Option<PeerTaskRecord>, StoreError> {
516        Ok(self
517            .lock()
518            .peer_tasks
519            .values()
520            .find(|t| {
521                t.inbound == inbound
522                    && t.team == team
523                    && t.peer == peer
524                    && t.message_id == message_id
525            })
526            .cloned())
527    }
528
529    async fn peer_tasks(&self, limit: usize) -> Result<Vec<PeerTaskRecord>, StoreError> {
530        let mut tasks: Vec<PeerTaskRecord> = self.lock().peer_tasks.values().cloned().collect();
531        tasks.sort_by_key(|t| std::cmp::Reverse((t.updated_at, t.created_at)));
532        tasks.truncate(limit);
533        Ok(tasks)
534    }
535
536    async fn interrupt_peer_tasks(&self, now: Timestamp) -> Result<u64, StoreError> {
537        let mut changed = 0;
538        for task in self.lock().peer_tasks.values_mut() {
539            if task.inbound && !task.state.is_final() {
540                task.state = PeerTaskState::Failed;
541                task.text = Some("interrupted: the bot stopped".into());
542                task.updated_at = now;
543                changed += 1;
544            }
545        }
546        Ok(changed)
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::ManualClock;
554
555    #[tokio::test]
556    async fn conformance() {
557        let clock = Arc::new(ManualClock::default());
558        let store = MemoryStore::new(clock.clone());
559        crate::conformance::conversation_store(&store, &clock).await;
560    }
561}