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    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
108/// SQLite-backed store for checkpoint blobs, runtime session state, and
109/// Lashlang artifacts.
110///
111/// This is the first-party local implementation of the runtime store traits.
112/// Internally it holds a single cloneable [`SqliteConnection`] (a
113/// tokio-rusqlite handle to one database thread).
114pub 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
127/// SQLite-backed process registry for one configured runtime deployment.
128///
129/// It is intentionally separate from [`Store`]: session databases persist one
130/// conversation, while this registry persists background process state and
131/// handle visibility across all sessions sharing the registry.
132pub 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/// Explicit first-party factory for one SQLite session database per Lash
281/// session.
282///
283/// Hosts opt into this by passing it to `lash::LashCoreBuilder::store_factory`.
284/// The factory never becomes a default: app storage and runtime storage remain
285/// host-owned decisions.
286#[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    /// Construct a factory with explicit process-owner liveness wiring for
318    /// attachment GC. This is the warning-free durable constructor when the
319    /// deployment uses a Lash SQLite process registry.
320    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        // Per-session-database topology: the factory owns the directory, so it
414        // computes the root set by unioning each session database's refs at
415        // sweep time (the ratified choice over a dual-written factory index).
416        let mut refs = std::collections::BTreeSet::new();
417        let entries = match std::fs::read_dir(&self.root) {
418            Ok(entries) => entries,
419            // No sessions written yet: an empty root set.
420            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            // `-wal`/`-shm` sidecars carry the `.db-wal` / `.db-shm` extension,
436            // so they are not `.db` files at all — skip them.
437            if path.extension().and_then(|ext| ext.to_str()) != Some("db") {
438                continue;
439            }
440            // Per-session sidecar databases (`<primary>.effects.db`,
441            // `.processes.db`, `.triggers.db`, `.artifacts.db`, `.process-env.db`)
442            // ARE `.db` files, but are named on top of a primary session database
443            // filename that itself ends in `.db`, so they carry an interior `.db.`
444            // that a primary never does. Skip them — a sidecar (even a corrupt
445            // one) is not a session database and must not abort GC, distinctly
446            // from an *unreadable primary session* database, which must (below).
447            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            // A primary session database that fails to open might still hold live
455            // refs. Aborting the whole sweep is the safe choice: treating it as
456            // empty would let GC delete blobs it actually references.
457            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            // Reconcile terminal-owner intents in this session database
471            // atomically: one conditional DELETE combines age with owner death
472            // (no list-then-forget race against a concurrent intent refresh),
473            // then union what remains.
474            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        // Targeted single-id probe: iterate the per-session databases only until
489        // the first hit rather than unioning every session's refs. Read-only — it
490        // does not reconcile aged intents (the reconciling snapshot already ran).
491        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
563/// Whether `file_name` is a primary session database rather than a per-session
564/// sidecar database.
565///
566/// A primary session database ends in `.db` with `.db` appearing *only* as the
567/// final extension. Sidecar databases are named by appending a suffix on top of
568/// the primary filename (which already ends in `.db`), e.g.
569/// `20260710_011120.db.effects.db` or `sess-<hash>.db.processes.db`, so they
570/// carry an interior `.db.` that a primary never does. This structural test is
571/// deliberately independent of how the primary base name is formed — both this
572/// factory's `<safe>-<hash>.db` names and the reference host's `<timestamp>.db`
573/// names are recognised as primaries — because the host, not the factory, owns
574/// the primary naming scheme in the shared sessions directory. (`-wal`/`-shm`
575/// sidecars are excluded earlier: they are not `.db` files at all.)
576fn is_primary_session_db_name(file_name: &str) -> bool {
577    let Some(stem) = file_name.strip_suffix(".db") else {
578        return false;
579    };
580    // A primary's stem is the base name with no further `.db` extension; a
581    // sidecar's stem still contains the primary's trailing `.db` (`...db.effects`).
582    !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
704/// Read the session head meta off a raw connection. Synchronous because it runs
705/// inside a `conn.call`/`conn.write` closure on the connection thread.
706fn 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    // Pre-size the buffer so the per-byte writes inside rmp_serde don't
772    // walk the Vec through 0→4→8→16→32… reallocations on every call.
773    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        // Both primary naming schemes are recognised: this factory's
837        // `<safe>-<hash>.db` and the reference host's `<timestamp>.db`.
838        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            // Sidecar databases suffixed on top of the primary `<name>.db` are not.
847            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            // WAL/SHM sidecars are not `.db` files at all.
860            assert!(!is_primary_session_db_name(&format!("{primary}-wal")));
861        }
862    }
863
864    // Fix D: the factory's root-set discovery iterates a directory that also holds
865    // per-session sidecar databases (`.effects.db`, `.processes.db`, ...). It must
866    // skip those without aborting, still surfacing the primary session database's
867    // committed refs.
868    #[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        // A primary session database with a committed attachment ref.
876        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        // Sidecar databases the CLI drops next to the primary, plus a stray file.
901        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        // A cutoff of 0 ages out no intents; the committed ref survives. The
921        // corrupt sidecars are skipped, not opened, so discovery does not abort.
922        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    // Fix D safety: an *unreadable primary* session database (as opposed to a
937    // sidecar) must abort the sweep — never be silently treated as empty, which
938    // would drop its refs and let GC delete blobs it references.
939    #[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        // A file that reads as a primary session database name (no interior
947        // `.db.`) but is not a valid SQLite database.
948        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(&registry))
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}