Skip to main content

omni_dev/daemon/services/
snowflake.rs

1//! The Snowflake daemon service.
2//!
3//! A thin adapter that hosts the account-agnostic [`SnowflakeEngine`] under the
4//! daemon's lifecycle and exposes query/sessions/disconnect over the control
5//! socket, plus a tray submenu.
6//!
7//! All real work (lazy multiplexed auth, per-query `USE …`, heartbeats, the
8//! arbitrary-schema → JSON mapping) lives in [`crate::snowflake`]; this adapter
9//! only routes ops and renders the menu/status. Unlike the bridge it persists no
10//! secret to disk — sessions live only in memory.
11
12use anyhow::{anyhow, bail, Context, Result};
13use async_trait::async_trait;
14use chrono::Utc;
15use serde_json::{json, Value};
16
17use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
18use crate::snowflake::session::SessionInfo;
19use crate::snowflake::{QueryRequest, SnowflakeEngine, SnowflakeEngineConfig};
20
21/// The Snowflake service name (the control-socket routing key).
22pub const SERVICE_NAME: &str = "snowflake";
23
24/// Hosts a [`SnowflakeEngine`] as a [`DaemonService`].
25pub struct SnowflakeService {
26    engine: SnowflakeEngine,
27}
28
29impl SnowflakeService {
30    /// Creates the service and starts the engine's background keep-alive
31    /// heartbeat. Cheap — no eager auth or I/O; each `(account, user)` session
32    /// is authenticated lazily on its first query, and the heartbeat only
33    /// touches sessions that exist.
34    #[must_use]
35    pub fn new(config: SnowflakeEngineConfig) -> Self {
36        let engine = SnowflakeEngine::new(config);
37        engine.start_heartbeat();
38        Self { engine }
39    }
40
41    /// Handles the `disconnect` op's three mutually-exclusive selectors — every
42    /// pool (`{ all: true }`), a pool by numeric id (`{ id }`), or the
43    /// `(account, user)` pair (`{ account, user }`, the original contract). The
44    /// bulk and by-id forms were previously reachable only from the macOS tray
45    /// (#1228). Every reply carries `count` (pools evicted) plus `disconnected`
46    /// (`count > 0`), so the by-id and pair forms stay wire-compatible with the
47    /// pre-#1228 `{ disconnected }` reply.
48    fn handle_disconnect(&self, payload: &Value) -> Result<Value> {
49        if payload.get("all").and_then(Value::as_bool) == Some(true) {
50            let count = self.engine.disconnect_all();
51            return Ok(json!({ "disconnected": count > 0, "count": count }));
52        }
53        if let Some(id) = payload.get("id").and_then(Value::as_u64) {
54            let removed = self.engine.disconnect_by_id(id);
55            return Ok(json!({ "disconnected": removed, "count": u64::from(removed) }));
56        }
57        let account = payload
58            .get("account")
59            .and_then(Value::as_str)
60            .ok_or_else(|| anyhow!("`disconnect` requires `account`, `id`, or `all`"))?;
61        let user = payload
62            .get("user")
63            .and_then(Value::as_str)
64            .ok_or_else(|| anyhow!("`disconnect` requires `user`"))?;
65        let removed = self.engine.disconnect(account, user);
66        Ok(json!({ "disconnected": removed, "count": u64::from(removed) }))
67    }
68}
69
70#[async_trait]
71impl DaemonService for SnowflakeService {
72    fn name(&self) -> &'static str {
73        SERVICE_NAME
74    }
75
76    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
77        match op {
78            "query" => {
79                let req: QueryRequest =
80                    serde_json::from_value(payload).context("invalid `query` payload")?;
81                if req.sql.trim().is_empty() {
82                    bail!("`query` requires a non-empty `sql`");
83                }
84                self.engine.query(req).await
85            }
86            "sessions" => Ok(json!({ "sessions": self.engine.sessions() })),
87            "disconnect" => self.handle_disconnect(&payload),
88            other => bail!("unknown snowflake op: {other}"),
89        }
90    }
91
92    fn menu(&self) -> MenuSnapshot {
93        let sessions = self.engine.sessions();
94        let items = if sessions.is_empty() {
95            vec![MenuItem::Label("No sessions".to_string())]
96        } else {
97            session_menu_items(&sessions)
98        };
99        MenuSnapshot {
100            title: "Snowflake".to_string(),
101            items,
102        }
103    }
104
105    async fn menu_action(&self, action_id: &str) -> Result<()> {
106        if action_id == "disconnect-all" {
107            self.engine.disconnect_all();
108            return Ok(());
109        }
110        if let Some(id) = action_id.strip_prefix("disconnect:") {
111            let id: u64 = id
112                .parse()
113                .with_context(|| format!("invalid session id in action {action_id}"))?;
114            self.engine.disconnect_by_id(id);
115            return Ok(());
116        }
117        bail!("unknown snowflake menu action: {action_id}")
118    }
119
120    async fn status(&self) -> ServiceStatus {
121        let sessions = self.engine.sessions();
122        let live: usize = sessions.iter().map(|s| s.sessions).sum();
123        ServiceStatus {
124            name: SERVICE_NAME.to_string(),
125            healthy: true,
126            summary: format!("{} pool(s), {live} session(s)", sessions.len()),
127            detail: json!({ "sessions": sessions }),
128        }
129    }
130
131    async fn shutdown(&self) {
132        self.engine.shutdown().await;
133    }
134}
135
136/// Builds the tray items for a non-empty session list: a label per pool, an
137/// indented label per authenticated session (with what it's doing), a separator,
138/// and the per-pool + "Disconnect all" actions.
139fn session_menu_items(sessions: &[SessionInfo]) -> Vec<MenuItem> {
140    let mut items = Vec::new();
141    for session in sessions {
142        items.push(MenuItem::Label(format!(
143            "{} · {} · {}/{} sessions · {} queries",
144            session.account,
145            session.user,
146            session.sessions,
147            session.max_sessions,
148            session.query_count
149        )));
150        // One line per individual authenticated session (auth), with what it's
151        // doing: the running query + elapsed when busy, else idle time.
152        for member in &session.members {
153            let state = if let Some(running) = &member.running {
154                let secs = (Utc::now() - running.started_at).num_seconds().max(0);
155                format!("running {secs}s: {}", running.sql)
156            } else if member.busy {
157                "busy".to_string()
158            } else {
159                let idle = (Utc::now() - member.last_used).num_seconds().max(0);
160                format!("idle {idle}s · {} queries", member.query_count)
161            };
162            items.push(MenuItem::Label(format!(
163                "    #{} {} · {state}",
164                member.id,
165                member.context.summary(),
166            )));
167        }
168    }
169    items.push(MenuItem::Separator);
170    for session in sessions {
171        items.push(MenuItem::Action(MenuAction {
172            id: format!("disconnect:{}", session.id),
173            label: format!("Disconnect {} · {}", session.account, session.user),
174            enabled: true,
175        }));
176    }
177    items.push(MenuItem::Action(MenuAction {
178        id: "disconnect-all".to_string(),
179        label: "Disconnect all".to_string(),
180        enabled: true,
181    }));
182    items
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used)]
187mod tests {
188    use super::*;
189
190    /// A service with no resolvable defaults, so query resolution fails before
191    /// any network/auth — keeping these tests offline and deterministic.
192    fn offline_service() -> SnowflakeService {
193        SnowflakeService::new(SnowflakeEngineConfig::default())
194    }
195
196    #[tokio::test]
197    async fn name_and_unknown_op() {
198        let svc = offline_service();
199        assert_eq!(svc.name(), "snowflake");
200        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
201    }
202
203    #[tokio::test]
204    async fn sessions_op_is_empty_initially() {
205        let svc = offline_service();
206        let payload = svc.handle("sessions", Value::Null).await.unwrap();
207        assert_eq!(payload, json!({ "sessions": [] }));
208    }
209
210    #[tokio::test]
211    async fn empty_sql_is_rejected_before_auth() {
212        let svc = offline_service();
213        assert!(svc.handle("query", json!({ "sql": "   " })).await.is_err());
214    }
215
216    #[tokio::test]
217    async fn query_without_account_errors_not_panics() {
218        let svc = offline_service();
219        // Non-empty SQL but no resolvable account: errors on resolution, no auth.
220        let err = svc
221            .handle("query", json!({ "sql": "SELECT 1" }))
222            .await
223            .unwrap_err();
224        assert!(err.to_string().contains("account"));
225    }
226
227    #[tokio::test]
228    async fn disconnect_requires_account_and_user() {
229        let svc = offline_service();
230        assert!(svc.handle("disconnect", json!({})).await.is_err());
231        assert!(svc
232            .handle("disconnect", json!({ "account": "ACCT" }))
233            .await
234            .is_err());
235        // Both present: evicts nothing on an empty engine, but succeeds.
236        let payload = svc
237            .handle("disconnect", json!({ "account": "ACCT", "user": "me" }))
238            .await
239            .unwrap();
240        assert_eq!(payload, json!({ "disconnected": false, "count": 0 }));
241    }
242
243    #[tokio::test]
244    async fn disconnect_by_id_and_all_selectors() {
245        let svc = offline_service();
246        // Nothing to evict on an empty engine, but both bulk forms succeed and
247        // report a zero count.
248        let by_id = svc.handle("disconnect", json!({ "id": 7 })).await.unwrap();
249        assert_eq!(by_id, json!({ "disconnected": false, "count": 0 }));
250        let all = svc
251            .handle("disconnect", json!({ "all": true }))
252            .await
253            .unwrap();
254        assert_eq!(all, json!({ "disconnected": false, "count": 0 }));
255    }
256
257    #[tokio::test]
258    async fn menu_and_status_shape_with_no_sessions() {
259        let svc = offline_service();
260        let menu = svc.menu();
261        assert_eq!(menu.title, "Snowflake");
262        assert!(matches!(
263            menu.items.first(),
264            Some(MenuItem::Label(text)) if text == "No sessions"
265        ));
266        let status = svc.status().await;
267        assert_eq!(status.name, "snowflake");
268        assert!(status.healthy);
269        assert_eq!(status.summary, "0 pool(s), 0 session(s)");
270    }
271
272    #[tokio::test]
273    async fn menu_actions_route_and_reject_unknown() {
274        let svc = offline_service();
275        // Both forms are no-ops on an empty engine but must not error.
276        svc.menu_action("disconnect-all").await.unwrap();
277        svc.menu_action("disconnect:7").await.unwrap();
278        assert!(svc.menu_action("disconnect:not-a-number").await.is_err());
279        assert!(svc.menu_action("bogus").await.is_err());
280        svc.shutdown().await;
281    }
282
283    #[test]
284    fn session_menu_items_render_each_member_state_and_actions() {
285        use crate::snowflake::session::{MemberInfo, QueryContext, RunningQuery};
286
287        let now = Utc::now();
288        let wh_ctx = QueryContext {
289            warehouse: Some("WH".to_string()),
290            role: Some("R".to_string()),
291            ..QueryContext::default()
292        };
293        let sessions = vec![SessionInfo {
294            id: 5,
295            account: "ACME".to_string(),
296            user: "me".to_string(),
297            created_at: now,
298            last_used: now,
299            query_count: 9,
300            sessions: 3,
301            max_sessions: 4,
302            members: vec![
303                MemberInfo {
304                    id: 1,
305                    busy: true,
306                    context: wh_ctx,
307                    last_used: now,
308                    query_count: 3,
309                    running: Some(RunningQuery {
310                        sql: "SELECT 42".to_string(),
311                        started_at: now,
312                    }),
313                },
314                MemberInfo {
315                    id: 2,
316                    busy: true,
317                    context: QueryContext::default(),
318                    last_used: now,
319                    query_count: 1,
320                    running: None,
321                },
322                MemberInfo {
323                    id: 3,
324                    busy: false,
325                    context: QueryContext::default(),
326                    last_used: now,
327                    query_count: 0,
328                    running: None,
329                },
330            ],
331        }];
332
333        let items = session_menu_items(&sessions);
334        let labels: Vec<&str> = items
335            .iter()
336            .filter_map(|i| match i {
337                MenuItem::Label(t) => Some(t.as_str()),
338                _ => None,
339            })
340            .collect();
341        assert!(labels
342            .iter()
343            .any(|l| l.contains("ACME · me · 3/4 sessions · 9 queries")));
344        assert!(labels
345            .iter()
346            .any(|l| l.contains("running") && l.contains("SELECT 42") && l.contains("WH/R")));
347        assert!(labels.iter().any(|l| l.contains("busy")));
348        assert!(labels
349            .iter()
350            .any(|l| l.contains("idle") && l.contains("(default)")));
351
352        assert!(items.iter().any(|i| matches!(i, MenuItem::Separator)));
353        let action_ids: Vec<&str> = items
354            .iter()
355            .filter_map(|i| match i {
356                MenuItem::Action(a) => Some(a.id.as_str()),
357                _ => None,
358            })
359            .collect();
360        assert!(action_ids.contains(&"disconnect:5"));
361        assert!(action_ids.contains(&"disconnect-all"));
362    }
363}