use crate::automation::command::{lookup, Ctx, Envelope, Handler, Notice, NoticeKind, Outcome, Phase, Reply};
use crate::automation::pointer::Pointer;
use std::collections::VecDeque;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
pub struct Pending {
pub envelope: Envelope,
pub reply: Sender<Reply>,
deadline: Option<std::time::Instant>,
client: Option<u64>,
}
impl Pending {
fn expired(&self, frame: u64) -> bool {
if self.deadline.is_some_and(|d| std::time::Instant::now() >= d) {
let _ = self.reply.send(Reply::err(self.envelope.id, frame,
"host_deadline_expired: command expired before dispatch; no effects", vec![]));
true
} else { false }
}
}
struct Awaiting {
token: u64,
region: crate::automation::cmd_capture::Region,
document: Option<String>,
pending: Pending,
}
struct Polling {
pending: Pending,
phase: Phase,
poller: crate::automation::command::Poller,
bound: Option<String>,
}
#[derive(Default)]
struct Inner {
pending: VecDeque<Pending>,
awaiting: Vec<Awaiting>,
polling: Vec<Polling>,
notices: Vec<Notice>,
notices_replied: usize,
pointer: Pointer,
next_token: u64,
frame: u64,
poisoned: Option<String>,
view: Option<(String, u64)>,
layout_at: Option<web_time::Instant>,
layout_dirty: bool,
input_this_pass: bool,
}
impl Inner {
fn layout_current(&self) -> bool {
!self.layout_dirty && self.layout_at.is_some_and(|at| at.elapsed() < LAYOUT_FRESH)
}
fn need_at(&self, index: usize) -> Need {
let p = &self.pending[index];
lookup(&p.envelope.cmd).map_or(Need::State, |spec| need(spec, &p.envelope.args))
}
fn dispatchable(&self, index: usize) -> bool {
match self.need_at(index) {
Need::State => true,
Need::Layout => self.layout_current(),
Need::Pass => false,
}
}
fn is_next(&self, index: usize) -> bool {
let Some(client) = self.pending[index].client else { return index == 0 };
self.pending.iter().take(index).all(|p| p.client.is_some_and(|other| other != client))
}
fn next_dispatchable(&self) -> Option<usize> {
(0..self.pending.len()).find(|&i| self.is_next(i) && self.dispatchable(i))
}
fn waits_for_pass(&self) -> bool {
(0..self.pending.len()).any(|i| self.is_next(i) && !self.dispatchable(i))
}
}
const MAX_NOTICES: usize = 256;
impl Inner {
fn reply_notices(&mut self) -> Vec<Notice> {
let from = self.notices_replied.min(self.notices.len());
self.notices_replied = self.notices.len();
self.notices[from..].to_vec()
}
}
pub type Waker = Arc<dyn Fn() + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Need {
State,
Layout,
Pass,
}
pub const PASS_BOUND: &[&str] = &["screenshot", "illustration_presentation", "ping"];
pub const LAYOUT_BOUND: &[&str] = &["hit_rects", "state_list", "state_get", "state_schema", "locate", "pick"];
pub const LAYOUT_FRESH: std::time::Duration = std::time::Duration::from_millis(16);
pub fn need(spec: &crate::automation::command::CommandSpec, args: &serde_json::Value) -> Need {
match spec.handler {
Handler::Input(_) if spec.phase == Phase::Input && spec.annotations.read_only => Need::State,
Handler::Input(_) => Need::Pass,
Handler::App(_) if spec.phase == Phase::Input || PASS_BOUND.contains(&spec.name) => Need::Pass,
Handler::App(_) if matches!(spec.name, "state_get" | "state_schema") => {
let names = args.get("names").and_then(serde_json::Value::as_array);
let state = names.is_some_and(|names| {
names.iter().all(|n| n.as_str().is_some_and(crate::automation::state_blobs::is_state_blob))
});
if state { Need::State } else { Need::Layout }
}
Handler::App(_) if LAYOUT_BOUND.contains(&spec.name) => Need::Layout,
Handler::App(_) => Need::State,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Withdrawn {
NotDispatched,
Accepted,
}
#[derive(Default)]
pub struct AutomationQueue {
inner: Mutex<Inner>,
ctx: Mutex<Option<egui::Context>>,
waker: Mutex<Option<Waker>>,
}
impl AutomationQueue {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn attach(&self, ctx: &egui::Context) {
*self.ctx.lock().unwrap() = Some(ctx.clone());
if self.has_pending() { ctx.request_repaint(); }
}
pub fn set_waker(&self, waker: Waker) {
*self.waker.lock().unwrap() = Some(waker.clone());
if self.has_pending() { waker(); }
}
fn wake(&self) {
let waker = self.waker.lock().unwrap().clone();
if let Some(waker) = waker { waker(); }
if let Some(ctx) = self.ctx.lock().unwrap().as_ref() {
ctx.request_repaint();
}
}
pub fn submit(&self, envelope: Envelope) -> Receiver<Reply> {
self.submit_before(envelope, None)
}
pub fn submit_before(&self, envelope: Envelope, deadline: Option<std::time::Instant>) -> Receiver<Reply> {
self.submit_for(envelope, deadline, None)
}
pub fn submit_for(&self, envelope: Envelope, deadline: Option<std::time::Instant>, client: Option<u64>) -> Receiver<Reply> {
let (tx, rx) = channel();
let mut inner = self.inner.lock().unwrap();
if let Some(why) = &inner.poisoned {
let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("session poisoned: {why}"), vec![]));
return rx;
}
if lookup(&envelope.cmd).is_none() {
let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("unknown command `{}`", envelope.cmd), vec![]));
return rx;
}
inner.pending.push_back(Pending { envelope, reply: tx, deadline, client });
drop(inner);
self.wake();
rx
}
pub fn withdraw(&self, id: u64) -> Withdrawn {
let mut inner = self.inner.lock().unwrap();
let frame = inner.frame;
if let Some(index) = inner.pending.iter().position(|p| p.envelope.id == id) {
let p = inner.pending.remove(index).expect("index from position");
let _ = p.reply.send(Reply::err(id, frame, "host_deadline_expired: command expired before dispatch; no effects", vec![]));
return Withdrawn::NotDispatched;
}
Withdrawn::Accepted
}
pub fn has_frameless_work(&self) -> bool {
let inner = self.inner.lock().unwrap();
!inner.polling.is_empty() || inner.next_dispatchable().is_some()
}
pub fn needs_pass(&self) -> bool {
self.inner.lock().unwrap().waits_for_pass()
}
pub fn mark_layout_dirty(&self) {
self.inner.lock().unwrap().layout_dirty = true;
}
pub fn note_pass(&self) {
let mut inner = self.inner.lock().unwrap();
inner.layout_at = Some(web_time::Instant::now());
inner.layout_dirty = std::mem::take(&mut inner.input_this_pass);
}
pub fn push_notice(&self, kind: NoticeKind, text: impl Into<String>) {
let mut inner = self.inner.lock().unwrap();
let frame = inner.frame;
inner.notices.push(Notice { kind, frame, text: text.into() });
let overflow = inner.notices.len().saturating_sub(MAX_NOTICES);
if overflow > 0 {
inner.notices.drain(0..overflow);
inner.notices_replied = inner.notices_replied.saturating_sub(overflow);
}
}
pub fn take_notices(&self) -> Vec<Notice> {
let mut inner = self.inner.lock().unwrap();
inner.notices_replied = 0;
std::mem::take(&mut inner.notices)
}
pub fn pointer(&self) -> Pointer {
self.inner.lock().unwrap().pointer.clone()
}
pub fn frame(&self) -> u64 {
self.inner.lock().unwrap().frame
}
pub fn has_pending(&self) -> bool {
let inner = self.inner.lock().unwrap();
!inner.pending.is_empty() || !inner.awaiting.is_empty() || !inner.polling.is_empty()
}
pub fn is_polling(&self, id: u64) -> bool {
self.inner.lock().unwrap().polling.iter().any(|p| p.pending.envelope.id == id)
}
pub fn poison(&self, why: impl Into<String>) {
let why = why.into();
let mut inner = self.inner.lock().unwrap();
inner.poisoned = Some(why.clone());
let frame = inner.frame;
for p in inner.pending.drain(..) {
let _ = p.reply.send(Reply::err(p.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
}
for a in inner.awaiting.drain(..) {
let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
}
}
pub fn is_poisoned(&self) -> bool {
self.inner.lock().unwrap().poisoned.is_some()
}
pub fn drain_input(&self, raw: &mut egui::RawInput, frame: u64, ppp: f32, view: Option<egui::Rect>, ctx: &egui::Context) {
let mut inner = self.inner.lock().unwrap();
inner.frame = frame;
inner.pointer.now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time) + f64::from(raw.predicted_dt));
if !inner.awaiting.is_empty() {
let mut done = Vec::new();
for event in &raw.events {
if let egui::Event::Screenshot { user_data, image, .. } = event {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied();
if let Some(token) = token {
if let Some(pos) = inner.awaiting.iter().position(|a| a.token == token) {
let a = inner.awaiting.remove(pos);
done.push((a, image.clone()));
}
}
}
}
for (a, image) in done {
let notices = inner.reply_notices();
let shown = inner.view.clone();
let switched = match (&a.document, &shown) {
(Some(asked), Some((drawn, _))) => asked != drawn,
_ => false,
};
let result = if switched {
Err("document_not_active: the visible document changed between the request and the capture".to_string())
} else {
crate::automation::cmd_capture::encode_capture(&image, ppp, view, &a.region).map(|(mut json, png)| {
if let Some((handle, revision)) = &shown {
json["document"] = serde_json::json!({ "documentId": handle, "revision": revision.to_string() });
}
(json, png)
})
};
let reply = match result {
Ok((json, png)) => {
let mut r = Reply::ok(a.pending.envelope.id, frame, json, notices);
r.blob = Some(png);
r
}
Err(e) => Reply::err(a.pending.envelope.id, frame, e, notices),
};
let _ = a.pending.reply.send(reply);
}
}
let mut keep = VecDeque::new();
let mut events = Vec::new();
while let Some(p) = inner.pending.pop_front() {
if p.expired(frame) { continue; }
let Some(spec) = lookup(&p.envelope.cmd) else { continue };
match (&spec.handler, spec.phase) {
(Handler::Input(f), Phase::Input) => {
let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
let notices = inner.reply_notices();
let reply = match result {
Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
Err(e) => Reply::err(p.envelope.id, frame, e, notices),
};
let _ = p.reply.send(reply);
}
_ => keep.push_back(p),
}
}
inner.pending = keep;
if std::mem::take(&mut inner.pointer.reset_clicks) {
ctx.input_mut(|i| i.pointer = egui::PointerState::default());
}
raw.modifiers = inner.pointer.modifiers.egui();
inner.input_this_pass = !events.is_empty();
raw.events.extend(events);
}
pub fn drain_app(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
let batch: Vec<Pending> = {
let mut inner = self.inner.lock().unwrap();
inner.frame = frame;
let mut keep = VecDeque::new();
let mut batch = Vec::new();
while let Some(p) = inner.pending.pop_front() {
match lookup(&p.envelope.cmd) {
Some(spec) if spec.phase == phase => batch.push(p),
_ => keep.push_back(p),
}
}
inner.pending = keep;
batch
};
for p in batch {
self.dispatch(p, phase, ctx, frame);
}
if self.poll(phase, ctx, frame) {
ctx.egui.request_repaint();
}
}
pub fn drain_frameless(&self, ctx: &mut Ctx<'_>) -> bool {
let frame = self.frame();
loop {
let next = {
let mut inner = self.inner.lock().unwrap();
let ready = inner.next_dispatchable();
match ready.and_then(|i| lookup(&inner.pending[i].envelope.cmd).map(|spec| (i, spec))) {
Some((index, spec)) => {
let p = inner.pending.remove(index).expect("index from next_dispatchable");
if let Handler::Input(f) = &spec.handler {
if !p.expired(frame) {
let mut events = Vec::new();
let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
debug_assert!(events.is_empty(), "{} is read-only but emitted input", spec.name);
let notices = inner.reply_notices();
let reply = match result {
Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
Err(e) => Reply::err(p.envelope.id, frame, e, notices),
};
let _ = p.reply.send(reply);
}
continue;
}
Some((p, spec.phase))
}
_ => None,
}
};
let Some((p, phase)) = next else { break };
self.dispatch(p, phase, ctx, frame);
}
let mutate = self.poll(Phase::Mutate, ctx, frame);
let read = self.poll(Phase::Read, ctx, frame);
mutate || read
}
fn dispatch(&self, p: Pending, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
if p.expired(frame) { return; }
let spec = lookup(&p.envelope.cmd).expect("checked at submit");
let Handler::App(f) = &spec.handler else { return };
if !spec.annotations.read_only {
self.mark_layout_dirty();
}
let (args, bound) = match bind_context(&ctx.app.docs, phase, &p.envelope.cmd, p.envelope.args.clone()) {
Ok(bound) => bound,
Err(e) => {
let mut inner = self.inner.lock().unwrap();
let notices = inner.reply_notices();
let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
return;
}
};
let before = refused_counts(&ctx.app.docs);
let route = bound.as_ref().and_then(|b| b.route).map(|index| ctx.app.docs.route_begin(index));
let result = f(ctx, args);
if let Some(visible) = route {
ctx.app.docs.route_end(visible);
}
let result = refused_by_lock(&ctx.app.docs, &before, result);
let handle = bound.map(|b| b.handle);
let result = match &handle {
Some(handle) => tag_context(&ctx.app.docs, handle, result),
None => result,
};
let mut inner = self.inner.lock().unwrap();
answer(&mut inner, p, phase, frame, result, handle);
}
fn poll(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) -> bool {
let polling: Vec<Polling> = {
let mut inner = self.inner.lock().unwrap();
let (mine, rest) = std::mem::take(&mut inner.polling).into_iter().partition(|p| p.phase == phase);
inner.polling = rest;
mine
};
let mut still = false;
for mut entry in polling {
match (entry.poller)(ctx) {
None => {
still = true;
self.inner.lock().unwrap().polling.push(entry);
}
Some(result) => {
self.mark_layout_dirty();
let result = match &entry.bound {
Some(handle) => tag_context(&ctx.app.docs, handle, result),
None => result,
};
let mut inner = self.inner.lock().unwrap();
answer(&mut inner, entry.pending, phase, frame, result, entry.bound);
still |= !inner.polling.is_empty();
}
}
}
still
}
pub fn publish_view(&self, handle: &str, revision: u64) {
let mut inner = self.inner.lock().unwrap();
if inner.view.as_ref().is_none_or(|(h, r)| h != handle || *r != revision) {
inner.view = Some((handle.to_string(), revision));
}
}
pub fn fail_captures(&self, error: &str) {
let mut inner = self.inner.lock().unwrap();
let frame = inner.frame;
for a in std::mem::take(&mut inner.awaiting) {
let notices = inner.reply_notices();
let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, error.to_string(), notices));
}
}
pub fn next_token(&self) -> u64 {
let mut inner = self.inner.lock().unwrap();
inner.next_token += 1;
inner.next_token
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct DocumentContext {
document_id: String,
expected_revision: String,
}
pub const ENGINE_ONLY: &[&str] = &[
"feature_stage",
"feature_stage_result",
"run_cancel",
"history_group_begin",
"history_group_end",
"feature_add_many",
"doc_import",
"doc_export",
"model_inspect",
"mass_properties",
"history_listing",
"frame_info",
"metadata_set",
"metadata_set_many",
"metadata_remove",
];
#[derive(Debug)]
struct Bound {
handle: String,
route: Option<usize>,
}
fn bind_context(
docs: &crate::document::Documents,
phase: Phase,
cmd: &str,
mut args: serde_json::Value,
) -> Result<(serde_json::Value, Option<Bound>), String> {
let Some(raw) = args.as_object_mut().and_then(|o| o.remove("context")) else {
return Ok((args, None));
};
let context: DocumentContext = serde_json::from_value(raw)
.map_err(|e| format!("invalid_arguments: context must be {{documentId, expectedRevision}}: {e}"))?;
let expected = parse_revision(&context.expected_revision)?;
match crate::handles::parse("doc", &context.document_id) {
crate::handles::Parsed::Current => {}
crate::handles::Parsed::OtherEpoch => {
return Err("handle_epoch_mismatch: the document handle is from another run of the app".into())
}
crate::handles::Parsed::Malformed => return Err("invalid_arguments: documentId is not a document handle".into()),
}
let Some(index) = docs.index_of_handle(&context.document_id) else {
return Err("document_unknown: no open document has that handle (closed, or never opened)".into());
};
if docs.work_part_in_place() {
return Err("document_busy: a Work part is edited in place; a document command would write the part".into());
}
let route = if index == docs.active_index() {
None
} else if ENGINE_ONLY.contains(&cmd) {
Some(index)
} else {
return Err("document_not_active: this command acts on the visible document; activate the named one first".into());
};
let current = docs.get(index).map_or(0, |d| d.committed_revision());
if phase == Phase::Mutate && current != expected {
return Err(format!(
"revision_conflict: the document is at revision {current}, not {expected}; read it again before writing"
));
}
Ok((args, Some(Bound { handle: context.document_id, route })))
}
fn parse_revision(text: &str) -> Result<u64, String> {
let canonical = text == "0" || (!text.is_empty() && !text.starts_with('0') && text.bytes().all(|b| b.is_ascii_digit()));
text.parse::<u64>()
.ok()
.filter(|_| canonical)
.ok_or_else(|| "invalid_arguments: expectedRevision must be a canonical unsigned decimal string".into())
}
fn tag_context(
docs: &crate::document::Documents,
handle: &str,
result: Result<Outcome, String>,
) -> Result<Outcome, String> {
let context = docs.index_of_handle(handle).and_then(|i| docs.get(i)).map(|doc| {
serde_json::json!({ "documentId": handle, "expectedRevision": doc.committed_revision().to_string() })
});
let tag = |v: &mut serde_json::Value| {
if let (Some(object), Some(context)) = (v.as_object_mut(), context.clone()) {
object.insert("context".into(), context);
}
};
match result {
Ok(Outcome::Done(mut v)) => {
tag(&mut v);
Ok(Outcome::Done(v))
}
Ok(Outcome::Blob { mut json, bytes }) => {
tag(&mut json);
Ok(Outcome::Blob { json, bytes })
}
other => other,
}
}
fn answer(inner: &mut Inner, p: Pending, phase: Phase, frame: u64, result: Result<Outcome, String>, bound: Option<String>) {
match result {
Ok(Outcome::Done(v)) => {
let notices = inner.reply_notices();
let _ = p.reply.send(Reply::ok(p.envelope.id, frame, v, notices));
}
Ok(Outcome::Blob { json, bytes }) => {
let notices = inner.reply_notices();
let mut r = Reply::ok(p.envelope.id, frame, json, notices);
r.blob = Some(bytes);
let _ = p.reply.send(r);
}
Ok(Outcome::AwaitScreenshot { token, region, document }) => {
inner.awaiting.push(Awaiting { token, region, document, pending: p });
}
Ok(Outcome::Poll(poller)) => inner.polling.push(Polling { pending: p, phase, poller, bound }),
Err(e) => {
let notices = inner.reply_notices();
let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
}
}
}
fn refused_counts(docs: &crate::document::Documents) -> Vec<(u64, u64)> {
docs.iter().map(|d| (d.id(), d.engine.history.refused_user_edits())).collect()
}
fn refused_by_lock(
docs: &crate::document::Documents,
before: &[(u64, u64)],
result: Result<Outcome, String>,
) -> Result<Outcome, String> {
if result.is_err() {
return result;
}
for doc in docs.iter() {
let was = before.iter().find(|(id, _)| *id == doc.id()).map(|(_, n)| *n);
if was.is_some_and(|was| doc.engine.history.refused_user_edits() > was) {
if let crate::document::Access::ReadOnly { reason } = doc.access() {
return Err(format!("{} is read-only: {reason}", doc.title()));
}
}
}
result
}