Skip to main content

vifu_runtime/
sqlite_store.rs

1use std::path::Path;
2use std::sync::Mutex;
3use std::time::Duration;
4
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7
8use rusqlite::{params, Connection, OptionalExtension};
9
10use crate::{
11    LocalProviderBinding, RuntimeError, RuntimeRelease, RuntimeSnapshot, RuntimeStore,
12    RuntimeTraceRecord,
13};
14
15const MAX_TRACE_OUTBOX_RECORDS: i64 = 1_000;
16const MAX_HOST_STATE_BYTES: usize = 64 * 1024;
17
18/// SQLite-backed application state for an embedded Vifu runtime.
19///
20/// The database contains portable releases, session state, local provider
21/// references, and a bounded trace upload queue. Credentials remain the host's
22/// responsibility and should be represented by Keychain or credential-store
23/// references rather than copied into a release manifest.
24pub struct SqliteRuntimeStore {
25    connection: Mutex<Connection>,
26}
27
28impl SqliteRuntimeStore {
29    pub fn open(path: impl AsRef<Path>) -> Result<Self, RuntimeError> {
30        let path = path.as_ref();
31        if let Some(parent) = path
32            .parent()
33            .filter(|parent| !parent.as_os_str().is_empty())
34        {
35            std::fs::create_dir_all(parent)
36                .map_err(|error| RuntimeError::store(error.to_string()))?;
37        }
38        let connection =
39            Connection::open(path).map_err(|error| RuntimeError::store(error.to_string()))?;
40        protect_state_file(path)?;
41        Self::from_connection(connection)
42    }
43
44    pub fn in_memory() -> Result<Self, RuntimeError> {
45        let connection =
46            Connection::open_in_memory().map_err(|error| RuntimeError::store(error.to_string()))?;
47        Self::from_connection(connection)
48    }
49
50    fn from_connection(connection: Connection) -> Result<Self, RuntimeError> {
51        connection
52            .busy_timeout(Duration::from_secs(5))
53            .map_err(|error| RuntimeError::store(error.to_string()))?;
54        connection
55            .execute_batch(
56                "PRAGMA foreign_keys = ON;
57                 PRAGMA journal_mode = WAL;
58                 CREATE TABLE IF NOT EXISTS runtime_sessions (
59                    project_id TEXT NOT NULL,
60                    session_id TEXT NOT NULL,
61                    revision INTEGER NOT NULL,
62                    state_json TEXT NOT NULL,
63                    PRIMARY KEY (project_id, session_id)
64                 );
65                 CREATE TABLE IF NOT EXISTS runtime_releases (
66                    project_id TEXT NOT NULL,
67                    version INTEGER NOT NULL,
68                    content_hash TEXT NOT NULL,
69                    manifest_json TEXT NOT NULL,
70                    installed_at_ms INTEGER NOT NULL,
71                    PRIMARY KEY (project_id, version),
72                    UNIQUE (project_id, content_hash)
73                 );
74                 CREATE TABLE IF NOT EXISTS runtime_active_releases (
75                    project_id TEXT PRIMARY KEY,
76                    version INTEGER NOT NULL,
77                    FOREIGN KEY (project_id, version)
78                      REFERENCES runtime_releases(project_id, version)
79                 );
80                 CREATE TABLE IF NOT EXISTS runtime_provider_bindings (
81                    project_id TEXT NOT NULL,
82                    provider_id TEXT NOT NULL,
83                    binding_json TEXT NOT NULL,
84                    PRIMARY KEY (project_id, provider_id)
85                 );
86                 CREATE TABLE IF NOT EXISTS runtime_trace_outbox (
87                    trace_id TEXT PRIMARY KEY,
88                    project_id TEXT NOT NULL,
89                    payload_json TEXT NOT NULL,
90                    created_at_ms INTEGER NOT NULL
91                 );
92                 CREATE INDEX IF NOT EXISTS runtime_trace_outbox_created_idx
93                   ON runtime_trace_outbox(created_at_ms, trace_id);
94                 CREATE TABLE IF NOT EXISTS runtime_host_state (
95                    namespace TEXT NOT NULL,
96                    state_key TEXT NOT NULL,
97                    value_json TEXT NOT NULL,
98                    updated_at_ms INTEGER NOT NULL,
99                    PRIMARY KEY (namespace, state_key)
100                 );",
101            )
102            .map_err(|error| RuntimeError::store(error.to_string()))?;
103        Ok(Self {
104            connection: Mutex::new(connection),
105        })
106    }
107
108    fn connection(&self) -> Result<std::sync::MutexGuard<'_, Connection>, RuntimeError> {
109        self.connection.lock().map_err(|_| RuntimeError::Internal)
110    }
111
112    /// Loads host-owned local state kept beside the runtime database.
113    ///
114    /// This is intended for adapters such as Agent Gateway. Portable runtime
115    /// releases must not depend on these records.
116    pub fn load_host_state(
117        &self,
118        namespace: &str,
119        state_key: &str,
120    ) -> Result<Option<serde_json::Value>, RuntimeError> {
121        validate_host_state_key(namespace, "namespace")?;
122        validate_host_state_key(state_key, "state key")?;
123        self.connection()?
124            .query_row(
125                "SELECT value_json
126                 FROM runtime_host_state
127                 WHERE namespace = ?1 AND state_key = ?2",
128                params![namespace, state_key],
129                |row| row.get::<_, String>(0),
130            )
131            .optional()
132            .map_err(|error| RuntimeError::store(error.to_string()))?
133            .map(|value_json| {
134                serde_json::from_str(&value_json)
135                    .map_err(|error| RuntimeError::store(error.to_string()))
136            })
137            .transpose()
138    }
139
140    /// Saves bounded host-owned local state atomically.
141    pub fn save_host_state(
142        &self,
143        namespace: &str,
144        state_key: &str,
145        value: &serde_json::Value,
146    ) -> Result<(), RuntimeError> {
147        validate_host_state_key(namespace, "namespace")?;
148        validate_host_state_key(state_key, "state key")?;
149        let value_json =
150            serde_json::to_string(value).map_err(|error| RuntimeError::store(error.to_string()))?;
151        if value_json.len() > MAX_HOST_STATE_BYTES {
152            return Err(RuntimeError::store("host state is too large".to_string()));
153        }
154        let updated_at_ms = i64::try_from(crate::unix_time_ms())
155            .map_err(|error| RuntimeError::store(error.to_string()))?;
156        self.connection()?
157            .execute(
158                "INSERT INTO runtime_host_state(namespace, state_key, value_json, updated_at_ms)
159                 VALUES (?1, ?2, ?3, ?4)
160                 ON CONFLICT(namespace, state_key) DO UPDATE SET
161                   value_json = excluded.value_json,
162                   updated_at_ms = excluded.updated_at_ms",
163                params![namespace, state_key, value_json, updated_at_ms],
164            )
165            .map_err(|error| RuntimeError::store(error.to_string()))?;
166        Ok(())
167    }
168}
169
170fn validate_host_state_key(value: &str, label: &str) -> Result<(), RuntimeError> {
171    if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
172        return Err(RuntimeError::store(format!("invalid host state {label}")));
173    }
174    Ok(())
175}
176
177#[cfg(unix)]
178fn protect_state_file(path: &Path) -> Result<(), RuntimeError> {
179    let mut permissions = std::fs::metadata(path)
180        .map_err(|error| RuntimeError::store(error.to_string()))?
181        .permissions();
182    permissions.set_mode(0o600);
183    std::fs::set_permissions(path, permissions)
184        .map_err(|error| RuntimeError::store(error.to_string()))
185}
186
187#[cfg(not(unix))]
188fn protect_state_file(_path: &Path) -> Result<(), RuntimeError> {
189    Ok(())
190}
191
192impl RuntimeStore for SqliteRuntimeStore {
193    fn load(
194        &self,
195        project_id: &str,
196        session_id: &str,
197    ) -> Result<Option<RuntimeSnapshot>, RuntimeError> {
198        let connection = self.connection()?;
199        connection
200            .query_row(
201                "SELECT revision, state_json
202                 FROM runtime_sessions
203                 WHERE project_id = ?1 AND session_id = ?2",
204                params![project_id, session_id],
205                |row| {
206                    let revision = row.get::<_, i64>(0)?;
207                    let state_json = row.get::<_, String>(1)?;
208                    Ok((revision, state_json))
209                },
210            )
211            .optional()
212            .map_err(|error| RuntimeError::store(error.to_string()))?
213            .map(|(revision, state_json)| {
214                Ok(RuntimeSnapshot {
215                    revision: u64::try_from(revision)
216                        .map_err(|error| RuntimeError::store(error.to_string()))?,
217                    state: serde_json::from_str(&state_json)
218                        .map_err(|error| RuntimeError::store(error.to_string()))?,
219                })
220            })
221            .transpose()
222    }
223
224    fn save(
225        &self,
226        project_id: &str,
227        session_id: &str,
228        snapshot: &RuntimeSnapshot,
229    ) -> Result<(), RuntimeError> {
230        let state_json = serde_json::to_string(&snapshot.state)
231            .map_err(|error| RuntimeError::store(error.to_string()))?;
232        let revision = i64::try_from(snapshot.revision)
233            .map_err(|error| RuntimeError::store(error.to_string()))?;
234        self.connection()?
235            .execute(
236                "INSERT INTO runtime_sessions(project_id, session_id, revision, state_json)
237                 VALUES (?1, ?2, ?3, ?4)
238                 ON CONFLICT(project_id, session_id) DO UPDATE SET
239                   revision = excluded.revision,
240                   state_json = excluded.state_json",
241                params![project_id, session_id, revision, state_json],
242            )
243            .map_err(|error| RuntimeError::store(error.to_string()))?;
244        Ok(())
245    }
246
247    fn save_release(&self, release: &RuntimeRelease) -> Result<(), RuntimeError> {
248        release.validate()?;
249        let version = i64::try_from(release.version)
250            .map_err(|error| RuntimeError::store(error.to_string()))?;
251        let manifest_json = serde_json::to_string(&release.manifest)
252            .map_err(|error| RuntimeError::store(error.to_string()))?;
253        let installed_at_ms = i64::try_from(crate::unix_time_ms())
254            .map_err(|error| RuntimeError::store(error.to_string()))?;
255        let connection = self.connection()?;
256        let inserted = connection
257            .execute(
258                "INSERT OR IGNORE INTO runtime_releases(
259                    project_id, version, content_hash, manifest_json, installed_at_ms
260                 ) VALUES (?1, ?2, ?3, ?4, ?5)",
261                params![
262                    release.manifest.project_id,
263                    version,
264                    release.content_hash,
265                    manifest_json,
266                    installed_at_ms
267                ],
268            )
269            .map_err(|error| RuntimeError::store(error.to_string()))?;
270        if inserted == 0 {
271            let existing =
272                load_release(&connection, &release.manifest.project_id, release.version)?;
273            if existing.as_ref() != Some(release) {
274                return Err(RuntimeError::store(
275                    "runtime release versions are immutable".to_string(),
276                ));
277            }
278        }
279        Ok(())
280    }
281
282    fn load_release(
283        &self,
284        project_id: &str,
285        version: u64,
286    ) -> Result<Option<RuntimeRelease>, RuntimeError> {
287        let connection = self.connection()?;
288        load_release(&connection, project_id, version)
289    }
290
291    fn list_releases(&self, project_id: &str) -> Result<Vec<RuntimeRelease>, RuntimeError> {
292        let connection = self.connection()?;
293        let mut statement = connection
294            .prepare(
295                "SELECT version, content_hash, manifest_json
296                 FROM runtime_releases
297                 WHERE project_id = ?1
298                 ORDER BY version DESC",
299            )
300            .map_err(|error| RuntimeError::store(error.to_string()))?;
301        let rows = statement
302            .query_map(params![project_id], release_from_row)
303            .map_err(|error| RuntimeError::store(error.to_string()))?;
304        rows.map(|row| row.map_err(|error| RuntimeError::store(error.to_string())))
305            .collect()
306    }
307
308    fn active_release(&self, project_id: &str) -> Result<Option<u64>, RuntimeError> {
309        self.connection()?
310            .query_row(
311                "SELECT version FROM runtime_active_releases WHERE project_id = ?1",
312                params![project_id],
313                |row| row.get::<_, i64>(0),
314            )
315            .optional()
316            .map_err(|error| RuntimeError::store(error.to_string()))?
317            .map(|version| {
318                u64::try_from(version).map_err(|error| RuntimeError::store(error.to_string()))
319            })
320            .transpose()
321    }
322
323    fn set_active_release(&self, project_id: &str, version: u64) -> Result<(), RuntimeError> {
324        let version =
325            i64::try_from(version).map_err(|error| RuntimeError::store(error.to_string()))?;
326        self.connection()?
327            .execute(
328                "INSERT INTO runtime_active_releases(project_id, version)
329                 VALUES (?1, ?2)
330                 ON CONFLICT(project_id) DO UPDATE SET version = excluded.version",
331                params![project_id, version],
332            )
333            .map_err(|error| RuntimeError::store(error.to_string()))?;
334        Ok(())
335    }
336
337    fn save_local_provider_binding(
338        &self,
339        project_id: &str,
340        binding: &LocalProviderBinding,
341    ) -> Result<(), RuntimeError> {
342        let binding_json = serde_json::to_string(binding)
343            .map_err(|error| RuntimeError::store(error.to_string()))?;
344        self.connection()?
345            .execute(
346                "INSERT INTO runtime_provider_bindings(project_id, provider_id, binding_json)
347                 VALUES (?1, ?2, ?3)
348                 ON CONFLICT(project_id, provider_id) DO UPDATE SET
349                   binding_json = excluded.binding_json",
350                params![project_id, binding.provider_id, binding_json],
351            )
352            .map_err(|error| RuntimeError::store(error.to_string()))?;
353        Ok(())
354    }
355
356    fn local_provider_bindings(
357        &self,
358        project_id: &str,
359    ) -> Result<Vec<LocalProviderBinding>, RuntimeError> {
360        let connection = self.connection()?;
361        let mut statement = connection
362            .prepare(
363                "SELECT binding_json
364                 FROM runtime_provider_bindings
365                 WHERE project_id = ?1
366                 ORDER BY provider_id",
367            )
368            .map_err(|error| RuntimeError::store(error.to_string()))?;
369        let rows = statement
370            .query_map(params![project_id], |row| row.get::<_, String>(0))
371            .map_err(|error| RuntimeError::store(error.to_string()))?;
372        rows.map(|row| {
373            let json = row.map_err(|error| RuntimeError::store(error.to_string()))?;
374            serde_json::from_str(&json).map_err(|error| RuntimeError::store(error.to_string()))
375        })
376        .collect()
377    }
378
379    fn enqueue_trace(&self, trace: &RuntimeTraceRecord) -> Result<(), RuntimeError> {
380        let payload_json =
381            serde_json::to_string(trace).map_err(|error| RuntimeError::store(error.to_string()))?;
382        let created_at_ms = i64::try_from(trace.created_at_ms)
383            .map_err(|error| RuntimeError::store(error.to_string()))?;
384        let mut connection = self.connection()?;
385        let transaction = connection
386            .transaction()
387            .map_err(|error| RuntimeError::store(error.to_string()))?;
388        transaction
389            .execute(
390                "INSERT OR IGNORE INTO runtime_trace_outbox(
391                    trace_id, project_id, payload_json, created_at_ms
392                 ) VALUES (?1, ?2, ?3, ?4)",
393                params![trace.id, trace.project_id, payload_json, created_at_ms],
394            )
395            .map_err(|error| RuntimeError::store(error.to_string()))?;
396        transaction
397            .execute(
398                "DELETE FROM runtime_trace_outbox
399                 WHERE trace_id IN (
400                   SELECT trace_id FROM runtime_trace_outbox
401                   ORDER BY created_at_ms DESC, trace_id DESC
402                   LIMIT -1 OFFSET ?1
403                 )",
404                params![MAX_TRACE_OUTBOX_RECORDS],
405            )
406            .map_err(|error| RuntimeError::store(error.to_string()))?;
407        transaction
408            .commit()
409            .map_err(|error| RuntimeError::store(error.to_string()))?;
410        Ok(())
411    }
412
413    fn pending_traces(&self, limit: usize) -> Result<Vec<RuntimeTraceRecord>, RuntimeError> {
414        let limit = i64::try_from(limit.min(MAX_TRACE_OUTBOX_RECORDS as usize))
415            .map_err(|error| RuntimeError::store(error.to_string()))?;
416        let connection = self.connection()?;
417        let mut statement = connection
418            .prepare(
419                "SELECT payload_json
420                 FROM runtime_trace_outbox
421                 ORDER BY created_at_ms, trace_id
422                 LIMIT ?1",
423            )
424            .map_err(|error| RuntimeError::store(error.to_string()))?;
425        let rows = statement
426            .query_map(params![limit], |row| row.get::<_, String>(0))
427            .map_err(|error| RuntimeError::store(error.to_string()))?;
428        rows.map(|row| {
429            let json = row.map_err(|error| RuntimeError::store(error.to_string()))?;
430            serde_json::from_str(&json).map_err(|error| RuntimeError::store(error.to_string()))
431        })
432        .collect()
433    }
434
435    fn acknowledge_traces(&self, trace_ids: &[String]) -> Result<(), RuntimeError> {
436        let mut connection = self.connection()?;
437        let transaction = connection
438            .transaction()
439            .map_err(|error| RuntimeError::store(error.to_string()))?;
440        for trace_id in trace_ids {
441            transaction
442                .execute(
443                    "DELETE FROM runtime_trace_outbox WHERE trace_id = ?1",
444                    params![trace_id],
445                )
446                .map_err(|error| RuntimeError::store(error.to_string()))?;
447        }
448        transaction
449            .commit()
450            .map_err(|error| RuntimeError::store(error.to_string()))?;
451        Ok(())
452    }
453}
454
455fn load_release(
456    connection: &Connection,
457    project_id: &str,
458    version: u64,
459) -> Result<Option<RuntimeRelease>, RuntimeError> {
460    let version = i64::try_from(version).map_err(|error| RuntimeError::store(error.to_string()))?;
461    connection
462        .query_row(
463            "SELECT version, content_hash, manifest_json
464             FROM runtime_releases
465             WHERE project_id = ?1 AND version = ?2",
466            params![project_id, version],
467            release_from_row,
468        )
469        .optional()
470        .map_err(|error| RuntimeError::store(error.to_string()))
471}
472
473fn release_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RuntimeRelease> {
474    let version = row.get::<_, i64>(0)?;
475    let content_hash = row.get::<_, String>(1)?;
476    let manifest_json = row.get::<_, String>(2)?;
477    let manifest = serde_json::from_str(&manifest_json).map_err(|error| {
478        rusqlite::Error::FromSqlConversionFailure(
479            manifest_json.len(),
480            rusqlite::types::Type::Text,
481            Box::new(error),
482        )
483    })?;
484    let version = u64::try_from(version).map_err(|error| {
485        rusqlite::Error::FromSqlConversionFailure(
486            8,
487            rusqlite::types::Type::Integer,
488            Box::new(error),
489        )
490    })?;
491    Ok(RuntimeRelease {
492        version,
493        content_hash,
494        manifest,
495    })
496}
497
498#[cfg(test)]
499mod tests {
500    use std::collections::BTreeMap;
501
502    use serde_json::json;
503
504    use super::*;
505    use crate::{
506        AgentDefinition, EndpointDefinition, ProviderRequirement, RuntimeManifest,
507        RUNTIME_MANIFEST_SCHEMA_VERSION,
508    };
509
510    fn release(version: u64) -> RuntimeRelease {
511        RuntimeRelease::new(
512            version,
513            RuntimeManifest {
514                schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
515                project_id: "test-project".to_string(),
516                providers: vec![ProviderRequirement {
517                    id: "native".to_string(),
518                    provider_type: "native".to_string(),
519                    capabilities: vec!["chat".to_string()],
520                    settings: json!({}),
521                    resources: BTreeMap::new(),
522                }],
523                agents: vec![AgentDefinition {
524                    id: "guide".to_string(),
525                    name: "Guide".to_string(),
526                    provider: "native".to_string(),
527                    capabilities: vec!["chat".to_string()],
528                    metadata: json!({}),
529                }],
530                endpoints: vec![EndpointDefinition {
531                    name: "guide".to_string(),
532                    agent: "guide".to_string(),
533                    capability: "chat".to_string(),
534                    timeout_ms: 30_000,
535                }],
536                metadata: json!({}),
537            },
538        )
539        .unwrap()
540    }
541
542    #[test]
543    fn sqlite_store_persists_release_session_binding_and_trace() {
544        let store = SqliteRuntimeStore::in_memory().unwrap();
545        let release = release(1);
546        store.save_release(&release).unwrap();
547        store.set_active_release("test-project", 1).unwrap();
548        store
549            .save(
550                "test-project",
551                "player",
552                &RuntimeSnapshot {
553                    revision: 2,
554                    state: json!({ "chapter": 3 }),
555                },
556            )
557            .unwrap();
558        store
559            .save_local_provider_binding(
560                "test-project",
561                &LocalProviderBinding {
562                    provider_id: "native".to_string(),
563                    configuration: json!({ "credentialRef": "keychain:vifu/native" }),
564                },
565            )
566            .unwrap();
567        store
568            .enqueue_trace(&RuntimeTraceRecord {
569                id: "trace-1".to_string(),
570                project_id: "test-project".to_string(),
571                invocation_id: "invocation-1".to_string(),
572                endpoint: "guide".to_string(),
573                agent: Some("guide".to_string()),
574                provider: Some("native".to_string()),
575                capability: Some("chat".to_string()),
576                status: "completed".to_string(),
577                duration_ms: 4,
578                created_at_ms: 10,
579            })
580            .unwrap();
581
582        assert_eq!(store.active_release("test-project").unwrap(), Some(1));
583        assert_eq!(
584            store
585                .load("test-project", "player")
586                .unwrap()
587                .unwrap()
588                .revision,
589            2
590        );
591        assert_eq!(
592            store.local_provider_bindings("test-project").unwrap().len(),
593            1
594        );
595        assert_eq!(store.pending_traces(10).unwrap().len(), 1);
596
597        store.acknowledge_traces(&["trace-1".to_string()]).unwrap();
598        assert!(store.pending_traces(10).unwrap().is_empty());
599    }
600
601    #[test]
602    fn sqlite_store_rejects_mutating_an_existing_release_version() {
603        let store = SqliteRuntimeStore::in_memory().unwrap();
604        store.save_release(&release(1)).unwrap();
605        let mut changed = release(1);
606        changed.manifest.metadata = json!({ "changed": true });
607        changed.content_hash = changed.manifest.content_hash().unwrap();
608        assert!(store.save_release(&changed).is_err());
609    }
610
611    #[test]
612    fn sqlite_store_persists_and_updates_bounded_host_state() {
613        let store = SqliteRuntimeStore::in_memory().unwrap();
614        let value = json!({ "gatewayId": "gateway-test", "resume": true });
615
616        store
617            .save_host_state(
618                "agent-gateway-session",
619                "cloud|https://api.example.com/",
620                &value,
621            )
622            .unwrap();
623
624        assert_eq!(
625            store
626                .load_host_state("agent-gateway-session", "cloud|https://api.example.com/")
627                .unwrap(),
628            Some(value)
629        );
630        let updated = json!({ "gatewayId": "gateway-test", "resume": false });
631        store
632            .save_host_state(
633                "agent-gateway-session",
634                "cloud|https://api.example.com/",
635                &updated,
636            )
637            .unwrap();
638        assert_eq!(
639            store
640                .load_host_state("agent-gateway-session", "cloud|https://api.example.com/")
641                .unwrap(),
642            Some(updated)
643        );
644    }
645}