1use std::path::Path;
2
3use crate::{Database, DbResultExt};
4use roboticus_core::{RoboticusError, Result};
5
6const SCHEMA_SQL: &str = r#"
7CREATE TABLE IF NOT EXISTS schema_version (
8 version INTEGER NOT NULL,
9 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
10);
11
12CREATE TABLE IF NOT EXISTS sessions (
13 id TEXT PRIMARY KEY,
14 agent_id TEXT NOT NULL,
15 scope_key TEXT NOT NULL DEFAULT 'agent',
16 status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived', 'expired')),
17 model TEXT,
18 nickname TEXT,
19 created_at TEXT NOT NULL DEFAULT (datetime('now')),
20 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
21 metadata TEXT
22);
23CREATE INDEX IF NOT EXISTS idx_sessions_scope ON sessions(agent_id, scope_key, status);
24CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_active_scope_unique ON sessions(agent_id, scope_key) WHERE status = 'active';
25CREATE INDEX IF NOT EXISTS idx_sessions_status_updated ON sessions(status, updated_at);
26
27CREATE TABLE IF NOT EXISTS session_messages (
28 id TEXT PRIMARY KEY,
29 session_id TEXT NOT NULL REFERENCES sessions(id),
30 parent_id TEXT,
31 role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
32 content TEXT NOT NULL,
33 usage_json TEXT,
34 created_at TEXT NOT NULL DEFAULT (datetime('now'))
35);
36CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id, created_at);
37
38CREATE TABLE IF NOT EXISTS turns (
39 id TEXT PRIMARY KEY,
40 session_id TEXT NOT NULL REFERENCES sessions(id),
41 thinking TEXT,
42 tool_calls_json TEXT,
43 tokens_in INTEGER,
44 tokens_out INTEGER,
45 cost REAL,
46 model TEXT,
47 created_at TEXT NOT NULL DEFAULT (datetime('now'))
48);
49CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, created_at);
50
51CREATE TABLE IF NOT EXISTS tool_calls (
52 id TEXT PRIMARY KEY,
53 turn_id TEXT NOT NULL REFERENCES turns(id),
54 tool_name TEXT NOT NULL,
55 input TEXT NOT NULL,
56 output TEXT,
57 skill_id TEXT,
58 skill_name TEXT,
59 skill_hash TEXT,
60 status TEXT NOT NULL,
61 duration_ms INTEGER,
62 created_at TEXT NOT NULL DEFAULT (datetime('now'))
63);
64CREATE INDEX IF NOT EXISTS idx_tool_calls_turn ON tool_calls(turn_id);
65
66CREATE TABLE IF NOT EXISTS policy_decisions (
67 id TEXT PRIMARY KEY,
68 turn_id TEXT,
69 tool_name TEXT NOT NULL,
70 decision TEXT NOT NULL CHECK(decision IN ('allow', 'deny')),
71 rule_name TEXT,
72 reason TEXT,
73 context_json TEXT,
74 created_at TEXT NOT NULL DEFAULT (datetime('now'))
75);
76CREATE INDEX IF NOT EXISTS idx_policy_decisions_session ON policy_decisions(turn_id);
77CREATE INDEX IF NOT EXISTS idx_policy_decisions_created ON policy_decisions(created_at);
78
79CREATE TABLE IF NOT EXISTS working_memory (
80 id TEXT PRIMARY KEY,
81 session_id TEXT NOT NULL,
82 entry_type TEXT NOT NULL CHECK(entry_type IN ('goal', 'note', 'turn_summary', 'decision', 'observation', 'fact')),
83 content TEXT NOT NULL,
84 importance INTEGER NOT NULL DEFAULT 5,
85 created_at TEXT NOT NULL DEFAULT (datetime('now'))
86);
87CREATE INDEX IF NOT EXISTS idx_working_memory_session ON working_memory(session_id);
88
89CREATE TABLE IF NOT EXISTS episodic_memory (
90 id TEXT PRIMARY KEY,
91 classification TEXT NOT NULL,
92 content TEXT NOT NULL,
93 importance INTEGER NOT NULL DEFAULT 5,
94 created_at TEXT NOT NULL DEFAULT (datetime('now'))
95);
96CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memory(importance DESC, created_at DESC);
97
98CREATE TABLE IF NOT EXISTS semantic_memory (
99 id TEXT PRIMARY KEY,
100 category TEXT NOT NULL,
101 key TEXT NOT NULL,
102 value TEXT NOT NULL,
103 confidence REAL NOT NULL DEFAULT 0.8,
104 created_at TEXT NOT NULL DEFAULT (datetime('now')),
105 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
106 UNIQUE(category, key)
107);
108
109CREATE TABLE IF NOT EXISTS procedural_memory (
110 id TEXT PRIMARY KEY,
111 name TEXT NOT NULL UNIQUE,
112 steps TEXT NOT NULL,
113 success_count INTEGER NOT NULL DEFAULT 0,
114 failure_count INTEGER NOT NULL DEFAULT 0,
115 created_at TEXT NOT NULL DEFAULT (datetime('now')),
116 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
117);
118
119CREATE TABLE IF NOT EXISTS relationship_memory (
120 id TEXT PRIMARY KEY,
121 entity_id TEXT NOT NULL UNIQUE,
122 entity_name TEXT,
123 trust_score REAL NOT NULL DEFAULT 0.5,
124 interaction_summary TEXT,
125 interaction_count INTEGER NOT NULL DEFAULT 0,
126 last_interaction TEXT,
127 created_at TEXT NOT NULL DEFAULT (datetime('now'))
128);
129
130CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
131 content,
132 category,
133 source_table,
134 source_id
135);
136
137-- Keep FTS in sync with episodic_memory
138CREATE TRIGGER IF NOT EXISTS episodic_ai AFTER INSERT ON episodic_memory BEGIN
139 INSERT INTO memory_fts(content, category, source_table, source_id)
140 VALUES (new.content, new.classification, 'episodic', new.id);
141END;
142
143CREATE TRIGGER IF NOT EXISTS episodic_ad AFTER DELETE ON episodic_memory BEGIN
144 DELETE FROM memory_fts WHERE source_table = 'episodic' AND source_id = old.id;
145END;
146
147CREATE TABLE IF NOT EXISTS tasks (
148 id TEXT PRIMARY KEY,
149 title TEXT NOT NULL,
150 description TEXT,
151 status TEXT NOT NULL DEFAULT 'pending',
152 priority INTEGER NOT NULL DEFAULT 0,
153 source TEXT,
154 created_at TEXT NOT NULL DEFAULT (datetime('now')),
155 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
156);
157
158CREATE TABLE IF NOT EXISTS cron_jobs (
159 id TEXT PRIMARY KEY,
160 name TEXT NOT NULL,
161 description TEXT,
162 enabled INTEGER NOT NULL DEFAULT 1,
163 schedule_kind TEXT NOT NULL,
164 schedule_expr TEXT,
165 schedule_every_ms INTEGER,
166 schedule_tz TEXT DEFAULT 'UTC',
167 agent_id TEXT NOT NULL,
168 session_target TEXT NOT NULL DEFAULT 'main',
169 payload_json TEXT NOT NULL,
170 delivery_mode TEXT DEFAULT 'none',
171 delivery_channel TEXT,
172 last_run_at TEXT,
173 last_status TEXT,
174 last_duration_ms INTEGER,
175 consecutive_errors INTEGER NOT NULL DEFAULT 0,
176 next_run_at TEXT,
177 last_error TEXT,
178 lease_holder TEXT,
179 lease_expires_at TEXT
180);
181
182CREATE TABLE IF NOT EXISTS cron_runs (
183 id TEXT PRIMARY KEY,
184 job_id TEXT NOT NULL REFERENCES cron_jobs(id),
185 status TEXT NOT NULL CHECK(status IN ('success', 'error')),
186 duration_ms INTEGER,
187 error TEXT,
188 output_text TEXT,
189 created_at TEXT NOT NULL DEFAULT (datetime('now'))
190);
191CREATE INDEX IF NOT EXISTS idx_cron_runs_job ON cron_runs(job_id, created_at);
192
193CREATE TABLE IF NOT EXISTS transactions (
194 id TEXT PRIMARY KEY,
195 tx_type TEXT NOT NULL,
196 amount REAL NOT NULL,
197 currency TEXT NOT NULL DEFAULT 'USD',
198 counterparty TEXT,
199 tx_hash TEXT,
200 metadata_json TEXT,
201 created_at TEXT NOT NULL DEFAULT (datetime('now'))
202);
203
204CREATE TABLE IF NOT EXISTS service_requests (
205 id TEXT PRIMARY KEY,
206 service_id TEXT NOT NULL,
207 requester TEXT NOT NULL,
208 parameters_json TEXT NOT NULL,
209 status TEXT NOT NULL,
210 quoted_amount REAL NOT NULL,
211 currency TEXT NOT NULL DEFAULT 'USDC',
212 recipient TEXT NOT NULL,
213 quote_expires_at TEXT NOT NULL,
214 payment_tx_hash TEXT,
215 paid_amount REAL,
216 payment_verified_at TEXT,
217 fulfillment_output TEXT,
218 fulfilled_at TEXT,
219 failure_reason TEXT,
220 created_at TEXT NOT NULL DEFAULT (datetime('now')),
221 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
222);
223CREATE INDEX IF NOT EXISTS idx_service_requests_status ON service_requests(status, created_at DESC);
224CREATE INDEX IF NOT EXISTS idx_service_requests_service ON service_requests(service_id, created_at DESC);
225
226CREATE TABLE IF NOT EXISTS revenue_opportunities (
227 id TEXT PRIMARY KEY,
228 source TEXT NOT NULL,
229 strategy TEXT NOT NULL,
230 payload_json TEXT NOT NULL,
231 expected_revenue_usdc REAL NOT NULL,
232 status TEXT NOT NULL,
233 qualification_reason TEXT,
234 confidence_score REAL NOT NULL DEFAULT 0,
235 effort_score REAL NOT NULL DEFAULT 0,
236 risk_score REAL NOT NULL DEFAULT 0,
237 priority_score REAL NOT NULL DEFAULT 0,
238 recommended_approved INTEGER NOT NULL DEFAULT 0,
239 score_reason TEXT,
240 plan_json TEXT,
241 evidence_json TEXT,
242 request_id TEXT,
243 settlement_ref TEXT UNIQUE,
244 settled_amount_usdc REAL,
245 attributable_costs_usdc REAL NOT NULL DEFAULT 0,
246 net_profit_usdc REAL,
247 tax_rate REAL NOT NULL DEFAULT 0,
248 tax_amount_usdc REAL NOT NULL DEFAULT 0,
249 retained_earnings_usdc REAL,
250 tax_destination_wallet TEXT,
251 settled_at TEXT,
252 created_at TEXT NOT NULL DEFAULT (datetime('now')),
253 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
254);
255CREATE INDEX IF NOT EXISTS idx_revenue_opportunities_status ON revenue_opportunities(status, created_at DESC);
256CREATE INDEX IF NOT EXISTS idx_revenue_opportunities_strategy ON revenue_opportunities(strategy, created_at DESC);
257CREATE INDEX IF NOT EXISTS idx_revenue_opportunities_request ON revenue_opportunities(request_id);
258
259CREATE TABLE IF NOT EXISTS revenue_feedback (
260 id TEXT PRIMARY KEY,
261 opportunity_id TEXT NOT NULL,
262 strategy TEXT NOT NULL,
263 grade REAL NOT NULL,
264 source TEXT NOT NULL,
265 comment TEXT,
266 created_at TEXT NOT NULL DEFAULT (datetime('now'))
267);
268CREATE INDEX IF NOT EXISTS idx_revenue_feedback_opportunity ON revenue_feedback(opportunity_id, created_at DESC);
269CREATE INDEX IF NOT EXISTS idx_revenue_feedback_strategy ON revenue_feedback(strategy, created_at DESC);
270
271CREATE TABLE IF NOT EXISTS inference_costs (
272 id TEXT PRIMARY KEY,
273 model TEXT NOT NULL,
274 provider TEXT NOT NULL,
275 tokens_in INTEGER NOT NULL,
276 tokens_out INTEGER NOT NULL,
277 cost REAL NOT NULL,
278 tier TEXT,
279 cached INTEGER NOT NULL DEFAULT 0,
280 latency_ms INTEGER,
281 quality_score REAL,
282 escalation INTEGER NOT NULL DEFAULT 0,
283 turn_id TEXT,
284 created_at TEXT NOT NULL DEFAULT (datetime('now'))
285);
286CREATE INDEX IF NOT EXISTS idx_inference_costs_time ON inference_costs(created_at DESC);
287
288CREATE TABLE IF NOT EXISTS semantic_cache (
289 id TEXT PRIMARY KEY,
290 prompt_hash TEXT NOT NULL,
291 embedding BLOB,
292 response TEXT NOT NULL,
293 model TEXT NOT NULL,
294 tokens_saved INTEGER NOT NULL DEFAULT 0,
295 hit_count INTEGER NOT NULL DEFAULT 0,
296 created_at TEXT NOT NULL DEFAULT (datetime('now')),
297 expires_at TEXT
298);
299CREATE INDEX IF NOT EXISTS idx_cache_hash ON semantic_cache(prompt_hash);
300
301CREATE TABLE IF NOT EXISTS identity (
302 key TEXT PRIMARY KEY,
303 value TEXT NOT NULL
304);
305
306CREATE TABLE IF NOT EXISTS os_personality_history (
307 id TEXT PRIMARY KEY,
308 content TEXT NOT NULL,
309 content_hash TEXT NOT NULL,
310 created_at TEXT NOT NULL DEFAULT (datetime('now'))
311);
312
313CREATE TABLE IF NOT EXISTS metric_snapshots (
314 id TEXT PRIMARY KEY,
315 metrics_json TEXT NOT NULL,
316 alerts_json TEXT,
317 created_at TEXT NOT NULL DEFAULT (datetime('now'))
318);
319
320CREATE TABLE IF NOT EXISTS discovered_agents (
321 id TEXT PRIMARY KEY,
322 did TEXT NOT NULL UNIQUE,
323 agent_card_json TEXT NOT NULL,
324 capabilities TEXT,
325 endpoint_url TEXT NOT NULL,
326 chain_id INTEGER NOT NULL DEFAULT 8453,
327 trust_score REAL NOT NULL DEFAULT 0.5,
328 last_verified_at TEXT,
329 expires_at TEXT,
330 created_at TEXT NOT NULL DEFAULT (datetime('now'))
331);
332CREATE INDEX IF NOT EXISTS idx_discovered_agents_did ON discovered_agents(did);
333
334CREATE TABLE IF NOT EXISTS skills (
335 id TEXT PRIMARY KEY,
336 name TEXT NOT NULL UNIQUE,
337 kind TEXT NOT NULL CHECK(kind IN ('structured', 'instruction', 'scripted', 'builtin')),
338 description TEXT,
339 source_path TEXT NOT NULL,
340 content_hash TEXT NOT NULL,
341 triggers_json TEXT,
342 tool_chain_json TEXT,
343 policy_overrides_json TEXT,
344 script_path TEXT,
345 risk_level TEXT NOT NULL DEFAULT 'Caution' CHECK(risk_level IN ('Safe', 'Caution', 'Dangerous', 'Forbidden')),
346 enabled INTEGER NOT NULL DEFAULT 1,
347 last_loaded_at TEXT,
348 created_at TEXT NOT NULL DEFAULT (datetime('now')),
349 version TEXT NOT NULL DEFAULT '0.0.0',
350 author TEXT NOT NULL DEFAULT 'local',
351 registry_source TEXT NOT NULL DEFAULT 'local'
352);
353CREATE INDEX IF NOT EXISTS idx_skills_kind ON skills(kind);
354
355CREATE TABLE IF NOT EXISTS delivery_queue (
356 id TEXT PRIMARY KEY,
357 channel TEXT NOT NULL,
358 recipient_id TEXT NOT NULL,
359 content TEXT NOT NULL,
360 idempotency_key TEXT NOT NULL DEFAULT '',
361 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_flight', 'delivered', 'dead_letter')),
362 attempts INTEGER NOT NULL DEFAULT 0,
363 max_attempts INTEGER NOT NULL DEFAULT 5,
364 next_retry_at TEXT NOT NULL DEFAULT (datetime('now')),
365 last_error TEXT,
366 created_at TEXT NOT NULL DEFAULT (datetime('now'))
367);
368CREATE INDEX IF NOT EXISTS idx_delivery_queue_status ON delivery_queue(status, next_retry_at);
369CREATE INDEX IF NOT EXISTS idx_delivery_queue_idem ON delivery_queue(idempotency_key);
370
371CREATE TABLE IF NOT EXISTS approval_requests (
372 id TEXT PRIMARY KEY,
373 tool_name TEXT NOT NULL,
374 tool_input TEXT NOT NULL,
375 session_id TEXT,
376 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'denied', 'timed_out')),
377 decided_by TEXT,
378 decided_at TEXT,
379 timeout_at TEXT NOT NULL,
380 created_at TEXT NOT NULL DEFAULT (datetime('now'))
381);
382CREATE INDEX IF NOT EXISTS idx_approvals_status ON approval_requests(status);
383
384CREATE TABLE IF NOT EXISTS plugins (
385 id TEXT PRIMARY KEY,
386 name TEXT NOT NULL UNIQUE,
387 version TEXT NOT NULL,
388 description TEXT,
389 enabled INTEGER NOT NULL DEFAULT 1,
390 manifest_path TEXT NOT NULL,
391 permissions_json TEXT,
392 installed_at TEXT NOT NULL DEFAULT (datetime('now'))
393);
394
395CREATE TABLE IF NOT EXISTS embeddings (
396 id TEXT PRIMARY KEY,
397 source_table TEXT NOT NULL,
398 source_id TEXT NOT NULL,
399 content_preview TEXT NOT NULL,
400 embedding_blob BLOB,
401 dimensions INTEGER NOT NULL DEFAULT 0,
402 created_at TEXT NOT NULL DEFAULT (datetime('now'))
403);
404CREATE INDEX IF NOT EXISTS idx_embeddings_source ON embeddings(source_table, source_id);
405
406CREATE TABLE IF NOT EXISTS sub_agents (
407 id TEXT PRIMARY KEY,
408 name TEXT NOT NULL UNIQUE,
409 display_name TEXT,
410 model TEXT NOT NULL DEFAULT '',
411 fallback_models_json TEXT NOT NULL DEFAULT '[]',
412 role TEXT NOT NULL DEFAULT 'specialist',
413 description TEXT,
414 skills_json TEXT,
415 enabled INTEGER NOT NULL DEFAULT 1,
416 session_count INTEGER NOT NULL DEFAULT 0,
417 created_at TEXT NOT NULL DEFAULT (datetime('now'))
418);
419
420CREATE TABLE IF NOT EXISTS context_checkpoints (
421 id TEXT PRIMARY KEY,
422 session_id TEXT NOT NULL REFERENCES sessions(id),
423 system_prompt_hash TEXT NOT NULL,
424 memory_summary TEXT NOT NULL,
425 active_tasks TEXT,
426 conversation_digest TEXT,
427 turn_count INTEGER NOT NULL DEFAULT 0,
428 created_at TEXT NOT NULL DEFAULT (datetime('now'))
429);
430CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON context_checkpoints(session_id, created_at DESC);
431
432CREATE TABLE IF NOT EXISTS hippocampus (
433 table_name TEXT PRIMARY KEY,
434 description TEXT NOT NULL,
435 columns_json TEXT NOT NULL,
436 created_by TEXT NOT NULL DEFAULT 'system',
437 agent_owned INTEGER NOT NULL DEFAULT 0,
438 created_at TEXT NOT NULL DEFAULT (datetime('now')),
439 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
440);
441CREATE INDEX IF NOT EXISTS idx_hippocampus_agent ON hippocampus(created_by, agent_owned);
442
443CREATE TABLE IF NOT EXISTS turn_feedback (
444 id TEXT PRIMARY KEY,
445 turn_id TEXT NOT NULL UNIQUE REFERENCES turns(id),
446 session_id TEXT NOT NULL REFERENCES sessions(id),
447 grade INTEGER NOT NULL CHECK (grade BETWEEN 1 AND 5),
448 source TEXT NOT NULL DEFAULT 'dashboard',
449 comment TEXT,
450 created_at TEXT NOT NULL DEFAULT (datetime('now'))
451);
452CREATE INDEX IF NOT EXISTS idx_turn_feedback_session ON turn_feedback(session_id);
453
454CREATE TABLE IF NOT EXISTS context_snapshots (
455 turn_id TEXT PRIMARY KEY REFERENCES turns(id),
456 complexity_level TEXT NOT NULL CHECK(complexity_level IN ('L0', 'L1', 'L2', 'L3')),
457 token_budget INTEGER NOT NULL,
458 system_prompt_tokens INTEGER,
459 memory_tokens INTEGER,
460 history_tokens INTEGER,
461 history_depth INTEGER,
462 memory_tiers_json TEXT,
463 retrieved_memories_json TEXT,
464 model TEXT,
465 created_at TEXT NOT NULL DEFAULT (datetime('now'))
466);
467
468CREATE TABLE IF NOT EXISTS model_selection_events (
469 id TEXT PRIMARY KEY,
470 turn_id TEXT NOT NULL,
471 session_id TEXT NOT NULL,
472 agent_id TEXT NOT NULL,
473 channel TEXT NOT NULL,
474 selected_model TEXT NOT NULL,
475 strategy TEXT NOT NULL,
476 primary_model TEXT NOT NULL,
477 override_model TEXT,
478 complexity TEXT,
479 user_excerpt TEXT NOT NULL,
480 candidates_json TEXT NOT NULL,
481 created_at TEXT NOT NULL DEFAULT (datetime('now')),
482 schema_version INTEGER NOT NULL DEFAULT 1,
483 attribution TEXT,
484 metascore_json TEXT,
485 features_json TEXT
486);
487CREATE INDEX IF NOT EXISTS idx_model_selection_events_turn ON model_selection_events(turn_id);
488CREATE INDEX IF NOT EXISTS idx_model_selection_events_created ON model_selection_events(created_at DESC);
489
490CREATE TABLE IF NOT EXISTS shadow_routing_predictions (
491 id TEXT PRIMARY KEY,
492 turn_id TEXT NOT NULL,
493 production_model TEXT NOT NULL,
494 shadow_model TEXT,
495 production_complexity REAL,
496 shadow_complexity REAL,
497 agreed INTEGER NOT NULL DEFAULT 0,
498 detail_json TEXT,
499 created_at TEXT NOT NULL DEFAULT (datetime('now'))
500);
501CREATE INDEX IF NOT EXISTS idx_shadow_routing_created ON shadow_routing_predictions(created_at DESC);
502CREATE INDEX IF NOT EXISTS idx_shadow_routing_turn ON shadow_routing_predictions(turn_id);
503
504CREATE TABLE IF NOT EXISTS abuse_events (
505 id TEXT PRIMARY KEY,
506 actor_id TEXT NOT NULL,
507 origin TEXT NOT NULL,
508 channel TEXT NOT NULL,
509 signal_type TEXT NOT NULL,
510 severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high')),
511 action_taken TEXT NOT NULL,
512 detail TEXT,
513 score REAL NOT NULL,
514 created_at TEXT NOT NULL DEFAULT (datetime('now'))
515);
516CREATE INDEX IF NOT EXISTS idx_abuse_events_actor ON abuse_events(actor_id, created_at DESC);
517CREATE INDEX IF NOT EXISTS idx_abuse_events_origin ON abuse_events(origin, created_at DESC);
518CREATE INDEX IF NOT EXISTS idx_abuse_events_created ON abuse_events(created_at DESC);
519
520CREATE TABLE IF NOT EXISTS learned_skills (
521 id TEXT PRIMARY KEY,
522 name TEXT NOT NULL UNIQUE,
523 description TEXT NOT NULL DEFAULT '',
524 trigger_tools TEXT NOT NULL DEFAULT '[]',
525 steps_json TEXT NOT NULL DEFAULT '[]',
526 source_session_id TEXT,
527 success_count INTEGER NOT NULL DEFAULT 1,
528 failure_count INTEGER NOT NULL DEFAULT 0,
529 priority INTEGER NOT NULL DEFAULT 50,
530 skill_md_path TEXT,
531 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
532 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
533);
534CREATE INDEX IF NOT EXISTS idx_learned_skills_priority ON learned_skills(priority DESC);
535
536CREATE TABLE IF NOT EXISTS hygiene_log (
537 id TEXT PRIMARY KEY,
538 sweep_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
539 stale_procedural_days INTEGER NOT NULL,
540 dead_skill_priority_threshold INTEGER NOT NULL,
541 proc_total INTEGER NOT NULL DEFAULT 0,
542 proc_stale INTEGER NOT NULL DEFAULT 0,
543 proc_pruned INTEGER NOT NULL DEFAULT 0,
544 skills_total INTEGER NOT NULL DEFAULT 0,
545 skills_dead INTEGER NOT NULL DEFAULT 0,
546 skills_pruned INTEGER NOT NULL DEFAULT 0,
547 avg_skill_priority REAL NOT NULL DEFAULT 0.0
548);
549CREATE INDEX IF NOT EXISTS idx_hygiene_log_sweep ON hygiene_log(sweep_at DESC);
550"#;
551const EMBEDDED_SCHEMA_VERSION: i64 = 23;
552
553pub fn initialize_db(db: &Database) -> Result<()> {
554 {
555 let conn = db.conn();
556 conn.execute_batch(SCHEMA_SQL)
557 .map_err(|e| RoboticusError::Database(format!("schema init failed: {e}")))?;
558
559 let version_exists: bool = conn
560 .query_row("SELECT COUNT(*) FROM schema_version", [], |row| {
561 row.get::<_, i64>(0)
562 })
563 .map(|c| c > 0)
564 .db_err()?;
565
566 if !version_exists {
567 conn.execute(
570 "INSERT INTO schema_version (version) VALUES (?1)",
571 [EMBEDDED_SCHEMA_VERSION],
572 )
573 .db_err()?;
574 }
575 }
576
577 run_migrations(db)?;
578 ensure_optional_columns(db)?;
579 crate::hippocampus::bootstrap_hippocampus(db)?;
580 Ok(())
581}
582
583fn has_column(conn: &rusqlite::Connection, table: &str, column: &str) -> Result<bool> {
584 let mut stmt = conn
585 .prepare(&format!(
586 "PRAGMA table_info(\"{}\")",
587 table.replace('"', "\"\"")
588 ))
589 .db_err()?;
590 let rows = stmt.query_map([], |row| row.get::<_, String>(1)).db_err()?;
591 for col in rows {
592 if col.db_err()? == column {
593 return Ok(true);
594 }
595 }
596 Ok(false)
597}
598
599fn ensure_optional_columns(db: &Database) -> Result<()> {
600 let conn = db.conn();
601 if !has_column(&conn, "skills", "risk_level")? {
602 conn.execute(
603 "ALTER TABLE skills ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'Caution'",
604 [],
605 )
606 .db_err()?;
607 }
608 if !has_column(&conn, "tool_calls", "skill_id")? {
609 conn.execute("ALTER TABLE tool_calls ADD COLUMN skill_id TEXT", [])
610 .db_err()?;
611 }
612 if !has_column(&conn, "tool_calls", "skill_name")? {
613 conn.execute("ALTER TABLE tool_calls ADD COLUMN skill_name TEXT", [])
614 .db_err()?;
615 }
616 if !has_column(&conn, "tool_calls", "skill_hash")? {
617 conn.execute("ALTER TABLE tool_calls ADD COLUMN skill_hash TEXT", [])
618 .db_err()?;
619 }
620 if !has_column(&conn, "delivery_queue", "idempotency_key")? {
621 conn.execute(
622 "ALTER TABLE delivery_queue ADD COLUMN idempotency_key TEXT NOT NULL DEFAULT ''",
623 [],
624 )
625 .db_err()?;
626 conn.execute(
627 "UPDATE delivery_queue SET idempotency_key = id WHERE idempotency_key = ''",
628 [],
629 )
630 .db_err()?;
631 }
632 if !has_column(&conn, "inference_costs", "latency_ms")? {
634 conn.execute(
635 "ALTER TABLE inference_costs ADD COLUMN latency_ms INTEGER",
636 [],
637 )
638 .db_err()?;
639 }
640 if !has_column(&conn, "inference_costs", "quality_score")? {
641 conn.execute(
642 "ALTER TABLE inference_costs ADD COLUMN quality_score REAL",
643 [],
644 )
645 .db_err()?;
646 }
647 if !has_column(&conn, "inference_costs", "escalation")? {
648 conn.execute(
649 "ALTER TABLE inference_costs ADD COLUMN escalation INTEGER NOT NULL DEFAULT 0",
650 [],
651 )
652 .db_err()?;
653 }
654 if !has_column(&conn, "hippocampus", "access_level")? {
656 conn.execute(
657 "ALTER TABLE hippocampus ADD COLUMN access_level TEXT NOT NULL DEFAULT 'internal'",
658 [],
659 )
660 .db_err()?;
661 }
662 if !has_column(&conn, "hippocampus", "row_count")? {
663 conn.execute(
664 "ALTER TABLE hippocampus ADD COLUMN row_count INTEGER NOT NULL DEFAULT 0",
665 [],
666 )
667 .db_err()?;
668 }
669 if !has_column(&conn, "sub_agents", "fallback_models_json")? {
670 conn.execute(
671 "ALTER TABLE sub_agents ADD COLUMN fallback_models_json TEXT NOT NULL DEFAULT '[]'",
672 [],
673 )
674 .db_err()?;
675 }
676 if !has_column(&conn, "model_selection_events", "schema_version")? {
678 conn.execute(
679 "ALTER TABLE model_selection_events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1",
680 [],
681 )
682 .db_err()?;
683 }
684 if !has_column(&conn, "model_selection_events", "attribution")? {
685 conn.execute(
686 "ALTER TABLE model_selection_events ADD COLUMN attribution TEXT",
687 [],
688 )
689 .db_err()?;
690 }
691 if !has_column(&conn, "model_selection_events", "metascore_json")? {
692 conn.execute(
693 "ALTER TABLE model_selection_events ADD COLUMN metascore_json TEXT",
694 [],
695 )
696 .db_err()?;
697 }
698 if !has_column(&conn, "model_selection_events", "features_json")? {
699 conn.execute(
700 "ALTER TABLE model_selection_events ADD COLUMN features_json TEXT",
701 [],
702 )
703 .db_err()?;
704 }
705 if !has_column(&conn, "inference_costs", "turn_id")? {
707 conn.execute("ALTER TABLE inference_costs ADD COLUMN turn_id TEXT", [])
708 .db_err()?;
709 }
710 if has_column(&conn, "inference_costs", "turn_id")? {
711 conn.execute(
712 "CREATE INDEX IF NOT EXISTS idx_inference_costs_turn ON inference_costs(turn_id)",
713 [],
714 )
715 .db_err()?;
716 }
717 if !has_column(&conn, "cron_runs", "output_text")? {
718 conn.execute("ALTER TABLE cron_runs ADD COLUMN output_text TEXT", [])
719 .db_err()?;
720 }
721 if !has_column(&conn, "revenue_opportunities", "attributable_costs_usdc")? {
722 conn.execute(
723 "ALTER TABLE revenue_opportunities ADD COLUMN attributable_costs_usdc REAL NOT NULL DEFAULT 0",
724 [],
725 )
726 .db_err()?;
727 }
728 if !has_column(&conn, "revenue_opportunities", "net_profit_usdc")? {
729 conn.execute(
730 "ALTER TABLE revenue_opportunities ADD COLUMN net_profit_usdc REAL",
731 [],
732 )
733 .db_err()?;
734 }
735 if !has_column(&conn, "revenue_opportunities", "tax_rate")? {
736 conn.execute(
737 "ALTER TABLE revenue_opportunities ADD COLUMN tax_rate REAL NOT NULL DEFAULT 0",
738 [],
739 )
740 .db_err()?;
741 }
742 if !has_column(&conn, "revenue_opportunities", "tax_amount_usdc")? {
743 conn.execute(
744 "ALTER TABLE revenue_opportunities ADD COLUMN tax_amount_usdc REAL NOT NULL DEFAULT 0",
745 [],
746 )
747 .db_err()?;
748 }
749 if !has_column(&conn, "revenue_opportunities", "retained_earnings_usdc")? {
750 conn.execute(
751 "ALTER TABLE revenue_opportunities ADD COLUMN retained_earnings_usdc REAL",
752 [],
753 )
754 .db_err()?;
755 }
756 if !has_column(&conn, "revenue_opportunities", "tax_destination_wallet")? {
757 conn.execute(
758 "ALTER TABLE revenue_opportunities ADD COLUMN tax_destination_wallet TEXT",
759 [],
760 )
761 .db_err()?;
762 }
763 if !has_column(&conn, "revenue_opportunities", "confidence_score")? {
764 conn.execute(
765 "ALTER TABLE revenue_opportunities ADD COLUMN confidence_score REAL NOT NULL DEFAULT 0",
766 [],
767 )
768 .db_err()?;
769 }
770 if !has_column(&conn, "revenue_opportunities", "effort_score")? {
771 conn.execute(
772 "ALTER TABLE revenue_opportunities ADD COLUMN effort_score REAL NOT NULL DEFAULT 0",
773 [],
774 )
775 .db_err()?;
776 }
777 if !has_column(&conn, "revenue_opportunities", "risk_score")? {
778 conn.execute(
779 "ALTER TABLE revenue_opportunities ADD COLUMN risk_score REAL NOT NULL DEFAULT 0",
780 [],
781 )
782 .db_err()?;
783 }
784 if !has_column(&conn, "revenue_opportunities", "priority_score")? {
785 conn.execute(
786 "ALTER TABLE revenue_opportunities ADD COLUMN priority_score REAL NOT NULL DEFAULT 0",
787 [],
788 )
789 .db_err()?;
790 }
791 if !has_column(&conn, "revenue_opportunities", "recommended_approved")? {
792 conn.execute(
793 "ALTER TABLE revenue_opportunities ADD COLUMN recommended_approved INTEGER NOT NULL DEFAULT 0",
794 [],
795 )
796 .db_err()?;
797 }
798 if !has_column(&conn, "revenue_opportunities", "score_reason")? {
799 conn.execute(
800 "ALTER TABLE revenue_opportunities ADD COLUMN score_reason TEXT",
801 [],
802 )
803 .db_err()?;
804 }
805 if !has_column(&conn, "revenue_opportunities", "settled_at")? {
807 conn.execute(
808 "ALTER TABLE revenue_opportunities ADD COLUMN settled_at TEXT",
809 [],
810 )
811 .db_err()?;
812 }
813 if !has_column(&conn, "skills", "version")? {
815 conn.execute(
816 "ALTER TABLE skills ADD COLUMN version TEXT NOT NULL DEFAULT '0.0.0'",
817 [],
818 )
819 .db_err()?;
820 }
821 if !has_column(&conn, "skills", "author")? {
822 conn.execute(
823 "ALTER TABLE skills ADD COLUMN author TEXT NOT NULL DEFAULT 'local'",
824 [],
825 )
826 .db_err()?;
827 }
828 if !has_column(&conn, "skills", "registry_source")? {
829 conn.execute(
830 "ALTER TABLE skills ADD COLUMN registry_source TEXT NOT NULL DEFAULT 'local'",
831 [],
832 )
833 .db_err()?;
834 }
835 conn.execute_batch("DROP TABLE IF EXISTS proxy_stats;")
837 .db_err()?;
838 if has_column(&conn, "embeddings", "embedding_json")? {
841 let migrated: usize = conn
844 .execute(
845 "UPDATE embeddings SET embedding_blob = NULL \
846 WHERE (embedding_blob IS NULL OR length(embedding_blob) = 0) \
847 AND embedding_json = ''",
848 [],
849 )
850 .unwrap_or(0);
851 let _ = conn.execute_batch("ALTER TABLE embeddings DROP COLUMN embedding_json;");
855 if migrated > 0 {
856 tracing::info!(count = migrated, "DF-15: cleaned empty embedding rows");
857 }
858 }
859 Ok(())
860}
861
862fn migrations_dir() -> Option<std::path::PathBuf> {
864 std::env::current_dir()
865 .ok()
866 .map(|p| p.join("migrations"))
867 .filter(|p| p.is_dir())
868 .or_else(|| {
869 let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
870 if p.is_dir() { Some(p) } else { None }
871 })
872}
873
874pub fn run_migrations(db: &Database) -> Result<()> {
877 let dir = match migrations_dir() {
878 Some(d) => d,
879 None => return Ok(()),
880 };
881
882 let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
883 .map_err(|e| RoboticusError::Database(format!("read migrations dir: {e}")))?
884 .filter_map(|e| {
885 e.inspect_err(|e| tracing::warn!("skipping unreadable migration entry: {e}"))
886 .ok()
887 .map(|e| e.path())
888 })
889 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("sql"))
890 .collect();
891
892 entries.sort_by(|a, b| {
893 let va = version_from_name(a.file_name().and_then(|n| n.to_str()).unwrap_or(""));
894 let vb = version_from_name(b.file_name().and_then(|n| n.to_str()).unwrap_or(""));
895 va.cmp(&vb)
896 });
897
898 let conn = db.conn();
899 let max_version: i64 = conn
900 .query_row(
901 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
902 [],
903 |row| row.get(0),
904 )
905 .db_err()?;
906
907 for path in entries {
908 let version = version_from_name(path.file_name().and_then(|n| n.to_str()).unwrap_or(""));
909 if version <= max_version {
910 continue;
911 }
912 let sql = std::fs::read_to_string(&path)
913 .map_err(|e| RoboticusError::Database(format!("read migration {:?}: {e}", path)))?;
914 let tx = conn.unchecked_transaction().map_err(|e| {
915 RoboticusError::Database(format!("begin tx for migration {version}: {e}"))
916 })?;
917 if version == 13 {
918 apply_migration_13_idempotent(&tx)
919 .map_err(|e| RoboticusError::Database(format!("migration {version}: {e}")))?;
920 } else if version == 22 {
921 apply_migration_22_idempotent(&tx)
922 .map_err(|e| RoboticusError::Database(format!("migration {version}: {e}")))?;
923 } else {
924 tx.execute_batch(sql.trim())
925 .map_err(|e| RoboticusError::Database(format!("migration {version}: {e}")))?;
926 }
927 tx.execute(
928 "INSERT INTO schema_version (version) VALUES (?1)",
929 [version],
930 )
931 .db_err()?;
932 tx.commit()
933 .map_err(|e| RoboticusError::Database(format!("commit migration {version}: {e}")))?;
934 }
935
936 Ok(())
937}
938
939fn apply_migration_13_idempotent(conn: &rusqlite::Transaction<'_>) -> Result<()> {
940 if !has_column(conn, "model_selection_events", "schema_version")? {
942 conn.execute(
943 "ALTER TABLE model_selection_events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1",
944 [],
945 )
946 .db_err()?;
947 }
948 if !has_column(conn, "model_selection_events", "attribution")? {
949 conn.execute(
950 "ALTER TABLE model_selection_events ADD COLUMN attribution TEXT",
951 [],
952 )
953 .db_err()?;
954 }
955 if !has_column(conn, "model_selection_events", "metascore_json")? {
956 conn.execute(
957 "ALTER TABLE model_selection_events ADD COLUMN metascore_json TEXT",
958 [],
959 )
960 .db_err()?;
961 }
962 if !has_column(conn, "model_selection_events", "features_json")? {
963 conn.execute(
964 "ALTER TABLE model_selection_events ADD COLUMN features_json TEXT",
965 [],
966 )
967 .db_err()?;
968 }
969
970 if !has_column(conn, "inference_costs", "turn_id")? {
972 conn.execute("ALTER TABLE inference_costs ADD COLUMN turn_id TEXT", [])
973 .db_err()?;
974 }
975 if has_column(conn, "inference_costs", "turn_id")? {
976 conn.execute(
977 "CREATE INDEX IF NOT EXISTS idx_inference_costs_turn ON inference_costs(turn_id)",
978 [],
979 )
980 .db_err()?;
981 }
982
983 conn.execute_batch(
985 r#"
986CREATE TABLE IF NOT EXISTS shadow_routing_predictions (
987 id TEXT PRIMARY KEY,
988 turn_id TEXT NOT NULL,
989 production_model TEXT NOT NULL,
990 shadow_model TEXT,
991 production_complexity REAL,
992 shadow_complexity REAL,
993 agreed INTEGER NOT NULL DEFAULT 0,
994 detail_json TEXT,
995 created_at TEXT NOT NULL DEFAULT (datetime('now'))
996);
997CREATE INDEX IF NOT EXISTS idx_shadow_routing_created ON shadow_routing_predictions(created_at DESC);
998CREATE INDEX IF NOT EXISTS idx_shadow_routing_turn ON shadow_routing_predictions(turn_id);
999"#,
1000 )
1001 .db_err()?;
1002
1003 Ok(())
1004}
1005
1006fn apply_migration_22_idempotent(conn: &rusqlite::Transaction<'_>) -> Result<()> {
1012 if !has_column(conn, "skills", "version")? {
1013 conn.execute(
1014 "ALTER TABLE skills ADD COLUMN version TEXT NOT NULL DEFAULT '0.0.0'",
1015 [],
1016 )
1017 .db_err()?;
1018 }
1019 if !has_column(conn, "skills", "author")? {
1020 conn.execute(
1021 "ALTER TABLE skills ADD COLUMN author TEXT NOT NULL DEFAULT 'local'",
1022 [],
1023 )
1024 .db_err()?;
1025 }
1026 if !has_column(conn, "skills", "registry_source")? {
1027 conn.execute(
1028 "ALTER TABLE skills ADD COLUMN registry_source TEXT NOT NULL DEFAULT 'local'",
1029 [],
1030 )
1031 .db_err()?;
1032 }
1033 Ok(())
1034}
1035
1036fn version_from_name(name: &str) -> i64 {
1038 name.find('_')
1039 .and_then(|i| name.get(..i))
1040 .and_then(|s| s.parse::<i64>().ok())
1041 .unwrap_or(0)
1042}
1043
1044#[cfg(test)]
1045pub(crate) fn table_count(db: &Database) -> Result<usize> {
1046 let conn = db.conn();
1047 let count: usize = conn
1048 .query_row(
1049 "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_data' AND name NOT LIKE '%_idx' AND name NOT LIKE '%_content' AND name NOT LIKE '%_docsize' AND name NOT LIKE '%_config'",
1050 [],
1051 |row| row.get(0),
1052 )
1053 .db_err()?;
1054 Ok(count)
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn schema_creates_all_tables() {
1063 let db = Database::new(":memory:").unwrap();
1064 let count = table_count(&db).unwrap();
1065 assert_eq!(count, 40, "expected 40 user-defined tables, got {count}");
1071 }
1072
1073 #[test]
1074 fn schema_idempotent() {
1075 let db = Database::new(":memory:").unwrap();
1076 initialize_db(&db).unwrap();
1077 initialize_db(&db).unwrap();
1078 let count = table_count(&db).unwrap();
1079 assert_eq!(count, 40);
1080 }
1081
1082 #[test]
1083 fn schema_version_inserted() {
1084 let db = Database::new(":memory:").unwrap();
1085 let conn = db.conn();
1086 let version: i64 = conn
1087 .query_row(
1088 "SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1",
1089 [],
1090 |row| row.get(0),
1091 )
1092 .unwrap();
1093 assert!(
1094 version >= EMBEDDED_SCHEMA_VERSION,
1095 "embedded schema seeds at version {EMBEDDED_SCHEMA_VERSION}"
1096 );
1097 }
1098
1099 #[test]
1100 fn wal_mode_enabled() {
1101 let db = Database::new(":memory:").unwrap();
1102 let conn = db.conn();
1103 let mode: String = conn
1104 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1105 .unwrap();
1106 assert!(mode == "wal" || mode == "memory");
1108 }
1109
1110 #[test]
1111 fn version_from_name_parses_correctly() {
1112 assert_eq!(super::version_from_name("001_initial.sql"), 1);
1113 assert_eq!(super::version_from_name("002_add_indexes.sql"), 2);
1114 assert_eq!(super::version_from_name("010_foo.sql"), 10);
1115 assert_eq!(super::version_from_name("no_underscore.sql"), 0);
1116 }
1117
1118 #[test]
1119 fn run_migrations_applies_in_order() {
1120 let db = Database::new(":memory:").unwrap();
1121 let conn = db.conn();
1122 let versions: Vec<i64> = conn
1123 .prepare("SELECT version FROM schema_version ORDER BY version")
1124 .unwrap()
1125 .query_map([], |row| row.get(0))
1126 .unwrap()
1127 .collect::<std::result::Result<Vec<_>, _>>()
1128 .unwrap();
1129 assert!(
1130 !versions.is_empty(),
1131 "schema_version should have at least one entry"
1132 );
1133 assert!(
1134 versions[0] >= EMBEDDED_SCHEMA_VERSION,
1135 "embedded schema seeds at version {EMBEDDED_SCHEMA_VERSION}"
1136 );
1137 for w in versions.windows(2) {
1138 assert!(w[1] > w[0], "versions must be strictly increasing");
1139 }
1140 }
1141
1142 #[test]
1143 fn version_from_name_edge_cases() {
1144 assert_eq!(super::version_from_name(""), 0);
1145 assert_eq!(super::version_from_name("_no_number.sql"), 0);
1146 assert_eq!(super::version_from_name("abc_nonnumeric.sql"), 0);
1147 assert_eq!(super::version_from_name("999_big.sql"), 999);
1148 }
1149
1150 #[test]
1151 fn initialize_db_creates_version_row() {
1152 let db = Database::new(":memory:").unwrap();
1153 let conn = db.conn();
1154 let count: i64 = conn
1155 .query_row(
1156 "SELECT COUNT(*) FROM schema_version WHERE version >= ?1",
1157 [EMBEDDED_SCHEMA_VERSION],
1158 |row| row.get(0),
1159 )
1160 .unwrap();
1161 assert!(
1162 count >= 1,
1163 "embedded schema should seed at least version {EMBEDDED_SCHEMA_VERSION}"
1164 );
1165 }
1166
1167 #[test]
1168 fn run_migrations_no_dir_is_noop() {
1169 let db = Database::new(":memory:").unwrap();
1170 run_migrations(&db).unwrap();
1171 }
1172
1173 #[test]
1174 fn migrations_dir_returns_option() {
1175 let result = migrations_dir();
1176 if let Some(path) = result {
1178 assert!(path.is_dir());
1179 }
1180 }
1181
1182 #[test]
1183 fn fts_table_exists() {
1184 let db = Database::new(":memory:").unwrap();
1185 let conn = db.conn();
1186 let exists: bool = conn
1187 .prepare(
1188 "SELECT COUNT(*) FROM sqlite_master WHERE name = 'memory_fts' AND type = 'table'",
1189 )
1190 .unwrap()
1191 .query_row([], |row| {
1192 let count: i64 = row.get(0)?;
1193 Ok(count > 0)
1194 })
1195 .unwrap();
1196 assert!(exists, "memory_fts FTS5 table should exist");
1197 }
1198
1199 #[test]
1200 fn has_column_returns_true_for_existing() {
1201 let db = Database::new(":memory:").unwrap();
1202 let conn = db.conn();
1203 assert!(has_column(&conn, "sessions", "id").unwrap());
1204 assert!(has_column(&conn, "sessions", "agent_id").unwrap());
1205 assert!(has_column(&conn, "sessions", "status").unwrap());
1206 }
1207
1208 #[test]
1209 fn has_column_returns_false_for_missing() {
1210 let db = Database::new(":memory:").unwrap();
1211 let conn = db.conn();
1212 assert!(!has_column(&conn, "sessions", "nonexistent_col").unwrap());
1213 }
1214
1215 #[test]
1216 fn has_column_returns_false_for_nonexistent_table() {
1217 let db = Database::new(":memory:").unwrap();
1219 let conn = db.conn();
1220 assert!(!has_column(&conn, "no_such_table", "id").unwrap());
1221 }
1222
1223 #[test]
1224 fn has_column_with_quotes_in_table_name() {
1225 let db = Database::new(":memory:").unwrap();
1227 let conn = db.conn();
1228 assert!(!has_column(&conn, "tab\"le", "id").unwrap());
1230 }
1231
1232 #[test]
1233 fn ensure_optional_columns_idempotent() {
1234 let db = Database::new(":memory:").unwrap();
1235 ensure_optional_columns(&db).unwrap();
1237
1238 let conn = db.conn();
1240 assert!(has_column(&conn, "skills", "risk_level").unwrap());
1241 assert!(has_column(&conn, "tool_calls", "skill_id").unwrap());
1242 assert!(has_column(&conn, "tool_calls", "skill_name").unwrap());
1243 assert!(has_column(&conn, "tool_calls", "skill_hash").unwrap());
1244 assert!(has_column(&conn, "delivery_queue", "idempotency_key").unwrap());
1245 assert!(has_column(&conn, "skills", "version").unwrap());
1247 assert!(has_column(&conn, "skills", "author").unwrap());
1248 assert!(has_column(&conn, "skills", "registry_source").unwrap());
1249 }
1250
1251 #[test]
1252 fn table_count_is_consistent() {
1253 let db = Database::new(":memory:").unwrap();
1254 let c1 = table_count(&db).unwrap();
1255 let c2 = table_count(&db).unwrap();
1256 assert_eq!(c1, c2, "table_count should be deterministic");
1257 }
1258
1259 #[test]
1260 fn schema_indexes_created() {
1261 let db = Database::new(":memory:").unwrap();
1262 let conn = db.conn();
1263 let idx_count: i64 = conn
1264 .query_row(
1265 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'",
1266 [],
1267 |row| row.get(0),
1268 )
1269 .unwrap();
1270 assert!(
1271 idx_count >= 10,
1272 "expected at least 10 custom indexes, got {idx_count}"
1273 );
1274 }
1275
1276 #[test]
1277 fn schema_triggers_created() {
1278 let db = Database::new(":memory:").unwrap();
1279 let conn = db.conn();
1280 let trigger_count: i64 = conn
1281 .query_row(
1282 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger'",
1283 [],
1284 |row| row.get(0),
1285 )
1286 .unwrap();
1287 assert!(
1288 trigger_count >= 2,
1289 "expected at least 2 triggers (episodic_ai, episodic_ad), got {trigger_count}"
1290 );
1291 }
1292
1293 #[test]
1294 fn episodic_trigger_populates_fts() {
1295 let db = Database::new(":memory:").unwrap();
1296 let conn = db.conn();
1297 conn.execute(
1298 "INSERT INTO episodic_memory (id, classification, content, importance) VALUES ('e1', 'fact', 'Paris is the capital of France', 5)",
1299 [],
1300 )
1301 .unwrap();
1302
1303 let fts_count: i64 = conn
1304 .query_row(
1305 "SELECT COUNT(*) FROM memory_fts WHERE source_table = 'episodic' AND source_id = 'e1'",
1306 [],
1307 |row| row.get(0),
1308 )
1309 .unwrap();
1310 assert_eq!(
1311 fts_count, 1,
1312 "FTS insert trigger should fire on episodic insert"
1313 );
1314 }
1315
1316 #[test]
1317 fn episodic_delete_trigger_removes_fts() {
1318 let db = Database::new(":memory:").unwrap();
1319 let conn = db.conn();
1320 conn.execute(
1321 "INSERT INTO episodic_memory (id, classification, content, importance) VALUES ('e2', 'fact', 'test content', 5)",
1322 [],
1323 )
1324 .unwrap();
1325 conn.execute("DELETE FROM episodic_memory WHERE id = 'e2'", [])
1326 .unwrap();
1327
1328 let fts_count: i64 = conn
1329 .query_row(
1330 "SELECT COUNT(*) FROM memory_fts WHERE source_table = 'episodic' AND source_id = 'e2'",
1331 [],
1332 |row| row.get(0),
1333 )
1334 .unwrap();
1335 assert_eq!(
1336 fts_count, 0,
1337 "FTS delete trigger should fire on episodic delete"
1338 );
1339 }
1340
1341 #[test]
1342 fn fts_search_returns_results() {
1343 let db = Database::new(":memory:").unwrap();
1344 let conn = db.conn();
1345 conn.execute(
1346 "INSERT INTO episodic_memory (id, classification, content) VALUES ('e3', 'fact', 'Rust is a systems programming language')",
1347 [],
1348 )
1349 .unwrap();
1350
1351 let found: i64 = conn
1352 .query_row(
1353 "SELECT COUNT(*) FROM memory_fts WHERE memory_fts MATCH 'Rust'",
1354 [],
1355 |row| row.get(0),
1356 )
1357 .unwrap();
1358 assert_eq!(found, 1);
1359 }
1360
1361 #[test]
1362 fn foreign_keys_enabled() {
1363 let db = Database::new(":memory:").unwrap();
1364 let conn = db.conn();
1365 let fk: i64 = conn
1366 .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
1367 .unwrap();
1368 assert_eq!(fk, 1, "foreign_keys pragma should be ON");
1369 }
1370
1371 #[test]
1372 fn version_from_name_leading_zeros() {
1373 assert_eq!(version_from_name("0001_migration.sql"), 1);
1374 assert_eq!(version_from_name("0100_big.sql"), 100);
1375 }
1376
1377 #[test]
1378 fn schema_version_no_duplicates_on_reinit() {
1379 let db = Database::new(":memory:").unwrap();
1380 initialize_db(&db).unwrap();
1382 let conn = db.conn();
1383 let count: i64 = conn
1384 .query_row(
1385 "SELECT COUNT(*) FROM schema_version WHERE version = ?1",
1386 [EMBEDDED_SCHEMA_VERSION],
1387 |row| row.get(0),
1388 )
1389 .unwrap();
1390 assert_eq!(
1392 count, 1,
1393 "reinitialize should not duplicate the seed version row"
1394 );
1395 }
1396
1397 #[test]
1402 fn ensure_optional_columns_adds_risk_level_when_missing() {
1403 let conn = rusqlite::Connection::open_in_memory().unwrap();
1405 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
1406 conn.execute_batch(SCHEMA_SQL).unwrap();
1407 conn.execute(
1408 "INSERT INTO schema_version (version) VALUES (?1)",
1409 [EMBEDDED_SCHEMA_VERSION],
1410 )
1411 .unwrap();
1412
1413 let db = Database::new(":memory:").unwrap();
1418 ensure_optional_columns(&db).unwrap();
1419 ensure_optional_columns(&db).unwrap();
1420
1421 let conn = db.conn();
1422 assert!(has_column(&conn, "skills", "risk_level").unwrap());
1423 assert!(has_column(&conn, "tool_calls", "skill_id").unwrap());
1424 assert!(has_column(&conn, "tool_calls", "skill_name").unwrap());
1425 assert!(has_column(&conn, "tool_calls", "skill_hash").unwrap());
1426 assert!(has_column(&conn, "delivery_queue", "idempotency_key").unwrap());
1427 assert!(has_column(&conn, "skills", "version").unwrap());
1428 assert!(has_column(&conn, "skills", "author").unwrap());
1429 assert!(has_column(&conn, "skills", "registry_source").unwrap());
1430 }
1431
1432 #[test]
1433 fn run_migrations_multiple_times_is_idempotent() {
1434 let db = Database::new(":memory:").unwrap();
1435 run_migrations(&db).unwrap();
1436 run_migrations(&db).unwrap();
1437
1438 let conn = db.conn();
1439 let max_version: i64 = conn
1440 .query_row(
1441 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
1442 [],
1443 |row| row.get(0),
1444 )
1445 .unwrap();
1446 assert!(max_version >= EMBEDDED_SCHEMA_VERSION);
1447 }
1448
1449 #[test]
1450 fn embedded_schema_does_not_fail_when_inference_costs_lacks_turn_id() {
1451 let conn = rusqlite::Connection::open_in_memory().unwrap();
1453 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
1454 conn.execute_batch(
1455 r#"
1456CREATE TABLE IF NOT EXISTS schema_version (
1457 version INTEGER NOT NULL,
1458 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1459);
1460INSERT INTO schema_version(version) VALUES (12);
1461CREATE TABLE IF NOT EXISTS inference_costs (
1462 id TEXT PRIMARY KEY,
1463 model TEXT NOT NULL,
1464 provider TEXT NOT NULL,
1465 tokens_in INTEGER NOT NULL,
1466 tokens_out INTEGER NOT NULL,
1467 cost REAL NOT NULL,
1468 tier TEXT,
1469 cached INTEGER NOT NULL DEFAULT 0,
1470 latency_ms INTEGER,
1471 quality_score REAL,
1472 escalation INTEGER NOT NULL DEFAULT 0,
1473 created_at TEXT NOT NULL DEFAULT (datetime('now'))
1474);
1475CREATE INDEX IF NOT EXISTS idx_inference_costs_time ON inference_costs(created_at DESC);
1476"#,
1477 )
1478 .unwrap();
1479
1480 conn.execute_batch(SCHEMA_SQL).unwrap();
1482 }
1483
1484 #[test]
1485 fn migration_13_is_idempotent_when_columns_already_exist() {
1486 let conn = rusqlite::Connection::open_in_memory().unwrap();
1487 conn.execute_batch(SCHEMA_SQL).unwrap();
1488 let tx = conn.unchecked_transaction().unwrap();
1489
1490 apply_migration_13_idempotent(&tx).unwrap();
1492 apply_migration_13_idempotent(&tx).unwrap();
1493
1494 let idx_count: i64 = tx
1496 .query_row(
1497 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_inference_costs_turn'",
1498 [],
1499 |row| row.get(0),
1500 )
1501 .unwrap();
1502 assert_eq!(idx_count, 1);
1503 }
1504
1505 #[test]
1506 fn version_from_name_no_underscore_returns_zero() {
1507 assert_eq!(version_from_name("noseparator"), 0);
1508 assert_eq!(version_from_name("noseparator.sql"), 0);
1509 }
1510
1511 #[test]
1512 fn version_from_name_various_formats() {
1513 assert_eq!(version_from_name("42_answer.sql"), 42);
1514 assert_eq!(version_from_name("0_zero.sql"), 0);
1515 assert_eq!(version_from_name("9999_huge.sql"), 9999);
1516 }
1517
1518 #[test]
1519 fn initialize_db_then_query_all_tables() {
1520 let db = Database::new(":memory:").unwrap();
1521
1522 let conn = db.conn();
1524
1525 conn.execute(
1527 "INSERT INTO sessions (id, agent_id, scope_key) VALUES ('s1', 'a1', 'agent')",
1528 [],
1529 )
1530 .unwrap();
1531 let count: i64 = conn
1532 .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
1533 .unwrap();
1534 assert_eq!(count, 1);
1535
1536 conn.execute(
1538 "INSERT INTO working_memory (id, session_id, entry_type, content) VALUES ('w1', 's1', 'note', 'test')",
1539 [],
1540 ).unwrap();
1541
1542 conn.execute(
1544 "INSERT INTO episodic_memory (id, classification, content) VALUES ('e1', 'event', 'something happened')",
1545 [],
1546 ).unwrap();
1547 let fts_count: i64 = conn
1548 .query_row(
1549 "SELECT COUNT(*) FROM memory_fts WHERE source_table = 'episodic'",
1550 [],
1551 |r| r.get(0),
1552 )
1553 .unwrap();
1554 assert_eq!(fts_count, 1);
1555 }
1556}