Skip to main content

agentmux_srv/backend/storage/
history.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent session history — append-only per-agent entries with
5//! date-bucket + LIKE search.
6//!
7//! Extracted from `store.rs` in Phase R.4 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::agent_history_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::AgentHistory` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// An append-only session history entry for a agent definition.
21#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
22#[ts(export, export_to = "../../../frontend/types/rpc/")]
23pub struct AgentHistory {
24    #[ts(type = "number")]
25    pub id: i64,
26    pub agent_id: String,
27    /// Local date the entry was appended, `YYYY-MM-DD`. Set by the server, not
28    /// by the caller -- `appendagenthistory` takes only `agent_id` + `entry`.
29    pub session_date: String,
30    pub entry: String,
31    #[ts(type = "number")]
32    pub timestamp: i64,
33}
34
35impl Store {
36    /// Append a history entry for an agent. Auto-sets session_date (today) and timestamp.
37    pub fn agent_history_append(
38        &self,
39        agent_id: &str,
40        entry: &str,
41    ) -> Result<AgentHistory, StoreError> {
42        let now = agentmux_common::time::now_ms();
43        // session_date as YYYY-MM-DD
44        let secs = (now / 1000) as u64;
45        let days = secs / 86400;
46        // Simple date calculation (no chrono dependency needed)
47        let session_date = format_epoch_date(days);
48        let conn = self.conn.lock().unwrap();
49        conn.execute(
50            "INSERT INTO db_agent_history (agent_id, session_date, entry, timestamp) VALUES (?1, ?2, ?3, ?4)",
51            params![agent_id, session_date, entry, now],
52        )?;
53        let id = conn.last_insert_rowid();
54        Ok(AgentHistory {
55            id,
56            agent_id: agent_id.to_string(),
57            session_date,
58            entry: entry.to_string(),
59            timestamp: now,
60        })
61    }
62
63    /// List history entries for an agent, with optional date filter and pagination.
64    pub fn agent_history_list(
65        &self,
66        agent_id: &str,
67        session_date: Option<&str>,
68        limit: i64,
69        offset: i64,
70    ) -> Result<Vec<AgentHistory>, StoreError> {
71        let conn = self.conn.lock().unwrap();
72        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match session_date {
73            Some(date) => (
74                "SELECT id, agent_id, session_date, entry, timestamp
75                 FROM db_agent_history WHERE agent_id=?1 AND session_date=?2
76                 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4"
77                    .to_string(),
78                vec![
79                    Box::new(agent_id.to_string()),
80                    Box::new(date.to_string()),
81                    Box::new(limit),
82                    Box::new(offset),
83                ],
84            ),
85            None => (
86                "SELECT id, agent_id, session_date, entry, timestamp
87                 FROM db_agent_history WHERE agent_id=?1
88                 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3"
89                    .to_string(),
90                vec![
91                    Box::new(agent_id.to_string()),
92                    Box::new(limit),
93                    Box::new(offset),
94                ],
95            ),
96        };
97        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
98            params_vec.iter().map(|b| b.as_ref()).collect();
99        let mut stmt = conn.prepare(&sql)?;
100        let rows = stmt.query_map(params_refs.as_slice(), |row| {
101            Ok(AgentHistory {
102                id: row.get(0)?,
103                agent_id: row.get(1)?,
104                session_date: row.get(2)?,
105                entry: row.get(3)?,
106                timestamp: row.get(4)?,
107            })
108        })?;
109        let mut entries = Vec::new();
110        for row in rows {
111            entries.push(row?);
112        }
113        Ok(entries)
114    }
115
116    /// Search history entries for an agent using LIKE-based matching.
117    pub fn agent_history_search(
118        &self,
119        agent_id: &str,
120        query: &str,
121        limit: i64,
122    ) -> Result<Vec<AgentHistory>, StoreError> {
123        let conn = self.conn.lock().unwrap();
124        let pattern = format!("%{}%", query);
125        let mut stmt = conn.prepare(
126            "SELECT id, agent_id, session_date, entry, timestamp
127             FROM db_agent_history WHERE agent_id=?1 AND entry LIKE ?2
128             ORDER BY timestamp DESC LIMIT ?3",
129        )?;
130        let rows = stmt.query_map(params![agent_id, pattern, limit], |row| {
131            Ok(AgentHistory {
132                id: row.get(0)?,
133                agent_id: row.get(1)?,
134                session_date: row.get(2)?,
135                entry: row.get(3)?,
136                timestamp: row.get(4)?,
137            })
138        })?;
139        let mut entries = Vec::new();
140        for row in rows {
141            entries.push(row?);
142        }
143        Ok(entries)
144    }
145}
146
147/// Format days-since-epoch as YYYY-MM-DD string.
148/// Simple implementation without chrono dependency.
149fn format_epoch_date(days_since_epoch: u64) -> String {
150    // Algorithm from https://howardhinnant.github.io/date_algorithms.html
151    let z = days_since_epoch + 719468;
152    let era = z / 146097;
153    let doe = z - era * 146097;
154    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
155    let y = yoe + era * 400;
156    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
157    let mp = (5 * doy + 2) / 153;
158    let d = doy - (153 * mp + 2) / 5 + 1;
159    let m = if mp < 10 { mp + 3 } else { mp - 9 };
160    let y = if m <= 2 { y + 1 } else { y };
161    format!("{:04}-{:02}-{:02}", y, m, d)
162}