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