Skip to main content

robit_agent/
storage.rs

1//! Session and message storage helpers.
2
3use std::path::{Path, PathBuf};
4
5use rusqlite::{params, Connection, Result as SqliteResult};
6use serde::{Deserialize, Serialize};
7
8use crate::datetime::current_timestamp;
9
10const ROBIT_DIR: &str = ".robit";
11const MEMORY_DIR: &str = "memory";
12const DB_FILE: &str = "robit.db";
13
14/// Resolve the session database path for a working directory and storage scope.
15pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf, String> {
16    if global_storage {
17        let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
18        Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
19    } else {
20        Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
21    }
22}
23
24/// Session metadata returned to frontends.
25#[derive(Debug, Clone, Serialize)]
26pub struct SessionInfo {
27    pub id: String,
28    /// Platform chat identifier (None for GUI/TUI, Some for Bot platforms).
29    pub chat_id: Option<String>,
30    pub title: String,
31    pub model: String,
32    /// Which frontend created the session: "gui" | "tui" | "qq" | "feishu".
33    pub source: String,
34    pub status: String, // "idle" | "ready" | "running"
35    pub created_at: String,
36    pub updated_at: String,
37}
38
39/// Message data returned to frontends.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct MessageData {
42    pub id: i64,
43    pub role: String,
44    pub content: String,
45    pub tool_name: Option<String>,
46    pub tool_call_id: Option<String>,
47    pub tool_info: Option<serde_json::Value>,
48    pub created_at: String,
49}
50
51/// Tool call info for storage in message.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ToolCallInfoData {
54    pub tool_call_id: String,
55    pub name: String,
56    pub arguments: String,
57    pub status: String,
58    pub output: Option<String>,
59    pub requires_confirm: bool,
60}
61
62/// Current schema version. Increment when the schema changes.
63const CURRENT_SCHEMA_VERSION: i32 = 2;
64
65/// Initialize the database: create tables if needed, then run migrations.
66///
67/// This is the single entry point used by all frontends. It auto-detects
68/// fresh databases (version 0) and existing databases, running the migration
69/// chain to bring them up to [`CURRENT_SCHEMA_VERSION`].
70pub fn init_db(conn: &Connection) -> SqliteResult<()> {
71    ensure_meta_table(conn)?;
72
73    let version = read_schema_version(conn)?;
74
75    if version == 0 {
76        // Fresh database — create everything at the current version in one shot.
77        create_all_tables(conn)?;
78        write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
79        tracing::info!(
80            "Database initialized at schema v{}",
81            CURRENT_SCHEMA_VERSION
82        );
83        return Ok(());
84    }
85
86    migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
87    Ok(())
88}
89
90/// Detect the schema version of an existing database.
91///
92/// Returns:
93/// - `0` for a truly fresh database (no `sessions` table, no recorded version).
94/// - `1` for a legacy v1 database that predates `_schema_meta` versioning
95///   (tables exist but no version row was ever written).
96/// - `N` for a versioned database, read from `_schema_meta`.
97fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
98    match conn.query_row(
99        "SELECT value FROM _schema_meta WHERE key = 'version'",
100        [],
101        |row| row.get::<_, String>(0),
102    ) {
103        Ok(v) => v.parse().map_err(|_| {
104            rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
105        }),
106        Err(rusqlite::Error::QueryReturnedNoRows) => {
107            // No recorded version — is this a fresh DB or a legacy v1 DB?
108            if sessions_table_exists(conn)? {
109                Ok(1) // tables exist but unversioned → legacy v1
110            } else {
111                Ok(0) // nothing exists → fresh
112            }
113        }
114        Err(e) => Err(e),
115    }
116}
117
118/// Check whether the `sessions` table exists.
119fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
120    let count: i64 = conn.query_row(
121        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sessions'",
122        [],
123        |row| row.get(0),
124    )?;
125    Ok(count > 0)
126}
127
128/// Create all tables and indexes at the current schema version (fresh DBs only).
129fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
130    conn.execute_batch(
131        "CREATE TABLE IF NOT EXISTS sessions (
132            id          TEXT PRIMARY KEY,
133            chat_id     TEXT,
134            title       TEXT NOT NULL,
135            model       TEXT NOT NULL,
136            source      TEXT NOT NULL DEFAULT 'gui',
137            created_at  TEXT NOT NULL,
138            updated_at  TEXT NOT NULL,
139            is_active   INTEGER DEFAULT 1
140        );
141
142        CREATE TABLE IF NOT EXISTS messages (
143            id           INTEGER PRIMARY KEY AUTOINCREMENT,
144            session_id   TEXT NOT NULL REFERENCES sessions(id),
145            role         TEXT NOT NULL,
146            content      TEXT NOT NULL,
147            tool_name    TEXT,
148            tool_call_id TEXT,
149            tool_info    TEXT,
150            tokens       INTEGER,
151            created_at   TEXT NOT NULL
152        );
153
154        CREATE INDEX IF NOT EXISTS idx_messages_session
155            ON messages(session_id);
156        CREATE INDEX IF NOT EXISTS idx_messages_created
157            ON messages(session_id, created_at);
158        CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
159            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
160    )?;
161    Ok(())
162}
163
164/// Run the migration chain from `from` to `to`, one step at a time.
165fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
166    let mut current = from;
167    while current < to {
168        tracing::info!("Migrating database: v{} → v{}", current, current + 1);
169        match current {
170            1 => migrate_v1_to_v2(conn)?,
171            other => {
172                return Err(rusqlite::Error::InvalidParameterName(format!(
173                    "Unknown schema version: {}",
174                    other
175                )))
176            }
177        }
178        current += 1;
179        write_schema_version(conn, current)?;
180        tracing::info!("Database migrated to v{}", current);
181    }
182    Ok(())
183}
184
185/// v1 → v2: add `chat_id`, `source` to sessions; ensure `tool_info` on messages;
186/// add the partial unique index on `sessions.chat_id`.
187///
188/// Each ALTER TABLE is wrapped to ignore "duplicate column" errors so the
189/// migration is idempotent (safe to run on a partially-migrated DB).
190fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
191    let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", ());
192    let _ = conn.execute(
193        "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
194        (),
195    );
196    let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", ());
197    conn.execute_batch(
198        "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
199            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
200    )?;
201    Ok(())
202}
203
204// ============================================================
205// Schema version helpers (private)
206// ============================================================
207
208fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
209    conn.execute_batch(
210        "CREATE TABLE IF NOT EXISTS _schema_meta (
211            key   TEXT PRIMARY KEY,
212            value TEXT NOT NULL
213        )",
214    )
215}
216
217fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
218    conn.execute(
219        "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
220        rusqlite::params![version.to_string()],
221    )?;
222    Ok(())
223}
224
225/// Insert a new session.
226///
227/// `chat_id` is `Some` only for Bot platforms (the platform chat identifier);
228/// pass `None` for GUI/TUI sessions. `source` records which frontend created
229/// the session (`"gui"`, `"tui"`, `"qq"`, `"feishu"`).
230pub fn insert_session(
231    conn: &Connection,
232    id: &str,
233    chat_id: Option<&str>,
234    title: &str,
235    model: &str,
236    source: &str,
237) -> SqliteResult<()> {
238    let now = current_timestamp();
239    conn.execute(
240        "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
241         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
242        params![id, chat_id, title, model, source, now, now],
243    )?;
244    Ok(())
245}
246
247/// List all active sessions, ordered by most recently updated.
248///
249/// Pass `Some(source)` to filter by frontend (e.g. `"qq"`); `None` returns
250/// sessions from all sources.
251pub fn list_sessions(
252    conn: &Connection,
253    source_filter: Option<&str>,
254) -> SqliteResult<Vec<SessionInfo>> {
255    let sql = if source_filter.is_some() {
256        "SELECT id, chat_id, title, model, source, created_at, updated_at \
257         FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
258    } else {
259        "SELECT id, chat_id, title, model, source, created_at, updated_at \
260         FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
261    };
262    let mut stmt = conn.prepare(sql)?;
263    let rows = if let Some(source) = source_filter {
264        stmt.query_map(params![source], map_session_row)?
265    } else {
266        stmt.query_map([], map_session_row)?
267    };
268    rows.collect()
269}
270
271/// Find an active session by its platform chat identifier.
272///
273/// Returns `None` for GUI/TUI sessions (where `chat_id` is NULL).
274pub fn find_session_by_chat_id(
275    conn: &Connection,
276    chat_id: &str,
277) -> SqliteResult<Option<SessionInfo>> {
278    let mut stmt = conn.prepare(
279        "SELECT id, chat_id, title, model, source, created_at, updated_at \
280         FROM sessions WHERE chat_id = ?1 AND is_active = 1",
281    )?;
282    let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
283    match rows.next() {
284        Some(Ok(session)) => Ok(Some(session)),
285        _ => Ok(None),
286    }
287}
288
289/// Get a single session by ID.
290pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
291    let mut stmt = conn.prepare(
292        "SELECT id, chat_id, title, model, source, created_at, updated_at \
293         FROM sessions WHERE id = ?1 AND is_active = 1",
294    )?;
295    let mut rows = stmt.query_map(params![id], map_session_row)?;
296    match rows.next() {
297        Some(Ok(session)) => Ok(Some(session)),
298        _ => Ok(None),
299    }
300}
301
302/// Row mapper shared by all session SELECT queries.
303fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
304    Ok(SessionInfo {
305        id: row.get(0)?,
306        chat_id: row.get(1)?,
307        title: row.get(2)?,
308        model: row.get(3)?,
309        source: row.get(4)?,
310        status: "idle".to_string(),
311        created_at: row.get(5)?,
312        updated_at: row.get(6)?,
313    })
314}
315
316/// Update a session's title.
317pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
318    let now = current_timestamp();
319    conn.execute(
320        "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
321        params![title, now, id],
322    )?;
323    Ok(())
324}
325
326/// Update a session's updated_at timestamp.
327pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
328    let now = current_timestamp();
329    conn.execute(
330        "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
331        params![now, id],
332    )?;
333    Ok(())
334}
335
336/// Soft-delete a session.
337pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
338    conn.execute(
339        "UPDATE sessions SET is_active = 0 WHERE id = ?1",
340        params![id],
341    )?;
342    Ok(())
343}
344
345/// Insert a message into a session.
346pub fn insert_message(
347    conn: &Connection,
348    session_id: &str,
349    role: &str,
350    content: &str,
351    tool_name: Option<&str>,
352    tool_call_id: Option<&str>,
353    tool_info: Option<&str>,
354) -> SqliteResult<i64> {
355    let now = current_timestamp();
356    conn.execute(
357        "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
358        params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
359    )?;
360    Ok(conn.last_insert_rowid())
361}
362
363/// Get all messages for a session, ordered by creation time.
364pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
365    let mut stmt = conn.prepare(
366        "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
367    )?;
368    let rows = stmt.query_map(params![session_id], |row| {
369        let tool_info_str: Option<String> = row.get(5)?;
370        let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
371        Ok(MessageData {
372            id: row.get(0)?,
373            role: row.get(1)?,
374            content: row.get(2)?,
375            tool_name: row.get(3)?,
376            tool_call_id: row.get(4)?,
377            tool_info,
378            created_at: row.get(6)?,
379        })
380    })?;
381    rows.collect()
382}
383
384/// Update a tool message with output and status.
385pub fn update_tool_message(
386    conn: &Connection,
387    session_id: &str,
388    tool_call_id: &str,
389    tool_info: &str,
390) -> SqliteResult<()> {
391    conn.execute(
392        "UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
393        params![tool_info, session_id, tool_call_id],
394    )?;
395    Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn resolves_local_db_path() {
404        let working_dir = PathBuf::from("project");
405        let path = resolve_db_path(&working_dir, false).unwrap();
406        assert_eq!(
407            path,
408            working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
409        );
410    }
411
412    #[test]
413    fn resolves_global_db_path() {
414        let path = resolve_db_path(&PathBuf::from("project"), true).unwrap();
415        assert_eq!(
416            path.file_name().and_then(|name| name.to_str()),
417            Some(DB_FILE)
418        );
419        assert!(path.ends_with(PathBuf::from(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)));
420    }
421
422    #[test]
423    fn session_crud() {
424        let conn = Connection::open_in_memory().unwrap();
425        init_db(&conn).unwrap();
426
427        insert_session(
428            &conn,
429            "test-123",
430            None,
431            "Test Session",
432            "deepseek/deepseek-chat",
433            "gui",
434        )
435        .unwrap();
436
437        let sessions = list_sessions(&conn, None).unwrap();
438        assert_eq!(sessions.len(), 1);
439        assert_eq!(sessions[0].id, "test-123");
440        assert_eq!(sessions[0].title, "Test Session");
441        assert_eq!(sessions[0].source, "gui");
442        assert_eq!(sessions[0].chat_id, None);
443        assert_eq!(sessions[0].status, "idle");
444
445        let session = get_session(&conn, "test-123").unwrap().unwrap();
446        assert_eq!(session.title, "Test Session");
447        assert_eq!(session.source, "gui");
448
449        update_session_title(&conn, "test-123", "Updated Title").unwrap();
450        let updated = get_session(&conn, "test-123").unwrap().unwrap();
451        assert_eq!(updated.title, "Updated Title");
452
453        delete_session(&conn, "test-123").unwrap();
454        assert!(get_session(&conn, "test-123").unwrap().is_none());
455        assert!(list_sessions(&conn, None).unwrap().is_empty());
456    }
457
458    #[test]
459    fn message_operations() {
460        let conn = Connection::open_in_memory().unwrap();
461        init_db(&conn).unwrap();
462
463        insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
464        let user_id = insert_message(
465            &conn,
466            "session-msg",
467            "user",
468            "Hello Robit",
469            None,
470            None,
471            None,
472        )
473        .unwrap();
474        let assistant_id = insert_message(
475            &conn,
476            "session-msg",
477            "assistant",
478            "Hello! How can I help?",
479            None,
480            None,
481            None,
482        )
483        .unwrap();
484
485        let messages = get_messages(&conn, "session-msg").unwrap();
486        assert_eq!(messages.len(), 2);
487        assert_eq!(messages[0].id, user_id);
488        assert_eq!(messages[0].role, "user");
489        assert_eq!(messages[0].content, "Hello Robit");
490        assert_eq!(messages[1].id, assistant_id);
491        assert_eq!(messages[1].role, "assistant");
492        assert_eq!(messages[1].content, "Hello! How can I help?");
493    }
494
495    #[test]
496    fn empty_sessions() {
497        let conn = Connection::open_in_memory().unwrap();
498        init_db(&conn).unwrap();
499
500        let sessions = list_sessions(&conn, None).unwrap();
501        assert_eq!(sessions.len(), 0);
502    }
503
504    #[test]
505    fn get_nonexistent_session() {
506        let conn = Connection::open_in_memory().unwrap();
507        init_db(&conn).unwrap();
508
509        let session = get_session(&conn, "nonexistent").unwrap();
510        assert!(session.is_none());
511    }
512
513    #[test]
514    fn tool_message_update() {
515        let conn = Connection::open_in_memory().unwrap();
516        init_db(&conn).unwrap();
517
518        insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
519        let initial = serde_json::json!({
520            "tool_call_id": "tool-1",
521            "name": "bash",
522            "arguments": "{}",
523            "status": "pending",
524            "requires_confirm": true
525        })
526        .to_string();
527        insert_message(
528            &conn,
529            "session-tool",
530            "tool",
531            "{}",
532            Some("bash"),
533            Some("tool-1"),
534            Some(&initial),
535        )
536        .unwrap();
537
538        let updated = serde_json::json!({
539            "tool_call_id": "tool-1",
540            "status": "success",
541            "output": "done"
542        })
543        .to_string();
544        update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
545
546        let messages = get_messages(&conn, "session-tool").unwrap();
547        assert_eq!(messages.len(), 1);
548        assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
549        assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
550        assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
551        assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
552    }
553
554    #[test]
555    fn chat_id_lookup_and_source_filter() {
556        let conn = Connection::open_in_memory().unwrap();
557        init_db(&conn).unwrap();
558
559        insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
560        insert_session(
561            &conn,
562            "qq-1",
563            Some("group:abc"),
564            "技术讨论群",
565            "model",
566            "qq",
567        )
568        .unwrap();
569        insert_session(
570            &conn,
571            "qq-2",
572            Some("private:xyz"),
573            "私聊",
574            "model",
575            "qq",
576        )
577        .unwrap();
578
579        // find_session_by_chat_id
580        let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
581        assert_eq!(found.id, "qq-1");
582        assert_eq!(found.source, "qq");
583        assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
584
585        // chat_id lookup returns None for GUI sessions (NULL chat_id)
586        assert!(find_session_by_chat_id(&conn, "does-not-exist")
587            .unwrap()
588            .is_none());
589
590        // source filter
591        let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
592        assert_eq!(qq_sessions.len(), 2);
593        assert!(qq_sessions.iter().all(|s| s.source == "qq"));
594
595        let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
596        assert_eq!(gui_sessions.len(), 1);
597        assert_eq!(gui_sessions[0].id, "gui-1");
598
599        // no filter returns all
600        assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
601    }
602
603    #[test]
604    fn chat_id_unique_per_chat() {
605        let conn = Connection::open_in_memory().unwrap();
606        init_db(&conn).unwrap();
607
608        insert_session(
609            &conn,
610            "qq-1",
611            Some("group:abc"),
612            "First",
613            "model",
614            "qq",
615        )
616        .unwrap();
617        // Inserting a second session with the same chat_id must fail (unique index).
618        let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
619        assert!(err.is_err());
620    }
621
622    #[test]
623    fn migrates_legacy_v1_database() {
624        let conn = Connection::open_in_memory().unwrap();
625        // Simulate a legacy v1 database: old schema, no _schema_meta.
626        conn.execute_batch(
627            "CREATE TABLE sessions (
628                id          TEXT PRIMARY KEY,
629                title       TEXT NOT NULL,
630                model       TEXT NOT NULL,
631                created_at  TEXT NOT NULL,
632                updated_at  TEXT NOT NULL,
633                is_active   INTEGER DEFAULT 1
634            );
635            CREATE TABLE messages (
636                id           INTEGER PRIMARY KEY AUTOINCREMENT,
637                session_id   TEXT NOT NULL REFERENCES sessions(id),
638                role         TEXT NOT NULL,
639                content      TEXT NOT NULL,
640                tool_name    TEXT,
641                tool_call_id TEXT,
642                tokens       INTEGER,
643                created_at   TEXT NOT NULL
644            );",
645        )
646        .unwrap();
647        conn.execute(
648            "INSERT INTO sessions (id, title, model, created_at, updated_at) \
649             VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
650            [],
651        )
652        .unwrap();
653
654        // Run init_db — it should detect v0 → ... actually no _schema_meta means version 0,
655        // but tables already exist. create_all_tables uses IF NOT EXISTS so it's safe,
656        // and version is written as current. The legacy row's source defaults to 'gui'.
657        init_db(&conn).unwrap();
658
659        // Schema version is now current.
660        let v: i32 = read_schema_version(&conn).unwrap();
661        assert_eq!(v, CURRENT_SCHEMA_VERSION);
662
663        // New columns exist and legacy data is preserved.
664        let session = get_session(&conn, "legacy-1").unwrap().unwrap();
665        assert_eq!(session.title, "Legacy");
666        assert_eq!(session.source, "gui");
667        assert_eq!(session.chat_id, None);
668    }
669
670    #[test]
671    fn init_db_is_idempotent() {
672        let conn = Connection::open_in_memory().unwrap();
673        init_db(&conn).unwrap();
674        // Running again on an already-current DB must not error.
675        init_db(&conn).unwrap();
676        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
677    }
678}