Plug-ins: a malformed ids is refused, and a command out of budget changes nothing

plugin.run and plug-in commands read `ids` strictly: a value that is not
a list, or an entry that is not a non-negative integer, is an error
naming `ids` ("ids must be an array of non-negative integer object
ids"). Left out or null, `ids` is still the selection.

A new test shows that a plug-in command that runs out of its
instruction budget leaves the document and the undo history unchanged,
and that the error names the plug-in and the command.

docs/plugins.md and docs/mcp.md name MCP's `command_list` and
`list_commands` as the listings that show plug-in commands, and the
params text, docs and progress line describe the `ids` rule.
This commit is contained in:
dotCooCoo
2026-11-07 18:47:22 -08:00
parent 864b9b9950
commit 14ea556e92
5 changed files with 90 additions and 13 deletions
+18 -4
View File
@@ -33,7 +33,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry};
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, str_param, switch_or};
use vectorcraft_plugins::permissions::{self, Grant, Permission};
@@ -94,6 +94,19 @@ fn plugin_params(p: &Value) -> Value {
}
}
/// The objects command `c`'s params `p` give as `ids`, or `None` when `p` leaves it out or gives
/// null. A value that is not a list, or an entry that is not a non-negative integer, is an error
/// naming `ids`: a malformed list is never read as the selection.
fn given_ids(c: &str, p: &Value) -> Result<Option<Vec<NodeId>>> {
let key = "ids";
let malformed = || bad(c, format!("{key} must be an array of non-negative integer object ids"));
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Array(a)) => a.iter().map(|v| v.as_u64().map(NodeId).ok_or_else(malformed)).collect::<Result<Vec<_>>>().map(Some),
Some(_) => Err(malformed()),
}
}
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
@@ -164,7 +177,7 @@ impl FilterRun {
let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?);
let may = m.permissions();
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
let selected = given_ids(c, p)?.unwrap_or_else(|| st.selection.objects.clone());
// Without `doc.read` a filter is given no objects: it can only add new ones.
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
let parent = st.insertion_parent();
@@ -362,7 +375,8 @@ fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
let label = c.shown_label();
let top = if m.kind == Kind::Effect { "Effect" } else { "Object" };
let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME);
let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)");
let mut fields =
String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)");
let doc = m.for_command(c).params_doc();
if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) {
fields.push_str(", ");
@@ -692,7 +706,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),