Skip to main content

tea_session/
catalog.rs

1use std::fmt;
2use std::str::FromStr;
3
4use tea_protocol::{ModelId, ModelRef, ProfileId, ProtocolTimestamp, ProviderId, SessionId};
5use thiserror::Error;
6
7use crate::{SessionStoreError, SessionStoreFuture};
8
9/// Maximum UTF-8 bytes accepted for a session display name.
10pub const MAX_SESSION_NAME_BYTES: usize = 256;
11
12/// Validated optional human-facing session name.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct SessionName(String);
15
16impl SessionName {
17    /// Creates a trimmed bounded display name.
18    ///
19    /// # Errors
20    ///
21    /// Returns an error for empty, oversized, or control-containing text.
22    pub fn new(value: impl Into<String>) -> Result<Self, SessionNameError> {
23        let value = value.into();
24        let value = value.trim();
25        if value.is_empty() {
26            return Err(SessionNameError::Empty);
27        }
28        if value.len() > MAX_SESSION_NAME_BYTES {
29            return Err(SessionNameError::TooLong);
30        }
31        if value.chars().any(char::is_control) {
32            return Err(SessionNameError::ControlCharacter);
33        }
34        Ok(Self(value.to_owned()))
35    }
36
37    /// Returns the validated display text.
38    #[must_use]
39    pub fn as_str(&self) -> &str {
40        &self.0
41    }
42}
43
44impl fmt::Display for SessionName {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter.write_str(self.as_str())
47    }
48}
49
50impl FromStr for SessionName {
51    type Err = SessionNameError;
52
53    fn from_str(value: &str) -> Result<Self, Self::Err> {
54        Self::new(value)
55    }
56}
57
58/// Session-name validation failure.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
60pub enum SessionNameError {
61    /// The trimmed name is empty.
62    #[error("session name cannot be empty")]
63    Empty,
64    /// The name exceeds the stable UTF-8 byte bound.
65    #[error("session name exceeds {MAX_SESSION_NAME_BYTES} bytes")]
66    TooLong,
67    /// The name contains a control character.
68    #[error("session name cannot contain control characters")]
69    ControlCharacter,
70}
71
72/// Immutable host-facing catalog entry derived from durable session state.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct SessionCatalogEntry {
75    session_id: SessionId,
76    name: Option<SessionName>,
77    updated_at: ProtocolTimestamp,
78    profile_id: ProfileId,
79    model: Option<ModelRef>,
80    message_count: usize,
81    pending_approval_count: usize,
82}
83
84impl SessionCatalogEntry {
85    #[allow(clippy::too_many_arguments)]
86    const fn new(
87        session_id: SessionId,
88        name: Option<SessionName>,
89        updated_at: ProtocolTimestamp,
90        profile_id: ProfileId,
91        model: Option<ModelRef>,
92        message_count: usize,
93        pending_approval_count: usize,
94    ) -> Self {
95        Self {
96            session_id,
97            name,
98            updated_at,
99            profile_id,
100            model,
101            message_count,
102            pending_approval_count,
103        }
104    }
105
106    /// Builds a catalog projection from one immutable durable snapshot.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error when the snapshot has no durable records.
111    pub fn from_snapshot(
112        snapshot: &crate::SessionSnapshot,
113        name: Option<SessionName>,
114    ) -> Result<Self, SessionStoreError> {
115        catalog_entry(snapshot, name)
116    }
117
118    /// Returns session identity.
119    #[must_use]
120    pub const fn session_id(&self) -> SessionId {
121        self.session_id
122    }
123
124    /// Returns the optional display name.
125    #[must_use]
126    pub const fn name(&self) -> Option<&SessionName> {
127        self.name.as_ref()
128    }
129
130    /// Returns the latest durable record timestamp.
131    #[must_use]
132    pub const fn updated_at(&self) -> ProtocolTimestamp {
133        self.updated_at
134    }
135
136    /// Returns the active product profile.
137    #[must_use]
138    pub const fn profile_id(&self) -> &ProfileId {
139        &self.profile_id
140    }
141
142    /// Returns the active model when configured.
143    #[must_use]
144    pub const fn model_id(&self) -> Option<&ModelId> {
145        match &self.model {
146            Some(model) => Some(model.model_id()),
147            None => None,
148        }
149    }
150
151    /// Returns the active provider when configured.
152    #[must_use]
153    pub const fn provider_id(&self) -> Option<&ProviderId> {
154        match &self.model {
155            Some(model) => Some(model.provider_id()),
156            None => None,
157        }
158    }
159
160    /// Returns the provider-qualified active model when configured.
161    #[must_use]
162    pub const fn model_ref(&self) -> Option<&ModelRef> {
163        self.model.as_ref()
164    }
165
166    /// Returns the active transcript message count.
167    #[must_use]
168    pub const fn message_count(&self) -> usize {
169        self.message_count
170    }
171
172    /// Returns the number of pending durable approvals.
173    #[must_use]
174    pub const fn pending_approval_count(&self) -> usize {
175        self.pending_approval_count
176    }
177}
178
179/// Read/list and display-metadata port kept separate from append semantics.
180pub trait SessionCatalog: fmt::Debug + Send + Sync {
181    /// Lists sessions ordered by latest durable timestamp descending, then ID.
182    fn list_sessions(&self) -> SessionStoreFuture<'_, Vec<SessionCatalogEntry>>;
183
184    /// Sets or clears one optional display name.
185    fn set_session_name(
186        &self,
187        session_id: SessionId,
188        name: Option<SessionName>,
189    ) -> SessionStoreFuture<'_, ()>;
190
191    /// Returns one optional display name.
192    fn session_name(&self, session_id: SessionId) -> SessionStoreFuture<'_, Option<SessionName>>;
193}
194
195pub(crate) fn catalog_entry(
196    snapshot: &crate::SessionSnapshot,
197    name: Option<SessionName>,
198) -> Result<SessionCatalogEntry, SessionStoreError> {
199    let state = snapshot.state();
200    let updated_at = snapshot
201        .records()
202        .last()
203        .map(tea_protocol::RecordEnvelope::timestamp)
204        .ok_or_else(|| {
205            SessionStoreError::new(
206                crate::SessionStoreErrorCode::InvalidRecord,
207                "stored session has no durable records",
208            )
209        })?;
210    Ok(SessionCatalogEntry::new(
211        state.session_id(),
212        name,
213        updated_at,
214        state.configuration().profile_id().clone(),
215        state.configuration().model_ref().cloned(),
216        state.messages().len(),
217        state.pending_approvals().len(),
218    ))
219}
220
221pub(crate) fn sort_catalog(entries: &mut [SessionCatalogEntry]) {
222    entries.sort_by(|left, right| {
223        right
224            .updated_at()
225            .cmp(&left.updated_at())
226            .then_with(|| left.session_id().cmp(&right.session_id()))
227    });
228}