Engine: an optional check before every command

An app that embeds the engine can install a check on the Session
(Session::set_command_check). Session::execute asks it before each command
with the command id, its parameters and the session (read only); a refusal
comes back as EngineError::Refused with the check's reason and leaves the
design unchanged. Without a check nothing changes.

The check belongs to the host: it survives opening and starting designs,
scratch copies (previews, off-thread evaluation) carry it, and the desktop
UI hands it on to every document tab and to designs opened in the
background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-25 12:42:23 -04:00
committed by Irakli Kourtis
parent c7373279d8
commit b3cb826ed4
5 changed files with 154 additions and 2 deletions
+3 -1
View File
@@ -81,7 +81,9 @@ fn classify(e: &EngineError) -> Outcome {
let msg = e.to_string();
match e {
EngineError::Internal(..) => Outcome::new("panic", msg),
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::Refused { .. } | EngineError::UnknownCommand(_) => {
Outcome::new("rejected", msg)
}
// A failed boolean is a failure even when it says "not supported": those are the cases
// to fix. Features the kernel doesn't do yet are limitations.
// Picks the generator got wrong (a centroid off its face).
+47 -1
View File
@@ -66,6 +66,10 @@ pub enum EngineError {
BadParams { cmd: String, msg: String },
#[error("{0}")]
Other(String),
/// The host's pre-command check ([`Session::set_command_check`]) refused the command; the
/// message is the reason it gave.
#[error("{reason}")]
Refused { cmd: String, reason: String },
/// A command panicked; the document was kept as it was (a bug: please report it).
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
Internal(String, String),
@@ -325,6 +329,11 @@ impl ComputedCommands {
}
}
/// A check an app that embeds the engine runs before every command ([`Session::set_command_check`]):
/// it is shown the command's id, its parameters and the session (read only), and returns `Ok` to
/// let the command run or `Err(reason)` to refuse it.
pub type CommandCheck = Arc<dyn Fn(&str, &Value, &Session) -> std::result::Result<(), String> + Send + Sync>;
/// An editing session on one design.
/// Starts an upload of a file (its name, its bytes) to EasyPrint and returns at once; the host
/// reports how it went and opens the answer itself. `Err`: it couldn't start.
@@ -442,6 +451,9 @@ pub struct Session {
/// that did is no edit of the design before it, so [`Session::execute`] never takes it for a
/// change of render intent or part properties alone.
replacements: u64,
/// The host's pre-command check (none by default: every command may run). It belongs to the
/// host, not the design: opening or starting a design keeps it, and scratch copies carry it.
command_check: Option<CommandCheck>,
/// Record this session's top-level commands in the crash journal (the app's own design;
/// never previews or other scratch sessions). See [`crash`].
pub crash_journal: bool,
@@ -621,6 +633,7 @@ impl Session {
#[cfg(test)]
evaluations: 0,
motion_traces: Default::default(),
command_check: None,
crash_journal: false,
}
}
@@ -644,8 +657,10 @@ impl Session {
// next frame, so commands sent right after `doc.open` or `file.new` are kept.
let crash_journal = self.crash_journal;
let replacements = self.replacements.wrapping_add(1);
let command_check = self.command_check.take();
*self = fresh;
self.crash_journal = crash_journal;
self.command_check = command_check;
self.auto_project = auto_project;
self.metric_tap_drill_major_less_pitch = metric_tap_drill_major_less_pitch;
self.param_check = param_check;
@@ -657,6 +672,32 @@ impl Session {
self.replacements = replacements;
}
/// Install (or with `None` remove) the check [`Session::execute`] runs before every command,
/// for an app that embeds the engine and decides which commands may run. A refusal comes back
/// as [`EngineError::Refused`] and leaves the session as it was.
pub fn set_command_check(&mut self, check: Option<CommandCheck>) {
self.command_check = check;
}
/// The check installed with [`Session::set_command_check`], to hand on to another session.
pub fn command_check(&self) -> Option<CommandCheck> {
self.command_check.clone()
}
/// Ask the host's check, if any, whether `spec` may run with `params`. A check that panics
/// refuses the command as an internal error instead of taking the session down.
fn check_command(&self, spec: &CommandSpec, params: &Value) -> Result<()> {
let Some(check) = &self.command_check else { return Ok(()) };
// The check sees the parameters as the command will (no parameters: an empty object).
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(spec.id, params, self))) {
Ok(Ok(())) => Ok(()),
Ok(Err(reason)) => Err(EngineError::Refused { cmd: spec.id.to_string(), reason }),
Err(_) => Err(EngineError::Internal(spec.id.to_string(), "the host's command check failed".into())),
}
}
/// The design has changes not in its file: in the document, or in what is hidden.
pub fn is_dirty(&self) -> bool {
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
@@ -1131,7 +1172,9 @@ impl Session {
}
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
/// when they change the document; a failing or panicking command leaves it unchanged.
/// when they change the document; a failing or panicking command leaves it unchanged. The
/// host's check ([`Session::set_command_check`]) is asked first, on every call (commands that
/// a command runs through `execute` included).
///
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
@@ -1154,6 +1197,8 @@ impl Session {
}
}
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
// Before anything changes: a refusal leaves the session exactly as it was.
self.check_command(spec, params)?;
let before = Snapshot {
label: spec.label.to_string(),
doc: self.doc.clone(),
@@ -1394,6 +1439,7 @@ impl Session {
#[cfg(test)]
evaluations: 0,
motion_traces: self.motion_traces.clone(),
command_check: self.command_check.clone(),
crash_journal: false,
}
}
+75
View File
@@ -4537,3 +4537,78 @@ fn join_leaves_a_hidden_body_alone() {
run(&mut s, "browser.visibility", json!({"items": ["Plate (2)"], "visible": true}));
assert_eq!(s.model.state().bodies.len(), 2);
}
/// A pre-command check that refuses one command id.
fn refusing(id: &'static str) -> CommandCheck {
std::sync::Arc::new(move |cmd: &str, _: &Value, _: &Session| if cmd == id { Err(format!("{cmd} is not allowed here")) } else { Ok(()) })
}
#[test]
fn without_a_command_check_every_command_runs() {
let mut s = Session::default();
assert!(s.command_check().is_none());
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]}));
assert_eq!(s.doc.features.len(), 2);
}
#[test]
fn a_command_check_refuses_a_command_and_leaves_the_design() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
s.set_command_check(Some(refusing("solid.box")));
let doc = s.doc.clone();
let (undo, revision) = (s.undo.len(), s.revision);
let r = s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]}));
match r {
Err(EngineError::Refused { cmd, reason }) => {
assert_eq!(cmd, "solid.box");
assert_eq!(reason, "solid.box is not allowed here");
}
other => panic!("expected a refusal, got {other:?}"),
}
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "the design is unchanged");
assert_eq!((s.undo.len(), s.revision), (undo, revision));
// Other commands still run, and the check stays with the session across new designs,
// scripts and previews.
run(&mut s, "file.new", json!({"name": "Next"}));
assert!(s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).is_err(), "kept by file.new");
assert!(s.run_script(&json!([{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}])).is_err(), "scripts");
assert!(s.preview(&[("solid.box".into(), json!({"length": 1, "width": 1, "height": 1}))]).is_err(), "previews");
s.set_command_check(None);
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1}));
}
#[test]
fn a_command_check_can_count_the_features() {
const MAX: usize = 3;
let mut s = Session::default();
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Value)>::new()));
let log = seen.clone();
s.set_command_check(Some(std::sync::Arc::new(move |cmd: &str, params: &Value, s: &Session| {
if let Ok(mut log) = log.lock() {
log.push((cmd.to_string(), params.clone()));
}
if cmd.starts_with("solid.") && s.doc.features.len() >= MAX { Err(format!("at most {MAX} features")) } else { Ok(()) }
})));
for i in 0..MAX {
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [i * 5, 0, 0]}));
}
let r = s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1, "corner": [50, 0, 0]}));
assert!(matches!(&r, Err(EngineError::Refused { .. })), "{r:?}");
assert_eq!(r.err().map(|e| e.to_string()).as_deref(), Some("at most 3 features"));
assert_eq!(s.doc.features.len(), MAX);
// The check saw each command's id and parameters.
let seen = seen.lock().map(|l| l.clone()).unwrap_or_default();
assert_eq!(seen.len(), MAX + 1);
assert!(seen.iter().all(|(id, p)| id == "solid.box" && p["length"] == 1));
}
#[test]
fn a_command_check_that_panics_refuses_the_command() {
let mut s = Session::default();
s.set_command_check(Some(std::sync::Arc::new(|_: &str, _: &Value, _: &Session| -> std::result::Result<(), String> { panic!("host bug") })));
let r = s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1}));
assert!(matches!(r, Err(EngineError::Internal(..))), "{r:?}");
assert!(s.doc.features.is_empty());
}
+26
View File
@@ -159,6 +159,8 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
// Preferences the session holds go with the user, not the tab (#680).
app.session.auto_project = out.session.auto_project;
app.session.metric_tap_drill_major_less_pitch = out.session.metric_tap_drill_major_less_pitch;
// So does the host's pre-command check: every tab is held to it.
app.session.set_command_check(out.session.command_check());
// Views cache by revision: the incoming design must not look unchanged.
let before = app.session.revision;
app.session.invalidate_view(out_rev);
@@ -1495,6 +1497,30 @@ mod tests {
assert!(!a.session.auto_project, "a design adopted over a blank one");
}
/// The host's pre-command check holds in every tab: new ones, the one switched to, a design
/// adopted over a blank one and after the last tab closes.
#[test]
fn the_command_check_follows_the_host_across_tabs() {
let mut a = app();
a.session.set_command_check(Some(std::sync::Arc::new(
|id: &str, _: &Value, _: &Session| {
if id == "solid.box" { Err("no boxes".to_string()) } else { Ok(()) }
},
)));
assert_eq!(a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})), Err("no boxes".to_string()));
a.run("solid.cylinder", json!({"radius": 5, "height": 10})).unwrap();
new_design(&mut a);
assert_eq!(a.docs.count(), 2);
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "a new tab");
switch(&mut a, 0);
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "the tab switched to");
close(&mut a, 1, true);
close(&mut a, 0, true);
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "after the last tab closes");
adopt(&mut a, Session::default());
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "a design adopted over a blank one");
}
/// Hide Origin by Default hides the Origin of designs that don't keep its eye; a design that
/// does keeps it, along with the rest of the browser's view, in its tab and in its file.
#[test]
+3
View File
@@ -408,6 +408,8 @@ fn start(app: &mut SolveApp) {
let before = if insert { crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let update = request.kind == Kind::UpdateLinks;
let mut scratch = if insert || update || request.kind == Kind::ExportDxf { app.session.scratch() } else { Session::default() };
// A design opened or recovered here is run through the host's pre-command check too.
scratch.set_command_check(app.session.command_check());
if insert {
scratch.selection = app.session.selection.clone();
}
@@ -534,6 +536,7 @@ fn start_staged(app: &mut SolveApp) {
let insert = request.insert();
let before = if insert { crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let mut session = if insert { app.session.scratch() } else { Session::default() };
session.set_command_check(app.session.command_check());
if insert {
session.selection = app.session.selection.clone();
}