Merge pull request #1409 from avhn/feat/1386-batch-continue

M0.9: let batches continue after failed steps (#1386)
This commit is contained in:
BFlat
2026-10-29 14:04:46 +07:00
committed by GitHub
12 changed files with 684 additions and 71 deletions
+13 -3
View File
@@ -5,7 +5,7 @@
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use serde_json::{Map, Value};
@@ -57,6 +57,7 @@ pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
failed: HashSet<usize>,
}
impl BatchScope {
@@ -69,10 +70,10 @@ impl BatchScope {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
Ok(Self { names, results: Vec::with_capacity(steps.len()), failed: HashSet::new() })
}
/// The results of the steps run so far, in order.
/// The results of the steps run so far, in order (failed slots hold null).
pub fn results(&self) -> &[Value] {
&self.results
}
@@ -90,6 +91,12 @@ impl BatchScope {
self.results.push(result);
}
pub(crate) fn push_failed(&mut self) {
// Keep positions even after a failure; a successful null result is not a failed step.
self.failed.insert(self.next_step());
self.results.push(Value::Null);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
@@ -137,6 +144,9 @@ impl BatchScope {
}
(n, format!("step {n} (\"{name}\")"))
};
if self.failed.contains(&n) {
return Err(fail(format!("references {who}, which failed")));
}
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
@@ -0,0 +1,33 @@
//! A batch that goes on after a failed step (`onError: "continue"`) reports the live events of the
//! steps that stayed, not of the ones rolled back.
use std::time::Duration;
use serde_json::json;
use crate::Session;
#[test]
fn a_continued_batch_reports_only_the_events_of_steps_that_stayed() {
let mut s = Session::new();
let before = s.events.latest();
let r = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}},
{"command": "command.batch", "params": {"commands": [
{"command": "comment.add", "params": {"x": 20, "y": 20, "text": "Rolled back"}},
{"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}}
]}},
{"command": "comment.add", "params": {"x": 30, "y": 30, "text": "Stays too"}}
]}),
)
.unwrap();
let ok: Vec<bool> = r.as_array().unwrap().iter().map(|o| o["ok"].as_bool().unwrap()).collect();
assert_eq!(ok, [true, false, true]);
let texts: Vec<String> = s.doc().unwrap().comments.iter().map(|c| c.first.text.clone()).collect();
assert_eq!(texts, ["Stays", "Stays too"]);
let events = s.events.poll(before, &["comments".into()], 10, Duration::ZERO).events;
assert_eq!(events.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(), ["comment.added", "comment.added"]);
}
+372
View File
@@ -280,3 +280,375 @@ fn a_failing_command_names_its_step_and_rolls_back() {
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
#[test]
fn a_continue_batch_can_undo_then_redo_between_steps() {
for mode in ["abort", "continue"] {
let mut s = Session::new();
let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap());
let before = s.doc().unwrap().clone();
let results =
s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap();
let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] };
let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] };
assert_eq!(undo["undone"], true, "{mode}: {results}");
assert_eq!(redo["redone"], true, "{mode}: {results}");
assert!(s.doc().unwrap().contains(rect));
assert_eq!(s.doc().unwrap(), &before);
}
}
#[test]
fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() {
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new"},
{"command": "page.rename", "params": {"name": "New page"}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}");
assert_eq!(s.docs.len(), 1);
assert_ne!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page");
assert!(s.closed_in_group.is_empty());
}
#[test]
fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle"})).unwrap();
let last = s.last_command.clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "text", "text": "Rolled back"}},
{"command": "no.such.command"}
]}},
{"command": "edit.repeat"}
]}),
)
.unwrap();
assert_eq!(results[0]["ok"], false);
assert_eq!(results[1]["ok"], true);
let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap();
assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}");
assert_eq!(s.last_command, last);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2);
}
#[test]
fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
assert_eq!((frame.0, text.0), (2, 3));
let undo = s.undo_depth().0;
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let results = s
.execute(
"command.batch",
&json!({"label": "Continue", "onError": "continue", "commands": [
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 3);
assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
assert_eq!(results[1]["step"], 1);
assert_eq!(results[1]["command"], "text.set");
assert_eq!(results[1]["ok"], false);
let error = results[1]["error"].as_str().unwrap();
assert!(error.contains("step 1:") && error.contains("is not text"), "{error}");
assert!(results[1].get("result").is_none());
undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo);
}
/// The successful steps of the batch above undo and redo as one step.
fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) {
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
assert_eq!(s.undo_labels(1), ["Continue"]);
assert!(s.undo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone");
assert_eq!(s.undo_depth(), (undo, 1));
assert!(s.redo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
}
#[test]
fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() {
let mut s = Session::new();
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}},
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}},
{"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, result) in results.as_array().unwrap().iter().enumerate() {
assert_eq!(result["step"], i);
assert_eq!(result["ok"], ![0, 2, 3].contains(&i));
}
assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed"));
assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed"));
assert!(
matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"),
"the failed step's partial edit was rolled back"
);
let card = id(&results[1]["result"]);
for i in [4, 5] {
assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes");
}
assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child");
}
#[test]
fn invalid_batch_policies_run_nothing() {
for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s
.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]}))
.unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }));
assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
#[test]
fn invalid_batch_structure_runs_nothing_in_either_mode() {
let rename = json!({"command": "page.rename", "params": {"name": "Changed"}});
for mode in ["abort", "continue"] {
for commands in [
json!(null),
json!({}),
json!([rename, null]),
json!([rename, {}]),
json!([rename, {"command": 3}]),
json!([rename, {"command": "page.rename", "as": ""}]),
json!([rename, {"command": "page.rename", "as": 3}]),
json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]),
] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands}));
assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
}
#[test]
fn an_explicit_abort_batch_keeps_the_default_results_and_errors() {
let commands = json!([
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "text": "orig"}}
]);
let mut default = Session::new();
let mut abort = Session::new();
let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap();
assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw);
assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results");
let commands = json!([
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]);
let before = abort.doc().unwrap().clone();
let undo = abort.undo_depth();
let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err();
let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err();
assert_eq!(explicit.to_string(), error.to_string());
assert!(matches!(explicit, EngineError::BadParams { .. }));
assert_eq!(abort.doc().unwrap(), &before);
assert_eq!(abort.undo_depth(), undo);
}
#[test]
fn nested_batch_error_policies_are_local() {
for outer in ["abort", "continue"] {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": outer, "commands": [
{"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}}
]}),
)
.unwrap();
let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] };
assert_eq!(inner[1]["ok"], false);
let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap();
assert_eq!(child.name, "Inner child");
assert_eq!(child.parent, Some(id(&inner[0]["result"])));
assert_eq!(s.undo_depth(), (1, 0));
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0);
assert!(s.redo().unwrap());
assert!(s.doc().unwrap().contains(id(&inner[2]["result"])));
}
}
#[test]
fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() {
for explicit in [false, true] {
let mut s = Session::new();
let mut inner = json!({"commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]});
if explicit {
inner["onError"] = json!("abort");
}
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "page.rename", "params": {"name": "Before"}},
{"command": "command.batch", "params": inner},
{"command": "page.rename", "params": {"name": "After"}}
]}),
)
.unwrap();
assert_eq!(results[1]["ok"], false);
assert!(results[1]["error"].as_str().unwrap().contains("unknown command"));
assert_eq!(results[2]["ok"], true);
assert_eq!(s.docs.len(), 1);
assert_eq!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After");
assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty());
assert!(s.closed_in_group.is_empty());
assert_eq!(s.undo_depth(), (1, 0));
}
}
#[test]
fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let result = s.execute(
"command.batch",
&json!({"commands": [
{"command": "command.batch", "params": {"onError": "continue", "commands": [
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]}},
{"command": "no.such.command"}
]}),
);
assert!(matches!(result, Err(EngineError::UnknownCommand(_))));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
s.execute("page.rename", &json!({"name": "Redo name"})).unwrap();
assert!(s.undo().unwrap());
let depth = s.undo_depth();
let before = s.doc().unwrap().clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
assert!(s.redo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name");
}
#[test]
fn an_empty_continue_batch_returns_an_empty_array_without_undo() {
let mut s = Session::new();
assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([]));
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn a_null_batch_result_is_not_a_failed_slot() {
let mut scope = crate::BatchScope::default();
scope.push(serde_json::Value::Null);
scope.push_failed();
let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string();
assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}");
let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string();
assert!(failed.contains("step 2: references step 1, which failed"), "{failed}");
assert_eq!(scope.next_step(), 2);
}
#[test]
fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "no-such-type"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}},
{"command": "page.rename", "params": {"name": "Done"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] {
assert_eq!(results[i]["ok"], false);
assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]);
}
assert_eq!(results[4]["ok"], true);
assert_eq!(results[6]["ok"], true);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done");
}
+9 -2
View File
@@ -460,7 +460,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
spec!("command.batch", "Run several commands", "{label?, onError?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
// `continue` rolls back only the failed steps and returns one outcome per step.
let continue_on_error = match get(p, "onError") {
None => false,
Some(Value::String(mode)) if mode == "abort" => false,
Some(Value::String(mode)) if mode == "continue" => true,
Some(_) => return Err(bad("command.batch", "`onError` must be \"abort\" or \"continue\"")),
};
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<BatchStep> = match get(p, "commands") {
Some(Value::Array(a)) => {
@@ -476,7 +483,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
_ => return Err(bad("command.batch", "`commands` must be an array")),
};
Ok(Value::Array(s.batch(&label, &cmds)?))
Ok(Value::Array(if continue_on_error { s.batch_continue(&label, &cmds)? } else { s.batch(&label, &cmds)? }))
}),
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
+2
View File
@@ -179,6 +179,8 @@ mod arc_handles_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod batch_continue_events_tests;
#[cfg(test)]
mod batch_rollback_tests;
#[cfg(test)]
mod batch_step_ref_tests;
+137 -63
View File
@@ -1,8 +1,29 @@
use super::{
Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
Value, cmd, figcraft_collab, normalize, textpreview,
Value, cmd, figcraft_collab, json, normalize, textpreview,
};
struct GroupDocCheckpoint {
uid: u64,
doc: Arc<Document>,
selection: Vec<NodeId>,
page: NodeId,
undo_len: usize,
/// An undo inside pops undo steps; a failed group has to bring them back.
undo: Vec<HistoryEntry>,
redo: Vec<HistoryEntry>,
limit: usize,
}
struct GroupCheckpoint {
docs: Vec<GroupDocCheckpoint>,
active: usize,
journal_len: usize,
/// Live events buffered before the group: a failed one drops only its own.
pending_events: usize,
last_command: Option<(String, Value)>,
}
impl Session {
/// How many undo and redo steps the active document has (the user's own in a live session).
pub fn undo_depth(&self) -> (usize, usize) {
@@ -179,18 +200,45 @@ impl Session {
})
}
/// Run every step, rolling back failed steps and returning each outcome, still as one undo step.
pub(crate) fn batch_continue(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
let mut scope = BatchScope::new(steps)?;
self.grouped(label, |s| {
let mut outcomes = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let outcome = match s.checkpointed(None, |s| s.execute_batch_step(&scope, &step.command, &step.params)) {
Ok(v) => {
scope.push(v.clone());
json!({"step": i, "command": step.command, "ok": true, "result": v})
}
Err(EngineError::Waiting) => return Err(EngineError::Waiting),
Err(e) => {
scope.push_failed();
json!({"step": i, "command": step.command, "ok": false, "error": e.to_string()})
}
};
outcomes.push(outcome);
}
Ok(outcomes)
})
}
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
// Say which step failed; a nested batch names its own steps.
let v = self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })?;
let v = self.execute_batch_step(scope, cmd, params)?;
scope.push(v);
Ok(())
}
fn execute_batch_step(&mut self, scope: &BatchScope, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
// Say which step failed; a nested batch names its own steps.
self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })
}
/// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
/// group can reopen them.
pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
@@ -205,80 +253,106 @@ impl Session {
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
self.ds()?;
let pending_mark = self.pending_events.len();
let before: Vec<_> =
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
// An undo inside pops the undo steps; a failed group has to bring them back.
let undo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.undo.clone()).collect();
// An edit inside clears the redo steps; a failed group has to bring them back.
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
let active = self.active;
let journal_len = self.journal.len();
self.checkpointed(Some(label), f)
}
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
for ds in &mut self.docs {
ds.history.limit = usize::MAX;
}
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, true);
}
self.group_depth += 1;
/// Without a label, only roll back failures: leave successful steps' history to the outer group.
/// A checkpoint also works between closing the last tab and opening another one.
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let before = self.group_checkpoint();
let result = f(self);
self.group_depth -= 1;
let at_root = self.group_depth == 0;
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
for ds in &before.docs {
self.live_hold(ds.uid, false);
}
if result.is_err() {
self.pending_events.truncate(pending_mark);
// Tabs the group closed come back from the stash, and the group-start order returns.
let mut current = std::mem::take(&mut self.docs);
for (uid, ..) in &before {
let tab = match current.iter().position(|d| d.uid == *uid) {
Some(i) => Some(current.swap_remove(i)),
None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)),
};
self.docs.extend(tab);
}
for (((uid, doc, selection, page, _, limit), undo), redo) in before.iter().zip(undo_before).zip(redo_before) {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.history.redo = redo;
ds.doc = doc.clone();
ds.selection = selection.clone();
ds.page = *page;
ds.history.undo = undo;
ds.history.limit = *limit;
self.restore_group_checkpoint(before, label.is_none());
} else {
let mut changed = Vec::new();
for saved in &before.docs {
let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue };
ds.history.limit = saved.limit;
if label.is_some() {
if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() {
changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page));
}
ds.history.undo.truncate(saved.undo_len);
}
}
self.active = active;
self.journal.truncate(journal_len);
if self.group_depth == 0 {
self.closed_in_group.clear();
if let Some(label) = label {
for (uid, doc, selection, page) in changed {
self.record(uid, Some(label), doc, selection, page);
}
}
return result;
}
if at_root {
self.closed_in_group.clear();
self.flush_pending_events();
}
let changed: Vec<_> = before
.iter()
.filter_map(|(uid, doc, selection, page, _, _)| {
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
})
.collect();
for ds in &mut self.docs {
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
ds.history.limit = *limit;
ds.history.undo.truncate(*undo_len);
}
}
for (uid, doc, selection, page) in changed {
self.record(uid, Some(label), doc, selection, page);
}
result
}
fn group_checkpoint(&mut self) -> GroupCheckpoint {
let docs = self
.docs
.iter()
.map(|ds| GroupDocCheckpoint {
uid: ds.uid,
doc: ds.doc.clone(),
selection: ds.selection.clone(),
page: ds.page,
undo_len: ds.history.undo.len(),
undo: ds.history.undo.clone(),
// An edit inside clears redo; a failed group has to bring it back.
redo: ds.history.redo.clone(),
limit: ds.history.limit,
})
.collect();
let before = GroupCheckpoint {
docs,
active: self.active,
journal_len: self.journal.len(),
pending_events: self.pending_events.len(),
last_command: self.last_command.clone(),
};
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
for ds in &mut self.docs {
ds.history.limit = usize::MAX;
}
for ds in &before.docs {
self.live_hold(ds.uid, true);
}
self.group_depth += 1;
before
}
fn restore_group_checkpoint(&mut self, before: GroupCheckpoint, restore_last_command: bool) {
// Tabs the group closed come back from the stash, and the group-start order returns.
let mut current = std::mem::take(&mut self.docs);
for saved in &before.docs {
let tab = match current.iter().position(|d| d.uid == saved.uid) {
Some(i) => Some(current.swap_remove(i)),
None => self.closed_in_group.iter().position(|d| d.uid == saved.uid).map(|i| self.closed_in_group.swap_remove(i)),
};
self.docs.extend(tab);
}
for saved in before.docs {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) {
ds.history.redo = saved.redo;
ds.doc = saved.doc;
ds.selection = saved.selection;
ds.page = saved.page;
ds.history.undo = saved.undo;
ds.history.limit = saved.limit;
}
}
self.active = before.active;
self.journal.truncate(before.journal_len);
self.pending_events.truncate(before.pending_events);
if restore_last_command {
self.last_command = before.last_command;
}
}
}
@@ -129,6 +129,35 @@ fn published() -> Vec<u8> {
archive(s.doc().unwrap().published.as_ref().unwrap()).unwrap()
}
#[test]
fn a_continue_batch_waits_instead_of_skipping_background_work() {
for nested in [false, true] {
let (mut s, wire) = session(true);
let before = s.doc().unwrap().clone();
let request = json!({"command": "plugin.directory.list", "params": {"server": SERVER}});
let step = if nested { json!({"command": "command.batch", "params": {"onError": "continue", "commands": [request]}}) } else { request };
let params = json!({"onError": "continue", "commands": [
{"command": "page.rename", "params": {"name": "Before request"}},
step,
{"command": "page.rename", "params": {"name": "After request"}}
]});
assert_eq!(s.start_background("command.batch", params).unwrap(), None);
assert!(s.waiting_for("command.batch"));
assert_eq!(s.doc().unwrap(), &before, "waiting rolls back the whole batch");
assert_eq!(s.undo_depth(), (0, 0));
assert_eq!(answer(&mut s, &wire), 1);
let (command, result) = s.take_background_done().unwrap();
assert_eq!(command, "command.batch");
let result = result.unwrap();
assert!(result.as_array().unwrap().iter().all(|r| r["ok"] == true));
let directory = if nested { &result[1]["result"][0]["result"] } else { &result[1]["result"] };
assert_eq!(directory["plugins"][0]["id"], ID);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After request");
assert_eq!(s.undo_depth(), (1, 0));
assert!(!s.waiting_for("command.batch"));
}
}
#[test]
fn browsing_and_installing_wait_for_nothing_and_scripts_still_wait() {
let (mut s, wire) = session(true);
+7 -2
View File
@@ -129,11 +129,12 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"batch",
"Run commands as one step",
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.",
"Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.",
obj(
json!({
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])},
"label": string("Undo history label (default \"Batch\")"),
"onError": {"type": "string", "enum": ["abort", "continue"], "description": "abort (default) rolls back the whole batch on the first failure; continue rolls back only the failed step, runs the rest and returns one {step, command, ok, result|error} object per step"},
}),
&["commands"],
),
@@ -580,7 +581,11 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Stri
steps.push(step);
}
let label = s_arg(a, "label")?.unwrap_or("Batch");
exec(b, "command.batch", json!({"label": label, "commands": steps})).map(|v| ToolResult::json(&v))
let mut params = json!({"label": label, "commands": steps});
if let Some(mode) = a.get("onError") {
params["onError"] = mode.clone();
}
exec(b, "command.batch", params).map(|v| ToolResult::json(&v))
}
fn create_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
+50
View File
@@ -58,6 +58,56 @@ fn tools_list_is_complete_and_schemas_are_objects() {
}
}
#[test]
fn batch_schema_exposes_the_optional_error_policy() {
let mut c = Client::new();
let r = c.rpc("tools/list", json!({}));
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "batch").unwrap();
let schema = &batch["inputSchema"];
let mode = &schema["properties"]["onError"];
assert_eq!(mode["type"], "string");
assert_eq!(mode["enum"], json!(["abort", "continue"]));
assert!(mode["description"].as_str().unwrap().contains("default"));
assert!(!schema["required"].as_array().unwrap().contains(&json!("onError")));
}
#[test]
fn a_continue_batch_returns_outcomes_without_a_tool_error() {
let mut c = Client::new();
let frame = c.create(json!({"type": "frame"}));
let text = c.create(json!({"type": "text", "text": "orig"}));
assert_eq!((frame.clone(), text.clone()), (json!(2), json!(3)));
let commands = json!([
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "as": "bad", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}}
]);
let r = c.call("batch", json!({"onError": "continue", "commands": commands}));
assert_eq!(r["isError"], false);
let outcomes: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(outcomes.as_array().unwrap().len(), 4);
assert_eq!(outcomes[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
assert_eq!(outcomes[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
assert_eq!(outcomes[1]["ok"], false);
assert_eq!(outcomes[1]["command"], "text.set");
assert_eq!(outcomes[1]["step"], 1);
assert!(outcomes[1]["error"].as_str().unwrap().contains("is not text"));
assert!(outcomes[3]["error"].as_str().unwrap().contains("step 3: references step 1 (\"bad\"), which failed"));
assert_eq!(c.node(&text)["characters"], "novo");
assert_eq!(c.doc()["pages"][0]["name"], "Depois do erro");
assert_eq!(c.page_children().len(), 2, "failed steps created nothing");
c.ok("undo", json!({}));
assert_eq!(c.node(&text)["characters"], "orig");
c.ok("redo", json!({}));
assert_eq!(c.node(&text)["characters"], "novo");
assert!(c.err("batch", json!({"onError": "skip", "commands": commands})).contains("`onError`"));
// A null policy is the default: the failed step aborts the whole batch.
assert!(c.err("batch", json!({"onError": null, "commands": commands})).contains("is not text"));
let run = c.run("command.batch", json!({"onError": "continue", "commands": [{"command": "no.such.command"}]}));
assert_eq!(run[0]["ok"], false, "run_command forwards the policy too");
}
#[test]
fn tools_call_happy_and_error_paths() {
let mut c = Client::new();
+4
View File
@@ -153,6 +153,10 @@ created ([Step references](mcp.md#step-references) has the rules):
The result lines are printed once the script is through, so none of them reports work a later step
took back.
To keep going past a failed command, make a step a `command.batch` with `"onError": "continue"`
([MCP server](mcp.md) has the rules). The script itself stays all-or-nothing: if a
later top-level step fails, that batch's edits are rolled back with everything else.
A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes
nothing (`--out` is not saved, and the session's documents go back to how the script found them),
and exits with code 1:
+4
View File
@@ -109,6 +109,10 @@ naming it and the ones it takes, never ignored; `ids` and `id` are always taken.
{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
```
With `"onError": "continue"` in its params, `command.batch` rolls back only the steps that fail,
runs the rest and returns one `{step, command, ok, result}` or `{step, command, ok, error}` object
per step; the request succeeds even when some steps fail (see [MCP server](mcp.md)).
Inside `command.batch`, a step can name itself with `"as"` and later steps use its result:
`{"$ref": "name"}` in a step's params stands for the `id` in that step's result, and `{"$step": n}`
for the `id` in step `n`'s result (zero-based). Either takes `"field"` for another key or a slash
+24 -1
View File
@@ -1983,6 +1983,26 @@ Align and distribute as one undo step:
{"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}}
```
By default (`"onError": "abort"`) the first failure rolls back the whole batch, and success
returns the raw results in order. With `"onError": "continue"` next to `commands`, a failed step's
document edits are rolled back on their own, the later steps still run, and the result is instead
one object per step:
```json
[
{"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}},
{"step": 1, "command": "text.set", "ok": false, "error": "invalid parameters for `text.set`: step 1: node 2 is not text"},
{"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}
]
```
The tool call is not an error (`isError` is false) even when some steps fail. Successful document
edits form one undo step; if every step fails, none is added. Unknown commands, bad params and bad
references fail only their step, but a malformed `commands` list, an invalid `onError` or two steps
with the same `as` still reject the whole batch before any step runs. A step that has to wait for
background work makes the whole call wait, as without `onError`. A nested batch has its own
`onError`.
### Step references
A step in a `batch` can use what an earlier step returned. Give the step a name with `"as"` (next to
@@ -2020,7 +2040,10 @@ These fail the whole batch, which is rolled back, with an error naming the step:
same `as` (before any step runs); a `$ref` to a name no step has (the error lists the names there
are) or to the same or a later step; a `$step` to the same or a later step, or that is negative or
not an integer; a `field` the result doesn't have (the error names the full path and, for a list,
how many items it has); any key next to `$ref` or `$step` other than `field`. Only an object holding
how many items it has); any key next to `$ref` or `$step` other than `field`. With `"onError":
"continue"` all but the duplicate names fail only their step, and so does a reference to a step that
failed (the error names both). Steps keep their positions, and references read the raw results. Only
an object holding
a `$ref` or `$step` key is a reference: text such as `"$ref card"` is left as written. Both keys are
reserved in step params: an object holding either anywhere (in plugin data or a variable value too)
is read as a reference. A step that is itself a `command.batch` is passed on as written, so the