Skip to main content

lash_sqlite_store/
lib.rs

1//! # lash-sqlite-store
2//!
3//! The high-performance local **durable** persistence backend for the lash
4//! agent runtime. One SQLite database per session, opened in WAL journal mode
5//! with a 15-second busy timeout, satisfying the full [`RuntimePersistence`] +
6//! [`AttachmentManifest`] contract from `lash-core`.
7//!
8//! This crate is a drop-in replacement for `lash-sqlite-store`: it exposes the
9//! same public surface (`Store`, `SqliteProcessRegistry`,
10//! `SqliteSessionStoreFactory`, `SqliteEffectHost`, the option/descriptor types)
11//! with identical async signatures, so a consumer swaps backends by renaming
12//! the crate path only. The difference is the engine underneath: tokio-rusqlite
13//! over a statically-linked SQLite with real WAL (`-wal`/`-shm` sidecars,
14//! multi-process readers + single writer) instead of the prior store's experimental mvcc.
15//!
16//! ## Why this is "the durable backend" not just "an option"
17//!
18//! Lash's runtime layer treats persistence as a first-class boundary, not a
19//! debug-only convenience. Every primitive that lets the runtime survive a
20//! crash — head-revision CAS, final turn-commit idempotency, attachment
21//! write-ahead manifests, blob content-addressing with optional compression —
22//! is implemented in this crate against SQLite for one reason: SQLite is the
23//! simplest backend that gives us *atomic multi-statement transactions on a
24//! single file* with durability guarantees we can reason about.
25//!
26//! ## Schema cutover, not migrations
27//!
28//! There is exactly one supported schema (see [`schema::SCHEMA`]). Older
29//! databases must be deleted before opening — we do not carry migration code.
30//!
31//! [`RuntimePersistence`]: lash_core::RuntimePersistence
32//! [`AttachmentManifest`]: lash_core::AttachmentManifest
33
34use 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
107/// SQLite-backed store for checkpoint blobs, runtime session state, and
108/// Lashlang artifacts.
109///
110/// This is the first-party local implementation of the runtime store traits.
111/// Internally it holds a single cloneable [`SqliteConnection`] (a
112/// tokio-rusqlite handle to one database thread).
113pub 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
125/// SQLite-backed process registry for one configured runtime deployment.
126///
127/// It is intentionally separate from [`Store`]: session databases persist one
128/// conversation, while this registry persists background process state and
129/// handle visibility across all sessions sharing the registry.
130pub 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/// Explicit first-party factory for one SQLite session database per Lash
278/// session.
279///
280/// Hosts opt into this by passing it to `lash::LashCoreBuilder::store_factory`.
281/// The factory never becomes a default: app storage and runtime storage remain
282/// host-owned decisions.
283#[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        // Per-session-database topology: the factory owns the directory, so it
385        // computes the root set by unioning each session database's refs at
386        // sweep time (the ratified choice over a dual-written factory index).
387        let mut refs = std::collections::BTreeSet::new();
388        let entries = match std::fs::read_dir(&self.root) {
389            Ok(entries) => entries,
390            // No sessions written yet: an empty root set.
391            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            // `-wal`/`-shm` sidecars carry the `.db-wal` / `.db-shm` extension,
407            // so they are not `.db` files at all — skip them.
408            if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
409                continue;
410            }
411            // Per-session sidecar databases (`<primary>.effects.db`,
412            // `.processes.db`, `.triggers.db`, `.artifacts.db`, `.process-env.db`)
413            // ARE `.db` files, but are named on top of a primary session database
414            // filename that itself ends in `.db`, so they carry an interior `.db.`
415            // that a primary never does. Skip them — a sidecar (even a corrupt
416            // one) is not a session database and must not abort GC, distinctly
417            // from an *unreadable primary session* database, which must (below).
418            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            // A primary session database that fails to open might still hold live
426            // refs. Aborting the whole sweep is the safe choice: treating it as
427            // empty would let GC delete blobs it actually references.
428            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            // Reconcile crash orphans in this session database atomically: one
438            // conditional DELETE of uncommitted intents aged past the cutoff (no
439            // list-then-forget race against a concurrent `record_intent` refresh),
440            // then union what remains.
441            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        // Targeted single-id probe: iterate the per-session databases only until
456        // the first hit rather than unioning every session's refs. Read-only — it
457        // does not reconcile aged intents (the reconciling snapshot already ran).
458        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
505/// Whether `file_name` is a primary session database rather than a per-session
506/// sidecar database.
507///
508/// A primary session database ends in `.db` with `.db` appearing *only* as the
509/// final extension. Sidecar databases are named by appending a suffix on top of
510/// the primary filename (which already ends in `.db`), e.g.
511/// `20260710_011120.db.effects.db` or `sess-<hash>.db.processes.db`, so they
512/// carry an interior `.db.` that a primary never does. This structural test is
513/// deliberately independent of how the primary base name is formed — both this
514/// factory's `<safe>-<hash>.db` names and the reference host's `<timestamp>.db`
515/// names are recognised as primaries — because the host, not the factory, owns
516/// the primary naming scheme in the shared sessions directory. (`-wal`/`-shm`
517/// sidecars are excluded earlier: they are not `.db` files at all.)
518fn is_primary_session_db_name(file_name: &str) -> bool {
519    let Some(stem) = file_name.strip_suffix(".db") else {
520        return false;
521    };
522    // A primary's stem is the base name with no further `.db` extension; a
523    // sidecar's stem still contains the primary's trailing `.db` (`...db.effects`).
524    !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
646/// Read the session head meta off a raw connection. Synchronous because it runs
647/// inside a `conn.call`/`conn.write` closure on the connection thread.
648fn 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    // Pre-size the buffer so the per-byte writes inside rmp_serde don't
714    // walk the Vec through 0→4→8→16→32… reallocations on every call.
715    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        // Both primary naming schemes are recognised: this factory's
779        // `<safe>-<hash>.db` and the reference host's `<timestamp>.db`.
780        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            // Sidecar databases suffixed on top of the primary `<name>.db` are not.
789            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            // WAL/SHM sidecars are not `.db` files at all.
802            assert!(!is_primary_session_db_name(&format!("{primary}-wal")));
803        }
804    }
805
806    // Fix D: the factory's root-set discovery iterates a directory that also holds
807    // per-session sidecar databases (`.effects.db`, `.processes.db`, ...). It must
808    // skip those without aborting, still surfacing the primary session database's
809    // committed refs.
810    #[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        // A primary session database with a committed attachment ref.
818        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        // Sidecar databases the CLI drops next to the primary, plus a stray file.
841        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        // A cutoff of 0 ages out no intents; the committed ref survives. The
861        // corrupt sidecars are skipped, not opened, so discovery does not abort.
862        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    // Fix D safety: an *unreadable primary* session database (as opposed to a
877    // sidecar) must abort the sweep — never be silently treated as empty, which
878    // would drop its refs and let GC delete blobs it references.
879    #[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        // A file that reads as a primary session database name (no interior
887        // `.db.`) but is not a valid SQLite database.
888        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(&registry))
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}