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