Skip to main content

okbot_core/
store.rs

1//! The conversation store port.
2
3use std::time::Duration;
4
5use jiff::Timestamp;
6use serde::{Deserialize, Serialize};
7
8use crate::agent::TokenUsage;
9use crate::caps::ToolPolicy;
10use crate::id::{ChannelId, ConversationKey, MessageId, RunId, UserId};
11use crate::{BoxError, async_trait};
12
13/// A storage failure.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum StoreError {
17    /// The database was written by a newer okbot.
18    #[error("the database schema version {found} is newer than this okbot supports ({supported})")]
19    TooNew {
20        /// Version in the database.
21        found: u32,
22        /// Newest version this build knows.
23        supported: u32,
24    },
25    /// Any other failure.
26    #[error("storage error: {0}")]
27    Backend(#[source] BoxError),
28}
29
30/// Wrong pairing codes tolerated before pending codes are cancelled.
31pub const PAIRING_MAX_FAILURES: u32 = 5;
32
33/// Who wrote a stored message.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum MessageRole {
37    /// A chat user.
38    User,
39    /// The bot.
40    Bot,
41}
42
43/// A message to store.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct NewMessage {
46    /// Conversation.
47    pub conversation: ConversationKey,
48    /// Author role.
49    pub role: MessageRole,
50    /// Author id (the user, or the bot's own id when known).
51    pub author_id: Option<UserId>,
52    /// Author display name.
53    pub author_name: String,
54    /// Text (canonical Markdown for bot messages).
55    pub text: String,
56    /// Platform message id.
57    pub message_id: Option<MessageId>,
58    /// When it was sent.
59    pub at: Timestamp,
60}
61
62/// A stored message.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct StoredMessage {
65    /// Author role.
66    pub role: MessageRole,
67    /// Author display name.
68    pub author_name: String,
69    /// Text.
70    pub text: String,
71    /// When it was sent.
72    pub at: Timestamp,
73}
74
75/// One agent run, for the audit table.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct AgentRunRecord {
78    /// Run id.
79    pub run_id: RunId,
80    /// Conversation.
81    pub conversation: ConversationKey,
82    /// Backend that produced the result (or the last one tried).
83    pub backend: String,
84    /// Model, when known.
85    pub model: Option<String>,
86    /// Tool policy of the run.
87    pub tools: ToolPolicy,
88    /// Token usage, when reported.
89    pub usage: Option<TokenUsage>,
90    /// Duration.
91    pub duration: Duration,
92    /// Error text, when the run failed.
93    pub error: Option<String>,
94    /// When the run started.
95    pub started_at: Timestamp,
96}
97
98/// Where one firing of a task is (design §5.6).
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum FireState {
101    /// Claimed; being delivered or waiting for a retry.
102    Sending,
103    /// Delivered.
104    Sent,
105    /// Gave up after retries.
106    Failed,
107    /// Too late to deliver (the bot was down).
108    Missed,
109}
110
111impl FireState {
112    /// The name stored and shown.
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::Sending => "sending",
116            Self::Sent => "sent",
117            Self::Failed => "failed",
118            Self::Missed => "missed",
119        }
120    }
121
122    /// Parses [`FireState::as_str`].
123    pub fn parse(value: &str) -> Option<Self> {
124        match value {
125            "sending" => Some(Self::Sending),
126            "sent" => Some(Self::Sent),
127            "failed" => Some(Self::Failed),
128            "missed" => Some(Self::Missed),
129            _ => None,
130        }
131    }
132
133    /// No more delivery attempts follow.
134    pub fn is_final(self) -> bool {
135        !matches!(self, Self::Sending)
136    }
137}
138
139/// One firing of a task: the task id and its planned time identify it.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Fire {
142    /// Task id (`tasks/people/owner/t-7k2m9q`).
143    pub task_id: String,
144    /// The planned time.
145    pub fire_at: Timestamp,
146    /// Where it is.
147    pub state: FireState,
148    /// Delivery attempts so far.
149    pub attempts: u32,
150    /// Last change.
151    pub updated_at: Timestamp,
152}
153
154/// Stores conversations, deduplication marks and the audit trail (design §6.1).
155#[async_trait]
156pub trait ConversationStore: Send + Sync {
157    /// Records an inbound event id. Returns `false` when it was already seen within `ttl`.
158    async fn mark_seen(
159        &self,
160        channel: &ChannelId,
161        event_id: &str,
162        ttl: Duration,
163    ) -> Result<bool, StoreError>;
164
165    /// Appends a message to a conversation.
166    async fn append(&self, message: NewMessage) -> Result<(), StoreError>;
167
168    /// The newest `limit` messages after the last reset, oldest first.
169    async fn recent(
170        &self,
171        conversation: &ConversationKey,
172        limit: usize,
173    ) -> Result<Vec<StoredMessage>, StoreError>;
174
175    /// Starts a fresh history for the conversation (`/reset`). Old messages stay for audit
176    /// until retention removes them.
177    async fn reset(&self, conversation: &ConversationKey) -> Result<(), StoreError>;
178
179    /// The native agent session of a conversation: `(backend, session id)`.
180    async fn session(
181        &self,
182        conversation: &ConversationKey,
183    ) -> Result<Option<(String, String)>, StoreError>;
184
185    /// Remembers the native agent session of a conversation (cleared by [`ConversationStore::reset`]).
186    async fn set_session(
187        &self,
188        conversation: &ConversationKey,
189        backend: &str,
190        session: &str,
191    ) -> Result<(), StoreError>;
192
193    /// Records an agent run.
194    async fn record_run(&self, run: AgentRunRecord) -> Result<(), StoreError>;
195
196    /// Accounts of the owner of a personal bot (paired with [`ConversationStore::redeem_pairing_code`]).
197    async fn owners(&self) -> Result<Vec<(ChannelId, UserId)>, StoreError>;
198
199    /// Removes an owner account. Returns whether it was one.
200    async fn remove_owner(&self, channel: &ChannelId, user: &UserId) -> Result<bool, StoreError>;
201
202    /// Stores a one-time pairing code, valid for `ttl`.
203    async fn create_pairing_code(&self, code: &str, ttl: Duration) -> Result<(), StoreError>;
204
205    /// Consumes `code` and makes `(channel, user)` an owner account when the code is valid.
206    ///
207    /// A wrong code counts as a failed attempt against every pending code; after
208    /// [`PAIRING_MAX_FAILURES`] failures the pending codes are cancelled, so codes cannot be
209    /// guessed.
210    async fn redeem_pairing_code(
211        &self,
212        code: &str,
213        channel: &ChannelId,
214        user: &UserId,
215    ) -> Result<bool, StoreError>;
216
217    /// Claims a task firing: records it as [`FireState::Sending`] the first time and returns its
218    /// current record, so a firing that was already sent is never sent again.
219    async fn claim_fire(&self, task_id: &str, fire_at: Timestamp) -> Result<Fire, StoreError>;
220
221    /// Updates a claimed firing.
222    async fn set_fire(
223        &self,
224        task_id: &str,
225        fire_at: Timestamp,
226        state: FireState,
227        attempts: u32,
228    ) -> Result<(), StoreError>;
229
230    /// The newest `limit` firings, newest first (for `okbot doctor` and `okbot task`).
231    async fn recent_fires(&self, limit: usize) -> Result<Vec<Fire>, StoreError>;
232
233    /// Remembers the direct conversation of `user` on `channel` (reminders sent there).
234    async fn set_direct_chat(
235        &self,
236        channel: &ChannelId,
237        user: &UserId,
238        conversation: &ConversationKey,
239    ) -> Result<(), StoreError>;
240
241    /// The latest direct conversation of `user` on `channel`.
242    async fn direct_chat(
243        &self,
244        channel: &ChannelId,
245        user: &UserId,
246    ) -> Result<Option<ConversationKey>, StoreError>;
247
248    /// Deletes messages, runs, dedupe marks and finished firings older than `before`. Returns rows
249    /// removed.
250    async fn purge_before(&self, before: Timestamp) -> Result<u64, StoreError>;
251}