1use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36#[cfg(test)]
37use std::sync::atomic::AtomicUsize;
38use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
39use std::sync::{Arc, Mutex};
40
41use flate2::Compression;
42use flate2::read::ZlibDecoder;
43use flate2::write::ZlibEncoder;
44use lash_core::runtime::ProcessHandleGrantEntry;
45use lash_core::runtime::{
46 QueuedWorkBatch, QueuedWorkBatchDraft, QueuedWorkClaim, QueuedWorkClaimBoundary,
47 QueuedWorkCompletion, QueuedWorkItem, QueuedWorkPayload, prepare_process_event_append,
48 prepare_process_registration,
49};
50use lash_core::store::queued_work::{
51 ClaimCandidate, QueuedWorkClaimLease, claim_scan_limit, derive_batch_id,
52 ensure_completion_owns_all_batches, select_leading_session_command,
53 select_turn_work_claim_prefix,
54};
55use lash_core::store::{
56 GraphCommitDelta, HydratedSessionCheckpoint, PersistedSessionRead, RuntimeCommit,
57 RuntimeCommitResult, SessionCheckpoint, SessionHead, SessionHeadMeta,
58};
59use lash_core::{
60 AbandonRequest, AttachmentId, AttachmentIntent, AttachmentManifest, AttachmentManifestEntry,
61 BlobRef, DeliveryPolicy, DurabilityTier, GcReport, LeaseOwnerIdentity, LeaseOwnerLiveness,
62 MergeKey, PROCESS_LEASE_SCHEMA_VERSION, PersistedSegmentHandover, ProcessAwaitOutput,
63 ProcessChangeCursor, ProcessEvent, ProcessEventAppendRequest, ProcessEventAppendResult,
64 ProcessExternalRef, ProcessHandleDescriptor, ProcessHandleGrant, ProcessLease,
65 ProcessLeaseClaimOutcome, ProcessLeaseCompletion, ProcessListFilter,
66 ProcessLiveReferenceSummary, ProcessPruneReport, ProcessRecord, ProcessRegistration,
67 ProcessRegistry, ProcessStarted, QueuedWorkStore, RuntimePersistence, SessionCommitStore,
68 SessionExecutionLease, SessionExecutionLeaseClaimOutcome, SessionExecutionLeaseCompletion,
69 SessionExecutionLeaseFence, SessionExecutionLeaseStore, SessionMeta, SessionPickerInfo,
70 SessionReadScope, SessionScope, SessionStoreCreateRequest, SessionStoreFactory, SlotPolicy,
71 StoreError, StoreMaintenance, TurnInputStore, VacuumReport,
72};
73use rusqlite::{Connection, OptionalExtension, Transaction, params};
74use sha2::{Digest, Sha256};
75
76use conn::SqliteConnection;
77
78mod attachments;
79mod blobs;
80mod conn;
81mod effect_replay;
82mod graph;
83mod leases;
84mod lifecycle;
85mod pending_turn_inputs;
86mod persistence;
87mod process_registry;
88mod process_registry_change;
89mod process_registry_completion;
90mod queued_work;
91mod schema;
92mod triggers;
93
94use conn::TxOutcome;
95pub use effect_replay::{
96 SqliteEffectHost, SqliteEffectReplayOptions, SqliteRuntimeEffectController,
97};
98use leases::*;
99use pending_turn_inputs::*;
100use queued_work::*;
101use schema::{
102 StoreBacking, apply_pragmas, ensure_effect_schema, ensure_process_schema, ensure_schema,
103 ensure_trigger_schema,
104};
105pub use triggers::SqliteTriggerStore;
106
107pub struct Store {
114 conn: SqliteConnection,
115 clock: Arc<dyn lash_core::Clock>,
116 artifact_cache: Mutex<BTreeMap<lashlang::ModuleRef, Arc<lashlang::ModuleArtifact>>>,
117 options: StoreOptions,
118 commit_count: AtomicU64,
119 #[cfg(test)]
120 checkpoint_probe_count: AtomicUsize,
121 #[cfg(test)]
122 checkpoint_write_transaction_count: AtomicUsize,
123}
124
125pub struct SqliteProcessRegistry {
131 conn: SqliteConnection,
132 clock: Arc<dyn lash_core::Clock>,
133}
134
135fn sqlite_error(err: rusqlite::Error) -> StoreError {
136 StoreError::Backend(err.to_string())
137}
138
139fn process_sqlite_error(err: rusqlite::Error) -> lash_core::PluginError {
140 lash_core::PluginError::Session(err.to_string())
141}
142
143fn process_decode_error(err: serde_json::Error) -> lash_core::PluginError {
144 lash_core::PluginError::Session(format!("failed to decode process registry row: {err}"))
145}
146
147fn process_encode_json<T: serde::Serialize>(value: &T) -> Result<String, lash_core::PluginError> {
148 serde_json::to_string(value).map_err(|err| {
149 lash_core::PluginError::Session(format!("failed to encode process row: {err}"))
150 })
151}
152
153fn block_on_store<T>(future: impl std::future::Future<Output = T>) -> T {
154 futures_executor::block_on(future)
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
158pub enum PersistedArtifactKind {
159 GenericBlob,
160 CheckpointManifest,
161 ToolState,
162 PluginSessionSnapshot,
163 ExecutionStateSnapshot,
164 LashlangModule,
165 ProcessExecutionEnv,
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
169pub enum BlobStorageHint {
170 Compressible,
171 InlinePreferred,
172 LargePayload,
173}
174
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
176enum BlobCompression {
177 None,
178 Zlib,
179}
180
181#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
182pub struct BlobArtifactDescriptor {
183 pub kind: PersistedArtifactKind,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub hints: Vec<BlobStorageHint>,
186}
187
188impl BlobArtifactDescriptor {
189 pub fn new(kind: PersistedArtifactKind, hints: impl Into<Vec<BlobStorageHint>>) -> Self {
190 Self {
191 kind,
192 hints: hints.into(),
193 }
194 }
195
196 pub fn checkpoint_manifest() -> Self {
197 Self::new(
198 PersistedArtifactKind::CheckpointManifest,
199 vec![BlobStorageHint::Compressible],
200 )
201 }
202
203 pub fn tool_state_snapshot() -> Self {
204 Self::new(
205 PersistedArtifactKind::ToolState,
206 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
207 )
208 }
209
210 pub fn plugin_session_snapshot() -> Self {
211 Self::new(
212 PersistedArtifactKind::PluginSessionSnapshot,
213 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
214 )
215 }
216
217 pub fn execution_state_snapshot() -> Self {
218 Self::new(
219 PersistedArtifactKind::ExecutionStateSnapshot,
220 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
221 )
222 }
223
224 pub fn lashlang_module() -> Self {
225 Self::new(
226 PersistedArtifactKind::LashlangModule,
227 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
228 )
229 }
230
231 pub fn process_execution_env() -> Self {
232 Self::new(
233 PersistedArtifactKind::ProcessExecutionEnv,
234 vec![BlobStorageHint::Compressible],
235 )
236 }
237}
238
239#[derive(Clone, Debug, PartialEq, Eq, Hash)]
240struct RetainedArtifactRef {
241 pub blob_ref: BlobRef,
242 pub kind: PersistedArtifactKind,
243}
244
245#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
246pub enum BuiltinBlobProfile {
247 LowLatency,
248 #[default]
249 Balanced,
250 Compact,
251}
252
253#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
254pub struct StoreGcPolicy {
255 pub auto_run_every_commits: Option<u64>,
256}
257
258#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
259pub struct StoreOptions {
260 pub blob_profile: BuiltinBlobProfile,
261 pub gc_policy: StoreGcPolicy,
262}
263
264#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
265struct StoredBlobEnvelope {
266 descriptor: BlobArtifactDescriptor,
267 compression: BlobCompression,
268 content: Vec<u8>,
269}
270
271#[derive(Clone, Debug)]
272pub struct StoredSessionCheckpoint {
273 pub checkpoint_ref: BlobRef,
274 pub manifest: SessionCheckpoint,
275}
276
277#[derive(Clone, Debug)]
284pub struct SqliteSessionStoreFactory {
285 root: PathBuf,
286 options: StoreOptions,
287 clock: Arc<dyn lash_core::Clock>,
288}
289
290impl SqliteSessionStoreFactory {
291 pub fn new(root: impl Into<PathBuf>) -> Self {
292 Self {
293 root: root.into(),
294 options: StoreOptions::default(),
295 clock: Arc::new(lash_core::SystemClock),
296 }
297 }
298
299 pub fn with_options(root: impl Into<PathBuf>, options: StoreOptions) -> Self {
300 Self {
301 root: root.into(),
302 options,
303 clock: Arc::new(lash_core::SystemClock),
304 }
305 }
306
307 pub fn with_clock(mut self, clock: Arc<dyn lash_core::Clock>) -> Self {
308 self.clock = clock;
309 self
310 }
311
312 pub fn path_for_session(&self, session_id: &str) -> PathBuf {
313 self.root.join(safe_session_db_file_name(session_id))
314 }
315}
316
317#[async_trait::async_trait]
318impl SessionStoreFactory for SqliteSessionStoreFactory {
319 fn durability_tier(&self) -> DurabilityTier {
320 DurabilityTier::Durable
321 }
322
323 async fn create_store(
324 &self,
325 request: &SessionStoreCreateRequest,
326 ) -> Result<Arc<dyn RuntimePersistence>, String> {
327 std::fs::create_dir_all(&self.root).map_err(|err| err.to_string())?;
328 let path = self.path_for_session(&request.session_id);
329 let store = Arc::new(
330 Store::open_with_options_and_clock(&path, self.options, Arc::clone(&self.clock))
331 .await
332 .map_err(|err| err.to_string())?,
333 );
334 if store.load_session_meta().await.is_none() {
335 store
336 .save_session_meta(SessionMeta {
337 session_id: request.session_id.clone(),
338 session_name: request.session_id.clone(),
339 created_at: self.clock.timestamp_rfc3339(),
340 model: request.policy.model.id.clone(),
341 cwd: std::env::current_dir()
342 .ok()
343 .and_then(|path| path.to_str().map(str::to_string)),
344 relation: request.relation.clone(),
345 })
346 .await;
347 }
348 Ok(store as Arc<dyn RuntimePersistence>)
349 }
350
351 async fn open_existing_store(
352 &self,
353 request: &SessionStoreCreateRequest,
354 ) -> Result<Option<Arc<dyn RuntimePersistence>>, String> {
355 let path = self.path_for_session(&request.session_id);
356 if !path.exists() {
357 return Ok(None);
358 }
359 self.create_store(request).await.map(Some)
360 }
361
362 async fn delete_session(&self, session_id: &str) -> Result<(), String> {
363 let db_path = self.path_for_session(session_id);
364 for path in [
365 db_path.clone(),
366 sqlite_sidecar_path(&db_path, "-wal"),
367 sqlite_sidecar_path(&db_path, "-shm"),
368 ] {
369 match std::fs::remove_file(&path) {
370 Ok(()) => {}
371 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
372 Err(err) => {
373 return Err(format!("remove session store {}: {err}", path.display()));
374 }
375 }
376 }
377 Ok(())
378 }
379
380 async fn live_attachment_refs(
381 &self,
382 intent_grace_cutoff_epoch_ms: u64,
383 ) -> Result<std::collections::BTreeSet<lash_core::AttachmentId>, lash_core::StoreError> {
384 let mut refs = std::collections::BTreeSet::new();
388 let entries = match std::fs::read_dir(&self.root) {
389 Ok(entries) => entries,
390 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(refs),
392 Err(err) => {
393 return Err(lash_core::StoreError::Backend(format!(
394 "read session store directory {}: {err}",
395 self.root.display()
396 )));
397 }
398 };
399 for entry in entries {
400 let path = entry
401 .map_err(|err| lash_core::StoreError::Backend(err.to_string()))?
402 .path();
403 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
404 continue;
405 };
406 if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
409 continue;
410 }
411 if !is_primary_session_db_name(file_name) {
419 tracing::warn!(
420 path = %path.display(),
421 "attachment GC: skipping sidecar database in sessions directory"
422 );
423 continue;
424 }
425 let store =
429 Store::open_with_options_and_clock(&path, self.options, Arc::clone(&self.clock))
430 .await
431 .map_err(|err| {
432 lash_core::StoreError::Backend(format!(
433 "attachment GC aborted: session database {} could not be opened: {err}",
434 path.display()
435 ))
436 })?;
437 lash_core::AttachmentManifest::forget_aged_uncommitted_intents(
442 &store,
443 intent_grace_cutoff_epoch_ms,
444 )?;
445 refs.extend(lash_core::AttachmentManifest::list_all_refs(&store)?);
446 }
447 Ok(refs)
448 }
449
450 async fn has_live_attachment_ref(
451 &self,
452 id: &lash_core::AttachmentId,
453 intent_grace_cutoff_epoch_ms: u64,
454 ) -> Result<bool, lash_core::StoreError> {
455 let entries = match std::fs::read_dir(&self.root) {
459 Ok(entries) => entries,
460 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
461 Err(err) => {
462 return Err(lash_core::StoreError::Backend(format!(
463 "read session store directory {}: {err}",
464 self.root.display()
465 )));
466 }
467 };
468 for entry in entries {
469 let path = entry
470 .map_err(|err| lash_core::StoreError::Backend(err.to_string()))?
471 .path();
472 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
473 continue;
474 };
475 if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
476 continue;
477 }
478 if !is_primary_session_db_name(file_name) {
479 continue;
480 }
481 let store = Store::open_with_options_and_clock(
482 &path,
483 self.options,
484 Arc::clone(&self.clock),
485 )
486 .await
487 .map_err(|err| {
488 lash_core::StoreError::Backend(format!(
489 "attachment GC root re-check aborted: session database {} could not be opened: {err}",
490 path.display()
491 ))
492 })?;
493 if lash_core::AttachmentManifest::has_live_ref_for_id(
494 &store,
495 id,
496 intent_grace_cutoff_epoch_ms,
497 )? {
498 return Ok(true);
499 }
500 }
501 Ok(false)
502 }
503}
504
505fn is_primary_session_db_name(file_name: &str) -> bool {
519 let Some(stem) = file_name.strip_suffix(".db") else {
520 return false;
521 };
522 !stem.contains(".db")
525}
526
527fn safe_session_db_file_name(session_id: &str) -> String {
528 let mut safe = session_id
529 .chars()
530 .map(|ch| match ch {
531 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch,
532 _ => '_',
533 })
534 .collect::<String>();
535 safe = safe.trim_matches('_').to_string();
536 if safe.is_empty() {
537 safe.push_str("session");
538 }
539 safe.truncate(80);
540 let hash = format!("{:x}", Sha256::digest(session_id.as_bytes()));
541 format!("{safe}-{}.db", &hash[..16])
542}
543
544fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
545 let mut sidecar = path.as_os_str().to_os_string();
546 sidecar.push(suffix);
547 PathBuf::from(sidecar)
548}
549
550fn retained_artifact_refs(checkpoint: &SessionCheckpoint) -> Vec<RetainedArtifactRef> {
551 let mut refs = Vec::new();
552 if let Some(blob_ref) = &checkpoint.tool_state_ref {
553 refs.push(RetainedArtifactRef {
554 blob_ref: blob_ref.clone(),
555 kind: PersistedArtifactKind::ToolState,
556 });
557 }
558 if let Some(blob_ref) = &checkpoint.plugin_snapshot_ref {
559 refs.push(RetainedArtifactRef {
560 blob_ref: blob_ref.clone(),
561 kind: PersistedArtifactKind::PluginSessionSnapshot,
562 });
563 }
564 if let Some(blob_ref) = &checkpoint.execution_state_ref {
565 refs.push(RetainedArtifactRef {
566 blob_ref: blob_ref.clone(),
567 kind: PersistedArtifactKind::ExecutionStateSnapshot,
568 });
569 }
570 refs
571}
572
573fn session_head_meta(head: &SessionHead) -> SessionHeadMeta {
574 SessionHeadMeta {
575 schema_version: lash_core::store::SESSION_HEAD_META_SCHEMA_VERSION,
576 session_id: head.session_id.clone(),
577 head_revision: 0,
578 config: head.config.clone(),
579 agent_frames: head.agent_frames.clone(),
580 current_agent_frame_id: head.current_agent_frame_id.clone(),
581 checkpoint_ref: head.checkpoint_ref.clone(),
582 leaf_node_id: head.graph.leaf_node_id.clone(),
583 graph_node_count: head.graph.nodes.len(),
584 token_ledger: Vec::new(),
585 }
586}
587
588fn encode_json<T: serde::Serialize>(value: &T) -> String {
589 serde_json::to_string(value).expect("persisted state should serialize")
590}
591
592fn should_compress_blob(
593 profile: BuiltinBlobProfile,
594 descriptor: &BlobArtifactDescriptor,
595 len: usize,
596) -> bool {
597 if !descriptor.hints.contains(&BlobStorageHint::Compressible) {
598 return false;
599 }
600 match profile {
601 BuiltinBlobProfile::LowLatency => false,
602 BuiltinBlobProfile::Balanced => len >= 4 * 1024,
603 BuiltinBlobProfile::Compact => len >= 1024,
604 }
605}
606
607fn compress_blob(content: &[u8]) -> Vec<u8> {
608 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
609 std::io::Write::write_all(&mut encoder, content).expect("compress blob");
610 encoder.finish().expect("submit blob compression")
611}
612
613fn decompress_blob(content: &[u8]) -> Option<Vec<u8>> {
614 let mut decoder = ZlibDecoder::new(content);
615 let mut out = Vec::new();
616 std::io::Read::read_to_end(&mut decoder, &mut out).ok()?;
617 Some(out)
618}
619
620fn encode_artifact_blob(
621 descriptor: &BlobArtifactDescriptor,
622 profile: BuiltinBlobProfile,
623 content: &[u8],
624) -> Vec<u8> {
625 let (compression, stored_content) = if should_compress_blob(profile, descriptor, content.len())
626 {
627 (BlobCompression::Zlib, compress_blob(content))
628 } else {
629 (BlobCompression::None, content.to_vec())
630 };
631 encode_msgpack(&StoredBlobEnvelope {
632 descriptor: descriptor.clone(),
633 compression,
634 content: stored_content,
635 })
636}
637
638fn decode_artifact_blob(bytes: &[u8]) -> Option<Vec<u8>> {
639 let envelope = decode_msgpack::<StoredBlobEnvelope>(bytes)?;
640 match envelope.compression {
641 BlobCompression::None => Some(envelope.content),
642 BlobCompression::Zlib => decompress_blob(&envelope.content),
643 }
644}
645
646fn try_load_session_head_meta_from_conn(
649 conn: &Connection,
650) -> Result<Option<SessionHeadMeta>, StoreError> {
651 let row = conn
652 .query_row(
653 "SELECT head_json, head_revision FROM session_head WHERE singleton = 1",
654 [],
655 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
656 )
657 .optional()
658 .map_err(sqlite_error)?;
659 let Some((head_json, head_revision)) = row else {
660 return Ok(None);
661 };
662 let mut meta: SessionHeadMeta = lash_core::store::decode_versioned_json_record(
663 &head_json,
664 "SessionHeadMeta",
665 lash_core::store::SESSION_HEAD_META_SCHEMA_VERSION,
666 )?;
667 meta.head_revision = head_revision as u64;
668 Ok(Some(meta))
669}
670
671fn load_session_head_meta_from_conn(conn: &Connection) -> Option<SessionHeadMeta> {
672 try_load_session_head_meta_from_conn(conn).ok().flatten()
673}
674
675fn load_session_meta_from_conn(conn: &Connection) -> Option<SessionMeta> {
676 conn.query_row(
677 "SELECT session_id, session_name, created_at, model, cwd, relation_json
678 FROM session_meta WHERE singleton = 1",
679 [],
680 |row| {
681 let relation_json: Option<String> = row.get(5)?;
682 let relation = relation_json
683 .and_then(|json| serde_json::from_str(&json).ok())
684 .unwrap_or_default();
685 Ok(SessionMeta {
686 session_id: row.get(0)?,
687 session_name: row.get(1)?,
688 created_at: row.get(2)?,
689 model: row.get(3)?,
690 cwd: row.get(4)?,
691 relation,
692 })
693 },
694 )
695 .optional()
696 .ok()
697 .flatten()
698}
699
700fn decode_checkpoint(bytes: &[u8]) -> Result<SessionCheckpoint, StoreError> {
701 let value: serde_json::Value = rmp_serde::from_slice(bytes)
702 .map_err(|err| StoreError::Backend(format!("failed to decode SessionCheckpoint: {err}")))?;
703 lash_core::store::ensure_supported_record_schema_version(
704 "SessionCheckpoint",
705 &value,
706 lash_core::store::SESSION_CHECKPOINT_SCHEMA_VERSION,
707 )?;
708 rmp_serde::from_slice(bytes)
709 .map_err(|err| StoreError::Backend(format!("failed to decode SessionCheckpoint: {err}")))
710}
711
712fn encode_msgpack<T: serde::Serialize>(value: &T) -> Vec<u8> {
713 let mut buf = Vec::with_capacity(1024);
716 rmp_serde::encode::write_named(&mut buf, value).expect("value should serialize");
717 buf
718}
719
720fn decode_msgpack<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Option<T> {
721 rmp_serde::from_slice(bytes).ok()
722}
723
724fn merge_token_ledger_entries(
725 entries: Vec<lash_core::TokenLedgerEntry>,
726) -> Vec<lash_core::TokenLedgerEntry> {
727 let mut merged: Vec<lash_core::TokenLedgerEntry> = Vec::new();
728 for entry in entries {
729 if entry.usage.total() == 0 {
730 continue;
731 }
732 if let Some(existing) = merged
733 .iter_mut()
734 .find(|existing| existing.source == entry.source && existing.model == entry.model)
735 {
736 existing.usage.input_tokens += entry.usage.input_tokens;
737 existing.usage.output_tokens += entry.usage.output_tokens;
738 existing.usage.cache_read_input_tokens += entry.usage.cache_read_input_tokens;
739 existing.usage.cache_write_input_tokens += entry.usage.cache_write_input_tokens;
740 existing.usage.reasoning_output_tokens += entry.usage.reasoning_output_tokens;
741 } else {
742 merged.push(entry);
743 }
744 }
745 merged
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use lash_core::ProcessInput;
752 use lashlang::LashlangArtifactStore;
753
754 #[tokio::test]
755 async fn checkpoint_probe_skips_writes_for_deferred_head() {
756 let store = Arc::new(Store::memory().await.expect("open counter store"));
757 lash_core::testing::conformance::checkpoint_claim_probe_transaction_counts(
758 Arc::clone(&store) as Arc<dyn RuntimePersistence>,
759 "sqlite-checkpoint-counter",
760 || store.checkpoint_claim_counts(),
761 )
762 .await;
763 }
764
765 fn registration(id: &str) -> ProcessRegistration {
766 ProcessRegistration::new(
767 id,
768 ProcessInput::External {
769 metadata: serde_json::Value::Null,
770 },
771 lash_core::RecoveryDisposition::ExternallyOwned,
772 lash_core::ProcessProvenance::session(lash_core::SessionScope::new("session")),
773 )
774 }
775
776 #[test]
777 fn primary_session_db_names_exclude_sidecars() {
778 for primary in [
781 safe_session_db_file_name("sess-1"),
782 "20260710_011120.db".to_string(),
783 ] {
784 assert!(
785 is_primary_session_db_name(&primary),
786 "{primary} must be recognised as a primary session db"
787 );
788 for suffix in [
790 "effects.db",
791 "processes.db",
792 "triggers.db",
793 "artifacts.db",
794 "process-env.db",
795 ] {
796 assert!(
797 !is_primary_session_db_name(&format!("{primary}.{suffix}")),
798 "sidecar {suffix} of {primary} must not be treated as a primary session db"
799 );
800 }
801 assert!(!is_primary_session_db_name(&format!("{primary}-wal")));
803 }
804 }
805
806 #[tokio::test]
811 async fn live_attachment_refs_skips_sidecars() {
812 let dir = tempfile::tempdir().expect("tempdir");
813 let root = dir.path().join("sessions");
814 std::fs::create_dir_all(&root).expect("mkdir sessions");
815 let factory = SqliteSessionStoreFactory::new(&root);
816
817 let primary = factory.path_for_session("sess-1");
819 let attachment_id = lash_core::AttachmentId::new("a".repeat(64));
820 {
821 let store = Store::open(&primary).await.expect("open primary");
822 lash_core::AttachmentManifest::record_intent(
823 &store,
824 lash_core::AttachmentIntent {
825 attachment_id: attachment_id.clone(),
826 session_id: "sess-1".to_string(),
827 canonical_uri: format!("lash-attachment://sha256/{attachment_id}"),
828 intent_at_epoch_ms: 1_000,
829 },
830 )
831 .expect("record intent");
832 lash_core::AttachmentManifest::commit_refs(
833 &store,
834 "sess-1",
835 std::slice::from_ref(&attachment_id),
836 )
837 .expect("commit ref");
838 }
839
840 let primary_name = primary
842 .file_name()
843 .and_then(|name| name.to_str())
844 .expect("primary name")
845 .to_string();
846 for suffix in [
847 "effects.db",
848 "processes.db",
849 "triggers.db",
850 "artifacts.db",
851 "process-env.db",
852 ] {
853 std::fs::write(
854 root.join(format!("{primary_name}.{suffix}")),
855 b"not a sqlite database",
856 )
857 .expect("write sidecar");
858 }
859
860 let refs = SessionStoreFactory::live_attachment_refs(&factory, 0)
863 .await
864 .expect("root discovery must not abort on sidecars");
865 assert!(
866 refs.contains(&attachment_id),
867 "the primary session's committed ref must be discovered"
868 );
869 assert_eq!(
870 refs.len(),
871 1,
872 "only the primary db contributes refs: {refs:?}"
873 );
874 }
875
876 #[tokio::test]
880 async fn live_attachment_refs_aborts_on_unreadable_primary_session_db() {
881 let dir = tempfile::tempdir().expect("tempdir");
882 let root = dir.path().join("sessions");
883 std::fs::create_dir_all(&root).expect("mkdir sessions");
884 let factory = SqliteSessionStoreFactory::new(&root);
885
886 std::fs::write(root.join("20260710_010101.db"), b"corrupt not-a-db")
889 .expect("write corrupt");
890
891 let result = SessionStoreFactory::live_attachment_refs(&factory, 0).await;
892 assert!(
893 result.is_err(),
894 "an unreadable primary session database must abort discovery, got {result:?}"
895 );
896 }
897
898 #[tokio::test]
899 async fn segment_handover_persist_keeps_current_input_for_crash_replay() {
900 let registry = SqliteProcessRegistry::memory()
901 .await
902 .expect("memory registry");
903 registry
904 .register_process(registration("segment-crash"))
905 .await
906 .expect("register");
907 let handover = |segment_ordinal| PersistedSegmentHandover {
908 segment_ordinal,
909 program_hash: "program-v1".to_string(),
910 handover: lash_core::SegmentHandover {
911 reason: lash_core::BoundaryReason::JournalBudget,
912 program_hash: Some("program-v1".to_string()),
913 engine_state: vec![segment_ordinal as u8],
914 },
915 };
916 registry
917 .put_segment_handover("segment-crash", handover(1))
918 .await
919 .expect("persist current segment input");
920 registry
921 .put_segment_handover("segment-crash", handover(2))
922 .await
923 .expect("persist successor before send");
924
925 assert_eq!(
926 registry
927 .get_segment_handover("segment-crash", 1)
928 .await
929 .expect("replay read"),
930 Some(handover(1)),
931 "a crash before successor send must leave segment 1 replayable"
932 );
933 assert_eq!(
934 registry
935 .latest_segment_handover("segment-crash")
936 .await
937 .expect("latest handover"),
938 Some(handover(2))
939 );
940 }
941
942 #[tokio::test]
943 async fn terminal_segment_handover_cleanup_removes_continuation_state() {
944 let registry = SqliteProcessRegistry::memory()
945 .await
946 .expect("memory registry");
947 registry
948 .register_process(registration("segment-terminal"))
949 .await
950 .expect("register");
951 registry
952 .put_segment_handover(
953 "segment-terminal",
954 PersistedSegmentHandover {
955 segment_ordinal: 1,
956 program_hash: "program-v1".to_string(),
957 handover: lash_core::SegmentHandover {
958 reason: lash_core::BoundaryReason::JournalBudget,
959 program_hash: Some("program-v1".to_string()),
960 engine_state: vec![7],
961 },
962 },
963 )
964 .await
965 .expect("persist handover");
966 registry
967 .delete_segment_handovers("segment-terminal")
968 .await
969 .expect("terminal cleanup");
970 assert!(
971 registry
972 .latest_segment_handover("segment-terminal")
973 .await
974 .expect("latest handover")
975 .is_none()
976 );
977 }
978
979 #[tokio::test]
980 async fn sqlite_lashlang_artifact_store_round_trips_verified_module_artifacts() {
981 let store = Store::memory().await.expect("memory store");
982 let module =
983 lashlang::parse("process scan(root: str) { finish root }").expect("parse module");
984 let linked = lashlang::LinkedModule::link(
985 module,
986 lashlang::LashlangHostEnvironment::new(
987 lashlang::LashlangHostCatalog::new(),
988 lashlang::LashlangAbilities::all(),
989 ),
990 )
991 .expect("link module");
992
993 store
994 .put_module_artifact(&linked.artifact)
995 .await
996 .expect("put artifact");
997 let restored = store
998 .get_module_artifact(&linked.module_ref)
999 .await
1000 .expect("get artifact")
1001 .expect("artifact exists");
1002
1003 assert_eq!(restored.module_ref, linked.module_ref);
1004 assert_eq!(
1005 restored.process_ref("scan"),
1006 linked.artifact.process_ref("scan")
1007 );
1008 }
1009
1010 #[tokio::test]
1011 async fn sqlite_process_registry_persists_rows_after_reopen() {
1012 let dir = tempfile::tempdir().expect("tempdir");
1013 let path = dir.path().join("processes.db");
1014 {
1015 let registry = SqliteProcessRegistry::open(&path)
1016 .await
1017 .expect("open registry");
1018 let session_scope = lash_core::SessionScope::new("session");
1019 registry
1020 .register_process(registration("proc-persist"))
1021 .await
1022 .expect("register");
1023 registry
1024 .grant_handle(
1025 &session_scope,
1026 "proc-persist",
1027 ProcessHandleDescriptor::new(Some("tool"), Some("demo")),
1028 )
1029 .await
1030 .expect("grant");
1031 registry
1032 .complete_process(
1033 "proc-persist",
1034 ProcessAwaitOutput::Success {
1035 value: serde_json::json!({"ok": true}),
1036 control: None,
1037 },
1038 lash_core::ProcessCompletionAuthority::external_owner("session"),
1039 )
1040 .await
1041 .expect("complete");
1042 }
1043
1044 let registry = Arc::new(
1045 SqliteProcessRegistry::open(&path)
1046 .await
1047 .expect("reopen registry"),
1048 ) as Arc<dyn lash_core::ProcessRegistry>;
1049 let session_scope = lash_core::SessionScope::new("session");
1050 let record = registry
1051 .get_process("proc-persist")
1052 .await
1053 .expect("persisted process");
1054
1055 assert_eq!(record.originator_scope_id(), session_scope.id().as_str());
1056 assert_eq!(
1057 record.provenance.originator,
1058 lash_core::ProcessOriginator::session(session_scope.clone())
1059 );
1060 assert_eq!(
1061 lash_core::ProcessAwaiter::polling(Arc::clone(®istry))
1062 .await_terminal("proc-persist")
1063 .await
1064 .expect("await persisted"),
1065 ProcessAwaitOutput::Success {
1066 value: serde_json::json!({"ok": true}),
1067 control: None,
1068 }
1069 );
1070 assert_eq!(
1071 registry
1072 .list_handle_grants(&session_scope)
1073 .await
1074 .expect("grants")
1075 .len(),
1076 1
1077 );
1078 }
1079}