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
18pub 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 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 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}