Skip to main content

brep_app/automation/
queue.rs

1//! The command queue: the channel between a host and the app.
2//!
3//! A host calls [`AutomationQueue::submit`] with an [`Envelope`] and gets a
4//! receiver for the [`Reply`]. The app drains the queue at three fixed points
5//! in the frame (§4.4): [`drain_input`](AutomationQueue::drain_input) in
6//! `raw_input_hook`, then [`drain_app`](AutomationQueue::drain_app) for
7//! `Phase::Mutate` at the top of `ui` and for `Phase::Read` at the bottom. A
8//! command waits in the queue until its phase comes round.
9//!
10//! A desktop window is not always drawn: a Wayland compositor sends no frame
11//! callbacks to a window on a hidden workspace, and winit then withholds
12//! `RedrawRequested` indefinitely. So document-level commands do not wait for
13//! a frame. A submit calls the [waker](AutomationQueue::set_waker), which
14//! wakes the desktop event loop independently of egui's repaint bookkeeping,
15//! and the loop dispatches the queue's frame-free head with
16//! [`drain_frameless`](AutomationQueue::drain_frameless) (`desktop_loop`).
17//! Input that drives egui (pointer and keyboard events), captures and
18//! layout-registry commands need a frame ([`frameless`], [`FRAME_BOUND`]):
19//! they and every command queued after them wait for one, which keeps the
20//! queue's order. A read-only input command (`pointer_state`) does not.
21use crate::automation::command::{lookup, Ctx, Envelope, Handler, Notice, NoticeKind, Outcome, Phase, Reply};
22use crate::automation::pointer::Pointer;
23use std::collections::VecDeque;
24use std::sync::mpsc::{channel, Receiver, Sender};
25use std::sync::{Arc, Mutex};
26
27pub struct Pending {
28    pub envelope: Envelope,
29    pub reply: Sender<Reply>,
30    deadline: Option<std::time::Instant>,
31}
32
33impl Pending {
34    fn expired(&self, frame: u64) -> bool {
35        if self.deadline.is_some_and(|d| std::time::Instant::now() >= d) {
36            let _ = self.reply.send(Reply::err(self.envelope.id, frame,
37                "host_deadline_expired: command expired before dispatch; no effects", vec![]));
38            true
39        } else { false }
40    }
41}
42
43struct Awaiting {
44    token: u64,
45    region: crate::automation::cmd_capture::Region,
46    document: Option<String>,
47    pending: Pending,
48}
49
50struct Polling {
51    pending: Pending,
52    phase: Phase,
53    poller: crate::automation::command::Poller,
54    /// The document handle the command was bound to by its context: the
55    /// eventual reply is tagged with that document's context, as an
56    /// immediate one is.
57    bound: Option<String>,
58}
59
60#[derive(Default)]
61struct Inner {
62    pending: VecDeque<Pending>,
63    awaiting: Vec<Awaiting>,
64    polling: Vec<Polling>,
65    notices: Vec<Notice>,
66    /// How many of `notices` have already ridden out on a command REPLY.
67    /// See [`Inner::reply_notices`].
68    notices_replied: usize,
69    pointer: Pointer,
70    next_token: u64,
71    frame: u64,
72    poisoned: Option<String>,
73    /// The document the last drawn frame showed: `(handle, committed revision)`
74    /// ([`AutomationQueue::publish_view`]), which a capture landing now is of.
75    view: Option<(String, u64)>,
76}
77
78/// Most notices kept. The app pushes one per toast whether or not a host is
79/// listening, so an ordinary session (the `automation` feature is on by
80/// default) must not grow a buffer for a reader that never arrives; the oldest
81/// go first, like the toast overlay's own cap.
82const MAX_NOTICES: usize = 256;
83
84impl Inner {
85    /// The notices a command REPLY carries: the ones that have not gone out on
86    /// an earlier reply.
87    ///
88    /// Two readers share this one buffer, and each must see every notice once.
89    /// A reply piggy-backs the new ones so an agent reads a refusal in the
90    /// answer to the call that caused it; the `notices` COMMAND drains the whole
91    /// buffer on its own cursor, so "every notice since the last drain" stays
92    /// true no matter how many other calls went by in between. Taking the buffer
93    /// for the replies — which is what this used to do — made `notices` report
94    /// whatever happened to arrive after the last call, which for a script that
95    /// waits before reading is nothing at all.
96    fn reply_notices(&mut self) -> Vec<Notice> {
97        let from = self.notices_replied.min(self.notices.len());
98        self.notices_replied = self.notices.len();
99        self.notices[from..].to_vec()
100    }
101}
102
103/// Wakes the thread that owns the app when work is queued.
104pub type Waker = Arc<dyn Fn() + Send + Sync>;
105
106/// Commands that need a drawn frame, beyond every `Phase::Input` command:
107/// captures (the image IS a frame), the layout registry and hit rectangles
108/// (published while the panels draw), the dock's rendered panes, and frame
109/// timing. [`AutomationQueue::drain_frameless`] stops at the first of these,
110/// so they, and everything queued after them, keep their in-frame order.
111pub const FRAME_BOUND: &[&str] = &[
112    "screenshot",
113    "illustration_presentation",
114    "state_list",
115    "state_get",
116    "state_schema",
117    "hit_rects",
118    "hit_key_docs",
119    "describe_panes",
120    "perf_stats",
121    "perf_spin",
122    "perf_reset",
123];
124
125/// Whether a command can be dispatched without a frame: an app command not
126/// [`FRAME_BOUND`], or a read-only input command (`pointer_state`, which
127/// `session_info` issues). A read-only input command emits no events and only
128/// reads the virtual pointer; every input queued before it has already been
129/// applied to that pointer, so answering it at the head keeps queue order.
130pub fn frameless(spec: &crate::automation::command::CommandSpec) -> bool {
131    match spec.handler {
132        Handler::App(_) => spec.phase != Phase::Input && !FRAME_BOUND.contains(&spec.name),
133        Handler::Input(_) => spec.phase == Phase::Input && spec.annotations.read_only,
134    }
135}
136
137/// What became of a command whose host stopped waiting for it
138/// ([`AutomationQueue::withdraw`]).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Withdrawn {
141    /// It was still queued and has been removed: it never ran.
142    NotDispatched,
143    /// A handler accepted it (it is capturing or polling), or it already
144    /// answered: it may have had effects.
145    Accepted,
146}
147
148#[derive(Default)]
149pub struct AutomationQueue {
150    inner: Mutex<Inner>,
151    ctx: Mutex<Option<egui::Context>>,
152    waker: Mutex<Option<Waker>>,
153}
154
155impl AutomationQueue {
156    pub fn new() -> Arc<Self> {
157        Arc::new(Self::default())
158    }
159
160    /// Give the queue the egui context so a submit can wake an idle frame loop.
161    pub fn attach(&self, ctx: &egui::Context) {
162        *self.ctx.lock().unwrap() = Some(ctx.clone());
163        if self.has_pending() { ctx.request_repaint(); }
164    }
165
166    /// Install the app thread's waker (the desktop event loop's proxy). Work
167    /// queued before it was installed is not stranded: the waker fires at once.
168    pub fn set_waker(&self, waker: Waker) {
169        *self.waker.lock().unwrap() = Some(waker.clone());
170        if self.has_pending() { waker(); }
171    }
172
173    fn wake(&self) {
174        let waker = self.waker.lock().unwrap().clone();
175        if let Some(waker) = waker { waker(); }
176        if let Some(ctx) = self.ctx.lock().unwrap().as_ref() {
177            ctx.request_repaint();
178        }
179    }
180
181    /// Enqueue a command. The reply arrives on the returned receiver once the
182    /// app has reached the command's phase (or at once, for an unknown command
183    /// or a poisoned session).
184    pub fn submit(&self, envelope: Envelope) -> Receiver<Reply> {
185        self.submit_before(envelope, None)
186    }
187
188    /// A deadline applies before dispatch, never to work already accepted by a handler.
189    pub fn submit_before(&self, envelope: Envelope, deadline: Option<std::time::Instant>) -> Receiver<Reply> {
190        let (tx, rx) = channel();
191        let mut inner = self.inner.lock().unwrap();
192        if let Some(why) = &inner.poisoned {
193            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("session poisoned: {why}"), vec![]));
194            return rx;
195        }
196        if lookup(&envelope.cmd).is_none() {
197            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("unknown command `{}`", envelope.cmd), vec![]));
198            return rx;
199        }
200        inner.pending.push_back(Pending { envelope, reply: tx, deadline });
201        // Notify only after the command is visible to the app thread: a wake
202        // that arrives first finds it queued.
203        drop(inner);
204        self.wake();
205        rx
206    }
207
208    /// The host stopped waiting for command `id`. A command still queued is
209    /// removed and answered as never dispatched; anything else was accepted.
210    pub fn withdraw(&self, id: u64) -> Withdrawn {
211        let mut inner = self.inner.lock().unwrap();
212        let frame = inner.frame;
213        if let Some(index) = inner.pending.iter().position(|p| p.envelope.id == id) {
214            let p = inner.pending.remove(index).expect("index from position");
215            let _ = p.reply.send(Reply::err(id, frame, "host_deadline_expired: command expired before dispatch; no effects", vec![]));
216            return Withdrawn::NotDispatched;
217        }
218        Withdrawn::Accepted
219    }
220
221    /// Whether [`drain_frameless`](Self::drain_frameless) has anything to do:
222    /// a frame-free command at the head of the queue, or polled work.
223    pub fn has_frameless_work(&self) -> bool {
224        let inner = self.inner.lock().unwrap();
225        !inner.polling.is_empty()
226            || inner.pending.front().is_some_and(|p| lookup(&p.envelope.cmd).is_some_and(frameless))
227    }
228
229    /// Record something the session should know about — a toast the user would
230    /// have seen, a refusal, a panic. The app's own toast lane feeds this every
231    /// frame (`BrepApp::ui`), so `notices` reports what was on screen.
232    pub fn push_notice(&self, kind: NoticeKind, text: impl Into<String>) {
233        let mut inner = self.inner.lock().unwrap();
234        let frame = inner.frame;
235        inner.notices.push(Notice { kind, frame, text: text.into() });
236        let overflow = inner.notices.len().saturating_sub(MAX_NOTICES);
237        if overflow > 0 {
238            inner.notices.drain(0..overflow);
239            inner.notices_replied = inner.notices_replied.saturating_sub(overflow);
240        }
241    }
242
243    /// Drain for the `notices` COMMAND: everything since the last drain. The
244    /// reply cursor goes back to the start with them — the buffer is empty, so
245    /// the reply this drain rides out on carries nothing twice.
246    pub fn take_notices(&self) -> Vec<Notice> {
247        let mut inner = self.inner.lock().unwrap();
248        inner.notices_replied = 0;
249        std::mem::take(&mut inner.notices)
250    }
251
252    pub fn pointer(&self) -> Pointer {
253        self.inner.lock().unwrap().pointer.clone()
254    }
255
256    pub fn frame(&self) -> u64 {
257        self.inner.lock().unwrap().frame
258    }
259
260    pub fn has_pending(&self) -> bool {
261        let inner = self.inner.lock().unwrap();
262        !inner.pending.is_empty() || !inner.awaiting.is_empty() || !inner.polling.is_empty()
263    }
264
265    /// Whether a command has started work the queue is polling (see
266    /// `Outcome::Poll`): a host keeps stepping frames for it past its usual
267    /// frame budget, since the work is advancing, not stuck.
268    pub fn is_polling(&self, id: u64) -> bool {
269        self.inner.lock().unwrap().polling.iter().any(|p| p.pending.envelope.id == id)
270    }
271
272    /// Mark the session unusable (a panic unwound through the app). Every
273    /// pending and future command is answered with the reason.
274    pub fn poison(&self, why: impl Into<String>) {
275        let why = why.into();
276        let mut inner = self.inner.lock().unwrap();
277        inner.poisoned = Some(why.clone());
278        let frame = inner.frame;
279        for p in inner.pending.drain(..) {
280            let _ = p.reply.send(Reply::err(p.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
281        }
282        for a in inner.awaiting.drain(..) {
283            let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
284        }
285    }
286
287    pub fn is_poisoned(&self) -> bool {
288        self.inner.lock().unwrap().poisoned.is_some()
289    }
290
291    /// Phase 1 of the frame: apply input commands as `egui::Event`s and
292    /// complete captures whose `Event::Screenshot` arrived. `view` is the
293    /// viewport rect of the last frame (for `region: viewport` crops).
294    /// `ctx` is the app's: the pointer reads egui's clock from it (the
295    /// headless host leaves `raw.time` unset, and egui adds `predicted_dt`),
296    /// and a `click_gap` resets its click history.
297    pub fn drain_input(&self, raw: &mut egui::RawInput, frame: u64, ppp: f32, view: Option<egui::Rect>, ctx: &egui::Context) {
298        let mut inner = self.inner.lock().unwrap();
299        inner.frame = frame;
300        inner.pointer.now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time) + f64::from(raw.predicted_dt));
301
302        // Screenshots that landed in this frame's input.
303        if !inner.awaiting.is_empty() {
304            let mut done = Vec::new();
305            for event in &raw.events {
306                if let egui::Event::Screenshot { user_data, image, .. } = event {
307                    let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied();
308                    if let Some(token) = token {
309                        if let Some(pos) = inner.awaiting.iter().position(|a| a.token == token) {
310                            let a = inner.awaiting.remove(pos);
311                            done.push((a, image.clone()));
312                        }
313                    }
314                }
315            }
316            for (a, image) in done {
317                let notices = inner.reply_notices();
318                let shown = inner.view.clone();
319                // The image is of the frame drawn last: it shows `shown`. A
320                // capture asked for while another document was visible is
321                // refused rather than handed back as that document's picture.
322                let switched = match (&a.document, &shown) {
323                    (Some(asked), Some((drawn, _))) => asked != drawn,
324                    _ => false,
325                };
326                let result = if switched {
327                    Err("document_not_active: the visible document changed between the request and the capture".to_string())
328                } else {
329                    crate::automation::cmd_capture::encode_capture(&image, ppp, view, &a.region).map(|(mut json, png)| {
330                        if let Some((handle, revision)) = &shown {
331                            json["document"] = serde_json::json!({ "documentId": handle, "revision": revision.to_string() });
332                        }
333                        (json, png)
334                    })
335                };
336                let reply = match result {
337                    Ok((json, png)) => {
338                        let mut r = Reply::ok(a.pending.envelope.id, frame, json, notices);
339                        r.blob = Some(png);
340                        r
341                    }
342                    Err(e) => Reply::err(a.pending.envelope.id, frame, e, notices),
343                };
344                let _ = a.pending.reply.send(reply);
345            }
346        }
347
348        // Input-phase commands, in order.
349        let mut keep = VecDeque::new();
350        let mut events = Vec::new();
351        while let Some(p) = inner.pending.pop_front() {
352            if p.expired(frame) { continue; }
353            let Some(spec) = lookup(&p.envelope.cmd) else { continue };
354            match (&spec.handler, spec.phase) {
355                (Handler::Input(f), Phase::Input) => {
356                    let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
357                    let notices = inner.reply_notices();
358                    let reply = match result {
359                        Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
360                        Err(e) => Reply::err(p.envelope.id, frame, e, notices),
361                    };
362                    let _ = p.reply.send(reply);
363                }
364                _ => keep.push_back(p),
365            }
366        }
367        inner.pending = keep;
368        // A `click_gap` this frame: a fresh `PointerState` before the pass
369        // begins, so egui has no click to count the next one against. The
370        // pass builds on the state this replaces (`InputState::begin_pass`).
371        if std::mem::take(&mut inner.pointer.reset_clicks) {
372            ctx.input_mut(|i| i.pointer = egui::PointerState::default());
373        }
374        raw.modifiers = inner.pointer.modifiers.egui();
375        raw.events.extend(events);
376    }
377
378    /// Phases 2 and 3 of the frame. Runs every pending command registered for
379    /// `phase`, in order.
380    pub fn drain_app(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
381        let batch: Vec<Pending> = {
382            let mut inner = self.inner.lock().unwrap();
383            inner.frame = frame;
384            let mut keep = VecDeque::new();
385            let mut batch = Vec::new();
386            while let Some(p) = inner.pending.pop_front() {
387                match lookup(&p.envelope.cmd) {
388                    Some(spec) if spec.phase == phase => batch.push(p),
389                    _ => keep.push_back(p),
390                }
391            }
392            inner.pending = keep;
393            batch
394        };
395        for p in batch {
396            self.dispatch(p, phase, ctx, frame);
397        }
398        if self.poll(phase, ctx, frame) {
399            ctx.egui.request_repaint();
400        }
401    }
402
403    /// Dispatch without a frame (the desktop loop's service step): the
404    /// queue's head, in FIFO order, up to the first command that needs a
405    /// frame ([`frameless`]); then one poll of the work they started. Returns
406    /// whether polled work is still outstanding. Runs on the app's thread, so
407    /// mutations stay sequential with the UI's own writers.
408    pub fn drain_frameless(&self, ctx: &mut Ctx<'_>) -> bool {
409        let frame = self.frame();
410        loop {
411            let next = {
412                let mut inner = self.inner.lock().unwrap();
413                match inner.pending.front().and_then(|p| lookup(&p.envelope.cmd)) {
414                    Some(spec) if frameless(spec) => {
415                        let p = inner.pending.pop_front().expect("front exists");
416                        if let Handler::Input(f) = &spec.handler {
417                            // A read-only input command: answered here, as the
418                            // frame's input phase would answer it.
419                            if !p.expired(frame) {
420                                let mut events = Vec::new();
421                                let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
422                                debug_assert!(events.is_empty(), "{} is read-only but emitted input", spec.name);
423                                let notices = inner.reply_notices();
424                                let reply = match result {
425                                    Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
426                                    Err(e) => Reply::err(p.envelope.id, frame, e, notices),
427                                };
428                                let _ = p.reply.send(reply);
429                            }
430                            continue;
431                        }
432                        Some((p, spec.phase))
433                    }
434                    _ => None,
435                }
436            };
437            let Some((p, phase)) = next else { break };
438            self.dispatch(p, phase, ctx, frame);
439        }
440        let mutate = self.poll(Phase::Mutate, ctx, frame);
441        let read = self.poll(Phase::Read, ctx, frame);
442        mutate || read
443    }
444
445    /// Run one command's handler and answer (or park) it.
446    fn dispatch(&self, p: Pending, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
447        if p.expired(frame) { return; }
448        let spec = lookup(&p.envelope.cmd).expect("checked at submit");
449        let Handler::App(f) = &spec.handler else { return };
450        // A document context is checked HERE, on the frame thread, right
451        // before the handler: the only point atomic with the UI's own
452        // writers. It is stripped from the handler's arguments.
453        let (args, bound) = match bind_context(&ctx.app.docs, phase, &p.envelope.cmd, p.envelope.args.clone()) {
454            Ok(bound) => bound,
455            Err(e) => {
456                let mut inner = self.inner.lock().unwrap();
457                let notices = inner.reply_notices();
458                let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
459                return;
460            }
461        };
462        let before = refused_counts(&ctx.app.docs);
463        let route = bound.as_ref().and_then(|b| b.route).map(|index| ctx.app.docs.route_begin(index));
464        let result = f(ctx, args);
465        if let Some(visible) = route {
466            ctx.app.docs.route_end(visible);
467        }
468        let result = refused_by_lock(&ctx.app.docs, &before, result);
469        let handle = bound.map(|b| b.handle);
470        let result = match &handle {
471            Some(handle) => tag_context(&ctx.app.docs, handle, result),
472            None => result,
473        };
474        let mut inner = self.inner.lock().unwrap();
475        answer(&mut inner, p, phase, frame, result, handle);
476    }
477
478    /// Work started earlier: one poll each, in this phase. Returns whether
479    /// any is still outstanding.
480    fn poll(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) -> bool {
481        let polling: Vec<Polling> = {
482            let mut inner = self.inner.lock().unwrap();
483            let (mine, rest) = std::mem::take(&mut inner.polling).into_iter().partition(|p| p.phase == phase);
484            inner.polling = rest;
485            mine
486        };
487        let mut still = false;
488        for mut entry in polling {
489            match (entry.poller)(ctx) {
490                None => {
491                    still = true;
492                    self.inner.lock().unwrap().polling.push(entry);
493                }
494                Some(result) => {
495                    let result = match &entry.bound {
496                        Some(handle) => tag_context(&ctx.app.docs, handle, result),
497                        None => result,
498                    };
499                    let mut inner = self.inner.lock().unwrap();
500                    answer(&mut inner, entry.pending, phase, frame, result, entry.bound);
501                    still |= !inner.polling.is_empty();
502                }
503            }
504        }
505        still
506    }
507
508    /// Record which document the frame just drawn shows (called at the end of
509    /// every app frame), so a capture is bound to what it pictures.
510    pub fn publish_view(&self, handle: &str, revision: u64) {
511        let mut inner = self.inner.lock().unwrap();
512        if inner.view.as_ref().is_none_or(|(h, r)| h != handle || *r != revision) {
513            inner.view = Some((handle.to_string(), revision));
514        }
515    }
516
517    /// A fresh token for a screenshot request.
518    pub fn next_token(&self) -> u64 {
519        let mut inner = self.inner.lock().unwrap();
520        inner.next_token += 1;
521        inner.next_token
522    }
523}
524
525/// The root `context` key of a command's arguments: which document the
526/// command is for and the committed revision its writer last saw
527/// (contract §3 `DocumentContext`). Optional — a command without one
528/// acts on the visible document as it always has.
529#[derive(serde::Deserialize)]
530#[serde(deny_unknown_fields, rename_all = "camelCase")]
531struct DocumentContext {
532    document_id: String,
533    expected_revision: String,
534}
535
536/// Commands that read or write only a document's ENGINE — its history, run,
537/// metadata, model and exports — and so may run on a background tab: the
538/// queue routes them there for the one handler call (`Documents::route_begin`)
539/// without touching what the user sees. Every other command acts on the
540/// visible document and refuses a background context (`document_not_active`).
541pub const ENGINE_ONLY: &[&str] = &[
542    "feature_stage",
543    "feature_stage_result",
544    "run_cancel",
545    "history_group_begin",
546    "history_group_end",
547    "feature_add_many",
548    "doc_import",
549    "doc_export",
550    "model_inspect",
551    "mass_properties",
552    "history_listing",
553    "frame_info",
554    "metadata_set",
555    "metadata_set_many",
556    "metadata_remove",
557];
558
559/// A command bound to a document by its context: the handle it named and,
560/// for an engine-only command addressed to a background tab, that tab.
561#[derive(Debug)]
562struct Bound {
563    handle: String,
564    route: Option<usize>,
565}
566
567/// Take `context` out of `args` and check it against the documents as they
568/// are now. Errors begin with the contract's stable code (`revision_conflict:
569/// …`), so a caller need not parse the prose.
570///
571/// An [`ENGINE_ONLY`] command may name a background tab and is routed there;
572/// any other command must name the visible one (`document_not_active`).
573/// While a Work part is edited in place every context refuses
574/// (`document_busy`): the command would write the hosted part, not the tab
575/// named. A Mutate-phase command requires the expected revision to be the
576/// current one; a Read-phase command only reports the current revision.
577fn bind_context(
578    docs: &crate::document::Documents,
579    phase: Phase,
580    cmd: &str,
581    mut args: serde_json::Value,
582) -> Result<(serde_json::Value, Option<Bound>), String> {
583    let Some(raw) = args.as_object_mut().and_then(|o| o.remove("context")) else {
584        return Ok((args, None));
585    };
586    // A command with no arguments still parses `{}` after the strip.
587    let context: DocumentContext = serde_json::from_value(raw)
588        .map_err(|e| format!("invalid_arguments: context must be {{documentId, expectedRevision}}: {e}"))?;
589    let expected = parse_revision(&context.expected_revision)?;
590    match crate::handles::parse("doc", &context.document_id) {
591        crate::handles::Parsed::Current => {}
592        crate::handles::Parsed::OtherEpoch => {
593            return Err("handle_epoch_mismatch: the document handle is from another run of the app".into())
594        }
595        crate::handles::Parsed::Malformed => return Err("invalid_arguments: documentId is not a document handle".into()),
596    }
597    let Some(index) = docs.index_of_handle(&context.document_id) else {
598        return Err("document_unknown: no open document has that handle (closed, or never opened)".into());
599    };
600    if docs.work_part_in_place() {
601        return Err("document_busy: a Work part is edited in place; a document command would write the part".into());
602    }
603    let route = if index == docs.active_index() {
604        None
605    } else if ENGINE_ONLY.contains(&cmd) {
606        Some(index)
607    } else {
608        return Err("document_not_active: this command acts on the visible document; activate the named one first".into());
609    };
610    let current = docs.get(index).map_or(0, |d| d.committed_revision());
611    if phase == Phase::Mutate && current != expected {
612        return Err(format!(
613            "revision_conflict: the document is at revision {current}, not {expected}; read it again before writing"
614        ));
615    }
616    Ok((args, Some(Bound { handle: context.document_id, route })))
617}
618
619/// A decimal unsigned 64-bit revision, canonical (no sign, no leading zero).
620fn parse_revision(text: &str) -> Result<u64, String> {
621    let canonical = text == "0" || (!text.is_empty() && !text.starts_with('0') && text.bytes().all(|b| b.is_ascii_digit()));
622    text.parse::<u64>()
623        .ok()
624        .filter(|_| canonical)
625        .ok_or_else(|| "invalid_arguments: expectedRevision must be a canonical unsigned decimal string".into())
626}
627
628/// A command that was bound to a document answers with that document's
629/// context as it stands after the handler: `{documentId, expectedRevision}` under the
630/// result's `context` key (an object result), so the writer's next
631/// expectation comes from the answer, not from a guess. A geometry run the
632/// handler started moves the revision again when it lands.
633fn tag_context(
634    docs: &crate::document::Documents,
635    handle: &str,
636    result: Result<Outcome, String>,
637) -> Result<Outcome, String> {
638    let context = docs.index_of_handle(handle).and_then(|i| docs.get(i)).map(|doc| {
639        serde_json::json!({ "documentId": handle, "expectedRevision": doc.committed_revision().to_string() })
640    });
641    let tag = |v: &mut serde_json::Value| {
642        if let (Some(object), Some(context)) = (v.as_object_mut(), context.clone()) {
643            object.insert("context".into(), context);
644        }
645    };
646    match result {
647        Ok(Outcome::Done(mut v)) => {
648            tag(&mut v);
649            Ok(Outcome::Done(v))
650        }
651        Ok(Outcome::Blob { mut json, bytes }) => {
652            tag(&mut json);
653            Ok(Outcome::Blob { json, bytes })
654        }
655        other => other,
656    }
657}
658
659/// Send (or park) one command's reply.
660fn answer(inner: &mut Inner, p: Pending, phase: Phase, frame: u64, result: Result<Outcome, String>, bound: Option<String>) {
661    match result {
662        Ok(Outcome::Done(v)) => {
663            let notices = inner.reply_notices();
664            let _ = p.reply.send(Reply::ok(p.envelope.id, frame, v, notices));
665        }
666        Ok(Outcome::Blob { json, bytes }) => {
667            let notices = inner.reply_notices();
668            let mut r = Reply::ok(p.envelope.id, frame, json, notices);
669            r.blob = Some(bytes);
670            let _ = p.reply.send(r);
671        }
672        // No reply yet — the capture answers when its image arrives, and
673        // takes the notices then (the cursor must not move here).
674        Ok(Outcome::AwaitScreenshot { token, region, document }) => {
675            inner.awaiting.push(Awaiting { token, region, document, pending: p });
676        }
677        Ok(Outcome::Poll(poller)) => inner.polling.push(Polling { pending: p, phase, poller, bound }),
678        Err(e) => {
679            let notices = inner.reply_notices();
680            let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
681        }
682    }
683}
684
685/// Each open document's refused-edit count ([`crate::document::Document::access`]).
686fn refused_counts(docs: &crate::document::Documents) -> Vec<(u64, u64)> {
687    docs.iter().map(|d| (d.id(), d.engine.history.refused_user_edits())).collect()
688}
689
690/// A command that tried to change a read-only document (a PLM revision this
691/// user has not checked out, or a released one) did not change it — the
692/// document's history refused the write — so its reply is that refusal, not
693/// a success. A command that only reads, moves the camera, opens or closes a
694/// document refuses nothing and answers as it did.
695fn refused_by_lock(
696    docs: &crate::document::Documents,
697    before: &[(u64, u64)],
698    result: Result<Outcome, String>,
699) -> Result<Outcome, String> {
700    if result.is_err() {
701        return result;
702    }
703    for doc in docs.iter() {
704        let was = before.iter().find(|(id, _)| *id == doc.id()).map(|(_, n)| *n);
705        if was.is_some_and(|was| doc.engine.history.refused_user_edits() > was) {
706            if let crate::document::Access::ReadOnly { reason } = doc.access() {
707                return Err(format!("{} is read-only: {reason}", doc.title()));
708            }
709        }
710    }
711    result
712}
713