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