Skip to main content

vs_daemon/daemon/
mod.rs

1//! The [`Daemon`] — the brain of the vibesurfer daemon.
2//!
3//! Owns the SQLite store, the engine runtime, and the in-memory session
4//! cache. Each primitive is a `pub fn` on [`Daemon`] living in one of
5//! the per-group submodules ([`lifecycle`], [`page_ops`], [`store_ops`],
6//! [`engine_ops`]); shared helpers (audit, session lookup, key
7//! resolution) plus the [`Daemon`] struct + builders live here.
8//!
9//! Concurrency: every public method is `&self` and acquires
10//! fine-grained locks on the session map. Engine calls are dispatched
11//! onto the engine thread via `EngineRuntime`; the daemon's own state
12//! is protected by `std::sync::Mutex`.
13
14mod audit;
15pub mod responses;
16
17mod engine_ops;
18mod lifecycle;
19mod page_ops;
20mod store_ops;
21
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use uuid::Uuid;
27use vs_engine_webkit::EngineRuntime;
28use vs_protocol::{Node, StateToken};
29use vs_store::{ActionInsert, Store};
30
31use crate::error::{DaemonError, Result};
32use crate::page_state::PageState;
33
34pub(crate) use audit::AuditCtx;
35pub use responses::{
36    ActCall, ActResponse, AnnotateResponse, AuthClearResponse, AuthListResponse, AuthLoadResponse,
37    AuthSaveResponse, CaptureResponse, CloseResponse, ExtractResponse, FindHit, FindResponse,
38    LayoutResponse, LogResponse, MarkResponse, OpenResponse, ReadResponse, SessionCloseResponse,
39    SessionOpenResponse, SkillListResponse, SkillShowResponse, StatusResponse, ViewResponse,
40    ViewportResponse, WaitResponse,
41};
42
43/// One in-memory session.
44#[derive(Debug)]
45pub(crate) struct SessionState {
46    pub(crate) pages: HashMap<String, PageState>,
47}
48
49impl SessionState {
50    pub(crate) fn new() -> Self {
51        Self {
52            pages: HashMap::new(),
53        }
54    }
55}
56
57/// Shared daemon state. Cheap to clone (it's an `Arc` inside).
58#[derive(Clone)]
59pub struct Daemon {
60    pub(crate) inner: Arc<Inner>,
61}
62
63pub(crate) struct Inner {
64    pub(crate) store: Mutex<Store>,
65    pub(crate) engine: Arc<EngineRuntime>,
66    pub(crate) sessions: Mutex<HashMap<String, SessionState>>,
67    pub(crate) captures_dir: std::path::PathBuf,
68    pub(crate) skills_dir: std::path::PathBuf,
69    pub(crate) master_key: Option<vs_store::MasterKey>,
70}
71
72impl Daemon {
73    /// Build a daemon around an existing store and engine. Optional
74    /// fields default to sensible values; tune via the `with_*` chain.
75    #[must_use]
76    pub fn new(store: Store, engine: Arc<EngineRuntime>) -> Self {
77        Self {
78            inner: Arc::new(Inner {
79                store: Mutex::new(store),
80                engine,
81                sessions: Mutex::new(HashMap::new()),
82                captures_dir: std::env::temp_dir().join("vibesurfer-captures"),
83                skills_dir: std::path::PathBuf::from("./skills"),
84                master_key: None,
85            }),
86        }
87    }
88
89    /// Pin the on-disk path where `vs_capture` writes images.
90    /// Must run before the daemon is [`Arc::clone`]d.
91    #[must_use]
92    pub fn with_captures_dir(self, dir: impl Into<std::path::PathBuf>) -> Self {
93        let mut inner = Arc::try_unwrap(self.inner)
94            .map_err(|_| ())
95            .expect("Daemon::with_captures_dir must run before any clone of the daemon handle");
96        inner.captures_dir = dir.into();
97        Self {
98            inner: Arc::new(inner),
99        }
100    }
101
102    /// Pin the on-disk path where `vs_skill` looks up composed skills.
103    #[must_use]
104    pub fn with_skills_dir(self, dir: impl Into<std::path::PathBuf>) -> Self {
105        let mut inner = Arc::try_unwrap(self.inner)
106            .map_err(|_| ())
107            .expect("Daemon::with_skills_dir must run before any clone of the daemon handle");
108        inner.skills_dir = dir.into();
109        Self {
110            inner: Arc::new(inner),
111        }
112    }
113
114    /// Pin the master key used by `vs_auth save|load`. Without this,
115    /// auth-modifying primitives return `BadRequest "no master key"`.
116    #[must_use]
117    pub fn with_master_key(self, key: vs_store::MasterKey) -> Self {
118        let mut inner = Arc::try_unwrap(self.inner)
119            .map_err(|_| ())
120            .expect("Daemon::with_master_key must run before any clone of the daemon handle");
121        inner.master_key = Some(key);
122        Self {
123            inner: Arc::new(inner),
124        }
125    }
126
127    /// Wrap a primitive body so that `actions` is written exactly
128    /// once, regardless of `Ok`/`Err`. The closure receives `&mut
129    /// AuditCtx` and may mutate it as the call learns information.
130    pub(crate) fn audit_call<R, F>(&self, mut ctx: AuditCtx, f: F) -> Result<R>
131    where
132        F: FnOnce(&mut AuditCtx) -> Result<R>,
133    {
134        let started = Instant::now();
135        let result = f(&mut ctx);
136        let error_code = result.as_ref().err().map(|e| e.wire().0.to_string());
137        self.audit_from_ctx(&ctx, started.elapsed(), error_code)?;
138        result
139    }
140
141    /// Persist an audit row from a finished [`AuditCtx`].
142    fn audit_from_ctx(
143        &self,
144        ctx: &AuditCtx,
145        latency: Duration,
146        error_code: Option<String>,
147    ) -> Result<()> {
148        let now = vs_store::epoch_secs();
149        let row = ActionInsert {
150            session_id: ctx.session_id.clone(),
151            page_id: ctx.page_id.clone(),
152            primitive: ctx.primitive.to_string(),
153            args_redacted: ctx.args_redacted.clone(),
154            args_hash: ctx.args_hash.clone(),
155            before_token: ctx.before_token.map(|t| t.to_string()),
156            after_token: ctx.after_token.map(|t| t.to_string()),
157            idempotency_hit: ctx.idempotency_hit,
158            result_summary: ctx.result_summary.clone(),
159            latency_ms: i64::try_from(latency.as_millis()).unwrap_or(i64::MAX),
160            group_label: ctx.group_label.clone(),
161            started_at: now,
162            finished_at: now,
163            error_code,
164        };
165        self.inner
166            .store
167            .lock()
168            .expect("poisoned")
169            .record_action(&row)?;
170        Ok(())
171    }
172
173    pub(crate) fn require_session(&self, session_id: &str) -> Result<()> {
174        if !self
175            .inner
176            .sessions
177            .lock()
178            .expect("poisoned")
179            .contains_key(session_id)
180        {
181            return Err(DaemonError::UnknownSession(session_id.to_string()));
182        }
183        Ok(())
184    }
185
186    pub(crate) fn require_master_key(&self) -> Result<&vs_store::MasterKey> {
187        self.inner
188            .master_key
189            .as_ref()
190            .ok_or(DaemonError::BadRequest(
191                "no master key configured; daemon was not started with one".into(),
192            ))
193    }
194
195    pub(crate) fn engine_handle_for(
196        &self,
197        session_id: &str,
198        page_id: &str,
199    ) -> Result<vs_engine_webkit::PageHandle> {
200        let sessions = self.inner.sessions.lock().expect("poisoned");
201        let session = sessions
202            .get(session_id)
203            .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?;
204        let page = session
205            .pages
206            .get(page_id)
207            .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
208        Ok(page.engine_handle)
209    }
210
211    pub(crate) fn current_token(&self, session_id: &str, page_id: &str) -> Result<StateToken> {
212        let sessions = self.inner.sessions.lock().expect("poisoned");
213        let page = sessions
214            .get(session_id)
215            .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
216            .pages
217            .get(page_id)
218            .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
219        Ok(page.last_token.unwrap_or(StateToken::ZERO))
220    }
221
222    /// Direct read access for tests.
223    #[doc(hidden)]
224    pub fn audit_log(&self, filter: &vs_store::ActionFilter) -> Result<Vec<vs_store::Action>> {
225        Ok(self
226            .inner
227            .store
228            .lock()
229            .expect("poisoned")
230            .list_actions(filter)?)
231    }
232
233    /// Snapshot the engine's console ring buffer for `page`.
234    pub fn inspect_console(
235        &self,
236        session_id: &str,
237        page_id: &str,
238    ) -> Result<Vec<vs_engine_webkit::inspector::ConsoleEntry>> {
239        let ctx = AuditCtx::new("vs_inspect", session_id)
240            .with_page(page_id)
241            .with_args(
242                "console".into(),
243                crate::tokens::args_hash("vs_inspect", &["console".into()]),
244            );
245        self.audit_call(ctx, |ctx| {
246            self.require_session(session_id)?;
247            require_capability(self, |c| c.inspector_console, "vs_inspect console")?;
248            let handle = self.engine_handle_for(session_id, page_id)?;
249            let entries = self.inner.engine.console_entries(handle)?;
250            ctx.after_token = Some(self.current_token(session_id, page_id)?);
251            Ok(entries)
252        })
253    }
254
255    /// Snapshot the engine's network ring buffer for `page`.
256    pub fn inspect_network(
257        &self,
258        session_id: &str,
259        page_id: &str,
260    ) -> Result<Vec<vs_engine_webkit::inspector::NetworkEntry>> {
261        let ctx = AuditCtx::new("vs_inspect", session_id)
262            .with_page(page_id)
263            .with_args(
264                "network".into(),
265                crate::tokens::args_hash("vs_inspect", &["network".into()]),
266            );
267        self.audit_call(ctx, |ctx| {
268            self.require_session(session_id)?;
269            require_capability(self, |c| c.inspector_network, "vs_inspect network")?;
270            let handle = self.engine_handle_for(session_id, page_id)?;
271            let entries = self.inner.engine.network_entries(handle)?;
272            ctx.after_token = Some(self.current_token(session_id, page_id)?);
273            Ok(entries)
274        })
275    }
276    /// Look up the full detail (headers + bodies) for a captured
277    /// network request by `seq`.
278    pub fn inspect_request(
279        &self,
280        session_id: &str,
281        page_id: &str,
282        seq: u64,
283    ) -> Result<Option<vs_engine_webkit::inspector::RequestDetail>> {
284        let ctx = AuditCtx::new("vs_inspect", session_id)
285            .with_page(page_id)
286            .with_args(
287                format!("request {seq}"),
288                crate::tokens::args_hash("vs_inspect", &["request".into(), seq.to_string()]),
289            );
290        self.audit_call(ctx, |ctx| {
291            self.require_session(session_id)?;
292            require_capability(self, |c| c.inspector_network, "vs_inspect request")?;
293            let handle = self.engine_handle_for(session_id, page_id)?;
294            let detail = self.inner.engine.request_detail(handle, seq)?;
295            ctx.after_token = Some(self.current_token(session_id, page_id)?);
296            Ok(detail)
297        })
298    }
299
300    pub fn inspect_eval(
301        &self,
302        session_id: &str,
303        page_id: &str,
304        expr: &str,
305    ) -> Result<vs_engine_webkit::inspector::EvalResult> {
306        let redacted_expr = crate::redact::redact_string(expr);
307        let ctx = AuditCtx::new("vs_inspect", session_id)
308            .with_page(page_id)
309            .with_args(
310                format!("eval {redacted_expr}"),
311                crate::tokens::args_hash("vs_inspect", &["eval".into(), redacted_expr.clone()]),
312            );
313        self.audit_call(ctx, |ctx| {
314            self.require_session(session_id)?;
315            let handle = self.engine_handle_for(session_id, page_id)?;
316            let r = self.inner.engine.eval_js(handle, expr)?;
317            ctx.after_token = Some(self.current_token(session_id, page_id)?);
318            Ok(r)
319        })
320    }
321
322    pub fn inspect_storage(
323        &self,
324        session_id: &str,
325        page_id: &str,
326        scope: vs_engine_webkit::inspector::StorageScope,
327    ) -> Result<Vec<vs_engine_webkit::inspector::StorageEntry>> {
328        let ctx = AuditCtx::new("vs_inspect", session_id)
329            .with_page(page_id)
330            .with_args(
331                format!("storage {}", scope.as_str()),
332                crate::tokens::args_hash("vs_inspect", &["storage".into(), scope.as_str().into()]),
333            );
334        self.audit_call(ctx, |ctx| {
335            self.require_session(session_id)?;
336            let handle = self.engine_handle_for(session_id, page_id)?;
337            let entries = self.inner.engine.storage(handle, scope)?;
338            ctx.after_token = Some(self.current_token(session_id, page_id)?);
339            Ok(entries)
340        })
341    }
342
343    pub fn inspect_cookie_events(
344        &self,
345        session_id: &str,
346        page_id: &str,
347    ) -> Result<Vec<vs_engine_webkit::inspector::CookieEvent>> {
348        let ctx = AuditCtx::new("vs_inspect", session_id)
349            .with_page(page_id)
350            .with_args(
351                "cookie-events".to_string(),
352                crate::tokens::args_hash("vs_inspect", &["cookie-events".into()]),
353            );
354        self.audit_call(ctx, |ctx| {
355            self.require_session(session_id)?;
356            let handle = self.engine_handle_for(session_id, page_id)?;
357            let events = self.inner.engine.cookie_events(handle)?;
358            ctx.after_token = Some(self.current_token(session_id, page_id)?);
359            Ok(events)
360        })
361    }
362
363    pub fn cursor_op(
364        &self,
365        session_id: &str,
366        page_id: &str,
367        op: vs_engine_webkit::engine::CursorOp,
368        mode: vs_engine_webkit::engine::InputMode,
369    ) -> Result<vs_protocol::StateToken> {
370        let ctx = AuditCtx::new("vs_cursor_op", session_id)
371            .with_page(page_id)
372            .with_args(
373                format!("{op:?} mode={}", mode.as_str()),
374                crate::tokens::args_hash(
375                    "vs_cursor_op",
376                    &[format!("{op:?}"), mode.as_str().into()],
377                ),
378            );
379        self.audit_call(ctx, |ctx| {
380            self.require_session(session_id)?;
381            let handle = self.engine_handle_for(session_id, page_id)?;
382            self.inner.engine.cursor_op(handle, op, mode)?;
383            let token = self.current_token(session_id, page_id)?;
384            ctx.after_token = Some(token);
385            Ok(token)
386        })
387    }
388
389    pub fn inspect_scripts(
390        &self,
391        session_id: &str,
392        page_id: &str,
393    ) -> Result<Vec<vs_engine_webkit::inspector::ScriptEntry>> {
394        let ctx = AuditCtx::new("vs_inspect", session_id)
395            .with_page(page_id)
396            .with_args(
397                "scripts".into(),
398                crate::tokens::args_hash("vs_inspect", &["scripts".into()]),
399            );
400        self.audit_call(ctx, |ctx| {
401            self.require_session(session_id)?;
402            let handle = self.engine_handle_for(session_id, page_id)?;
403            let entries = self.inner.engine.scripts(handle)?;
404            ctx.after_token = Some(self.current_token(session_id, page_id)?);
405            Ok(entries)
406        })
407    }
408
409    pub fn inspect_script_source(
410        &self,
411        session_id: &str,
412        page_id: &str,
413        seq: u64,
414    ) -> Result<Option<vs_engine_webkit::inspector::ScriptSource>> {
415        let ctx = AuditCtx::new("vs_inspect", session_id)
416            .with_page(page_id)
417            .with_args(
418                format!("script {seq}"),
419                crate::tokens::args_hash("vs_inspect", &["script".into(), seq.to_string()]),
420            );
421        self.audit_call(ctx, |ctx| {
422            self.require_session(session_id)?;
423            let handle = self.engine_handle_for(session_id, page_id)?;
424            let src = self.inner.engine.script_source(handle, seq)?;
425            ctx.after_token = Some(self.current_token(session_id, page_id)?);
426            Ok(src)
427        })
428    }
429
430    pub fn inspect_dom(
431        &self,
432        session_id: &str,
433        page_id: &str,
434        r: vs_protocol::Ref,
435        extra_props: Vec<String>,
436    ) -> Result<Option<vs_engine_webkit::inspector::DomDetail>> {
437        let ctx = AuditCtx::new("vs_inspect", session_id)
438            .with_page(page_id)
439            .with_args(
440                format!("dom {}", r.0),
441                crate::tokens::args_hash("vs_inspect", &["dom".into(), r.0.to_string()]),
442            );
443        self.audit_call(ctx, |ctx| {
444            self.require_session(session_id)?;
445            let handle = self.engine_handle_for(session_id, page_id)?;
446            let d = self.inner.engine.dom(handle, r, extra_props)?;
447            ctx.after_token = Some(self.current_token(session_id, page_id)?);
448            Ok(d)
449        })
450    }
451
452    pub fn inspect_performance(
453        &self,
454        session_id: &str,
455        page_id: &str,
456    ) -> Result<vs_engine_webkit::inspector::PerformanceMetrics> {
457        let ctx = AuditCtx::new("vs_inspect", session_id)
458            .with_page(page_id)
459            .with_args(
460                "performance".into(),
461                crate::tokens::args_hash("vs_inspect", &["performance".into()]),
462            );
463        self.audit_call(ctx, |ctx| {
464            self.require_session(session_id)?;
465            let handle = self.engine_handle_for(session_id, page_id)?;
466            let m = self.inner.engine.performance(handle)?;
467            ctx.after_token = Some(self.current_token(session_id, page_id)?);
468            Ok(m)
469        })
470    }
471    /// order; per-primitive failures (parse errors, unknown sessions,
472    /// stale tokens, etc.) produce inline error envelopes but do not
473    /// abort the rest of the sequence.
474    ///
475    /// Audit rows are written per primitive — a sequence does not
476    /// become one audit row.
477    ///
478    /// Today every wire frame parses to exactly one [`Primitive`]
479    /// (i.e. one [`Request`](vs_protocol::Request)), so the inbound
480    /// vec has length 1. Composite-flag primitives (PRs 2–6 of
481    /// M5.5) and the v2 wire pipeline syntax (ADR 0007) both feed
482    #[must_use]
483    pub fn dispatch(
484        &self,
485        primitives: &[crate::dispatch::Primitive],
486    ) -> Vec<crate::dispatch::DispatchOutcome> {
487        primitives
488            .iter()
489            .map(|p| crate::dispatch::DispatchOutcome::from_wire(crate::server::dispatch(self, p)))
490            .collect()
491    }
492}
493
494pub(crate) fn short_id() -> String {
495    // Take 16 hex chars (64 bits): 12 chars of v7 ms timestamp + 4 chars
496    // of (version + 12 bits of v7 rand_a). Truncating to just the
497    // timestamp prefix collided whenever two ids were generated in the
498    // same millisecond — annotate-twice tests were flaky for exactly
499    // that reason. 64 bits is still short on the wire and far below the
500    // Take 24 hex chars (96 bits) — includes 12 hex of v7 timestamp,
501    // version + rand_a (12 bits), variant + ~30 bits of rand_b. The
502    // earlier 16-char form was still flaky in tests because rand_a is
503    // only 12 bits of randomness and uuid v7 implementations can use
504    // it as a counter rather than fresh random per-call. 24 chars
505    // gives us enough of rand_b that collision probability per pair is
506    // ~2^-30, vanishing for any test process.
507    Uuid::now_v7().simple().to_string()[..24].to_string()
508}
509
510pub(crate) fn render_subtree_text(node: &Node) -> String {
511    let mut out = String::new();
512    render_node_text(node, 0, &mut out);
513    out
514}
515
516fn render_node_text(node: &Node, depth: usize, out: &mut String) {
517    use std::fmt::Write as _;
518    for _ in 0..depth {
519        out.push_str("  ");
520    }
521    let _ = write!(out, "[{}] {}: {}", node.r, node.role, node.label);
522    out.push('\n');
523    for child in &node.children {
524        render_node_text(child, depth + 1, out);
525    }
526}
527
528/// Wire the capability gate: query the engine's current capabilities,
529/// route the requested flag through `pick`, and surface
530/// `EngineError::Unsupported` cleanly when the install path didn't
531/// succeed for this engine instance. Used by every `vs_inspect` daemon
532/// method to keep the wire honest — `! ENGINE_UNSUPPORTED <op>` flows
533/// out instead of an empty buffer that lies about coverage.
534fn require_capability<F>(daemon: &Daemon, pick: F, op: &'static str) -> Result<()>
535where
536    F: FnOnce(&vs_engine_webkit::EngineCapabilities) -> bool,
537{
538    let caps = daemon.inner.engine.capabilities()?;
539    if pick(&caps) {
540        Ok(())
541    } else {
542        Err(crate::error::DaemonError::Engine(
543            vs_engine_webkit::EngineError::Unsupported {
544                engine: caps.name,
545                primitive: op,
546            },
547        ))
548    }
549}