Skip to main content

aion_store/
assistant.rs

1//! Durable assistant-session records and their transcript contract.
2//!
3//! An assistant session is one live agent-harness process the server owns on a
4//! caller's behalf. The PROCESS dies with the server; the RECORD and the
5//! TRANSCRIPT do not. That split is the whole shape of this module:
6//!
7//! - **[`AssistantSessionRecord`]** — one per session: who owns it, which
8//!   harness and account it runs, when it was created, and the bookkeeping a
9//!   listing needs. It carries NO STATUS. Whether a session is live, dormant or
10//!   ended is a projection over its transcript records plus the live-process
11//!   registry ([`aion_core::AssistantSessionProjection`]), for the reason
12//!   `WorkflowStatus` is a projection: a stored status is a second answer that
13//!   drifts from the records that justify it. A session whose process is gone is
14//!   settled by an APPENDED record at the next boot — written back, not merely
15//!   displayed.
16//! - **[`AssistantTranscriptEvent`]** — one per event, at a dense
17//!   store-assigned index. Every frame the WebSocket streams is appended here
18//!   FIRST and broadcast second, so nothing a client saw is absent from the
19//!   record, and the socket's `?after=` replay is a read of the same rows.
20//!
21//! # The store assigns the index, inside the append
22//!
23//! [`AssistantSessionStore::append_assistant_transcript_event`] takes no index.
24//! The store reads the current head and commits at it under the backend's own
25//! optimistic-concurrency discipline, retrying on a conflict — so two appenders
26//! racing on one session receive `n` and `n+1`, never `n` twice. A caller
27//! cannot supply an index, so a caller cannot mint a duplicate one.
28//!
29//! # A poisoned row is listed, never skipped
30//!
31//! [`AssistantSessionListing`] carries both the decoded records and every row
32//! that was present and could not be decoded, with its decode error. The same
33//! discipline the workloop registry uses: an operator must be able to see that a
34//! session exists and is unreadable, rather than watch it silently vanish from
35//! a list.
36
37use aion_core::{
38    AssistantCommand, AssistantConfigOption, AssistantSessionId, AssistantSessionState,
39    AssistantSessionSummary, Payload,
40};
41use async_trait::async_trait;
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44
45use crate::StoreError;
46
47/// One durable assistant session.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct AssistantSessionRecord {
51    /// The session's identity (primary key).
52    pub session_id: AssistantSessionId,
53    /// The caller subject that owns it. The list surface is per caller, so this
54    /// is the field it filters on — never a namespace, because a session is not
55    /// a workflow and lives in no namespace.
56    pub subject: String,
57    /// The configured harness name it runs.
58    pub harness: String,
59    /// The configured account name, when the harness declares any.
60    pub account: Option<String>,
61    /// The first 80 characters of the first prompt; `None` before one.
62    pub title: Option<String>,
63    /// When the session was created.
64    pub created_at: DateTime<Utc>,
65    /// The last time anything about it changed.
66    pub updated_at: DateTime<Utc>,
67    /// How many turns have been submitted on it — bookkeeping the server keeps
68    /// in step with the transcript projection, so a listing does not have to
69    /// read every session's whole transcript to count them.
70    pub turns: u64,
71    /// The SHA-256 digest, lowercase hex, of the session-scoped bearer this
72    /// session's harness carries to the server's own MCP endpoint. `None` when
73    /// the session was given no aion MCP server.
74    ///
75    /// The digest, never the token: a store that held the token would be a
76    /// store that could hand it out. Verification hashes what the caller
77    /// presented and compares. Its LIFETIME is the session's — a call is
78    /// admitted only while the session's projected lifecycle is not `ended`, so
79    /// revocation is a durable fact rather than an in-memory set that dies with
80    /// the process that minted it.
81    pub mcp_token_digest: Option<String>,
82    /// The commands the harness advertised most recently.
83    ///
84    /// A DERIVED CACHE of the transcript's latest
85    /// [`aion_core::AssistantSessionEvent::AvailableCommands`] record, kept for
86    /// exactly the reason `turns` and `title` are: a listing must not read every
87    /// session's whole conversation to answer one field, and an advertisement
88    /// can arrive at the first turn of a conversation that runs for hours, so a
89    /// bounded tail read cannot find it.
90    ///
91    /// The TRANSCRIPT is the authority. The frame is appended first and this is
92    /// written from it inside the same append path, so the two cannot be written
93    /// out of order; `the_records_command_cache_is_what_the_transcript_projects`
94    /// pins that a rebuild from the transcript reproduces this field exactly.
95    /// Unlike a status, this is not a second ANSWER to a question the transcript
96    /// answers differently — it is the same answer, kept where a listing can
97    /// afford to read it.
98    #[serde(default)]
99    pub commands: Vec<AssistantCommand>,
100
101    /// The configuration options the harness advertised most recently — the
102    /// model picker among them. The same derived cache as `commands`, kept for
103    /// the same listing-cost reason, with the transcript's
104    /// [`aion_core::AssistantSessionEvent::ConfigOptions`] records as the
105    /// authority. `#[serde(default)]` because records written before options
106    /// existed decode as having none advertised, which is exactly true.
107    #[serde(default)]
108    pub config_options: Vec<AssistantConfigOption>,
109}
110
111impl AssistantSessionRecord {
112    /// Encode the stable backend-neutral representation.
113    ///
114    /// # Errors
115    ///
116    /// [`StoreError::Serialization`] when serialization fails.
117    pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
118        serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
119    }
120
121    /// Decode and re-validate a stored representation.
122    ///
123    /// # Errors
124    ///
125    /// [`StoreError::Serialization`] for malformed bytes or an unknown field.
126    pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
127        serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
128    }
129
130    /// The listing row for this record under a state the caller has projected.
131    ///
132    /// The state, its lifecycle and its cause are PARAMETERS because none of
133    /// them is stored: they are read off the session's transcript records plus
134    /// whether a process is running (see
135    /// [`aion_core::AssistantSessionProjection`]). A record that carried its own
136    /// status would be a second answer to a question the transcript already
137    /// answers, and the two would drift.
138    #[must_use]
139    pub fn summary(
140        &self,
141        state: AssistantSessionState,
142        reason: Option<String>,
143    ) -> AssistantSessionSummary {
144        AssistantSessionSummary {
145            session_id: self.session_id,
146            harness: self.harness.clone(),
147            account: self.account.clone(),
148            state,
149            reason,
150            created_at: self.created_at,
151            updated_at: self.updated_at,
152            turns: self.turns,
153            title: self.title.clone(),
154            commands: self.commands.clone(),
155            config_options: self.config_options.clone(),
156        }
157    }
158}
159
160/// One durable transcript event at its store-assigned index.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct AssistantTranscriptEvent {
164    /// The event's dense position in the session's transcript, assigned by the
165    /// store inside the append. Zero-based and contiguous.
166    pub index: u64,
167    /// When the server recorded it.
168    pub recorded_at: DateTime<Utc>,
169    /// The event itself, type-erased — the serialized
170    /// [`aion_core::AssistantSessionEvent`] the socket streams. Carried as a
171    /// [`Payload`] because the store is type-erased by construction: it
172    /// persists bytes and a content-type tag, never a domain type it would have
173    /// to keep in step.
174    pub payload: Payload,
175}
176
177impl AssistantTranscriptEvent {
178    /// Encode the stable backend-neutral representation.
179    ///
180    /// # Errors
181    ///
182    /// [`StoreError::Serialization`] when serialization fails.
183    pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
184        serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
185    }
186
187    /// Decode a stored representation.
188    ///
189    /// # Errors
190    ///
191    /// [`StoreError::Serialization`] for malformed bytes or an unknown field.
192    pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
193        serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
194    }
195}
196
197/// An assistant-session row that was present but could not be decoded.
198#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
199pub struct UndecodableAssistantSession {
200    /// The key the poisoned row is stored under (the session id's text form).
201    pub session_id: String,
202    /// The decode failure, rendered for operator diagnosis.
203    pub error: String,
204}
205
206/// Complete assistant-session listing, including poisoned-row visibility.
207#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
208pub struct AssistantSessionListing {
209    /// Successfully decoded records, ordered by `created_at` then session id.
210    pub sessions: Vec<AssistantSessionRecord>,
211    /// Present rows that could not be decoded, ordered by session id text.
212    pub undecodable: Vec<UndecodableAssistantSession>,
213}
214
215impl AssistantSessionListing {
216    /// Put the listing in its contract order: sessions by `created_at` then
217    /// session id, poisoned rows by session id.
218    ///
219    /// One helper rather than a sort at each backend, so "ordered by `created_at`
220    /// then id" is one rule with one implementation instead of two that could
221    /// tie-break differently on the same instant.
222    pub fn sort(&mut self) {
223        self.sessions
224            .sort_by(|left, right| match left.created_at.cmp(&right.created_at) {
225                std::cmp::Ordering::Equal => left.session_id.cmp(&right.session_id),
226                ordering => ordering,
227            });
228        self.undecodable
229            .sort_by(|left, right| left.session_id.cmp(&right.session_id));
230    }
231}
232
233/// Durable assistant-session persistence contract.
234///
235/// Implemented by every backend and exercised by
236/// [`crate::conformance::run_assistant_session_suite`] against all of them, so
237/// the index-assignment and poisoned-row guarantees are properties of the
238/// CONTRACT rather than of whichever backend a deployment happens to run.
239#[async_trait]
240pub trait AssistantSessionStore: Send + Sync + 'static {
241    /// Create or replace a session's record.
242    async fn put_assistant_session(&self, record: AssistantSessionRecord)
243    -> Result<(), StoreError>;
244
245    /// Look up one session by id.
246    async fn get_assistant_session(
247        &self,
248        session_id: &AssistantSessionId,
249    ) -> Result<Option<AssistantSessionRecord>, StoreError>;
250
251    /// List decodable sessions and report every undecodable row, ordered by
252    /// `created_at` then session id.
253    ///
254    /// Unfiltered: a store enumerates what it holds, and the per-caller
255    /// narrowing is the server's authorization decision, made where the caller
256    /// identity is.
257    async fn list_assistant_sessions(&self) -> Result<AssistantSessionListing, StoreError>;
258
259    /// Append one event to a session's transcript, returning the index the
260    /// store assigned it.
261    ///
262    /// The index is the store's to give: it is the next index after the last
263    /// stored one, committed under the backend's optimistic-concurrency
264    /// discipline so two appenders racing on one session get `n` and `n+1` and
265    /// never `n` twice. A caller cannot pass an index and so cannot mint a
266    /// duplicate.
267    ///
268    /// # Errors
269    ///
270    /// [`StoreError::AssistantSessionNotFound`] when no record exists for
271    /// `session_id` — an append never silently creates a session, because a
272    /// transcript with no record is a conversation with no owner. Otherwise a
273    /// backend or serialization error.
274    async fn append_assistant_transcript_event(
275        &self,
276        session_id: &AssistantSessionId,
277        recorded_at: DateTime<Utc>,
278        payload: Payload,
279    ) -> Result<u64, StoreError>;
280
281    /// The next index the store would assign this session — equivalently, how
282    /// many events its transcript holds.
283    ///
284    /// An unwritten transcript reads `0`. Cheap on every backend (it reads
285    /// stream metadata, never the events), which is what lets a listing find
286    /// each session's last record without reading its whole conversation.
287    async fn assistant_transcript_head(
288        &self,
289        session_id: &AssistantSessionId,
290    ) -> Result<u64, StoreError>;
291
292    /// A session's transcript in index order, excluding every event at or below
293    /// `after`.
294    ///
295    /// `None` reads the whole transcript. An unknown session reads empty rather
296    /// than refusing: a read of a session that is not there is an absence, and
297    /// the refusal that matters is on the WRITE.
298    async fn assistant_transcript(
299        &self,
300        session_id: &AssistantSessionId,
301        after: Option<u64>,
302    ) -> Result<Vec<AssistantTranscriptEvent>, StoreError>;
303
304    /// Remember the harness `subject` last opened a session on.
305    ///
306    /// Written by `createSession` and by nothing else: the memory is a record of
307    /// what the operator DID, not a preference they set, so there is no second
308    /// door through which it could say something a session never said.
309    ///
310    /// It lives in the store rather than in the process because it must survive
311    /// a restart — an operator who picked Claude Code yesterday must not be
312    /// asked again this morning — and CALLER-scoped because it is one person's
313    /// last choice, not a deployment default.
314    async fn put_assistant_default_harness(
315        &self,
316        subject: &str,
317        harness: &str,
318    ) -> Result<(), StoreError>;
319
320    /// The harness `subject` last opened a session on, or [`None`] before any.
321    ///
322    /// [`None`] is a complete answer and never a fallback: a caller who has
323    /// picked nothing has picked nothing, and a surface that invented a default
324    /// here would be reporting a choice its operator never made.
325    async fn assistant_default_harness(&self, subject: &str) -> Result<Option<String>, StoreError>;
326}
327
328#[cfg(test)]
329mod tests {
330    use aion_core::ContentType;
331    use chrono::TimeZone;
332
333    use super::*;
334
335    fn instant(offset: i64) -> Result<DateTime<Utc>, StoreError> {
336        Utc.with_ymd_and_hms(2026, 8, 29, 6, 0, 0)
337            .single()
338            .map(|base| base + chrono::Duration::seconds(offset))
339            .ok_or_else(|| StoreError::Backend("test instant must be valid".to_owned()))
340    }
341
342    fn record(offset: i64) -> Result<AssistantSessionRecord, StoreError> {
343        Ok(AssistantSessionRecord {
344            session_id: AssistantSessionId::new(uuid::Uuid::from_u128(7)),
345            subject: String::from("operator"),
346            harness: String::from("claude"),
347            account: Some(String::from("work")),
348            title: Some(String::from("fix the check")),
349            created_at: instant(offset)?,
350            updated_at: instant(offset + 10)?,
351            turns: 2,
352            mcp_token_digest: Some("0".repeat(64)),
353            commands: vec![AssistantCommand {
354                name: String::from("compact"),
355                description: String::from("compact the conversation"),
356                input_hint: None,
357            }],
358            config_options: Vec::new(),
359        })
360    }
361
362    #[test]
363    fn a_session_record_round_trips() -> Result<(), StoreError> {
364        let expected = record(0)?;
365        assert_eq!(
366            AssistantSessionRecord::decode(&expected.encode()?)?,
367            expected
368        );
369        Ok(())
370    }
371
372    #[test]
373    fn a_record_carries_no_status_of_its_own() -> Result<(), StoreError> {
374        // The summary's state, lifecycle and cause all arrive as arguments:
375        // there is no field on the record a caller could read instead, which is
376        // what makes the projection the only answer.
377        let summary = record(0)?.summary(
378            AssistantSessionState::Dormant,
379            Some(String::from("process_exited")),
380        );
381        assert_eq!(summary.state, AssistantSessionState::Dormant);
382        assert_eq!(summary.reason.as_deref(), Some("process_exited"));
383        Ok(())
384    }
385
386    #[test]
387    fn a_transcript_event_round_trips() -> Result<(), StoreError> {
388        let event = AssistantTranscriptEvent {
389            index: 4,
390            recorded_at: instant(0)?,
391            payload: Payload::new(ContentType::Json, b"{\"type\":\"delta\"}".to_vec()),
392        };
393        assert_eq!(AssistantTranscriptEvent::decode(&event.encode()?)?, event);
394        Ok(())
395    }
396
397    #[test]
398    fn the_listing_orders_by_created_at_then_id() -> Result<(), StoreError> {
399        let mut early = record(0)?;
400        early.session_id = AssistantSessionId::new(uuid::Uuid::from_u128(2));
401        let mut tied = record(0)?;
402        tied.session_id = AssistantSessionId::new(uuid::Uuid::from_u128(1));
403        let late = record(100)?;
404        let mut listing = AssistantSessionListing {
405            sessions: vec![late.clone(), early.clone(), tied.clone()],
406            undecodable: vec![
407                UndecodableAssistantSession {
408                    session_id: String::from("b"),
409                    error: String::from("bad"),
410                },
411                UndecodableAssistantSession {
412                    session_id: String::from("a"),
413                    error: String::from("bad"),
414                },
415            ],
416        };
417        listing.sort();
418        assert_eq!(listing.sessions, vec![tied, early, late]);
419        assert_eq!(
420            listing
421                .undecodable
422                .iter()
423                .map(|row| row.session_id.as_str())
424                .collect::<Vec<_>>(),
425            vec!["a", "b"]
426        );
427        Ok(())
428    }
429}