1use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
37use std::sync::{Arc, Mutex};
38use std::time::{SystemTime, UNIX_EPOCH};
39
40use flate2::Compression;
41use flate2::read::ZlibDecoder;
42use flate2::write::ZlibEncoder;
43use lash_core::runtime::ProcessHandleGrantEntry;
44use lash_core::runtime::{
45 QueuedWorkBatch, QueuedWorkBatchDraft, QueuedWorkClaim, QueuedWorkClaimBoundary,
46 QueuedWorkCompletion, QueuedWorkItem, QueuedWorkPayload, prepare_process_event_append,
47 prepare_process_registration,
48};
49use lash_core::store::queued_work::{
50 ClaimCandidate, QueuedWorkClaimLease, claim_scan_limit, derive_batch_id,
51 ensure_completion_owns_all_batches, renewed_claim, select_leading_session_command,
52 select_turn_work_claim_prefix,
53};
54use lash_core::store::{
55 GraphCommitDelta, HydratedSessionCheckpoint, PersistedSessionRead, RuntimeCommit,
56 RuntimeCommitResult, SessionCheckpoint, SessionHead, SessionHeadMeta,
57};
58use lash_core::{
59 AbandonRequest, AttachmentId, AttachmentIntent, AttachmentManifest, AttachmentManifestEntry,
60 BlobRef, DeliveryPolicy, DurabilityTier, GcReport, LeaseOwnerIdentity, LeaseOwnerLiveness,
61 MergeKey, PROCESS_LEASE_SCHEMA_VERSION, ProcessAwaitOutput, ProcessChangeCursor, ProcessEvent,
62 ProcessEventAppendRequest, ProcessEventAppendResult, ProcessExternalRef,
63 ProcessHandleDescriptor, ProcessHandleGrant, ProcessLease, ProcessLeaseClaimOutcome,
64 ProcessLeaseCompletion, ProcessListFilter, ProcessLiveReferenceSummary, ProcessPruneReport,
65 ProcessRecord, ProcessRegistration, ProcessRegistry, ProcessStarted, QueuedWorkStore,
66 RuntimePersistence, SessionCommitStore, SessionExecutionLease,
67 SessionExecutionLeaseClaimOutcome, SessionExecutionLeaseCompletion, SessionExecutionLeaseFence,
68 SessionExecutionLeaseStore, SessionMeta, SessionPickerInfo, SessionReadScope, SessionScope,
69 SessionStoreCreateRequest, SessionStoreFactory, SlotPolicy, StoreError, StoreMaintenance,
70 TurnInputStore, VacuumReport,
71};
72use rusqlite::{Connection, OptionalExtension, Transaction, params};
73use sha2::{Digest, Sha256};
74
75use conn::SqliteConnection;
76
77mod attachments;
78mod blobs;
79mod conn;
80mod effect_replay;
81mod graph;
82mod leases;
83mod lifecycle;
84mod pending_turn_inputs;
85mod persistence;
86mod process_registry;
87mod process_registry_change;
88mod process_registry_completion;
89mod queued_work;
90mod schema;
91mod triggers;
92
93use conn::TxOutcome;
94pub use effect_replay::{
95 SqliteEffectHost, SqliteEffectReplayOptions, SqliteRuntimeEffectController,
96};
97use leases::*;
98use pending_turn_inputs::*;
99use queued_work::*;
100use schema::{
101 StoreBacking, apply_pragmas, ensure_effect_schema, ensure_process_schema, ensure_schema,
102 ensure_trigger_schema,
103};
104pub use triggers::SqliteTriggerStore;
105
106pub struct Store {
113 conn: SqliteConnection,
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}
127
128fn sqlite_error(err: rusqlite::Error) -> StoreError {
129 StoreError::Backend(err.to_string())
130}
131
132fn process_sqlite_error(err: rusqlite::Error) -> lash_core::PluginError {
133 lash_core::PluginError::Session(err.to_string())
134}
135
136fn process_decode_error(err: serde_json::Error) -> lash_core::PluginError {
137 lash_core::PluginError::Session(format!("failed to decode process registry row: {err}"))
138}
139
140fn process_encode_json<T: serde::Serialize>(value: &T) -> Result<String, lash_core::PluginError> {
141 serde_json::to_string(value).map_err(|err| {
142 lash_core::PluginError::Session(format!("failed to encode process row: {err}"))
143 })
144}
145
146fn block_on_store<T>(future: impl std::future::Future<Output = T>) -> T {
147 futures_executor::block_on(future)
148}
149
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
151pub enum PersistedArtifactKind {
152 GenericBlob,
153 CheckpointManifest,
154 ToolState,
155 PluginSessionSnapshot,
156 ExecutionStateSnapshot,
157 LashlangModule,
158 ProcessExecutionEnv,
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
162pub enum BlobStorageHint {
163 Compressible,
164 InlinePreferred,
165 LargePayload,
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
169enum BlobCompression {
170 None,
171 Zlib,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
175pub struct BlobArtifactDescriptor {
176 pub kind: PersistedArtifactKind,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub hints: Vec<BlobStorageHint>,
179}
180
181impl BlobArtifactDescriptor {
182 pub fn new(kind: PersistedArtifactKind, hints: impl Into<Vec<BlobStorageHint>>) -> Self {
183 Self {
184 kind,
185 hints: hints.into(),
186 }
187 }
188
189 pub fn checkpoint_manifest() -> Self {
190 Self::new(
191 PersistedArtifactKind::CheckpointManifest,
192 vec![BlobStorageHint::Compressible],
193 )
194 }
195
196 pub fn tool_state_snapshot() -> Self {
197 Self::new(
198 PersistedArtifactKind::ToolState,
199 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
200 )
201 }
202
203 pub fn plugin_session_snapshot() -> Self {
204 Self::new(
205 PersistedArtifactKind::PluginSessionSnapshot,
206 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
207 )
208 }
209
210 pub fn execution_state_snapshot() -> Self {
211 Self::new(
212 PersistedArtifactKind::ExecutionStateSnapshot,
213 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
214 )
215 }
216
217 pub fn lashlang_module() -> Self {
218 Self::new(
219 PersistedArtifactKind::LashlangModule,
220 vec![BlobStorageHint::Compressible, BlobStorageHint::LargePayload],
221 )
222 }
223
224 pub fn process_execution_env() -> Self {
225 Self::new(
226 PersistedArtifactKind::ProcessExecutionEnv,
227 vec![BlobStorageHint::Compressible],
228 )
229 }
230}
231
232#[derive(Clone, Debug, PartialEq, Eq, Hash)]
233struct RetainedArtifactRef {
234 pub blob_ref: BlobRef,
235 pub kind: PersistedArtifactKind,
236}
237
238#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
239pub enum BuiltinBlobProfile {
240 LowLatency,
241 #[default]
242 Balanced,
243 Compact,
244}
245
246#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
247pub struct StoreGcPolicy {
248 pub auto_run_every_commits: Option<u64>,
249}
250
251#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
252pub struct StoreOptions {
253 pub blob_profile: BuiltinBlobProfile,
254 pub gc_policy: StoreGcPolicy,
255}
256
257#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
258struct StoredBlobEnvelope {
259 descriptor: BlobArtifactDescriptor,
260 compression: BlobCompression,
261 content: Vec<u8>,
262}
263
264#[derive(Clone, Debug)]
265pub struct StoredSessionCheckpoint {
266 pub checkpoint_ref: BlobRef,
267 pub manifest: SessionCheckpoint,
268}
269
270#[derive(Clone, Debug)]
277pub struct SqliteSessionStoreFactory {
278 root: PathBuf,
279 options: StoreOptions,
280}
281
282impl SqliteSessionStoreFactory {
283 pub fn new(root: impl Into<PathBuf>) -> Self {
284 Self {
285 root: root.into(),
286 options: StoreOptions::default(),
287 }
288 }
289
290 pub fn with_options(root: impl Into<PathBuf>, options: StoreOptions) -> Self {
291 Self {
292 root: root.into(),
293 options,
294 }
295 }
296
297 pub fn path_for_session(&self, session_id: &str) -> PathBuf {
298 self.root.join(safe_session_db_file_name(session_id))
299 }
300}
301
302#[async_trait::async_trait]
303impl SessionStoreFactory for SqliteSessionStoreFactory {
304 fn durability_tier(&self) -> DurabilityTier {
305 DurabilityTier::Durable
306 }
307
308 async fn create_store(
309 &self,
310 request: &SessionStoreCreateRequest,
311 ) -> Result<Arc<dyn RuntimePersistence>, String> {
312 std::fs::create_dir_all(&self.root).map_err(|err| err.to_string())?;
313 let path = self.path_for_session(&request.session_id);
314 let store = Arc::new(
315 Store::open_with_options(&path, self.options)
316 .await
317 .map_err(|err| err.to_string())?,
318 );
319 if store.load_session_meta().await.is_none() {
320 store
321 .save_session_meta(SessionMeta {
322 session_id: request.session_id.clone(),
323 session_name: request.session_id.clone(),
324 created_at: current_timestamp_string(),
325 model: request.policy.model.id.clone(),
326 cwd: std::env::current_dir()
327 .ok()
328 .and_then(|path| path.to_str().map(str::to_string)),
329 relation: request.relation.clone(),
330 })
331 .await;
332 }
333 Ok(store as Arc<dyn RuntimePersistence>)
334 }
335
336 async fn open_existing_store(
337 &self,
338 request: &SessionStoreCreateRequest,
339 ) -> Result<Option<Arc<dyn RuntimePersistence>>, String> {
340 let path = self.path_for_session(&request.session_id);
341 if !path.exists() {
342 return Ok(None);
343 }
344 self.create_store(request).await.map(Some)
345 }
346
347 async fn delete_session(&self, session_id: &str) -> Result<(), String> {
348 let db_path = self.path_for_session(session_id);
349 for path in [
350 db_path.clone(),
351 sqlite_sidecar_path(&db_path, "-wal"),
352 sqlite_sidecar_path(&db_path, "-shm"),
353 ] {
354 match std::fs::remove_file(&path) {
355 Ok(()) => {}
356 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
357 Err(err) => {
358 return Err(format!("remove session store {}: {err}", path.display()));
359 }
360 }
361 }
362 Ok(())
363 }
364
365 async fn live_attachment_refs(
366 &self,
367 intent_grace_cutoff_epoch_ms: u64,
368 ) -> Result<std::collections::BTreeSet<lash_core::AttachmentId>, lash_core::StoreError> {
369 let mut refs = std::collections::BTreeSet::new();
373 let entries = match std::fs::read_dir(&self.root) {
374 Ok(entries) => entries,
375 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(refs),
377 Err(err) => {
378 return Err(lash_core::StoreError::Backend(format!(
379 "read session store directory {}: {err}",
380 self.root.display()
381 )));
382 }
383 };
384 for entry in entries {
385 let path = entry
386 .map_err(|err| lash_core::StoreError::Backend(err.to_string()))?
387 .path();
388 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
389 continue;
390 };
391 if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
394 continue;
395 }
396 if !is_primary_session_db_name(file_name) {
404 tracing::warn!(
405 path = %path.display(),
406 "attachment GC: skipping sidecar database in sessions directory"
407 );
408 continue;
409 }
410 let store = Store::open_with_options(&path, self.options)
414 .await
415 .map_err(|err| {
416 lash_core::StoreError::Backend(format!(
417 "attachment GC aborted: session database {} could not be opened: {err}",
418 path.display()
419 ))
420 })?;
421 lash_core::AttachmentManifest::forget_aged_uncommitted_intents(
426 &store,
427 intent_grace_cutoff_epoch_ms,
428 )?;
429 refs.extend(lash_core::AttachmentManifest::list_all_refs(&store)?);
430 }
431 Ok(refs)
432 }
433
434 async fn has_live_attachment_ref(
435 &self,
436 id: &lash_core::AttachmentId,
437 intent_grace_cutoff_epoch_ms: u64,
438 ) -> Result<bool, lash_core::StoreError> {
439 let entries = match std::fs::read_dir(&self.root) {
443 Ok(entries) => entries,
444 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
445 Err(err) => {
446 return Err(lash_core::StoreError::Backend(format!(
447 "read session store directory {}: {err}",
448 self.root.display()
449 )));
450 }
451 };
452 for entry in entries {
453 let path = entry
454 .map_err(|err| lash_core::StoreError::Backend(err.to_string()))?
455 .path();
456 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
457 continue;
458 };
459 if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
460 continue;
461 }
462 if !is_primary_session_db_name(file_name) {
463 continue;
464 }
465 let store = Store::open_with_options(&path, self.options)
466 .await
467 .map_err(|err| {
468 lash_core::StoreError::Backend(format!(
469 "attachment GC root re-check aborted: session database {} could not be opened: {err}",
470 path.display()
471 ))
472 })?;
473 if lash_core::AttachmentManifest::has_live_ref_for_id(
474 &store,
475 id,
476 intent_grace_cutoff_epoch_ms,
477 )? {
478 return Ok(true);
479 }
480 }
481 Ok(false)
482 }
483}
484
485fn is_primary_session_db_name(file_name: &str) -> bool {
499 let Some(stem) = file_name.strip_suffix(".db") else {
500 return false;
501 };
502 !stem.contains(".db")
505}
506
507fn safe_session_db_file_name(session_id: &str) -> String {
508 let mut safe = session_id
509 .chars()
510 .map(|ch| match ch {
511 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch,
512 _ => '_',
513 })
514 .collect::<String>();
515 safe = safe.trim_matches('_').to_string();
516 if safe.is_empty() {
517 safe.push_str("session");
518 }
519 safe.truncate(80);
520 let hash = format!("{:x}", Sha256::digest(session_id.as_bytes()));
521 format!("{safe}-{}.db", &hash[..16])
522}
523
524fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
525 let mut sidecar = path.as_os_str().to_os_string();
526 sidecar.push(suffix);
527 PathBuf::from(sidecar)
528}
529
530fn current_timestamp_string() -> String {
531 let now = SystemTime::now()
532 .duration_since(UNIX_EPOCH)
533 .unwrap_or_default();
534 format!("unix:{}", now.as_secs())
535}
536
537fn current_epoch_ms() -> u64 {
538 SystemTime::now()
539 .duration_since(UNIX_EPOCH)
540 .unwrap_or_default()
541 .as_millis() as u64
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}