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_scripts(
344        &self,
345        session_id: &str,
346        page_id: &str,
347    ) -> Result<Vec<vs_engine_webkit::inspector::ScriptEntry>> {
348        let ctx = AuditCtx::new("vs_inspect", session_id)
349            .with_page(page_id)
350            .with_args(
351                "scripts".into(),
352                crate::tokens::args_hash("vs_inspect", &["scripts".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 entries = self.inner.engine.scripts(handle)?;
358            ctx.after_token = Some(self.current_token(session_id, page_id)?);
359            Ok(entries)
360        })
361    }
362
363    pub fn inspect_script_source(
364        &self,
365        session_id: &str,
366        page_id: &str,
367        seq: u64,
368    ) -> Result<Option<vs_engine_webkit::inspector::ScriptSource>> {
369        let ctx = AuditCtx::new("vs_inspect", session_id)
370            .with_page(page_id)
371            .with_args(
372                format!("script {seq}"),
373                crate::tokens::args_hash("vs_inspect", &["script".into(), seq.to_string()]),
374            );
375        self.audit_call(ctx, |ctx| {
376            self.require_session(session_id)?;
377            let handle = self.engine_handle_for(session_id, page_id)?;
378            let src = self.inner.engine.script_source(handle, seq)?;
379            ctx.after_token = Some(self.current_token(session_id, page_id)?);
380            Ok(src)
381        })
382    }
383
384    pub fn inspect_dom(
385        &self,
386        session_id: &str,
387        page_id: &str,
388        r: vs_protocol::Ref,
389        extra_props: Vec<String>,
390    ) -> Result<Option<vs_engine_webkit::inspector::DomDetail>> {
391        let ctx = AuditCtx::new("vs_inspect", session_id)
392            .with_page(page_id)
393            .with_args(
394                format!("dom {}", r.0),
395                crate::tokens::args_hash("vs_inspect", &["dom".into(), r.0.to_string()]),
396            );
397        self.audit_call(ctx, |ctx| {
398            self.require_session(session_id)?;
399            let handle = self.engine_handle_for(session_id, page_id)?;
400            let d = self.inner.engine.dom(handle, r, extra_props)?;
401            ctx.after_token = Some(self.current_token(session_id, page_id)?);
402            Ok(d)
403        })
404    }
405
406    pub fn inspect_performance(
407        &self,
408        session_id: &str,
409        page_id: &str,
410    ) -> Result<vs_engine_webkit::inspector::PerformanceMetrics> {
411        let ctx = AuditCtx::new("vs_inspect", session_id)
412            .with_page(page_id)
413            .with_args(
414                "performance".into(),
415                crate::tokens::args_hash("vs_inspect", &["performance".into()]),
416            );
417        self.audit_call(ctx, |ctx| {
418            self.require_session(session_id)?;
419            let handle = self.engine_handle_for(session_id, page_id)?;
420            let m = self.inner.engine.performance(handle)?;
421            ctx.after_token = Some(self.current_token(session_id, page_id)?);
422            Ok(m)
423        })
424    }
425    /// order; per-primitive failures (parse errors, unknown sessions,
426    /// stale tokens, etc.) produce inline error envelopes but do not
427    /// abort the rest of the sequence.
428    ///
429    /// Audit rows are written per primitive — a sequence does not
430    /// become one audit row.
431    ///
432    /// Today every wire frame parses to exactly one [`Primitive`]
433    /// (i.e. one [`Request`](vs_protocol::Request)), so the inbound
434    /// vec has length 1. Composite-flag primitives (PRs 2–6 of
435    /// M5.5) and the v2 wire pipeline syntax (ADR 0007) both feed
436    #[must_use]
437    pub fn dispatch(
438        &self,
439        primitives: &[crate::dispatch::Primitive],
440    ) -> Vec<crate::dispatch::DispatchOutcome> {
441        primitives
442            .iter()
443            .map(|p| crate::dispatch::DispatchOutcome::from_wire(crate::server::dispatch(self, p)))
444            .collect()
445    }
446}
447
448pub(crate) fn short_id() -> String {
449    // Take 16 hex chars (64 bits): 12 chars of v7 ms timestamp + 4 chars
450    // of (version + 12 bits of v7 rand_a). Truncating to just the
451    // timestamp prefix collided whenever two ids were generated in the
452    // same millisecond — annotate-twice tests were flaky for exactly
453    // that reason. 64 bits is still short on the wire and far below the
454    // Take 24 hex chars (96 bits) — includes 12 hex of v7 timestamp,
455    // version + rand_a (12 bits), variant + ~30 bits of rand_b. The
456    // earlier 16-char form was still flaky in tests because rand_a is
457    // only 12 bits of randomness and uuid v7 implementations can use
458    // it as a counter rather than fresh random per-call. 24 chars
459    // gives us enough of rand_b that collision probability per pair is
460    // ~2^-30, vanishing for any test process.
461    Uuid::now_v7().simple().to_string()[..24].to_string()
462}
463
464pub(crate) fn render_subtree_text(node: &Node) -> String {
465    let mut out = String::new();
466    render_node_text(node, 0, &mut out);
467    out
468}
469
470fn render_node_text(node: &Node, depth: usize, out: &mut String) {
471    use std::fmt::Write as _;
472    for _ in 0..depth {
473        out.push_str("  ");
474    }
475    let _ = write!(out, "[{}] {}: {}", node.r, node.role, node.label);
476    out.push('\n');
477    for child in &node.children {
478        render_node_text(child, depth + 1, out);
479    }
480}
481
482/// Wire the capability gate: query the engine's current capabilities,
483/// route the requested flag through `pick`, and surface
484/// `EngineError::Unsupported` cleanly when the install path didn't
485/// succeed for this engine instance. Used by every `vs_inspect` daemon
486/// method to keep the wire honest — `! ENGINE_UNSUPPORTED <op>` flows
487/// out instead of an empty buffer that lies about coverage.
488fn require_capability<F>(daemon: &Daemon, pick: F, op: &'static str) -> Result<()>
489where
490    F: FnOnce(&vs_engine_webkit::EngineCapabilities) -> bool,
491{
492    let caps = daemon.inner.engine.capabilities()?;
493    if pick(&caps) {
494        Ok(())
495    } else {
496        Err(crate::error::DaemonError::Engine(
497            vs_engine_webkit::EngineError::Unsupported {
498                engine: caps.name,
499                primitive: op,
500            },
501        ))
502    }
503}