1use crate::{EnumParseError, SessionId, TenantId, Timestamp};
19use serde::{Deserialize, Serialize};
20use std::fmt;
21use std::marker::PhantomData;
22use std::str::FromStr;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32pub enum SessionStatus {
33 Created,
35 Active,
37 Closed,
39 Expired,
41}
42
43impl SessionStatus {
44 pub fn as_db_str(&self) -> &'static str {
46 match self {
47 SessionStatus::Created => "created",
48 SessionStatus::Active => "active",
49 SessionStatus::Closed => "closed",
50 SessionStatus::Expired => "expired",
51 }
52 }
53
54 pub fn from_db_str(s: &str) -> Result<Self, EnumParseError> {
56 match s.to_lowercase().as_str() {
57 "created" => Ok(SessionStatus::Created),
58 "active" => Ok(SessionStatus::Active),
59 "closed" => Ok(SessionStatus::Closed),
60 "expired" => Ok(SessionStatus::Expired),
61 _ => Err(EnumParseError {
62 enum_name: "SessionStatus",
63 input: s.to_string(),
64 }),
65 }
66 }
67
68 pub fn is_terminal(&self) -> bool {
70 matches!(self, SessionStatus::Closed | SessionStatus::Expired)
71 }
72}
73
74impl fmt::Display for SessionStatus {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 write!(f, "{}", self.as_db_str())
77 }
78}
79
80impl FromStr for SessionStatus {
81 type Err = EnumParseError;
82
83 fn from_str(s: &str) -> Result<Self, Self::Err> {
84 Self::from_db_str(s)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
96pub struct SessionRecord {
97 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
98 pub session_id: SessionId,
99 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
100 pub tenant_id: TenantId,
101 pub provider_id: String,
103 pub model: String,
105 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
106 pub created_at: Timestamp,
107 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
108 pub activated_at: Option<Timestamp>,
109 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
110 pub closed_at: Option<Timestamp>,
111 pub ttl_secs: u64,
113 pub round_count: u64,
115 pub initial_token_count: u64,
117 pub delta_token_count: u64,
119 #[serde(default)]
121 pub metadata: serde_json::Value,
122}
123
124pub trait SessionState: private::Sealed + Send + Sync {}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct Created;
134impl SessionState for Created {}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct Active;
139impl SessionState for Active {}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct Closed;
144impl SessionState for Closed {}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct SessionExpired;
149impl SessionState for SessionExpired {}
150
151mod private {
152 pub trait Sealed {}
153 impl Sealed for super::Created {}
154 impl Sealed for super::Active {}
155 impl Sealed for super::Closed {}
156 impl Sealed for super::SessionExpired {}
157}
158
159#[derive(Debug, Clone)]
172pub struct Session<S: SessionState> {
173 data: SessionRecord,
174 _state: PhantomData<S>,
175}
176
177impl<S: SessionState> Session<S> {
178 pub fn data(&self) -> &SessionRecord {
180 &self.data
181 }
182
183 pub fn session_id(&self) -> SessionId {
185 self.data.session_id
186 }
187
188 pub fn tenant_id(&self) -> TenantId {
190 self.data.tenant_id
191 }
192
193 pub fn provider_id(&self) -> &str {
195 &self.data.provider_id
196 }
197
198 pub fn model(&self) -> &str {
200 &self.data.model
201 }
202
203 pub fn created_at(&self) -> Timestamp {
205 self.data.created_at
206 }
207
208 pub fn round_count(&self) -> u64 {
210 self.data.round_count
211 }
212
213 pub fn into_data(self) -> SessionRecord {
215 self.data
216 }
217}
218
219impl Session<Created> {
220 pub fn new(data: SessionRecord) -> Self {
222 Session {
223 data,
224 _state: PhantomData,
225 }
226 }
227
228 pub fn activate(mut self, activated_at: Timestamp) -> Session<Active> {
233 self.data.activated_at = Some(activated_at);
234 Session {
235 data: self.data,
236 _state: PhantomData,
237 }
238 }
239}
240
241impl Session<Active> {
242 pub fn activated_at(&self) -> Timestamp {
244 self.data
245 .activated_at
246 .expect("Active session must have activated_at")
247 }
248
249 pub fn record_delta(&mut self, delta_tokens: u64) {
254 self.data.round_count += 1;
255 self.data.delta_token_count += delta_tokens;
256 }
257
258 pub fn tokens_saved(&self) -> u64 {
263 let would_have_sent = self.data.round_count * self.data.initial_token_count;
264 would_have_sent.saturating_sub(self.data.delta_token_count)
265 }
266
267 pub fn close(mut self, closed_at: Timestamp) -> Session<Closed> {
272 self.data.closed_at = Some(closed_at);
273 Session {
274 data: self.data,
275 _state: PhantomData,
276 }
277 }
278
279 pub fn expire(mut self, expired_at: Timestamp) -> Session<SessionExpired> {
284 self.data.closed_at = Some(expired_at);
285 Session {
286 data: self.data,
287 _state: PhantomData,
288 }
289 }
290}
291
292impl Session<Closed> {
293 pub fn closed_at(&self) -> Timestamp {
295 self.data
296 .closed_at
297 .expect("Closed session must have closed_at")
298 }
299}
300
301impl Session<SessionExpired> {
302 pub fn expired_at(&self) -> Timestamp {
304 self.data
305 .closed_at
306 .expect("Expired session must have closed_at")
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319pub struct StoredSession {
320 pub data: SessionRecord,
321 pub status: SessionStatus,
322}
323
324#[derive(Debug, Clone)]
327pub enum LoadedSession {
328 Created(Session<Created>),
329 Active(Session<Active>),
330 Closed(Session<Closed>),
331 Expired(Session<SessionExpired>),
332}
333
334impl StoredSession {
335 pub fn into_typed(self) -> LoadedSession {
337 match self.status {
338 SessionStatus::Created => LoadedSession::Created(Session {
339 data: self.data,
340 _state: PhantomData,
341 }),
342 SessionStatus::Active => LoadedSession::Active(Session {
343 data: self.data,
344 _state: PhantomData,
345 }),
346 SessionStatus::Closed => LoadedSession::Closed(Session {
347 data: self.data,
348 _state: PhantomData,
349 }),
350 SessionStatus::Expired => LoadedSession::Expired(Session {
351 data: self.data,
352 _state: PhantomData,
353 }),
354 }
355 }
356
357 pub fn into_created(self) -> Result<Session<Created>, SessionStateError> {
359 if self.status != SessionStatus::Created {
360 return Err(SessionStateError::WrongState {
361 session_id: self.data.session_id,
362 expected: SessionStatus::Created,
363 actual: self.status,
364 });
365 }
366 Ok(Session {
367 data: self.data,
368 _state: PhantomData,
369 })
370 }
371
372 pub fn into_active(self) -> Result<Session<Active>, SessionStateError> {
374 if self.status != SessionStatus::Active {
375 return Err(SessionStateError::WrongState {
376 session_id: self.data.session_id,
377 expected: SessionStatus::Active,
378 actual: self.status,
379 });
380 }
381 Ok(Session {
382 data: self.data,
383 _state: PhantomData,
384 })
385 }
386
387 pub fn data(&self) -> &SessionRecord {
389 &self.data
390 }
391
392 pub fn status(&self) -> SessionStatus {
394 self.status
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum SessionStateError {
401 WrongState {
403 session_id: SessionId,
404 expected: SessionStatus,
405 actual: SessionStatus,
406 },
407}
408
409impl fmt::Display for SessionStateError {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 SessionStateError::WrongState {
413 session_id,
414 expected,
415 actual,
416 } => {
417 write!(
418 f,
419 "Session {} is in state {} but expected {}",
420 session_id, actual, expected
421 )
422 }
423 }
424 }
425}
426
427impl std::error::Error for SessionStateError {}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::EntityIdType;
433 use chrono::Utc;
434
435 fn make_session_data() -> SessionRecord {
436 let now = Utc::now();
437 SessionRecord {
438 session_id: SessionId::now_v7(),
439 tenant_id: TenantId::now_v7(),
440 provider_id: "openai".to_string(),
441 model: "gpt-4".to_string(),
442 created_at: now,
443 activated_at: None,
444 closed_at: None,
445 ttl_secs: 300,
446 round_count: 0,
447 initial_token_count: 5000,
448 delta_token_count: 0,
449 metadata: serde_json::Value::Object(serde_json::Map::new()),
450 }
451 }
452
453 #[test]
454 fn test_session_status_roundtrip() {
455 for status in [
456 SessionStatus::Created,
457 SessionStatus::Active,
458 SessionStatus::Closed,
459 SessionStatus::Expired,
460 ] {
461 let db_str = status.as_db_str();
462 let parsed =
463 SessionStatus::from_db_str(db_str).expect("SessionStatus roundtrip should succeed");
464 assert_eq!(status, parsed);
465 }
466 }
467
468 #[test]
469 fn test_session_status_terminal() {
470 assert!(!SessionStatus::Created.is_terminal());
471 assert!(!SessionStatus::Active.is_terminal());
472 assert!(SessionStatus::Closed.is_terminal());
473 assert!(SessionStatus::Expired.is_terminal());
474 }
475
476 #[test]
477 fn test_session_create_activate_delta_close() {
478 let now = Utc::now();
479 let data = make_session_data();
480 let session = Session::<Created>::new(data);
481
482 let mut active = session.activate(now);
484 assert_eq!(active.activated_at(), now);
485 assert_eq!(active.round_count(), 0);
486
487 active.record_delta(100);
489 active.record_delta(120);
490 active.record_delta(150);
491
492 assert_eq!(active.round_count(), 3);
493 assert_eq!(active.data().delta_token_count, 370);
494
495 assert_eq!(active.tokens_saved(), 14630);
497
498 let closed = active.close(now);
500 assert_eq!(closed.closed_at(), now);
501 }
502
503 #[test]
504 fn test_session_create_activate_expire() {
505 let now = Utc::now();
506 let data = make_session_data();
507 let session = Session::<Created>::new(data);
508
509 let active = session.activate(now);
510 let expired = active.expire(now);
511
512 assert_eq!(expired.expired_at(), now);
514 }
515
516 #[test]
517 fn test_tokens_saved_calculation() {
518 let now = Utc::now();
519 let data = make_session_data();
520 let session = Session::<Created>::new(data);
521 let mut active = session.activate(now);
522
523 active.record_delta(100);
526 active.record_delta(120);
527 active.record_delta(150);
528
529 assert_eq!(active.tokens_saved(), 14630);
530 }
531
532 #[test]
533 fn test_tokens_saved_zero_rounds() {
534 let now = Utc::now();
535 let data = make_session_data();
536 let session = Session::<Created>::new(data);
537 let active = session.activate(now);
538
539 assert_eq!(active.tokens_saved(), 0);
541 }
542
543 #[test]
544 fn test_stored_session_into_typed() {
545 let data = make_session_data();
546
547 let stored = StoredSession {
549 data: data.clone(),
550 status: SessionStatus::Created,
551 };
552 assert!(matches!(stored.into_typed(), LoadedSession::Created(_)));
553
554 let stored = StoredSession {
556 data: data.clone(),
557 status: SessionStatus::Active,
558 };
559 assert!(matches!(stored.into_typed(), LoadedSession::Active(_)));
560
561 let stored = StoredSession {
563 data: data.clone(),
564 status: SessionStatus::Closed,
565 };
566 assert!(matches!(stored.into_typed(), LoadedSession::Closed(_)));
567
568 let stored = StoredSession {
570 data,
571 status: SessionStatus::Expired,
572 };
573 assert!(matches!(stored.into_typed(), LoadedSession::Expired(_)));
574 }
575
576 #[test]
577 fn test_stored_session_wrong_state() {
578 let data = make_session_data();
579 let stored = StoredSession {
580 data,
581 status: SessionStatus::Active,
582 };
583
584 assert!(matches!(
585 stored.into_created(),
586 Err(SessionStateError::WrongState { .. })
587 ));
588 }
589
590 #[test]
591 fn test_stored_session_into_active() {
592 let data = make_session_data();
593 let stored = StoredSession {
594 data: data.clone(),
595 status: SessionStatus::Active,
596 };
597
598 let active = stored.into_active().expect("should convert to active");
599 assert_eq!(active.session_id(), data.session_id);
600 }
601
602 #[test]
603 fn test_session_status_display_fromstr() {
604 for status in [
605 SessionStatus::Created,
606 SessionStatus::Active,
607 SessionStatus::Closed,
608 SessionStatus::Expired,
609 ] {
610 let s = status.to_string();
611 let parsed: SessionStatus = s.parse().expect("should parse from display string");
612 assert_eq!(status, parsed);
613 }
614 }
615}