1use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8
9use super::event::{AppendEvent, EventId, StoredEvent};
10use super::redaction::SharedEventRedactor;
11use super::retention::{RetentionPolicy, SharedArchiveSink, Tombstone};
12use super::search::{default_embedder, Embedder, SearchQuery, SearchResponse};
13use super::signing::SessionSigner;
14
15pub type SessionId = String;
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ForkResult {
20 pub child_session_id: SessionId,
21 pub forked_from_event_id: EventId,
22 pub copied_event_count: usize,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct TruncateResult {
27 pub kept_event_count: usize,
28 pub removed_event_count: usize,
29 pub new_tip_event_id: Option<EventId>,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum SessionStatus {
36 Open,
37 Closed,
38 SoftDeleted,
41 HardDeleted,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SessionType {
47 User,
48 Subagent,
49 Scheduled,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct SessionMeta {
54 pub id: SessionId,
55 pub tenant_id: Option<String>,
56 pub persona: Option<String>,
57 pub parent_session_id: Option<SessionId>,
58 #[serde(default)]
59 pub title: Option<String>,
60 #[serde(default)]
62 pub title_pinned: bool,
63 #[serde(default)]
64 pub cwd: Option<String>,
65 #[serde(default)]
66 pub model: Option<String>,
67 #[serde(default)]
68 pub session_type: Option<SessionType>,
69 #[serde(default)]
70 pub project_scope: Option<String>,
71 #[serde(default)]
72 pub usage_input: u64,
73 #[serde(default)]
74 pub usage_output: u64,
75 #[serde(default)]
77 pub usage_cost_usd_micros: u64,
78 pub created_at_ms: i64,
79 pub created_at: String,
80 pub updated_at_ms: i64,
82 pub updated_at: String,
83 pub status: SessionStatus,
84 pub event_count: usize,
85 pub last_event_id: Option<EventId>,
86 pub chain_root_hash: Option<String>,
87 pub closed_at_ms: Option<i64>,
88 pub closed_at: Option<String>,
89 pub soft_deleted_at_ms: Option<i64>,
90 pub ttl_seconds: Option<u64>,
91 pub tags: Vec<String>,
92 pub attributes: BTreeMap<String, serde_json::Value>,
93}
94
95#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub struct CreateSession {
97 #[serde(default)]
98 pub id: Option<SessionId>,
99 #[serde(default)]
100 pub tenant_id: Option<String>,
101 #[serde(default)]
102 pub persona: Option<String>,
103 #[serde(default)]
104 pub parent_session_id: Option<SessionId>,
105 #[serde(default)]
106 pub title: Option<String>,
107 #[serde(default)]
110 pub title_pinned: bool,
111 #[serde(default)]
112 pub cwd: Option<String>,
113 #[serde(default)]
114 pub model: Option<String>,
115 #[serde(default)]
116 pub session_type: Option<SessionType>,
117 #[serde(default)]
118 pub project_scope: Option<String>,
119 #[serde(default)]
120 pub usage_input: u64,
121 #[serde(default)]
122 pub usage_output: u64,
123 #[serde(default)]
124 pub usage_cost_usd_micros: u64,
125 #[serde(default)]
126 pub ttl_seconds: Option<u64>,
127 #[serde(default)]
128 pub tags: Vec<String>,
129 #[serde(default)]
130 pub attributes: BTreeMap<String, serde_json::Value>,
131}
132
133#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
138pub struct UpdateSession {
139 #[serde(default)]
140 pub title: Option<String>,
141 #[serde(default)]
155 pub title_pinned: Option<bool>,
156 #[serde(default)]
157 pub cwd: Option<String>,
158 #[serde(default)]
159 pub model: Option<String>,
160 #[serde(default)]
161 pub parent_session_id: Option<SessionId>,
162 #[serde(default)]
163 pub session_type: Option<SessionType>,
164 #[serde(default)]
165 pub project_scope: Option<String>,
166 #[serde(default)]
167 pub usage_input: Option<u64>,
168 #[serde(default)]
169 pub usage_output: Option<u64>,
170 #[serde(default)]
171 pub usage_cost_usd_micros: Option<u64>,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180pub struct ImportSession {
181 pub source_id: String,
182 pub source_digest: String,
183 pub session: CreateSession,
184 #[serde(default)]
185 pub events: Vec<AppendEvent>,
186}
187
188impl ImportSession {
189 pub fn validate(&self) -> StoreResult<()> {
191 if self.source_id.trim().is_empty() || self.source_digest.trim().is_empty() {
192 return Err(StoreError::InvalidInput(
193 "import source_id and source_digest must be non-empty".to_string(),
194 ));
195 }
196 if self
197 .session
198 .id
199 .as_deref()
200 .is_none_or(|session_id| session_id.trim().is_empty())
201 {
202 return Err(StoreError::InvalidInput(
203 "import session id must be explicit and non-empty".to_string(),
204 ));
205 }
206 Ok(())
207 }
208}
209
210#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ImportResult {
212 pub source_id: String,
213 pub source_digest: String,
214 pub session_id: SessionId,
215 pub event_count: usize,
216 pub imported: bool,
218}
219
220#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ListFilter {
222 #[serde(default)]
223 pub tenant_id: Option<String>,
224 #[serde(default)]
225 pub persona: Option<String>,
226 #[serde(default)]
227 pub status: Option<SessionStatus>,
228 #[serde(default)]
229 pub tag: Option<String>,
230 #[serde(default)]
231 pub parent_session_id: Option<SessionId>,
232 #[serde(default)]
233 pub session_type: Option<SessionType>,
234 #[serde(default)]
235 pub project_scope: Option<String>,
236 #[serde(default)]
238 pub created_after_ms: Option<i64>,
239 #[serde(default)]
241 pub created_before_ms: Option<i64>,
242 #[serde(default)]
243 pub limit: Option<usize>,
244 #[serde(default)]
245 pub cursor: Option<String>,
246 #[serde(default)]
248 pub sort_by: ListSortKey,
249 #[serde(default)]
252 pub order: ListOrder,
253}
254
255#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum ListSortKey {
258 #[default]
259 CreatedAt,
260 UpdatedAt,
261}
262
263#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum ListOrder {
266 #[default]
267 Ascending,
268 Descending,
269}
270
271#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
272pub struct ReadRange {
273 #[serde(default)]
275 pub from_event_id: Option<EventId>,
276 #[serde(default)]
278 pub to_event_id: Option<EventId>,
279 #[serde(default)]
283 pub limit: Option<usize>,
284}
285
286#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
288pub struct EventPage {
289 pub events: Vec<StoredEvent>,
290 pub next_cursor: Option<EventId>,
293}
294
295#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
296pub struct SnapshotId(pub String);
297
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299pub struct Snapshot {
300 pub id: SnapshotId,
301 pub session: SessionMeta,
302 pub events: Vec<StoredEvent>,
303 pub captured_at_ms: i64,
304 pub captured_at: String,
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
308pub struct VerifyReport {
309 pub session_id: SessionId,
310 pub chain_root_hash: String,
311 pub event_count: usize,
312 pub signed_event_count: usize,
313 pub failures: Vec<VerifyFailure>,
314}
315
316#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
317pub struct VerifyFailure {
318 pub event_id: EventId,
319 pub reason: String,
320}
321
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub enum StoreContention {
325 DatabaseBusy,
327 DatabaseLocked,
329}
330
331impl std::fmt::Display for StoreContention {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 match self {
334 Self::DatabaseBusy => f.write_str("database_busy"),
335 Self::DatabaseLocked => f.write_str("database_locked"),
336 }
337 }
338}
339
340#[derive(Clone, Debug, PartialEq, Eq)]
342pub enum StoreError {
343 NotFound(String),
344 AlreadyExists(String),
345 Conflict(String),
346 InvalidInput(String),
347 Tenant(String),
348 Contention {
350 kind: StoreContention,
352 message: String,
354 },
355 SchemaIncompatible {
357 schema: String,
359 stored: i64,
361 supported: i64,
363 },
364 Backend(String),
365}
366
367impl std::fmt::Display for StoreError {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 match self {
370 Self::NotFound(message) => write!(f, "not found: {message}"),
371 Self::AlreadyExists(message) => write!(f, "already exists: {message}"),
372 Self::Conflict(message) => write!(f, "conflict: {message}"),
373 Self::InvalidInput(message) => write!(f, "invalid input: {message}"),
374 Self::Tenant(message) => write!(f, "tenant: {message}"),
375 Self::Contention { kind, message } => {
376 write!(f, "retryable backend contention ({kind}): {message}")
377 }
378 Self::SchemaIncompatible {
379 schema,
380 stored,
381 supported,
382 } => write!(
383 f,
384 "schema incompatible: {schema} version {stored} is newer than supported version {supported}"
385 ),
386 Self::Backend(message) => write!(f, "backend error: {message}"),
387 }
388 }
389}
390
391impl std::error::Error for StoreError {}
392
393pub type StoreResult<T> = Result<T, StoreError>;
394
395pub const MAX_READ_BATCH: usize = 1_000;
398
399#[derive(Clone)]
403pub struct StoreHooks {
404 pub redaction: Option<SharedEventRedactor>,
407 pub event_signer: Option<SessionSigner>,
411 pub receipt_signer: Option<SessionSigner>,
415 pub retention: RetentionPolicy,
418 pub archive_sink: Option<SharedArchiveSink>,
423 pub embedder: Arc<dyn Embedder>,
427}
428
429impl Default for StoreHooks {
430 fn default() -> Self {
431 Self {
432 redaction: None,
433 event_signer: None,
434 receipt_signer: None,
435 retention: RetentionPolicy::default(),
436 archive_sink: None,
437 embedder: default_embedder(),
438 }
439 }
440}
441
442#[async_trait]
443pub trait SessionStore: Send + Sync {
444 fn hooks(&self) -> &StoreHooks;
449
450 async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta>;
451 async fn update(&self, session_id: &str, request: UpdateSession) -> StoreResult<SessionMeta>;
452 async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta>;
453 async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>>;
454 async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent>;
455 async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage>;
456 async fn fork(
457 &self,
458 session_id: &str,
459 at_event_id: EventId,
460 child_id: Option<SessionId>,
461 ) -> StoreResult<ForkResult>;
462 async fn truncate(&self, session_id: &str, at_event_id: EventId)
463 -> StoreResult<TruncateResult>;
464 async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot>;
465 async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot>;
466 async fn close(&self, session_id: &str) -> StoreResult<StoredEvent>;
467 async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta>;
468 async fn hard_delete(&self, session_id: &str) -> StoreResult<()>;
469 async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport>;
470 async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse>;
471
472 async fn sweep_retention(
477 &self,
478 policy: &RetentionPolicy,
479 now_ms: i64,
480 ) -> StoreResult<SweepReport> {
481 use tracing::Instrument as _;
482 let span = tracing::info_span!(
483 "harn.session.sweep_retention",
484 harn.session.sweep.archive_sink_configured = self.hooks().archive_sink.is_some(),
485 harn.session.sweep.archived = tracing::field::Empty,
486 harn.session.sweep.soft_deleted = tracing::field::Empty,
487 harn.session.sweep.hard_deleted = tracing::field::Empty,
488 );
489 let span_for_record = span.clone();
490 let sink = self.hooks().archive_sink.clone();
491 let result = async move {
492 let mut report = SweepReport::default();
493 let sessions = self.list(ListFilter::default()).await?;
494 for session in sessions {
495 if policy.should_hard_delete(&session, now_ms) {
496 if let Some(sink) = sink.as_ref() {
497 let tombstone = Tombstone {
498 session_id: session.id.clone(),
499 tenant_id: session.tenant_id.clone(),
500 deleted_at_ms: now_ms,
501 deleted_at: super::event::ms_to_rfc3339(now_ms),
502 final_chain_root_hash: session.chain_root_hash.clone(),
503 final_event_id: session.last_event_id,
504 };
505 sink.tombstone(&tombstone).await?;
506 report.tombstoned += 1;
507 }
508 self.hard_delete(&session.id).await?;
509 report.hard_deleted += 1;
510 } else if policy.should_soft_delete(&session, now_ms) {
511 if policy.should_archive(&session, now_ms) {
512 if let Some(sink) = sink.as_ref() {
513 let events = read_all_events(self, &session.id).await?;
514 sink.archive(&session, &events).await?;
515 report.archived += 1;
516 }
517 }
518 self.soft_delete(&session.id).await?;
519 report.soft_deleted += 1;
520 }
521 }
522 Ok::<_, StoreError>(report)
523 }
524 .instrument(span)
525 .await?;
526 span_for_record.record("harn.session.sweep.archived", result.archived as i64);
527 span_for_record.record(
528 "harn.session.sweep.soft_deleted",
529 result.soft_deleted as i64,
530 );
531 span_for_record.record(
532 "harn.session.sweep.hard_deleted",
533 result.hard_deleted as i64,
534 );
535 Ok(result)
536 }
537}
538
539#[async_trait]
544pub trait SessionImporter: SessionStore {
545 async fn import(&self, request: ImportSession) -> StoreResult<ImportResult>;
546}
547
548async fn read_all_events<S: SessionStore + ?Sized>(
552 store: &S,
553 session_id: &str,
554) -> StoreResult<Vec<StoredEvent>> {
555 let mut all = Vec::new();
556 let mut cursor: Option<EventId> = None;
557 loop {
558 let page = store
559 .read(
560 session_id,
561 ReadRange {
562 from_event_id: cursor,
563 to_event_id: None,
564 limit: Some(MAX_READ_BATCH),
565 },
566 )
567 .await?;
568 let next = page.next_cursor;
569 all.extend(page.events);
570 match next {
571 Some(next_cursor) => cursor = Some(next_cursor),
572 None => break,
573 }
574 }
575 Ok(all)
576}
577
578#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
579pub struct SweepReport {
580 pub soft_deleted: usize,
581 pub hard_deleted: usize,
582 pub archived: usize,
585 pub tombstoned: usize,
588}
589
590pub type SharedSessionStore = Arc<dyn SessionStore>;