1use std::path::PathBuf;
29use std::sync::Arc;
30
31use meerkat::SessionStore;
32use meerkat_core::{DurabilityClass, DurabilityDeclaration, DurabilityResolution};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum BlobDurability {
37 PersistentDisk,
39 DeclaredEphemeral,
42 Custom { persistent: bool },
45}
46
47impl BlobDurability {
48 pub fn as_str(&self) -> &'static str {
50 match self {
51 Self::PersistentDisk => "persistent_disk",
52 Self::DeclaredEphemeral => "declared_ephemeral",
53 Self::Custom { .. } => "custom",
54 }
55 }
56
57 pub fn is_persistent(&self) -> bool {
59 match self {
60 Self::PersistentDisk => true,
61 Self::DeclaredEphemeral => false,
62 Self::Custom { persistent } => *persistent,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct StorageSlotSummary {
72 pub declaration: DurabilityDeclaration,
74 pub backend: String,
77 pub detail: Option<String>,
80 pub degraded: bool,
84}
85
86impl StorageSlotSummary {
87 pub fn persistent(domain: &str, backend: impl Into<String>) -> Self {
89 Self {
90 declaration: DurabilityDeclaration::durable(domain, DurabilityResolution::Persistent),
91 backend: backend.into(),
92 detail: None,
93 degraded: false,
94 }
95 }
96
97 pub fn declared_ephemeral(
100 domain: &str,
101 backend: impl Into<String>,
102 detail: impl Into<String>,
103 ) -> Self {
104 Self {
105 declaration: DurabilityDeclaration::durable(
106 domain,
107 DurabilityResolution::DeclaredEphemeral,
108 ),
109 backend: backend.into(),
110 detail: Some(detail.into()),
111 degraded: false,
112 }
113 }
114
115 pub fn degraded(domain: &str, detail: impl Into<String>) -> Self {
119 Self {
120 declaration: DurabilityDeclaration::durable(
121 domain,
122 DurabilityResolution::NonPersistent,
123 ),
124 backend: "disabled".to_string(),
125 detail: Some(detail.into()),
126 degraded: true,
127 }
128 }
129
130 #[must_use]
132 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
133 self.detail = Some(detail.into());
134 self
135 }
136
137 pub fn scratch(domain: &str, backend: impl Into<String>, detail: impl Into<String>) -> Self {
141 Self {
142 declaration: DurabilityDeclaration {
143 domain: domain.to_string(),
144 class: DurabilityClass::Scratch,
145 resolution: DurabilityResolution::DeclaredEphemeral,
146 },
147 backend: backend.into(),
148 detail: Some(detail.into()),
149 degraded: false,
150 }
151 }
152
153 fn status_json(&self) -> serde_json::Value {
154 let mut object = serde_json::json!({
155 "domain": self.declaration.domain,
156 "class": serde_json::to_value(self.declaration.class)
157 .unwrap_or(serde_json::Value::Null),
158 "resolution": serde_json::to_value(self.declaration.resolution)
159 .unwrap_or(serde_json::Value::Null),
160 "backend": self.backend,
161 "degraded": self.degraded,
162 });
163 if let (Some(detail), Some(map)) = (self.detail.as_ref(), object.as_object_mut()) {
164 map.insert(
165 "detail".to_string(),
166 serde_json::Value::String(detail.clone()),
167 );
168 }
169 object
170 }
171}
172
173pub fn blob_slot_summary(durability: BlobDurability) -> StorageSlotSummary {
175 match durability {
176 BlobDurability::PersistentDisk => {
177 StorageSlotSummary::persistent("blobs", "ObjectStoreBlobStore (local disk)")
178 }
179 BlobDurability::DeclaredEphemeral => StorageSlotSummary::declared_ephemeral(
180 "blobs",
181 "ObjectStoreBlobStore (memory)",
182 "explicitly declared (ephemeral launch mode or ephemeral_blobs(true))",
183 ),
184 BlobDurability::Custom { persistent: true } => {
185 StorageSlotSummary::persistent("blobs", "custom blob store")
186 .with_detail("caller-injected store reporting is_persistent()")
187 }
188 BlobDurability::Custom { persistent: false } => StorageSlotSummary::declared_ephemeral(
189 "blobs",
190 "custom blob store",
191 "caller-injected store reports !is_persistent()",
192 ),
193 }
194}
195
196pub fn scratch_ring_buffer_slots() -> Vec<StorageSlotSummary> {
201 vec![
202 StorageSlotSummary::scratch(
203 "gating_audit",
204 "in-process ring buffer",
205 "drop-oldest retention (512 entries); durable audit slot is a flagged follow-up",
206 ),
207 StorageSlotSummary::scratch(
208 "delivery_history",
209 "in-process ring buffer",
210 "drop-oldest retention (200 entries)",
211 ),
212 StorageSlotSummary::scratch(
213 "routing_resolutions",
214 "in-process ring buffer",
215 "drop-oldest retention (512 entries)",
216 ),
217 ]
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct ResolvedStorageSummary {
225 pub blob_durability: BlobDurability,
227 pub session_store_incremental: Option<bool>,
231 pub slots: Vec<StorageSlotSummary>,
235 pub state_dir: Option<PathBuf>,
240}
241
242impl ResolvedStorageSummary {
243 pub fn new(blob_durability: BlobDurability, session_store_incremental: Option<bool>) -> Self {
246 Self {
247 blob_durability,
248 session_store_incremental,
249 slots: Vec::new(),
250 state_dir: None,
251 }
252 }
253
254 #[must_use]
256 pub fn with_slots(mut self, slots: Vec<StorageSlotSummary>) -> Self {
257 self.slots = slots;
258 self
259 }
260
261 #[must_use]
264 pub fn with_state_dir(mut self, state_dir: impl Into<PathBuf>) -> Self {
265 self.state_dir = Some(state_dir.into());
266 self
267 }
268
269 pub fn status_json(&self) -> serde_json::Value {
273 serde_json::json!({
274 "blob_durability": self.blob_durability.as_str(),
275 "blob_store_persistent": self.blob_durability.is_persistent(),
276 "session_store_incremental": self.session_store_incremental,
277 "slots": self
278 .slots
279 .iter()
280 .map(StorageSlotSummary::status_json)
281 .collect::<Vec<_>>(),
282 })
283 }
284}
285
286#[derive(Debug)]
288pub enum BlobStoreResolutionError {
289 OpenFailed { path: PathBuf, message: String },
292 NonPersistentUndeclared,
295}
296
297impl std::fmt::Display for BlobStoreResolutionError {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 match self {
300 Self::OpenFailed { path, message } => write!(
301 f,
302 "failed to open persistent binary blob store at {}: {message} \
303 (fix the blob directory, or declare in-memory blobs explicitly \
304 via ephemeral_blobs(true))",
305 path.display()
306 ),
307 Self::NonPersistentUndeclared => write!(
308 f,
309 "persistent mode resolved a blob store that reports \
310 !is_persistent(); blobs would silently vanish on restart. \
311 Provide a persistent blob store, or declare the ephemeral \
312 choice explicitly via ephemeral_blobs(true)"
313 ),
314 }
315 }
316}
317
318impl std::error::Error for BlobStoreResolutionError {}
319
320#[derive(Debug)]
329pub struct RuntimeStoreResolutionError {
330 pub path: PathBuf,
331 pub message: String,
332}
333
334impl std::fmt::Display for RuntimeStoreResolutionError {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 write!(
337 f,
338 "failed to open the persistent runtime store at {}: {} \
339 (sessions would not survive restart and archive operations would \
340 fail; fix the database file, or declare an in-memory runtime \
341 store explicitly via ephemeral_runtime_store(true) / \
342 runtime_options.runtime_store = {{\"storage\": \"memory\"}})",
343 self.path.display(),
344 self.message
345 )
346 }
347}
348
349impl std::error::Error for RuntimeStoreResolutionError {}
350
351#[derive(Debug)]
353pub struct JobStoreResolutionError {
354 pub path: PathBuf,
355 pub message: String,
356}
357
358impl std::fmt::Display for JobStoreResolutionError {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 write!(
361 f,
362 "failed to open the canonical detached-job store at {}: {} \
363 (durable detached admission is unavailable; fix the database file)",
364 self.path.display(),
365 self.message
366 )
367 }
368}
369
370impl std::error::Error for JobStoreResolutionError {}
371
372#[derive(Debug)]
375pub enum StorageResolutionError {
376 Blob(BlobStoreResolutionError),
377 RuntimeStore(RuntimeStoreResolutionError),
378 JobStore(JobStoreResolutionError),
379}
380
381impl std::fmt::Display for StorageResolutionError {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 match self {
384 Self::Blob(error) => error.fmt(f),
385 Self::RuntimeStore(error) => error.fmt(f),
386 Self::JobStore(error) => error.fmt(f),
387 }
388 }
389}
390
391impl std::error::Error for StorageResolutionError {}
392
393impl From<BlobStoreResolutionError> for StorageResolutionError {
394 fn from(error: BlobStoreResolutionError) -> Self {
395 Self::Blob(error)
396 }
397}
398
399impl From<RuntimeStoreResolutionError> for StorageResolutionError {
400 fn from(error: RuntimeStoreResolutionError) -> Self {
401 Self::RuntimeStore(error)
402 }
403}
404
405impl From<JobStoreResolutionError> for StorageResolutionError {
406 fn from(error: JobStoreResolutionError) -> Self {
407 Self::JobStore(error)
408 }
409}
410
411pub fn probe_session_store_incremental(store: &Arc<dyn SessionStore>, store_kind: &str) -> bool {
421 let incremental = Arc::clone(store).as_incremental().is_some();
422 if !incremental {
423 tracing::warn!(
424 session_store = store_kind,
425 "session store does not advertise incremental persistence; \
426 session persistence degrades to whole-blob saves on every turn \
427 (incremental capability absent)"
428 );
429 }
430 incremental
431}
432
433#[cfg(test)]
434#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
435mod tests {
436 use super::*;
437
438 #[derive(Clone, Default)]
441 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
442
443 impl CaptureWriter {
444 fn contents(&self) -> String {
445 String::from_utf8_lossy(
446 &self
447 .0
448 .lock()
449 .unwrap_or_else(std::sync::PoisonError::into_inner),
450 )
451 .into_owned()
452 }
453 }
454
455 impl std::io::Write for CaptureWriter {
456 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
457 self.0
458 .lock()
459 .unwrap_or_else(std::sync::PoisonError::into_inner)
460 .extend_from_slice(buf);
461 Ok(buf.len())
462 }
463
464 fn flush(&mut self) -> std::io::Result<()> {
465 Ok(())
466 }
467 }
468
469 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
470 type Writer = CaptureWriter;
471
472 fn make_writer(&'a self) -> Self::Writer {
473 self.clone()
474 }
475 }
476
477 fn probe_with_captured_warnings(
478 store: &Arc<dyn SessionStore>,
479 store_kind: &str,
480 ) -> (bool, String) {
481 let writer = CaptureWriter::default();
482 let subscriber = tracing_subscriber::fmt()
483 .with_writer(writer.clone())
484 .with_max_level(tracing::Level::WARN)
485 .finish();
486 let incremental = tracing::subscriber::with_default(subscriber, || {
487 probe_session_store_incremental(store, store_kind)
488 });
489 (incremental, writer.contents())
490 }
491
492 #[tokio::test]
496 async fn probe_reports_continuity_adapter_as_incremental_without_warning() {
497 let dir = tempfile::tempdir().expect("temp dir");
498 let (store, _fencing_floor) =
499 crate::identity_first::LocalContinuityStore::open_with_fencing_floor(
500 dir.path().join("continuity.sqlite"),
501 )
502 .await
503 .expect("open continuity store");
504 let adapter: Arc<dyn SessionStore> = Arc::new(
505 crate::identity_first::ContinuitySessionStoreAdapter::new(Arc::new(store)),
506 );
507
508 let (incremental, warnings) =
509 probe_with_captured_warnings(&adapter, "ContinuitySessionStoreAdapter");
510 assert!(
511 incremental,
512 "the continuity adapter over the bundled store advertises incremental persistence"
513 );
514 assert!(
515 warnings.is_empty(),
516 "no whole-blob degradation warning expected, got: {warnings}"
517 );
518 }
519
520 #[test]
525 fn probe_still_warns_for_a_whole_blob_only_store() {
526 struct WholeBlobOnlyStore;
527
528 #[async_trait::async_trait]
529 impl SessionStore for WholeBlobOnlyStore {
530 async fn save(
531 &self,
532 _session: &meerkat_core::Session,
533 ) -> Result<(), meerkat_core::SessionStoreError> {
534 Ok(())
535 }
536
537 async fn load(
538 &self,
539 _id: &meerkat_core::types::SessionId,
540 ) -> Result<Option<meerkat_core::Session>, meerkat_core::SessionStoreError>
541 {
542 Ok(None)
543 }
544
545 async fn list(
546 &self,
547 _filter: meerkat_core::SessionFilter,
548 ) -> Result<Vec<meerkat_core::SessionMeta>, meerkat_core::SessionStoreError>
549 {
550 Ok(Vec::new())
551 }
552
553 async fn delete(
554 &self,
555 _id: &meerkat_core::types::SessionId,
556 ) -> Result<(), meerkat_core::SessionStoreError> {
557 Ok(())
558 }
559
560 async fn delete_if_current_revision(
561 &self,
562 _id: &meerkat_core::types::SessionId,
563 _expected_current_revision: &str,
564 ) -> Result<bool, meerkat_core::SessionStoreError> {
565 Ok(false)
566 }
567 }
568
569 let store: Arc<dyn SessionStore> = Arc::new(WholeBlobOnlyStore);
570 let (incremental, warnings) = probe_with_captured_warnings(&store, "WholeBlobOnlyStore");
571 assert!(!incremental);
572 assert!(
573 warnings.contains("whole-blob"),
574 "the startup warning must name the consequence, got: {warnings}"
575 );
576 assert!(
577 warnings.contains("WholeBlobOnlyStore"),
578 "the startup warning must name the store kind, got: {warnings}"
579 );
580 }
581
582 #[test]
584 fn probe_reports_incremental_sqlite_store_without_warning() {
585 let dir = tempfile::tempdir().expect("temp dir");
586 let store: Arc<dyn SessionStore> = Arc::new(
587 meerkat_store::SqliteSessionStore::open(dir.path().join("sessions.db"))
588 .expect("open sqlite session store"),
589 );
590
591 let (incremental, warnings) = probe_with_captured_warnings(&store, "SqliteSessionStore");
592 assert!(incremental, "SqliteSessionStore advertises as_incremental");
593 assert!(
594 warnings.is_empty(),
595 "no degradation warning expected, got: {warnings}"
596 );
597 }
598
599 #[test]
600 fn blob_durability_wire_spellings_are_stable() {
601 assert_eq!(BlobDurability::PersistentDisk.as_str(), "persistent_disk");
602 assert_eq!(
603 BlobDurability::DeclaredEphemeral.as_str(),
604 "declared_ephemeral"
605 );
606 assert_eq!(
607 BlobDurability::Custom { persistent: true }.as_str(),
608 "custom"
609 );
610 assert!(BlobDurability::PersistentDisk.is_persistent());
611 assert!(!BlobDurability::DeclaredEphemeral.is_persistent());
612 assert!(BlobDurability::Custom { persistent: true }.is_persistent());
613 assert!(!BlobDurability::Custom { persistent: false }.is_persistent());
614 }
615
616 #[test]
617 fn status_json_carries_all_health_fields() {
618 let summary = ResolvedStorageSummary::new(BlobDurability::DeclaredEphemeral, None);
619 let json = summary.status_json();
620 assert_eq!(json["blob_durability"], "declared_ephemeral");
621 assert_eq!(json["blob_store_persistent"], false);
622 assert!(json["session_store_incremental"].is_null());
623 assert_eq!(json["slots"], serde_json::json!([]));
624
625 let summary = ResolvedStorageSummary::new(BlobDurability::PersistentDisk, Some(true));
626 let json = summary.status_json();
627 assert_eq!(json["blob_durability"], "persistent_disk");
628 assert_eq!(json["blob_store_persistent"], true);
629 assert_eq!(json["session_store_incremental"], true);
630 }
631
632 #[test]
636 fn status_json_slot_census_is_additive_and_machine_readable() {
637 let summary = ResolvedStorageSummary::new(BlobDurability::PersistentDisk, Some(true))
638 .with_slots(vec![
639 StorageSlotSummary::persistent("runtime", "SqliteRuntimeStore"),
640 StorageSlotSummary::declared_ephemeral(
641 "metadata",
642 "InMemoryMetadataStore (declared default)",
643 "this surface keeps metadata in-memory by contract",
644 ),
645 StorageSlotSummary::degraded("schedule", "schedule store failed to open: disk"),
646 StorageSlotSummary::scratch("gating_audit", "in-process ring buffer", "512"),
647 ]);
648 let json = summary.status_json();
649 let slots = json["slots"].as_array().expect("slots array");
650 assert_eq!(slots.len(), 4);
651 assert_eq!(slots[0]["domain"], "runtime");
652 assert_eq!(slots[0]["class"], "durable");
653 assert_eq!(slots[0]["resolution"], "persistent");
654 assert_eq!(slots[0]["backend"], "SqliteRuntimeStore");
655 assert_eq!(slots[0]["degraded"], false);
656 assert!(slots[0].get("detail").is_none());
657 assert_eq!(slots[1]["resolution"], "declared_ephemeral");
658 assert_eq!(slots[2]["resolution"], "non_persistent");
659 assert_eq!(slots[2]["degraded"], true);
660 assert_eq!(slots[2]["backend"], "disabled");
661 assert_eq!(slots[3]["class"], "scratch");
662 }
663
664 #[test]
665 fn runtime_store_resolution_error_names_the_remediation() {
666 let error = RuntimeStoreResolutionError {
667 path: PathBuf::from("/state/runtime.sqlite"),
668 message: "disk I/O error".to_string(),
669 };
670 let text = error.to_string();
671 assert!(text.contains("/state/runtime.sqlite"));
672 assert!(text.contains("ephemeral_runtime_store(true)"));
673 assert!(text.contains("runtime_options.runtime_store"));
674 }
675}