1use crate::{Database, DbResultExt};
2use roboticus_core::{RoboticusError, Result};
3use rusqlite::OptionalExtension;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SchemaEntry {
9 pub table_name: String,
10 pub description: String,
11 pub columns: Vec<ColumnDef>,
12 pub created_by: String,
13 pub agent_owned: bool,
14 pub created_at: String,
15 pub updated_at: String,
16 pub access_level: String,
17 pub row_count: i64,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ColumnDef {
23 pub name: String,
24 pub col_type: String,
25 pub nullable: bool,
26 pub description: Option<String>,
27}
28
29#[allow(clippy::too_many_arguments)]
31pub fn register_table(
32 db: &Database,
33 table_name: &str,
34 description: &str,
35 columns: &[ColumnDef],
36 created_by: &str,
37 agent_owned: bool,
38 access_level: &str,
39 row_count: i64,
40) -> Result<()> {
41 let conn = db.conn();
42 let columns_json = serde_json::to_string(columns).db_err()?;
43
44 conn.execute(
45 "INSERT OR REPLACE INTO hippocampus \
46 (table_name, description, columns_json, created_by, agent_owned, access_level, row_count, updated_at) \
47 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))",
48 rusqlite::params![
49 table_name,
50 description,
51 columns_json,
52 created_by,
53 agent_owned as i32,
54 access_level,
55 row_count
56 ],
57 )
58 .db_err()?;
59
60 Ok(())
61}
62
63const SELECT_COLS: &str = "table_name, description, columns_json, created_by, agent_owned, \
64 created_at, updated_at, access_level, row_count";
65
66fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<SchemaEntry> {
67 let columns_json: String = row.get(2)?;
68 let columns: Vec<ColumnDef> = serde_json::from_str(&columns_json).unwrap_or_else(|e| {
69 tracing::warn!(error = %e, "failed to deserialize column definitions, using empty list");
70 Vec::new()
71 });
72 Ok(SchemaEntry {
73 table_name: row.get(0)?,
74 description: row.get(1)?,
75 columns,
76 created_by: row.get(3)?,
77 agent_owned: row.get::<_, i32>(4)? != 0,
78 created_at: row.get(5)?,
79 updated_at: row.get(6)?,
80 access_level: row
81 .get::<_, Option<String>>(7)?
82 .unwrap_or_else(|| "internal".into()),
83 row_count: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
84 })
85}
86
87pub fn get_table(db: &Database, table_name: &str) -> Result<Option<SchemaEntry>> {
89 let conn = db.conn();
90 conn.query_row(
91 &format!("SELECT {SELECT_COLS} FROM hippocampus WHERE table_name = ?1"),
92 [table_name],
93 row_to_entry,
94 )
95 .optional()
96 .db_err()
97}
98
99pub fn list_tables(db: &Database) -> Result<Vec<SchemaEntry>> {
101 let conn = db.conn();
102 let mut stmt = conn
103 .prepare(&format!(
104 "SELECT {SELECT_COLS} FROM hippocampus ORDER BY table_name"
105 ))
106 .db_err()?;
107
108 let rows = stmt.query_map([], row_to_entry).db_err()?;
109
110 rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
111}
112
113pub fn list_agent_tables(db: &Database, agent_id: &str) -> Result<Vec<SchemaEntry>> {
115 let conn = db.conn();
116 let mut stmt = conn
117 .prepare(&format!(
118 "SELECT {SELECT_COLS} FROM hippocampus WHERE agent_owned = 1 AND created_by = ?1 ORDER BY table_name"
119 ))
120 .db_err()?;
121
122 let rows = stmt.query_map([agent_id], row_to_entry).db_err()?;
123
124 rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
125}
126
127fn validate_identifier(s: &str) -> Result<()> {
128 if s.is_empty()
129 || s.chars().next().is_some_and(|c| c.is_ascii_digit())
130 || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
131 {
132 return Err(RoboticusError::Database(format!(
133 "invalid SQL identifier: {s}"
134 )));
135 }
136 Ok(())
137}
138
139pub fn create_agent_table(
142 db: &Database,
143 agent_id: &str,
144 table_suffix: &str,
145 description: &str,
146 columns: &[ColumnDef],
147) -> Result<String> {
148 let table_name = format!("{agent_id}_{table_suffix}");
149
150 if !table_name
151 .chars()
152 .all(|c| c.is_ascii_alphanumeric() || c == '_')
153 {
154 return Err(RoboticusError::Database(
155 "table name contains invalid characters".into(),
156 ));
157 }
158
159 for col in columns {
160 validate_identifier(&col.name)?;
161 validate_identifier(&col.col_type)?;
162 }
163
164 let col_defs: Vec<String> = columns
165 .iter()
166 .map(|c| {
167 let null = if c.nullable { "" } else { " NOT NULL" };
168 format!("{} {}{}", c.name, c.col_type, null)
169 })
170 .collect();
171
172 let middle = if col_defs.is_empty() {
173 String::new()
174 } else {
175 format!(", {}", col_defs.join(", "))
176 };
177
178 let create_sql = format!(
179 "CREATE TABLE IF NOT EXISTS \"{}\" (id TEXT PRIMARY KEY{}, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
180 table_name, middle
181 );
182
183 {
184 let conn = db.conn();
185 conn.execute(&create_sql, []).db_err()?;
186 }
187
188 register_table(
189 db,
190 &table_name,
191 description,
192 columns,
193 agent_id,
194 true,
195 "readwrite",
196 0,
197 )?;
198
199 Ok(table_name)
200}
201
202pub fn drop_agent_table(db: &Database, agent_id: &str, table_name: &str) -> Result<()> {
205 validate_identifier(table_name)?;
206
207 let conn = db.conn();
208 let tx = conn.unchecked_transaction().db_err()?;
209
210 let owned: bool = tx
212 .query_row(
213 "SELECT agent_owned AND created_by = ?2 FROM hippocampus WHERE table_name = ?1",
214 rusqlite::params![table_name, agent_id],
215 |row| row.get(0),
216 )
217 .map_err(|e| match e {
218 rusqlite::Error::QueryReturnedNoRows => {
219 RoboticusError::Database(format!("table {table_name} not found in hippocampus"))
220 }
221 other => RoboticusError::Database(other.to_string()),
222 })?;
223
224 if !owned {
225 return Err(RoboticusError::Database(
226 "cannot drop: table not owned by this agent".into(),
227 ));
228 }
229
230 tx.execute(&format!("DROP TABLE IF EXISTS \"{}\"", table_name), [])
231 .db_err()?;
232 tx.execute(
233 "DELETE FROM hippocampus WHERE table_name = ?1",
234 [table_name],
235 )
236 .db_err()?;
237
238 tx.commit().db_err()?;
239
240 Ok(())
241}
242
243pub fn schema_summary(db: &Database) -> Result<String> {
245 let tables = list_tables(db)?;
246 if tables.is_empty() {
247 return Ok("No tables registered in hippocampus.".into());
248 }
249
250 let mut summary = String::from("## Database Schema Map\n\n");
251 for entry in &tables {
252 let owner = if entry.agent_owned {
253 format!(" (owned by: {})", entry.created_by)
254 } else {
255 " (system)".to_string()
256 };
257 summary.push_str(&format!(
258 "### {}{} [{}, {} rows]\n",
259 entry.table_name, owner, entry.access_level, entry.row_count
260 ));
261 summary.push_str(&format!("{}\n", entry.description));
262 for col in &entry.columns {
263 let null_str = if col.nullable { ", nullable" } else { "" };
264 let desc = col.description.as_deref().unwrap_or("");
265 summary.push_str(&format!(
266 "- `{}` ({}{}){}\n",
267 col.name,
268 col.col_type,
269 null_str,
270 if desc.is_empty() {
271 String::new()
272 } else {
273 format!(" — {desc}")
274 }
275 ));
276 }
277 summary.push('\n');
278 }
279 Ok(summary)
280}
281
282pub fn compact_summary(db: &Database) -> Result<String> {
287 let tables = list_tables(db)?;
288 if tables.is_empty() {
289 return Ok(String::new());
290 }
291
292 let mut system_names = Vec::new();
293 let mut agent_lines = Vec::new();
294
295 for entry in &tables {
296 if entry.agent_owned {
297 agent_lines.push(format!(
298 "- {} ({} rows) — {}",
299 entry.table_name, entry.row_count, entry.description
300 ));
301 } else {
302 system_names.push(entry.table_name.as_str());
303 }
304 }
305
306 let mut summary = String::from("[Database]\n");
307
308 if !agent_lines.is_empty() {
309 summary.push_str("Your tables:\n");
310 for line in &agent_lines {
311 summary.push_str(line);
312 summary.push('\n');
313 }
314 }
315
316 if !system_names.is_empty() {
317 summary.push_str(&format!(
318 "System tables ({}): {}\n",
319 system_names.len(),
320 system_names.join(", ")
321 ));
322 }
323
324 summary.push_str("Use create_table/alter_table/drop_table tools to manage your tables. ");
325 summary.push_str("Use get_runtime_context for full schema details.");
326
327 if summary.len() > 1000 {
329 summary.truncate(1000);
330 if let Some(last_nl) = summary.rfind('\n') {
331 summary.truncate(last_nl);
332 }
333 summary.push_str("\n...(use introspection tools for details)\n");
334 }
335
336 Ok(summary)
337}
338
339fn system_table_metadata(table_name: &str) -> (&'static str, &'static str) {
341 match table_name {
342 "schema_version" => ("Schema migration version tracking", "internal"),
343 "sessions" => ("User conversation sessions", "read"),
344 "session_messages" => ("Messages within sessions", "read"),
345 "turns" => ("Conversation turn tracking", "internal"),
346 "tool_calls" => ("Tool invocation log", "read"),
347 "policy_decisions" => ("Policy evaluation results", "internal"),
348 "working_memory" => ("Session-scoped working memory", "read"),
349 "episodic_memory" => ("Long-term event memory", "read"),
350 "semantic_memory" => ("Factual knowledge store", "read"),
351 "procedural_memory" => ("Learned procedure memory", "read"),
352 "relationship_memory" => ("Entity relationship memory", "read"),
353 "tasks" => ("Task queue for agent work items", "read"),
354 "cron_jobs" => ("Scheduled cron jobs", "read"),
355 "cron_runs" => ("Cron job execution history", "read"),
356 "transactions" => ("Wallet transaction log", "internal"),
357 "inference_costs" => ("LLM inference cost tracking", "internal"),
358 "semantic_cache" => ("Semantic response cache", "internal"),
359 "identity" => ("Agent identity and credentials", "internal"),
360 "os_personality_history" => ("OS personality evolution log", "internal"),
361 "metric_snapshots" => ("System metric snapshots", "internal"),
362 "discovered_agents" => ("Discovered peer agents", "read"),
363 "skills" => ("Registered agent skills", "read"),
364 "delivery_queue" => ("Durable message delivery queue", "internal"),
365 "approval_requests" => ("Pending human approval requests", "read"),
366 "plugins" => ("Installed plugins", "read"),
367 "embeddings" => ("Vector embeddings store", "internal"),
368 "sub_agents" => ("Spawned sub-agent registry", "read"),
369 "context_checkpoints" => ("Context checkpoint snapshots", "internal"),
370 "hippocampus" => ("Schema map (this table)", "internal"),
371 "learned_skills" => ("Skills synthesized from successful tool sequences", "read"),
372 _ => ("Agent-managed table", "readwrite"),
373 }
374}
375
376fn introspect_columns(
378 conn: &rusqlite::Connection,
379 table_name: &str,
380) -> std::result::Result<Vec<ColumnDef>, rusqlite::Error> {
381 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table_name))?;
382 let cols = stmt.query_map([], |row| {
383 let name: String = row.get(1)?;
384 let col_type: String = row.get(2)?;
385 let notnull: i32 = row.get(3)?;
386 Ok(ColumnDef {
387 name,
388 col_type,
389 nullable: notnull == 0,
390 description: None,
391 })
392 })?;
393 cols.collect()
394}
395
396pub fn bootstrap_hippocampus(db: &Database) -> Result<()> {
400 let table_data: Vec<(String, Vec<ColumnDef>, i64)> = {
402 let conn = db.conn();
403 let mut stmt = conn
404 .prepare(
405 "SELECT name FROM sqlite_master \
406 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
407 ORDER BY name",
408 )
409 .db_err()?;
410
411 let table_names: Vec<String> = stmt
412 .query_map([], |row| row.get(0))
413 .db_err()?
414 .collect::<std::result::Result<Vec<_>, _>>()
415 .db_err()?;
416
417 let mut data = Vec::with_capacity(table_names.len());
418 for name in table_names {
419 let columns = introspect_columns(&conn, &name).db_err()?;
420
421 let row_count: i64 = conn
422 .query_row(&format!("SELECT COUNT(*) FROM \"{}\"", name), [], |row| {
423 row.get(0)
424 })
425 .unwrap_or(0);
426
427 data.push((name, columns, row_count));
428 }
429 data
430 };
431
432 for (name, columns, row_count) in &table_data {
434 let (description, access_level) = system_table_metadata(name);
435
436 if let Some(existing) = get_table(db, name)?
438 && existing.agent_owned
439 {
440 register_table(
442 db,
443 name,
444 &existing.description,
445 columns,
446 &existing.created_by,
447 true,
448 &existing.access_level,
449 *row_count,
450 )?;
451 continue;
452 }
453
454 register_table(
455 db,
456 name,
457 description,
458 columns,
459 "system",
460 false,
461 access_level,
462 *row_count,
463 )?;
464 }
465
466 let registered = list_tables(db)?;
468 let existing_names: std::collections::HashSet<&str> =
469 table_data.iter().map(|(n, _, _)| n.as_str()).collect();
470
471 for entry in ®istered {
472 if !existing_names.contains(entry.table_name.as_str()) {
473 tracing::warn!(
474 table = %entry.table_name,
475 "hippocampus entry for missing table, removing"
476 );
477 let conn = db.conn();
478 conn.execute(
479 "DELETE FROM hippocampus WHERE table_name = ?1",
480 [&entry.table_name],
481 )
482 .db_err()?;
483 }
484 }
485
486 tracing::info!(
487 tables = table_data.len(),
488 "hippocampus bootstrapped with schema map"
489 );
490 Ok(())
491}
492
493pub fn seed_system_tables(db: &Database) -> Result<()> {
495 let system_tables = vec![
496 (
497 "sessions",
498 "User conversation sessions",
499 vec![
500 ColumnDef {
501 name: "id".into(),
502 col_type: "TEXT".into(),
503 nullable: false,
504 description: Some("Primary key".into()),
505 },
506 ColumnDef {
507 name: "agent_id".into(),
508 col_type: "TEXT".into(),
509 nullable: false,
510 description: Some("Owning agent".into()),
511 },
512 ColumnDef {
513 name: "scope_key".into(),
514 col_type: "TEXT".into(),
515 nullable: true,
516 description: Some("Session scope identifier".into()),
517 },
518 ColumnDef {
519 name: "status".into(),
520 col_type: "TEXT".into(),
521 nullable: false,
522 description: Some("active/archived/expired".into()),
523 },
524 ],
525 ),
526 (
527 "episodic_memory",
528 "Long-term event memory",
529 vec![
530 ColumnDef {
531 name: "id".into(),
532 col_type: "TEXT".into(),
533 nullable: false,
534 description: Some("Primary key".into()),
535 },
536 ColumnDef {
537 name: "classification".into(),
538 col_type: "TEXT".into(),
539 nullable: false,
540 description: Some("Memory category".into()),
541 },
542 ColumnDef {
543 name: "content".into(),
544 col_type: "TEXT".into(),
545 nullable: false,
546 description: Some("Memory content".into()),
547 },
548 ColumnDef {
549 name: "importance".into(),
550 col_type: "INTEGER".into(),
551 nullable: false,
552 description: Some("1-10 importance score".into()),
553 },
554 ],
555 ),
556 (
557 "semantic_memory",
558 "Factual knowledge store",
559 vec![
560 ColumnDef {
561 name: "id".into(),
562 col_type: "TEXT".into(),
563 nullable: false,
564 description: Some("Primary key".into()),
565 },
566 ColumnDef {
567 name: "category".into(),
568 col_type: "TEXT".into(),
569 nullable: false,
570 description: Some("Knowledge category".into()),
571 },
572 ColumnDef {
573 name: "key".into(),
574 col_type: "TEXT".into(),
575 nullable: false,
576 description: Some("Fact key".into()),
577 },
578 ColumnDef {
579 name: "value".into(),
580 col_type: "TEXT".into(),
581 nullable: false,
582 description: Some("Fact value".into()),
583 },
584 ],
585 ),
586 (
587 "working_memory",
588 "Session-scoped working memory",
589 vec![
590 ColumnDef {
591 name: "id".into(),
592 col_type: "TEXT".into(),
593 nullable: false,
594 description: Some("Primary key".into()),
595 },
596 ColumnDef {
597 name: "session_id".into(),
598 col_type: "TEXT".into(),
599 nullable: false,
600 description: Some("Associated session".into()),
601 },
602 ColumnDef {
603 name: "entry_type".into(),
604 col_type: "TEXT".into(),
605 nullable: false,
606 description: Some("Type of entry".into()),
607 },
608 ColumnDef {
609 name: "content".into(),
610 col_type: "TEXT".into(),
611 nullable: false,
612 description: Some("Entry content".into()),
613 },
614 ],
615 ),
616 ];
617
618 for (name, desc, cols) in system_tables {
619 register_table(db, name, desc, &cols, "system", false, "read", 0)?;
620 }
621
622 Ok(())
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn test_db() -> Database {
630 Database::new(":memory:").unwrap()
631 }
632
633 #[test]
634 fn register_and_get_table() {
635 let db = test_db();
636 let cols = vec![
637 ColumnDef {
638 name: "name".into(),
639 col_type: "TEXT".into(),
640 nullable: false,
641 description: Some("User name".into()),
642 },
643 ColumnDef {
644 name: "age".into(),
645 col_type: "INTEGER".into(),
646 nullable: true,
647 description: None,
648 },
649 ];
650 register_table(
651 &db,
652 "users",
653 "User records",
654 &cols,
655 "system",
656 false,
657 "read",
658 0,
659 )
660 .unwrap();
661
662 let entry = get_table(&db, "users").unwrap().unwrap();
663 assert_eq!(entry.table_name, "users");
664 assert_eq!(entry.description, "User records");
665 assert_eq!(entry.columns.len(), 2);
666 assert!(!entry.agent_owned);
667 assert_eq!(entry.access_level, "read");
668 assert_eq!(entry.row_count, 0);
669 }
670
671 #[test]
672 fn get_table_not_found() {
673 let db = test_db();
674 assert!(get_table(&db, "nonexistent").unwrap().is_none());
675 }
676
677 #[test]
678 fn list_tables_includes_bootstrap() {
679 let db = test_db();
680 let tables = list_tables(&db).unwrap();
682 assert!(
683 tables.len() >= 20,
684 "bootstrap should register system tables, got {}",
685 tables.len()
686 );
687 }
688
689 #[test]
690 fn list_tables_grows_with_registration() {
691 let db = test_db();
692 let before = list_tables(&db).unwrap().len();
693 register_table(
694 &db,
695 "custom_a",
696 "Table A",
697 &[],
698 "test",
699 false,
700 "internal",
701 0,
702 )
703 .unwrap();
704 register_table(
705 &db,
706 "custom_b",
707 "Table B",
708 &[],
709 "test",
710 false,
711 "internal",
712 0,
713 )
714 .unwrap();
715 let after = list_tables(&db).unwrap().len();
716 assert_eq!(after, before + 2);
717 }
718
719 #[test]
720 fn create_agent_table_success() {
721 let db = test_db();
722 let cols = vec![
723 ColumnDef {
724 name: "key".into(),
725 col_type: "TEXT".into(),
726 nullable: false,
727 description: None,
728 },
729 ColumnDef {
730 name: "value".into(),
731 col_type: "TEXT".into(),
732 nullable: true,
733 description: None,
734 },
735 ];
736 let table_name = create_agent_table(&db, "agent42", "notes", "Agent notes", &cols).unwrap();
737 assert_eq!(table_name, "agent42_notes");
738
739 let entry = get_table(&db, "agent42_notes").unwrap().unwrap();
740 assert!(entry.agent_owned);
741 assert_eq!(entry.created_by, "agent42");
742 assert_eq!(entry.access_level, "readwrite");
743 }
744
745 #[test]
746 fn create_agent_table_invalid_chars() {
747 let db = test_db();
748 let result = create_agent_table(&db, "agent", "bad;name", "test", &[]);
749 assert!(result.is_err());
750 }
751
752 #[test]
753 fn drop_agent_table_success() {
754 let db = test_db();
755 create_agent_table(&db, "agent1", "temp", "temp table", &[]).unwrap();
756 drop_agent_table(&db, "agent1", "agent1_temp").unwrap();
757 assert!(get_table(&db, "agent1_temp").unwrap().is_none());
758 }
759
760 #[test]
761 fn drop_agent_table_wrong_owner() {
762 let db = test_db();
763 create_agent_table(&db, "agent1", "data", "data", &[]).unwrap();
764 let result = drop_agent_table(&db, "agent2", "agent1_data");
765 assert!(result.is_err());
766 }
767
768 #[test]
769 fn drop_system_table_fails() {
770 let db = test_db();
771 register_table(&db, "sessions", "Sessions", &[], "system", false, "read", 0).unwrap();
772 let result = drop_agent_table(&db, "agent1", "sessions");
773 assert!(result.is_err());
774 }
775
776 #[test]
777 fn list_agent_tables_filters() {
778 let db = test_db();
779 register_table(&db, "sessions", "System", &[], "system", false, "read", 0).unwrap();
780 create_agent_table(&db, "agent1", "notes", "Notes", &[]).unwrap();
781 create_agent_table(&db, "agent2", "data", "Data", &[]).unwrap();
782
783 let agent1_tables = list_agent_tables(&db, "agent1").unwrap();
784 assert_eq!(agent1_tables.len(), 1);
785 assert_eq!(agent1_tables[0].table_name, "agent1_notes");
786 }
787
788 #[test]
789 fn schema_summary_after_bootstrap() {
790 let db = test_db();
791 let summary = schema_summary(&db).unwrap();
792 assert!(summary.contains("## Database Schema Map"));
794 assert!(summary.contains("sessions"));
795 }
796
797 #[test]
798 fn compact_summary_includes_system_and_agent_tables() {
799 let db = test_db();
800 create_agent_table(
802 &db,
803 "agent1",
804 "notes",
805 "Agent scratchpad",
806 &[ColumnDef {
807 name: "body".into(),
808 col_type: "TEXT".into(),
809 nullable: true,
810 description: None,
811 }],
812 )
813 .unwrap();
814
815 let summary = compact_summary(&db).unwrap();
816 assert!(summary.contains("[Database]"), "missing header");
817 assert!(summary.contains("System tables ("), "missing system count");
818 assert!(summary.contains("Your tables:"), "missing agent section");
819 assert!(summary.contains("agent1_notes"), "missing agent table");
820 }
821
822 #[test]
823 fn compact_summary_fits_token_budget() {
824 let db = test_db();
825 let summary = compact_summary(&db).unwrap();
826 assert!(
828 summary.len() <= 1100,
829 "compact_summary too long: {} chars",
830 summary.len()
831 );
832 }
833
834 #[test]
835 fn schema_summary_with_tables() {
836 let db = test_db();
837 seed_system_tables(&db).unwrap();
838 let summary = schema_summary(&db).unwrap();
839 assert!(summary.contains("sessions"));
840 assert!(summary.contains("episodic_memory"));
841 assert!(summary.contains("(system)"));
842 assert!(summary.contains("[read, 0 rows]"));
843 }
844
845 #[test]
846 fn seed_system_tables_upserts_over_bootstrap() {
847 let db = test_db();
848 let before = list_tables(&db).unwrap().len();
849 seed_system_tables(&db).unwrap();
850 let after = list_tables(&db).unwrap().len();
851 assert_eq!(before, after, "seed should upsert, not add duplicates");
853 }
854
855 #[test]
856 fn register_table_upsert() {
857 let db = test_db();
858 register_table(
859 &db,
860 "test",
861 "Version 1",
862 &[],
863 "system",
864 false,
865 "internal",
866 0,
867 )
868 .unwrap();
869 register_table(&db, "test", "Version 2", &[], "system", false, "read", 42).unwrap();
870
871 let entry = get_table(&db, "test").unwrap().unwrap();
872 assert_eq!(entry.description, "Version 2");
873 assert_eq!(entry.access_level, "read");
874 assert_eq!(entry.row_count, 42);
875 }
876
877 #[test]
878 fn column_def_serialization() {
879 let col = ColumnDef {
880 name: "test_col".into(),
881 col_type: "TEXT".into(),
882 nullable: true,
883 description: Some("A test column".into()),
884 };
885 let json = serde_json::to_string(&col).unwrap();
886 let decoded: ColumnDef = serde_json::from_str(&json).unwrap();
887 assert_eq!(decoded.name, "test_col");
888 assert!(decoded.nullable);
889 }
890
891 #[test]
892 fn validate_identifier_valid() {
893 validate_identifier("hello").unwrap();
894 validate_identifier("my_table").unwrap();
895 validate_identifier("col123").unwrap();
896 validate_identifier("A").unwrap();
897 }
898
899 #[test]
900 fn validate_identifier_empty_fails() {
901 assert!(validate_identifier("").is_err());
902 }
903
904 #[test]
905 fn validate_identifier_special_chars_fail() {
906 assert!(validate_identifier("name;drop").is_err());
907 assert!(validate_identifier("col name").is_err());
908 assert!(validate_identifier("table-name").is_err());
909 assert!(validate_identifier("col.name").is_err());
910 }
911
912 #[test]
913 fn create_agent_table_empty_columns() {
914 let db = test_db();
915 let name = create_agent_table(&db, "agent", "empty", "No columns", &[]).unwrap();
916 assert_eq!(name, "agent_empty");
917
918 let conn = db.conn();
920 conn.execute("INSERT INTO \"agent_empty\" (id) VALUES ('row1')", [])
921 .unwrap();
922 let count: i64 = conn
923 .query_row("SELECT COUNT(*) FROM \"agent_empty\"", [], |row| row.get(0))
924 .unwrap();
925 assert_eq!(count, 1);
926 }
927
928 #[test]
929 fn create_agent_table_invalid_column_name() {
930 let db = test_db();
931 let cols = vec![ColumnDef {
932 name: "bad;col".into(),
933 col_type: "TEXT".into(),
934 nullable: false,
935 description: None,
936 }];
937 let result = create_agent_table(&db, "agent", "badcol", "test", &cols);
938 assert!(result.is_err());
939 }
940
941 #[test]
942 fn create_agent_table_invalid_column_type() {
943 let db = test_db();
944 let cols = vec![ColumnDef {
945 name: "good_col".into(),
946 col_type: "TEXT;DROP".into(),
947 nullable: false,
948 description: None,
949 }];
950 let result = create_agent_table(&db, "agent", "badtype", "test", &cols);
951 assert!(result.is_err());
952 }
953
954 #[test]
955 fn drop_agent_table_nonexistent() {
956 let db = test_db();
957 let result = drop_agent_table(&db, "agent", "nonexistent");
958 assert!(result.is_err());
959 }
960
961 #[test]
962 fn schema_summary_with_agent_owned_table() {
963 let db = test_db();
964 let cols = vec![
965 ColumnDef {
966 name: "note".into(),
967 col_type: "TEXT".into(),
968 nullable: false,
969 description: Some("The note content".into()),
970 },
971 ColumnDef {
972 name: "priority".into(),
973 col_type: "INTEGER".into(),
974 nullable: true,
975 description: None,
976 },
977 ];
978 create_agent_table(&db, "agent1", "notes", "Agent notes storage", &cols).unwrap();
979
980 let summary = schema_summary(&db).unwrap();
981 assert!(
982 summary.contains("(owned by: agent1)"),
983 "summary should show agent owner"
984 );
985 assert!(
986 summary.contains("note"),
987 "summary should include column names"
988 );
989 assert!(
990 summary.contains("nullable"),
991 "nullable columns should be marked"
992 );
993 assert!(
994 summary.contains("The note content"),
995 "column descriptions should appear"
996 );
997 assert!(
998 summary.contains("[readwrite, 0 rows]"),
999 "summary should show access level and row count"
1000 );
1001 }
1002
1003 #[test]
1004 fn schema_summary_column_without_description() {
1005 let db = test_db();
1006 let cols = vec![ColumnDef {
1007 name: "val".into(),
1008 col_type: "REAL".into(),
1009 nullable: false,
1010 description: None,
1011 }];
1012 register_table(
1013 &db,
1014 "metrics",
1015 "Metric values",
1016 &cols,
1017 "system",
1018 false,
1019 "internal",
1020 0,
1021 )
1022 .unwrap();
1023
1024 let summary = schema_summary(&db).unwrap();
1025 assert!(summary.contains("`val` (REAL)"));
1026 }
1027
1028 #[test]
1029 fn list_agent_tables_empty_for_unknown_agent() {
1030 let db = test_db();
1031 create_agent_table(&db, "agent1", "data", "Data", &[]).unwrap();
1032 let tables = list_agent_tables(&db, "agent_unknown").unwrap();
1033 assert!(tables.is_empty());
1034 }
1035
1036 #[test]
1037 fn seed_system_tables_idempotent() {
1038 let db = test_db();
1039 let baseline = list_tables(&db).unwrap().len();
1040 seed_system_tables(&db).unwrap();
1041 seed_system_tables(&db).unwrap();
1042 let after = list_tables(&db).unwrap().len();
1043 assert_eq!(
1044 baseline, after,
1045 "seeding twice should not create duplicates"
1046 );
1047 }
1048
1049 #[test]
1050 fn bootstrap_discovers_all_system_tables() {
1051 let db = test_db();
1052 bootstrap_hippocampus(&db).unwrap();
1053
1054 let tables = list_tables(&db).unwrap();
1055 assert!(
1058 tables.len() >= 20,
1059 "expected at least 20 system tables, got {}",
1060 tables.len()
1061 );
1062
1063 let names: Vec<&str> = tables.iter().map(|t| t.table_name.as_str()).collect();
1065 assert!(names.contains(&"sessions"), "missing sessions table");
1066 assert!(
1067 names.contains(&"inference_costs"),
1068 "missing inference_costs table"
1069 );
1070 assert!(
1071 names.contains(&"hippocampus"),
1072 "missing hippocampus table itself"
1073 );
1074 }
1075
1076 #[test]
1077 fn bootstrap_introspects_columns() {
1078 let db = test_db();
1079 bootstrap_hippocampus(&db).unwrap();
1080
1081 let entry = get_table(&db, "sessions").unwrap().unwrap();
1082 assert!(
1083 !entry.columns.is_empty(),
1084 "sessions should have introspected columns"
1085 );
1086 let col_names: Vec<&str> = entry.columns.iter().map(|c| c.name.as_str()).collect();
1087 assert!(col_names.contains(&"id"), "sessions should have id column");
1088 assert!(
1089 col_names.contains(&"agent_id"),
1090 "sessions should have agent_id column"
1091 );
1092 }
1093
1094 #[test]
1095 fn bootstrap_sets_access_levels() {
1096 let db = test_db();
1097 bootstrap_hippocampus(&db).unwrap();
1098
1099 let sessions = get_table(&db, "sessions").unwrap().unwrap();
1100 assert_eq!(sessions.access_level, "read");
1101
1102 let inference = get_table(&db, "inference_costs").unwrap().unwrap();
1103 assert_eq!(inference.access_level, "internal");
1104 }
1105
1106 #[test]
1107 fn bootstrap_preserves_agent_tables() {
1108 let db = test_db();
1109 create_agent_table(&db, "agent1", "notes", "My notes", &[]).unwrap();
1110 bootstrap_hippocampus(&db).unwrap();
1111
1112 let entry = get_table(&db, "agent1_notes").unwrap().unwrap();
1113 assert!(entry.agent_owned);
1114 assert_eq!(entry.created_by, "agent1");
1115 assert_eq!(entry.description, "My notes");
1116 assert_eq!(entry.access_level, "readwrite");
1117 }
1118
1119 #[test]
1120 fn bootstrap_idempotent() {
1121 let db = test_db();
1122 bootstrap_hippocampus(&db).unwrap();
1123 let count1 = list_tables(&db).unwrap().len();
1124 bootstrap_hippocampus(&db).unwrap();
1125 let count2 = list_tables(&db).unwrap().len();
1126 assert_eq!(count1, count2, "bootstrap should be idempotent");
1127 }
1128
1129 #[test]
1130 fn bootstrap_consistency_removes_stale_entries() {
1131 let db = test_db();
1132 register_table(
1134 &db,
1135 "phantom_table",
1136 "Does not exist",
1137 &[],
1138 "system",
1139 false,
1140 "internal",
1141 0,
1142 )
1143 .unwrap();
1144 assert!(get_table(&db, "phantom_table").unwrap().is_some());
1145
1146 bootstrap_hippocampus(&db).unwrap();
1148 assert!(
1149 get_table(&db, "phantom_table").unwrap().is_none(),
1150 "stale entry should be removed by consistency check"
1151 );
1152 }
1153
1154 #[test]
1155 fn bootstrap_counts_rows() {
1156 let db = test_db();
1157
1158 {
1160 let conn = db.conn();
1161 conn.execute(
1162 "INSERT INTO sessions (id, agent_id, scope_key, status) VALUES ('s1', 'test', 'scope_a', 'active')",
1163 [],
1164 )
1165 .unwrap();
1166 conn.execute(
1167 "INSERT INTO sessions (id, agent_id, scope_key, status) VALUES ('s2', 'test', 'scope_b', 'active')",
1168 [],
1169 )
1170 .unwrap();
1171 }
1172
1173 bootstrap_hippocampus(&db).unwrap();
1174
1175 let entry = get_table(&db, "sessions").unwrap().unwrap();
1176 assert_eq!(entry.row_count, 2, "should count existing rows");
1177 }
1178
1179 #[test]
1180 fn system_table_metadata_known_tables() {
1181 let (desc, level) = system_table_metadata("sessions");
1182 assert_eq!(desc, "User conversation sessions");
1183 assert_eq!(level, "read");
1184
1185 let (desc, level) = system_table_metadata("inference_costs");
1186 assert_eq!(desc, "LLM inference cost tracking");
1187 assert_eq!(level, "internal");
1188 }
1189
1190 #[test]
1191 fn system_table_metadata_unknown_table() {
1192 let (desc, level) = system_table_metadata("unknown_custom_table");
1193 assert_eq!(desc, "Agent-managed table");
1194 assert_eq!(level, "readwrite");
1195 }
1196
1197 #[test]
1198 fn access_level_and_row_count_round_trip() {
1199 let db = test_db();
1200 register_table(&db, "test", "Test", &[], "system", false, "read", 99).unwrap();
1201
1202 let entry = get_table(&db, "test").unwrap().unwrap();
1203 assert_eq!(entry.access_level, "read");
1204 assert_eq!(entry.row_count, 99);
1205 }
1206}