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