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 Backend(String),
356}
357
358impl std::fmt::Display for StoreError {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 match self {
361 Self::NotFound(message) => write!(f, "not found: {message}"),
362 Self::AlreadyExists(message) => write!(f, "already exists: {message}"),
363 Self::Conflict(message) => write!(f, "conflict: {message}"),
364 Self::InvalidInput(message) => write!(f, "invalid input: {message}"),
365 Self::Tenant(message) => write!(f, "tenant: {message}"),
366 Self::Contention { kind, message } => {
367 write!(f, "retryable backend contention ({kind}): {message}")
368 }
369 Self::Backend(message) => write!(f, "backend error: {message}"),
370 }
371 }
372}
373
374impl std::error::Error for StoreError {}
375
376pub type StoreResult<T> = Result<T, StoreError>;
377
378pub const MAX_READ_BATCH: usize = 1_000;
381
382#[derive(Clone)]
386pub struct StoreHooks {
387 pub redaction: Option<SharedEventRedactor>,
390 pub event_signer: Option<SessionSigner>,
394 pub receipt_signer: Option<SessionSigner>,
398 pub retention: RetentionPolicy,
401 pub archive_sink: Option<SharedArchiveSink>,
406 pub embedder: Arc<dyn Embedder>,
410}
411
412impl Default for StoreHooks {
413 fn default() -> Self {
414 Self {
415 redaction: None,
416 event_signer: None,
417 receipt_signer: None,
418 retention: RetentionPolicy::default(),
419 archive_sink: None,
420 embedder: default_embedder(),
421 }
422 }
423}
424
425#[async_trait]
426pub trait SessionStore: Send + Sync {
427 fn hooks(&self) -> &StoreHooks;
432
433 async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta>;
434 async fn update(&self, session_id: &str, request: UpdateSession) -> StoreResult<SessionMeta>;
435 async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta>;
436 async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>>;
437 async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent>;
438 async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage>;
439 async fn fork(
440 &self,
441 session_id: &str,
442 at_event_id: EventId,
443 child_id: Option<SessionId>,
444 ) -> StoreResult<ForkResult>;
445 async fn truncate(&self, session_id: &str, at_event_id: EventId)
446 -> StoreResult<TruncateResult>;
447 async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot>;
448 async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot>;
449 async fn close(&self, session_id: &str) -> StoreResult<StoredEvent>;
450 async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta>;
451 async fn hard_delete(&self, session_id: &str) -> StoreResult<()>;
452 async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport>;
453 async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse>;
454
455 async fn sweep_retention(
460 &self,
461 policy: &RetentionPolicy,
462 now_ms: i64,
463 ) -> StoreResult<SweepReport> {
464 use tracing::Instrument as _;
465 let span = tracing::info_span!(
466 "harn.session.sweep_retention",
467 harn.session.sweep.archive_sink_configured = self.hooks().archive_sink.is_some(),
468 harn.session.sweep.archived = tracing::field::Empty,
469 harn.session.sweep.soft_deleted = tracing::field::Empty,
470 harn.session.sweep.hard_deleted = tracing::field::Empty,
471 );
472 let span_for_record = span.clone();
473 let sink = self.hooks().archive_sink.clone();
474 let result = async move {
475 let mut report = SweepReport::default();
476 let sessions = self.list(ListFilter::default()).await?;
477 for session in sessions {
478 if policy.should_hard_delete(&session, now_ms) {
479 if let Some(sink) = sink.as_ref() {
480 let tombstone = Tombstone {
481 session_id: session.id.clone(),
482 tenant_id: session.tenant_id.clone(),
483 deleted_at_ms: now_ms,
484 deleted_at: super::event::ms_to_rfc3339(now_ms),
485 final_chain_root_hash: session.chain_root_hash.clone(),
486 final_event_id: session.last_event_id,
487 };
488 sink.tombstone(&tombstone).await?;
489 report.tombstoned += 1;
490 }
491 self.hard_delete(&session.id).await?;
492 report.hard_deleted += 1;
493 } else if policy.should_soft_delete(&session, now_ms) {
494 if policy.should_archive(&session, now_ms) {
495 if let Some(sink) = sink.as_ref() {
496 let events = read_all_events(self, &session.id).await?;
497 sink.archive(&session, &events).await?;
498 report.archived += 1;
499 }
500 }
501 self.soft_delete(&session.id).await?;
502 report.soft_deleted += 1;
503 }
504 }
505 Ok::<_, StoreError>(report)
506 }
507 .instrument(span)
508 .await?;
509 span_for_record.record("harn.session.sweep.archived", result.archived as i64);
510 span_for_record.record(
511 "harn.session.sweep.soft_deleted",
512 result.soft_deleted as i64,
513 );
514 span_for_record.record(
515 "harn.session.sweep.hard_deleted",
516 result.hard_deleted as i64,
517 );
518 Ok(result)
519 }
520}
521
522#[async_trait]
527pub trait SessionImporter: SessionStore {
528 async fn import(&self, request: ImportSession) -> StoreResult<ImportResult>;
529}
530
531async fn read_all_events<S: SessionStore + ?Sized>(
535 store: &S,
536 session_id: &str,
537) -> StoreResult<Vec<StoredEvent>> {
538 let mut all = Vec::new();
539 let mut cursor: Option<EventId> = None;
540 loop {
541 let page = store
542 .read(
543 session_id,
544 ReadRange {
545 from_event_id: cursor,
546 to_event_id: None,
547 limit: Some(MAX_READ_BATCH),
548 },
549 )
550 .await?;
551 let next = page.next_cursor;
552 all.extend(page.events);
553 match next {
554 Some(next_cursor) => cursor = Some(next_cursor),
555 None => break,
556 }
557 }
558 Ok(all)
559}
560
561#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
562pub struct SweepReport {
563 pub soft_deleted: usize,
564 pub hard_deleted: usize,
565 pub archived: usize,
568 pub tombstoned: usize,
571}
572
573pub type SharedSessionStore = Arc<dyn SessionStore>;