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}