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