Skip to main content

vs_daemon/daemon/
engine_ops.rs

1//! Engine-backed primitives: `vs_skill`, `vs_capture`, `vs_viewport`,
2//! `vs_layout`, `vs_auth`.
3
4use vs_engine_webkit::{AuthBlob, CaptureScope, Viewport};
5use vs_protocol::{Ref, StateToken, Warning, WarningCode};
6
7use super::audit::AuditCtx;
8use super::responses::{
9    AuthClearResponse, AuthListResponse, AuthLoadResponse, AuthSaveResponse, CaptureResponse,
10    LayoutResponse, SkillListResponse, SkillShowResponse, ViewportResponse,
11};
12use super::Daemon;
13use crate::error::{DaemonError, Result};
14use crate::tokens;
15
16impl Daemon {
17    /// List skills available in `skills_dir`. Execution dispatch lands
18    /// in M6.
19    pub fn skill_list(&self, session_id: &str) -> Result<SkillListResponse> {
20        let ctx = AuditCtx::new("vs_skill", session_id).with_args(
21            "list".into(),
22            tokens::args_hash("vs_skill", &["list".into()]),
23        );
24        self.audit_call(ctx, |_ctx| {
25            self.require_session(session_id)?;
26            let mut names = Vec::new();
27            let entries = match std::fs::read_dir(&self.inner.skills_dir) {
28                Ok(it) => it,
29                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
30                    return Ok(SkillListResponse { names });
31                }
32                Err(e) => return Err(DaemonError::Io(e)),
33            };
34            for entry in entries.flatten() {
35                if entry.file_type().is_ok_and(|t| t.is_dir()) {
36                    if let Some(n) = entry.file_name().to_str() {
37                        names.push(n.to_string());
38                    }
39                }
40            }
41            names.sort();
42            Ok(SkillListResponse { names })
43        })
44    }
45
46    /// Show the SKILL.md for a named skill.
47    pub fn skill_show(&self, session_id: &str, name: &str) -> Result<SkillShowResponse> {
48        let ctx = AuditCtx::new("vs_skill", session_id).with_args(
49            format!("show {name}"),
50            tokens::args_hash("vs_skill", &["show".into(), name.to_string()]),
51        );
52        self.audit_call(ctx, |_ctx| {
53            self.require_session(session_id)?;
54            let path = self.inner.skills_dir.join(name).join("SKILL.md");
55            let body = std::fs::read_to_string(&path).map_err(|e| match e.kind() {
56                std::io::ErrorKind::NotFound => DaemonError::BadRequest(format!(
57                    "skill not found: {name} (looked in {})",
58                    path.display()
59                )),
60                _ => DaemonError::Io(e),
61            })?;
62            Ok(SkillShowResponse { body })
63        })
64    }
65
66    /// Take a screenshot. Returns the on-disk path; bytes are not
67    /// inlined per `docs/PROTOCOL.md`.
68    pub fn capture(
69        &self,
70        session_id: &str,
71        page_id: &str,
72        scope: CaptureScope,
73    ) -> Result<CaptureResponse> {
74        let scope_label = match &scope {
75            CaptureScope::Viewport => "viewport".to_string(),
76            CaptureScope::Element(r) => format!("ref:{r}"),
77            CaptureScope::FullPage => "full-page".to_string(),
78        };
79        let ctx = AuditCtx::new("vs_capture", session_id)
80            .with_page(page_id)
81            .with_args(
82                scope_label.clone(),
83                tokens::args_hash("vs_capture", &[scope_label]),
84            );
85        self.audit_call(ctx, |ctx| {
86            let engine_handle = self.engine_handle_for(session_id, page_id)?;
87            std::fs::create_dir_all(&self.inner.captures_dir).map_err(DaemonError::Io)?;
88            let path = self.inner.engine.capture(engine_handle, scope)?;
89            let token = self
90                .current_token(session_id, page_id)
91                .unwrap_or(StateToken::ZERO);
92            ctx.after_token = Some(token);
93            ctx.result_summary = Some(path.display().to_string());
94            Ok(CaptureResponse { path, token })
95        })
96    }
97
98    /// Set the page viewport. Triggers a fresh-full re-baseline on the
99    /// next `vs_view` (the protocol's `? viewport_changed` warning is
100    /// the agent-visible cue).
101    pub fn viewport(
102        &self,
103        session_id: &str,
104        page_id: &str,
105        viewport: Viewport,
106    ) -> Result<ViewportResponse> {
107        let arg = format!("{}x{}@{}dpr", viewport.width, viewport.height, viewport.dpr);
108        let ctx = AuditCtx::new("vs_viewport", session_id)
109            .with_page(page_id)
110            .with_args(arg.clone(), tokens::args_hash("vs_viewport", &[arg]));
111        self.audit_call(ctx, |ctx| {
112            let engine_handle = self.engine_handle_for(session_id, page_id)?;
113            self.inner.engine.set_viewport(engine_handle, viewport)?;
114            {
115                let mut sessions = self.inner.sessions.lock().expect("poisoned");
116                let page = sessions
117                    .get_mut(session_id)
118                    .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
119                    .pages
120                    .get_mut(page_id)
121                    .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
122                page.invalidate_baseline();
123            }
124            let tree = self.inner.engine.snapshot(engine_handle)?;
125            let (token, _form) = {
126                let mut sessions = self.inner.sessions.lock().expect("poisoned");
127                let page = sessions
128                    .get_mut(session_id)
129                    .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
130                    .pages
131                    .get_mut(page_id)
132                    .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
133                let result = page.apply_snapshot(tree);
134                // Leave force_full=true so the *next* vs_view emits a
135                // fresh full tree per docs/PROTOCOL.md.
136                page.invalidate_baseline();
137                result
138            };
139            ctx.after_token = Some(token);
140
141            let mut store = self.inner.store.lock().expect("poisoned");
142            store.update_page_token(page_id, &token.to_string(), "engine", None)?;
143            drop(store);
144            Ok(ViewportResponse {
145                token,
146                warnings: vec![Warning::with_args(
147                    WarningCode::ViewportChanged,
148                    vec![format!("{}x{}", viewport.width, viewport.height)],
149                )],
150            })
151        })
152    }
153
154    pub fn layout(
155        &self,
156        session_id: &str,
157        page_id: &str,
158        refs: Vec<Ref>,
159    ) -> Result<LayoutResponse> {
160        let arg_strs: Vec<String> = refs.iter().map(Ref::to_string).collect();
161        let ctx = AuditCtx::new("vs_layout", session_id)
162            .with_page(page_id)
163            .with_args(
164                arg_strs.join(" "),
165                tokens::args_hash("vs_layout", &arg_strs),
166            );
167        self.audit_call(ctx, |ctx| {
168            let engine_handle = self.engine_handle_for(session_id, page_id)?;
169            let boxes = self.inner.engine.layout(engine_handle, refs)?;
170            let token = self
171                .current_token(session_id, page_id)
172                .unwrap_or(StateToken::ZERO);
173            ctx.after_token = Some(token);
174            Ok(LayoutResponse { boxes, token })
175        })
176    }
177
178    pub fn auth_save(
179        &self,
180        session_id: &str,
181        page_id: &str,
182        name: &str,
183    ) -> Result<AuthSaveResponse> {
184        let ctx = AuditCtx::new("vs_auth", session_id)
185            .with_page(page_id)
186            .with_args(
187                format!("save {name}"),
188                tokens::args_hash("vs_auth", &["save".into(), name.to_string()]),
189            );
190        self.audit_call(ctx, |_ctx| {
191            let key = self.require_master_key()?;
192            let engine_handle = self.engine_handle_for(session_id, page_id)?;
193            let blob = self.inner.engine.save_auth(engine_handle)?;
194            let mut store = self.inner.store.lock().expect("poisoned");
195            store.save_auth(name, key, &blob.bytes)?;
196            Ok(AuthSaveResponse {
197                name: name.to_string(),
198            })
199        })
200    }
201
202    pub fn auth_load(
203        &self,
204        session_id: &str,
205        page_id: &str,
206        name: &str,
207    ) -> Result<AuthLoadResponse> {
208        let ctx = AuditCtx::new("vs_auth", session_id)
209            .with_page(page_id)
210            .with_args(
211                format!("load {name}"),
212                tokens::args_hash("vs_auth", &["load".into(), name.to_string()]),
213            );
214        self.audit_call(ctx, |ctx| {
215            let key = self.require_master_key()?;
216            let engine_handle = self.engine_handle_for(session_id, page_id)?;
217            let bytes = {
218                let mut store = self.inner.store.lock().expect("poisoned");
219                store.load_auth(name, key)?
220            };
221            self.inner
222                .engine
223                .load_auth(engine_handle, AuthBlob { bytes })?;
224            {
225                let mut sessions = self.inner.sessions.lock().expect("poisoned");
226                let page = sessions
227                    .get_mut(session_id)
228                    .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
229                    .pages
230                    .get_mut(page_id)
231                    .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
232                page.invalidate_baseline();
233            }
234            let token = self
235                .current_token(session_id, page_id)
236                .unwrap_or(StateToken::ZERO);
237            ctx.after_token = Some(token);
238            Ok(AuthLoadResponse {
239                token,
240                warnings: vec![Warning::with_args(
241                    WarningCode::AuthLoaded,
242                    vec![name.to_string()],
243                )],
244            })
245        })
246    }
247
248    pub fn auth_list(&self, session_id: &str) -> Result<AuthListResponse> {
249        let ctx = AuditCtx::new("vs_auth", session_id).with_args(
250            "list".into(),
251            tokens::args_hash("vs_auth", &["list".into()]),
252        );
253        self.audit_call(ctx, |_ctx| {
254            self.require_session(session_id)?;
255            let store = self.inner.store.lock().expect("poisoned");
256            let entries = store.list_auth()?;
257            Ok(AuthListResponse {
258                names: entries.into_iter().map(|m| m.name).collect(),
259            })
260        })
261    }
262
263    pub fn auth_clear(&self, session_id: &str, name: &str) -> Result<AuthClearResponse> {
264        let ctx = AuditCtx::new("vs_auth", session_id).with_args(
265            format!("clear {name}"),
266            tokens::args_hash("vs_auth", &["clear".into(), name.to_string()]),
267        );
268        self.audit_call(ctx, |_ctx| {
269            self.require_session(session_id)?;
270            let mut store = self.inner.store.lock().expect("poisoned");
271            store.delete_auth(name)?;
272            Ok(AuthClearResponse)
273        })
274    }
275}