Engine: execute refuses keys a command doesn't name, in every build

Session::execute ran check_params only in the engine's own tests, so a
direct execute in a release build ran a misspelled key with the
command's default. It now checks every call, the commands a command runs
included, as MCP and the control port already did. A command.batch or
an action.batch is checked before its first step runs, and each step is
checked again as it runs, so an effect.setParams step is checked
against the effects the steps before it added.

Saved steps play through Session::replay, which takes keys a command no
longer reads, so an action recorded by an older version still plays and
Session::replay still plays an older journal. The Actions panel's Play
(Accelerated, Step by Step and Pause For, its function keys and
ui.action.play) and the Batch dialog use it.

Docs: the cmd/params.rs module doc, the Parameter keys section of
docs/mcp.md and a line in docs/target-app-parity.md describe both
paths.
This commit is contained in:
dotCooCoo
2026-11-08 11:22:28 +07:00
committed by bflatastic
parent a7733acc7e
commit 74863fa778
12 changed files with 229 additions and 35 deletions
+21 -11
View File
@@ -8,15 +8,19 @@
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked.
//!
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
//! effect (#1592): `effect.apply` and
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
//! what it is given, so the journal of an older version still replays, except for an effect's
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
//! [`check_effect_channel`]).
//! default, with a parameter error ([`check_effect_params`]).
//!
//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older
//! version, with a key a command no longer reads or an effect parameter it no longer takes, still
//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves
//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams`
//! themselves ([`check_effect_color`], [`check_effect_channel`]).
use serde_json::Value;
@@ -473,10 +477,16 @@ pub const UNCHECKED: &[&str] = &[
"webExport.settings",
];
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
/// passes: running it reports that.
pub fn check_params(id: &str, params: &Value) -> Result<()> {
check_params_in(id, params, false)
}
/// [`check_params`] for a step of a batch when `in_batch`: batches can't nest, so the steps of a
/// `command.batch` or `action.batch` step aren't checked (running it reports that), however deep they go.
fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
let Some(spec) = find_command(id) else { return Ok(()) };
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
@@ -487,10 +497,10 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
return Ok(());
}
check_keys(id, spec.params, params)?;
if id == "command.batch" {
if matches!(id, "command.batch" | "action.batch") && !in_batch {
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) {
check_params(step_id, step_params)?;
check_params_in(step_id, step_params, true)?;
}
}
}
+3
View File
@@ -70,6 +70,9 @@ pub struct Session {
/// cursor asked for again over an unchanged document and selection measures nothing again.
pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo,
depth: u32,
/// Set while saved steps play ([`Session::replay`]): [`Session::execute`] then takes keys the
/// command's params description doesn't name instead of refusing them.
replaying: bool,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
tool_panic: Option<EngineError>,
/// How many times switching or closing documents, or replacing the active one, has started the
+1
View File
@@ -44,6 +44,7 @@ impl Session {
last_view: ViewInfo::default(),
preview_memo: Default::default(),
depth: 0,
replaying: false,
tool_panic: None,
tool_resets: 0,
tool_set_since_reset: false,
+31 -4
View File
@@ -35,10 +35,14 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
self.check_params(id, params)?;
// A key the command's params description doesn't name is a parameter error (#1442), before
// anything runs, for the commands other commands run too. A `command.batch` or an
// `action.batch` checks its steps before the first one runs, and each step is checked again
// as it runs, against the document the steps before it left (the effects `effect.setParams`
// edits). Saved steps played with `replay` aren't checked.
if !self.replaying {
self.check_params(id, params)?;
}
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
// before anything else: a malformed one, or one naming no object, is an error under this
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
@@ -186,6 +190,29 @@ impl Session {
Ok((r?, noted))
}
/// [`Session::execute`] for saved steps: a journal entry or an action's steps, recorded by this
/// version or an older one. Keys a command's params description doesn't name (a key it no longer
/// reads, an effect parameter it no longer takes) are taken, by the commands it runs too, so what
/// was recorded still plays (#1442). Everything else is checked as `execute` checks it.
pub fn replay(&mut self, id: &str, params: &Value) -> Result<Value> {
let outer = self.set_replaying(true);
let r = self.execute(id, params);
self.set_replaying(outer);
r
}
/// Whether [`Session::execute`] plays saved steps as [`Session::replay`] does. A frontend that
/// runs saved steps through its own path to `execute` sets it around that call, and puts back the
/// value this returns, which it had before.
pub fn set_replaying(&mut self, on: bool) -> bool {
std::mem::replace(&mut self.replaying, on)
}
/// Whether saved steps are playing ([`Session::set_replaying`]).
pub fn replaying(&self) -> bool {
self.replaying
}
/// The journal from entry `start` on, as an action records it: without the params that only
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
+95 -6
View File
@@ -1,24 +1,112 @@
//! A caller's params are checked against the command's description, but a journal replays as it
//! was recorded (#1442).
//! `Session::execute` checks params against the command's description, but a journal replays as
//! it was recorded (#1442).
use serde_json::json;
use vectorcraft_engine::{Session, check_params};
/// `Session::execute` takes what it is given: the journal of an older version, an action recorded
/// with a key a command no longer reads, still plays. The check is the caller's.
/// The open document's art and undo history, and the session's journal length.
fn state(s: &Session) -> (Option<vectorcraft_doc::Document>, usize, usize) {
let st = s.active();
(st.map(|st| (*st.doc).clone()), st.map_or(0, |st| st.history.undo.len()), s.journal.len())
}
/// `Session::execute` answers a key the command's description doesn't name with a parameter
/// error, in a release build too, and the document, its undo history and the journal stay as
/// they were.
#[test]
fn execute_refuses_a_key_its_command_does_not_name() {
let mut s = Session::new();
let e = s.execute("file.new", &json!({"width": 300, "widht": 1})).unwrap_err().to_string();
assert!(e.contains("widht"), "{e}");
assert!(s.active().is_none());
s.execute("file.new", &json!({})).unwrap();
let before = state(&s);
let e = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})).unwrap_err();
assert!(e.to_string().contains("cornerStyle"), "{e}");
assert!(state(&s) == before);
}
/// A step of a `command.batch` with such a key fails the batch before any step runs, and a step
/// whose `effect.setParams` gives an effect a parameter it doesn't take fails it too, though that
/// effect is only there once the steps before it ran. Nothing the batch did stays.
#[test]
fn execute_refuses_a_batch_step_with_a_key_its_command_does_not_name() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = state(&s);
let rect = json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}});
let bad_step = json!({"commands": [rect, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}]});
let e = s.execute("command.batch", &bad_step).unwrap_err().to_string();
assert!(e.contains("dyy"), "{e}");
assert!(state(&s) == before);
let bad_effect = json!({"commands": [
rect,
{"command": "effect.apply", "params": {"effect": "blur.gaussian"}},
{"command": "effect.setParams", "params": {"index": 0, "params": {"blurr": 3}}},
]});
assert!(check_params("command.batch", &bad_effect).is_ok());
let e = s.execute("command.batch", &bad_effect).unwrap_err().to_string();
assert!(e.contains("blurr"), "{e}");
assert!(state(&s) == before);
}
/// The steps of a `command.batch` step aren't checked, however deep they go: batches can't nest,
/// which running the batch reports.
#[test]
fn the_steps_of_a_nested_batch_are_not_checked() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut batch = json!({"commands": [{"command": "object.move", "params": {"dyy": 5}}]});
assert!(check_params("command.batch", &batch).is_err());
for _ in 0..3 {
batch = json!({"commands": [{"command": "command.batch", "params": batch}]});
}
assert!(check_params("command.batch", &batch).is_ok());
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
assert!(e.contains("batches cannot nest"), "{e}");
}
/// An `action.batch` with a step that has such a key fails before it opens a source file or runs
/// a step.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn execute_refuses_an_action_batch_step_with_a_key_its_command_does_not_name() {
let dir = std::env::temp_dir().join(format!("vc-param-keys-action-batch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10"/></svg>"#;
std::fs::write(dir.join("a.svg"), svg).unwrap();
let mut s = Session::new();
let batch = json!({
"source": "folder",
"folder": dir.to_string_lossy(),
"commands": [{"command": "select.all", "params": {}}, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}],
});
let e = s.execute("action.batch", &batch).unwrap_err().to_string();
std::fs::remove_dir_all(&dir).ok();
assert!(e.contains("dyy"), "{e}");
assert!(s.documents().is_empty());
assert!(s.journal.is_empty());
}
/// `Session::replay` takes what it is given: the journal of an older version, an action recorded
/// with a key a command no longer reads, still plays, as a step of a `command.batch` too.
#[test]
fn a_journal_with_a_key_no_longer_read_still_replays() {
let old = [
("file.new", json!({"width": 300, "height": 200, "widht": 1})),
("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})),
("command.batch", json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 5, "dyy": 5}}]})),
];
let mut s = Session::new();
for (id, params) in &old {
assert!(check_params(id, params).is_err(), "{id}");
s.execute(id, params).unwrap();
s.replay(id, params).unwrap();
}
assert!(!s.replaying());
let st = s.active().unwrap();
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
assert_eq!(s.journal.len(), 3);
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
@@ -34,7 +122,8 @@ fn effect_params_no_longer_taken_still_replay_and_open() {
];
for (id, params) in &old {
assert!(s.check_params(id, params).is_err(), "{id}");
s.execute(id, params).unwrap();
assert!(s.execute(id, params).is_err(), "{id}");
s.replay(id, params).unwrap();
}
let doc = &s.active().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
+2
View File
@@ -255,6 +255,7 @@ impl VectorcraftApp {
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) {
self.picks.recover();
self.session.set_replaying(false);
self.status(format!("Internal error: {msg} (please report this bug)"));
}
}
@@ -494,6 +495,7 @@ impl VectorcraftApp {
})
}) {
self.picks.recover();
self.session.set_replaying(false);
self.status(format!("Internal error: {msg} (please report this bug)"));
}
crate::ime::finish_ui(self, &ctx);
+13 -2
View File
@@ -84,13 +84,24 @@ impl VectorcraftApp {
result
}
/// [`Self::run`] for saved steps (an action's): keys their commands no longer read are taken,
/// as [`vectorcraft_engine::Session::replay`] takes them, so an action recorded by an older
/// version still plays.
pub fn replay(&mut self, id: &str, params: Value) -> Result<Value, String> {
let outer = self.session.set_replaying(true);
let result = self.run(id, params);
self.session.set_replaying(outer);
result
}
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
self.run_count = self.run_count.wrapping_add(1);
// In debug builds the app's own calls are held to the params descriptions too (#1442): a
// panel passing a key its command doesn't read shows up in the tests.
if cfg!(debug_assertions) {
// panel passing a key its command doesn't read shows up in the tests. Saved steps aren't
// checked ([`Self::replay`]).
if cfg!(debug_assertions) && !self.session.replaying() {
menus::check_params(&self.session, id, &params)?;
}
// A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names
+1
View File
@@ -49,6 +49,7 @@ impl VectorcraftApp {
Ok(outcome) => outcome,
Err(msg) => {
self.picks.recover();
self.session.set_replaying(false);
control::err(format!("internal error: {msg} (please report this bug)"))
}
};
+20 -1
View File
@@ -112,7 +112,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
return Err("open a document with data sets".into());
}
let params = params(d, &a.name, commands);
let r = app.run("action.batch", params)?;
let r = app.replay("action.batch", params)?;
app.ui.dialog = None;
let count = r["count"].as_u64().unwrap_or(0);
let failed = r["failed"].as_u64().unwrap_or(0);
@@ -183,6 +183,25 @@ mod tests {
assert!(!shown("folder").iter().any(|label| label == "File Name:"));
}
/// Batch plays an action recorded with a key its command no longer reads, as Play does.
#[test]
fn an_action_with_a_key_no_longer_read_still_plays_in_a_batch() {
use crate::panels::actions::{Action, ActionSet};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("dataset.new", json!({"name": "a"})).unwrap();
let old = json!({"x": 10, "y": 10, "width": 20, "height": 20, "cornerStyle": "round"});
assert!(app.run("shape.rectangle", old.clone()).is_err());
app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Draw", vec![("shape.rectangle".into(), old)])])];
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Draw"));
d.fields["source"] = json!("datasets");
d.fields["destination"] = json!("none");
let r = confirm(&mut app, &d).unwrap();
assert_eq!((r["count"].clone(), r["failed"].clone()), (json!(1), json!(0)), "{r}");
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1);
assert!(!app.session.replaying());
}
#[test]
fn data_set_naming_params_follow_dialog_choice() {
let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test"));
+26 -2
View File
@@ -225,7 +225,7 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result<Value, S
}
let options = app.ui.action_playback;
if options.speed == PlaybackSpeed::Accelerated {
return app.run("command.batch", json!({"label": a.name, "commands": commands}));
return app.replay("command.batch", json!({"label": a.name, "commands": commands}));
}
let mut steps: std::collections::VecDeque<Value> = commands.into();
let Some(first) = steps.pop_front() else { return Err("the action has no included steps".into()) };
@@ -245,7 +245,7 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result<Value, S
/// Runs one step of a paced playback as its own undo step, named `label` → the step's result.
fn play_step(app: &mut VectorcraftApp, label: &str, step: Value) -> Result<Value, String> {
let r = app.run("command.batch", json!({"label": label, "commands": [step]}))?;
let r = app.replay("command.batch", json!({"label": label, "commands": [step]}))?;
Ok(r.get("results").and_then(|r| r.get(0)).cloned().unwrap_or(Value::Null))
}
@@ -1277,6 +1277,30 @@ mod tests {
}
}
/// An action recorded with a key its command no longer reads still plays, accelerated and one
/// step at a time, though the same command run by itself is refused.
#[test]
fn an_action_with_a_key_no_longer_read_still_plays() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = vectorcraft_doc::NodeId(
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(),
);
app.run("select.set", json!({"ids": [id.0]})).unwrap();
let old = json!({"dx": 5, "dy": 0, "dyy": 1});
assert!(app.run("object.move", old.clone()).is_err());
app.ui.action_sets =
vec![ActionSet::new("Test", vec![Action::new("Move", vec![("object.move".into(), old.clone()), ("object.move".into(), old)])])];
let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let before = x(&app);
play(&mut app, 0, 0).unwrap();
assert!((x(&app) - before - 10.0).abs() < 1e-9, "both moves ran");
app.ui.action_playback.speed = PlaybackSpeed::Step;
assert_eq!(play(&mut app, 0, 0).unwrap()["pending"], json!(1));
assert!((x(&app) - before - 15.0).abs() < 1e-9, "the first step ran");
assert!(!app.session.replaying());
}
#[test]
fn excluded_steps_do_not_play_and_survive_saved_sets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+15 -9
View File
@@ -1080,10 +1080,12 @@ PDF 1.3 export.
### Parameter keys
`command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check
the keys of `params` against the command's params description (the `params` text `command_list` returns): the object
it starts with, `{x, width?: pt, name?|title?: …}`, names the keys; `?` marks an optional one. A key it doesn't name
is refused before the command runs:
The engine checks the keys of `params` of every command it runs against the command's params description (the
`params` text `command_list` returns): `command_run`, each step of `command_batch` and of the `command.batch` and
`action.batch` commands, `run --cmd … --params`, the control channel's `engine.execute`, and the app's own calls. A
batch with such a step fails before its first step runs. The object the description
starts with, `{x, width?: pt, name?|title?: …}`, names the keys; `?` marks an optional one. A key it doesn't name is
refused before the command runs:
```json
{"id": "file.new", "params": {"widht": 300}}
@@ -1126,13 +1128,17 @@ one opens as before and draws it as `rgb`:
```
A number outside the range the effect documents is still stored as given and clamped when the effect draws. A
plug-in effect's parameters are resolved against its manifest instead, and `effect.setParams` as a step of
`command.batch` isn't checked before the batch runs (the effect it edits may only exist once the steps before it ran);
the command still refuses an effect `color` it can't read. A description with a `…` entry takes more keys than it lists: those of the command it names
plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of
`command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then
fails with no change. A description with a `…` entry takes more keys than it lists: those of the command it names
(`…settings as file.new`), or any key when it names none, as `document.export` and `document.save` do for the options
of the format they write (one object can carry several formats' options). A command that takes its own reply back
(`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or
journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read.
(`document.setup`, `text.areaOptions`) says so the same way.
Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function
key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored
and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect
`color` or `channel` it can't read.
An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when
one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error:
+1
View File
@@ -649,6 +649,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- JSON-lines control channel with real egui pointer and keyboard injection.
- Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709).
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`).