Skip to main content

vs_daemon/daemon/
page_ops.rs

1//! Page operations: `vs_view`, `vs_read`, `vs_act`, `vs_find`, `vs_wait`,
2//! `vs_status`.
3
4use std::time::Duration;
5
6use vs_engine_webkit::WaitCondition as EngineWait;
7use vs_protocol::{Ref, StateToken, Warning, WarningCode};
8
9use super::audit::AuditCtx;
10use super::responses::{
11    ActCall, ActResponse, FindHit, FindResponse, ReadResponse, StatusResponse, ViewResponse,
12    WaitResponse,
13};
14use super::{render_subtree_text, Daemon};
15use crate::error::{DaemonError, Result};
16use crate::tokens;
17
18impl Daemon {
19    pub fn view(&self, session_id: &str, page_id: &str, force_full: bool) -> Result<ViewResponse> {
20        let ctx = AuditCtx::new("vs_view", session_id)
21            .with_page(page_id)
22            .with_args(String::new(), tokens::args_hash("vs_view", &[]));
23        self.audit_call(ctx, |ctx| {
24            let engine_handle = self.engine_handle_for(session_id, page_id)?;
25            let tree = self.inner.engine.snapshot(engine_handle)?;
26            let (token, form) = {
27                let mut sessions = self.inner.sessions.lock().expect("poisoned");
28                let page = sessions
29                    .get_mut(session_id)
30                    .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
31                    .pages
32                    .get_mut(page_id)
33                    .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
34                if force_full {
35                    page.invalidate_baseline();
36                }
37                page.apply_snapshot(tree)
38            };
39            ctx.after_token = Some(token);
40            let mut store = self.inner.store.lock().expect("poisoned");
41            store.update_page_token(page_id, &token.to_string(), "engine", None)?;
42            drop(store);
43            Ok(ViewResponse {
44                token,
45                form,
46                warnings: Vec::new(),
47            })
48        })
49    }
50
51    pub fn read(&self, session_id: &str, page_id: &str, r: Ref) -> Result<ReadResponse> {
52        let ctx = AuditCtx::new("vs_read", session_id)
53            .with_page(page_id)
54            .with_args(
55                r.to_string(),
56                tokens::args_hash("vs_read", &[r.to_string()]),
57            );
58        self.audit_call(ctx, |ctx| {
59            let (token, body) = {
60                let sessions = self.inner.sessions.lock().expect("poisoned");
61                let page = sessions
62                    .get(session_id)
63                    .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?
64                    .pages
65                    .get(page_id)
66                    .ok_or_else(|| DaemonError::UnknownPage(page_id.to_string()))?;
67                let node = page.find_node(r).ok_or(DaemonError::UnknownRef(r.0))?;
68                (
69                    page.last_token.unwrap_or(StateToken::ZERO),
70                    render_subtree_text(node),
71                )
72            };
73            ctx.before_token = Some(token);
74            ctx.after_token = Some(token);
75            Ok(ReadResponse { token, body })
76        })
77    }
78
79    pub fn act(&self, call: ActCall) -> Result<ActResponse> {
80        let ActCall {
81            session_id,
82            page_id,
83            target,
84            action,
85            before_token,
86            args_hash,
87            args_redacted,
88            group_label,
89        } = call;
90        let ctx = AuditCtx::new("vs_act", &session_id)
91            .with_page(&page_id)
92            .with_args(args_redacted, args_hash.clone())
93            .with_before(before_token)
94            .with_group(group_label);
95        self.audit_call(ctx, |ctx| {
96            let engine_handle = self.engine_handle_for(&session_id, &page_id)?;
97            let current_token = self.current_token(&session_id, &page_id)?;
98            if current_token != before_token {
99                return Err(DaemonError::StaleToken {
100                    current: current_token,
101                    reason: "mutate",
102                });
103            }
104            let now = vs_store::epoch_secs();
105            let store = self.inner.store.lock().expect("poisoned");
106            let cached = store.lookup_idempotent(
107                &page_id,
108                &before_token.to_string(),
109                &args_hash,
110                now,
111                vs_store::IDEMPOTENCY_TTL_SECS,
112            )?;
113            drop(store);
114
115            if let Some(cached) = cached {
116                let token = cached
117                    .after_token
118                    .as_deref()
119                    .and_then(|s| s.parse::<StateToken>().ok())
120                    .unwrap_or(StateToken::ZERO);
121                ctx.idempotency_hit = true;
122                ctx.after_token = Some(token);
123                ctx.result_summary = Some("idem".into());
124                return Ok(ActResponse {
125                    token,
126                    warnings: vec![Warning::new(WarningCode::IdempotentHit)],
127                });
128            }
129
130            self.inner.engine.act(engine_handle, target, action)?;
131            let tree = self.inner.engine.snapshot(engine_handle)?;
132            let (token, _form) = {
133                let mut sessions = self.inner.sessions.lock().expect("poisoned");
134                let page = sessions
135                    .get_mut(&session_id)
136                    .ok_or_else(|| DaemonError::UnknownSession(session_id.clone()))?
137                    .pages
138                    .get_mut(&page_id)
139                    .ok_or_else(|| DaemonError::UnknownPage(page_id.clone()))?;
140                page.apply_snapshot(tree)
141            };
142            ctx.after_token = Some(token);
143
144            let mut store = self.inner.store.lock().expect("poisoned");
145            store.update_page_token(&page_id, &token.to_string(), "engine", None)?;
146            drop(store);
147
148            Ok(ActResponse {
149                token,
150                warnings: Vec::new(),
151            })
152        })
153    }
154
155    pub fn find(&self, session_id: &str, query: &str) -> Result<FindResponse> {
156        let ctx = AuditCtx::new("vs_find", session_id).with_args(
157            query.to_string(),
158            tokens::args_hash("vs_find", &[query.to_string()]),
159        );
160        self.audit_call(ctx, |_ctx| {
161            self.require_session(session_id)?;
162            let mut hits = Vec::new();
163            let sessions = self.inner.sessions.lock().expect("poisoned");
164            let s = sessions
165                .get(session_id)
166                .ok_or_else(|| DaemonError::UnknownSession(session_id.to_string()))?;
167            for (page_id, page) in &s.pages {
168                if let Some(tree) = &page.last_tree {
169                    for node in tree {
170                        if node.label.contains(query) {
171                            hits.push(FindHit {
172                                page_id: page_id.clone(),
173                                r: node.r,
174                                role: node.role.to_string(),
175                                label: node.label.clone(),
176                            });
177                        }
178                    }
179                }
180            }
181            Ok(FindResponse { hits })
182        })
183    }
184
185    pub fn wait(
186        &self,
187        session_id: &str,
188        page_id: &str,
189        cond: EngineWait,
190        budget: Duration,
191    ) -> Result<WaitResponse> {
192        let ctx = AuditCtx::new("vs_wait", session_id)
193            .with_page(page_id)
194            .with_args(String::new(), tokens::args_hash("vs_wait", &[]));
195        self.audit_call(ctx, |ctx| {
196            let engine_handle = self.engine_handle_for(session_id, page_id)?;
197            self.inner.engine.wait(engine_handle, cond, budget)?;
198            let token = self
199                .current_token(session_id, page_id)
200                .unwrap_or(StateToken::ZERO);
201            ctx.after_token = Some(token);
202            Ok(WaitResponse { token })
203        })
204    }
205
206    /// Single-block summary: open pages, recent actions, total counts.
207    /// `session_id_opt = None` returns a workspace-wide summary.
208    pub fn status(&self, session_id_opt: Option<&str>) -> Result<StatusResponse> {
209        let ctx = AuditCtx::new("vs_status", session_id_opt.unwrap_or("(none)"))
210            .with_args(String::new(), tokens::args_hash("vs_status", &[]));
211        self.audit_call(ctx, |_ctx| {
212            let body = self.render_status(session_id_opt)?;
213            Ok(StatusResponse { body })
214        })
215    }
216
217    fn render_status(&self, session_id_opt: Option<&str>) -> Result<String> {
218        use std::fmt::Write as _;
219        let mut out = String::new();
220        let sessions = self.inner.sessions.lock().expect("poisoned");
221        if let Some(sid) = session_id_opt {
222            let s = sessions
223                .get(sid)
224                .ok_or_else(|| DaemonError::UnknownSession(sid.to_string()))?;
225            writeln!(out, "session\t{sid}\tpages={}", s.pages.len()).ok();
226            for (page_id, page) in &s.pages {
227                let token = page.last_token.map(|t| t.to_string()).unwrap_or_default();
228                writeln!(out, "page\t{page_id}\turl={}\ttoken={token}", page.url).ok();
229            }
230        } else {
231            writeln!(out, "workspace\tsessions={}", sessions.len()).ok();
232            for (id, s) in sessions.iter() {
233                writeln!(out, "session\t{id}\tpages={}", s.pages.len()).ok();
234            }
235        }
236        Ok(out)
237    }
238}