Skip to main content

browser_control/registry/
tabs.rs

1//! `tabs` SQLite table: named tab handles addressable as `<browser>/<name>`
2//! across the CLI. Backs the `tab open` / `tab list` subcommands and the
3//! cross-command `<browser>/<name>` positional routing.
4//!
5//! Provenance distinguishes daemon-style (CLI-created) tabs that are
6//! eligible for sweep / LRU recycle from user-created tabs the CLI just
7//! adopted, which are never GC'd.
8//!
9//! This module is pure SQL CRUD. Orchestration (talking to CDP to
10//! create/navigate/close real tabs, and the sweep-on-read of stale
11//! `target_id`s) lives in `crate::session::tabs` so the registry layer
12//! stays narrow and testable in isolation.
13
14use anyhow::Result;
15
16use crate::registry::{db, now_epoch_s, Registry};
17
18/// In-memory view of a `tabs` row.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TabRow {
21    pub browser_name: String,
22    pub name: String,
23    pub target_id: String,
24    pub last_url: String,
25    pub last_used_at_epoch_s: i64,
26    pub daemon_created: bool,
27}
28
29impl Registry {
30    /// Read a single tab by composite key.
31    pub fn tab_get(&self, browser_name: &str, name: &str) -> Result<Option<TabRow>> {
32        db::query_optional(
33            &self.conn,
34            "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
35             FROM tabs WHERE browser_name = ?1 AND name = ?2",
36            rusqlite::params![browser_name, name],
37            row_to_tab,
38        )
39    }
40
41    /// All tabs for a browser, sorted by name (stable for `tab list` output).
42    pub fn tabs_list_for(&self, browser_name: &str) -> Result<Vec<TabRow>> {
43        db::query_vec(
44            &self.conn,
45            "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
46             FROM tabs WHERE browser_name = ?1 ORDER BY name ASC",
47            [browser_name],
48            row_to_tab,
49        )
50    }
51
52    /// Insert or replace a tab. Bumps `last_used_at` to "now".
53    pub fn tab_upsert(
54        &self,
55        browser_name: &str,
56        name: &str,
57        target_id: &str,
58        last_url: &str,
59        daemon_created: bool,
60    ) -> Result<()> {
61        let now = now_epoch_s();
62        db::execute(
63            &self.conn,
64            "INSERT INTO tabs \
65                   (browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created) \
66                 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
67                 ON CONFLICT(browser_name, name) DO UPDATE SET \
68                    target_id = excluded.target_id, \
69                    last_url = excluded.last_url, \
70                    last_used_at_epoch_s = excluded.last_used_at_epoch_s, \
71                    daemon_created = excluded.daemon_created",
72            rusqlite::params![
73                browser_name,
74                name,
75                target_id,
76                last_url,
77                now,
78                daemon_created as i32,
79            ],
80            || format!("upsert tab {browser_name}/{name}"),
81        )
82    }
83
84    /// Bump `last_used_at` without rewriting other columns. No-op if absent.
85    pub fn tab_touch(&self, browser_name: &str, name: &str) -> Result<()> {
86        let now = now_epoch_s();
87        db::execute_bare(
88            &self.conn,
89            "UPDATE tabs SET last_used_at_epoch_s = ?1 \
90             WHERE browser_name = ?2 AND name = ?3",
91            rusqlite::params![now, browser_name, name],
92        )
93    }
94
95    /// Rewrite just `last_url` (after a successful `Page.navigate`).
96    pub fn tab_set_url(&self, browser_name: &str, name: &str, url: &str) -> Result<()> {
97        let now = now_epoch_s();
98        db::execute_bare(
99            &self.conn,
100            "UPDATE tabs SET last_url = ?1, last_used_at_epoch_s = ?2 \
101             WHERE browser_name = ?3 AND name = ?4",
102            rusqlite::params![url, now, browser_name, name],
103        )
104    }
105
106    /// Delete by composite key. Idempotent.
107    pub fn tab_delete(&self, browser_name: &str, name: &str) -> Result<()> {
108        db::execute_bare(
109            &self.conn,
110            "DELETE FROM tabs WHERE browser_name = ?1 AND name = ?2",
111            rusqlite::params![browser_name, name],
112        )
113    }
114
115    /// Count `daemon_created = true` rows for a browser. Used by the
116    /// orchestration layer to decide when to recycle under budget pressure.
117    pub fn tabs_count_daemon_created(&self, browser_name: &str) -> Result<usize> {
118        let n: i64 = self.conn.query_row(
119            "SELECT COUNT(*) FROM tabs \
120             WHERE browser_name = ?1 AND daemon_created = 1",
121            [browser_name],
122            |r| r.get(0),
123        )?;
124        Ok(n as usize)
125    }
126
127    /// LRU daemon-created row for a browser. Returns the row with the
128    /// smallest `last_used_at_epoch_s`, or `None` if no eligible row.
129    pub fn tabs_lru_daemon_created(&self, browser_name: &str) -> Result<Option<TabRow>> {
130        db::query_optional(
131            &self.conn,
132            "SELECT browser_name, name, target_id, last_url, last_used_at_epoch_s, daemon_created \
133             FROM tabs \
134             WHERE browser_name = ?1 AND daemon_created = 1 \
135             ORDER BY last_used_at_epoch_s ASC, name ASC \
136             LIMIT 1",
137            [browser_name],
138            row_to_tab,
139        )
140    }
141}
142
143fn row_to_tab(r: &rusqlite::Row<'_>) -> Result<TabRow> {
144    Ok(TabRow {
145        browser_name: r.get(0)?,
146        name: r.get(1)?,
147        target_id: r.get(2)?,
148        last_url: r.get(3)?,
149        last_used_at_epoch_s: r.get(4)?,
150        daemon_created: r.get::<_, i64>(5)? != 0,
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn fresh() -> Registry {
159        Registry::open_in_memory().unwrap()
160    }
161
162    #[test]
163    fn upsert_then_get_round_trip() {
164        let reg = fresh();
165        reg.tab_upsert("brave", "scrape-cart", "T1", "https://shop", true)
166            .unwrap();
167        let got = reg.tab_get("brave", "scrape-cart").unwrap().unwrap();
168        assert_eq!(got.target_id, "T1");
169        assert_eq!(got.last_url, "https://shop");
170        assert!(got.daemon_created);
171    }
172
173    #[test]
174    fn upsert_replaces_target_id_and_url() {
175        let reg = fresh();
176        reg.tab_upsert("b", "n", "T1", "u1", true).unwrap();
177        reg.tab_upsert("b", "n", "T2", "u2", false).unwrap();
178        let got = reg.tab_get("b", "n").unwrap().unwrap();
179        assert_eq!(got.target_id, "T2");
180        assert_eq!(got.last_url, "u2");
181        assert!(!got.daemon_created, "provenance flips on upsert");
182    }
183
184    #[test]
185    fn list_returns_rows_sorted_by_name() {
186        let reg = fresh();
187        reg.tab_upsert("b", "zebra", "T1", "", true).unwrap();
188        reg.tab_upsert("b", "alpha", "T2", "", true).unwrap();
189        reg.tab_upsert("b", "monk", "T3", "", true).unwrap();
190        let names: Vec<String> = reg
191            .tabs_list_for("b")
192            .unwrap()
193            .into_iter()
194            .map(|t| t.name)
195            .collect();
196        assert_eq!(names, vec!["alpha", "monk", "zebra"]);
197    }
198
199    #[test]
200    fn list_is_scoped_per_browser() {
201        let reg = fresh();
202        reg.tab_upsert("a", "x", "T1", "", true).unwrap();
203        reg.tab_upsert("b", "y", "T2", "", true).unwrap();
204        assert_eq!(reg.tabs_list_for("a").unwrap().len(), 1);
205        assert_eq!(reg.tabs_list_for("b").unwrap().len(), 1);
206    }
207
208    #[test]
209    fn count_excludes_user_created() {
210        let reg = fresh();
211        reg.tab_upsert("b", "agent1", "T1", "", true).unwrap();
212        reg.tab_upsert("b", "agent2", "T2", "", true).unwrap();
213        reg.tab_upsert("b", "user-tab", "T3", "", false).unwrap();
214        assert_eq!(reg.tabs_count_daemon_created("b").unwrap(), 2);
215    }
216
217    #[test]
218    fn lru_picks_oldest_daemon_created() {
219        let reg = fresh();
220        reg.tab_upsert("b", "old-daemon", "T1", "", true).unwrap();
221        std::thread::sleep(std::time::Duration::from_millis(1100));
222        reg.tab_upsert("b", "new-daemon", "T2", "", true).unwrap();
223        reg.tab_upsert("b", "user-tab", "T3", "", false).unwrap();
224        let lru = reg.tabs_lru_daemon_created("b").unwrap().unwrap();
225        assert_eq!(lru.name, "old-daemon", "LRU honors last_used_at order");
226    }
227
228    #[test]
229    fn delete_removes_only_named_row() {
230        let reg = fresh();
231        reg.tab_upsert("b", "x", "T1", "", true).unwrap();
232        reg.tab_upsert("b", "y", "T2", "", true).unwrap();
233        reg.tab_delete("b", "x").unwrap();
234        assert!(reg.tab_get("b", "x").unwrap().is_none());
235        assert!(reg.tab_get("b", "y").unwrap().is_some());
236    }
237
238    #[test]
239    fn set_url_updates_just_the_url() {
240        let reg = fresh();
241        reg.tab_upsert("b", "n", "T1", "u1", true).unwrap();
242        reg.tab_set_url("b", "n", "u2").unwrap();
243        let got = reg.tab_get("b", "n").unwrap().unwrap();
244        assert_eq!(got.target_id, "T1");
245        assert_eq!(got.last_url, "u2");
246    }
247}