Engine: a recorded script run keeps the steps that ran

The recorder expanded an automate.runScript step from the workbook as it
stood when the recording stopped, so a script edited or deleted during the
recording changed the saved macro, or dropped the step. The run now leaves
the steps it ran with the recorder (Recorder::ran, by step index, within
the byte budget), and the conversion reads those, falling back to the
workbook only when they could not be kept.
This commit is contained in:
prxnvt
2026-10-25 02:18:58 -04:00
parent 1e34a76428
commit b995c7c9a0
7 changed files with 79 additions and 11 deletions
+9
View File
@@ -1074,6 +1074,9 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
// editing or deleting the script before the recording stops does not change what it saved.
let mut ran_steps: Vec<(String, Json)> = Vec::new();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
@@ -1092,6 +1095,12 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
}
ran?;
n += 1;
if s.recorder.is_some() {
ran_steps.push((id.to_string(), st["params"].clone()));
}
}
if s.recorder.is_some() {
s.script_ran = Some(ran_steps);
}
Ok(json!({"ran": n}))
}
+1 -1
View File
@@ -234,7 +234,7 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb);
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran, &wb);
// Steps past the macro limit, and ones recorded in another workbook, are left out and counted
// with the ones the recorder dropped.
let cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
+12 -1
View File
@@ -426,6 +426,9 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// The steps the last successful `automate.runScript` ran while a recorder was on, for the
/// recorder to keep with that step (see [`record::Recorder::ran`]); taken by the recording hook.
pub(crate) script_ran: Option<Vec<(String, Json)>>,
/// Macros the person allowed to run in this session: (document uid, the macro's content as
/// canonical JSON, from [`macros::fingerprint`]).
pub macro_approvals: std::collections::HashSet<(u64, String)>,
@@ -691,7 +694,15 @@ impl Session {
{
p.insert("on".into(), on.clone());
}
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
// A script run is kept with the steps it ran, so editing or deleting the script
// before the recording stops does not change what was recorded.
if let Some(ran) = self.script_ran.take()
&& kept
&& id == "automate.runScript"
{
rec.attach_ran(ran);
}
}
self.push_journal(id, cmd::journal_params(&params));
}
+9 -4
View File
@@ -17,6 +17,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook};
use serde_json::{Map, Value as Json, json};
use std::collections::BTreeMap;
use super::policy::{Policy, macro_policy};
use crate::Selection;
@@ -47,7 +48,9 @@ pub struct Converted {
}
/// Builds a macro called `name` from the recorded steps.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &Workbook) -> Converted {
/// `ran` is what a script step ran, by step index ([`crate::record::Recorder::ran`]); a script step
/// without an entry is read from `wb`.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap<usize, Vec<(String, Json)>>, wb: &Workbook) -> Converted {
let mut skipped = Vec::new();
// Commands as they'll be played back: (id, params, sheet before, selection before, whether
// they came from another macro and stay as written). At most the macro's step limit are
@@ -63,7 +66,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
cut = cut.saturating_add(1);
}
};
for st in steps.iter().filter(|st| st.doc == start.doc) {
for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) {
let id = st.id.as_str();
if id == "macro.run" {
// Another macro's steps, when they stand on their own: no inputs, no output and only
@@ -85,8 +88,10 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
continue;
}
if id == "automate.runScript" {
// An Automate script's steps, as the script runs them.
match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) {
// An Automate script's steps as they ran, or as the script stands now when the recorder
// could not keep them.
let now = || st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n));
match ran.get(&index).cloned().or_else(now) {
Some(steps) => {
for (cid, params) in steps {
if refused(&cid, &params) {
+23 -4
View File
@@ -8,6 +8,7 @@
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::BTreeMap;
use crate::Selection;
@@ -46,25 +47,43 @@ pub struct Recorder {
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
/// what ran, never a sequence with gaps.
pub dropped: usize,
/// The size of `steps` serialized as JSON.
/// The size of `steps` and `ran` serialized as JSON.
bytes: usize,
/// What a step ran, by its index in `steps`, for a command that runs steps stored in the
/// workbook (`automate.runScript`): the commands as they ran, so a recording keeps them even
/// when the script is edited or deleted before the recording stops. A step without an entry
/// is read from the workbook when the recording is used.
pub ran: BTreeMap<usize, Vec<(String, Json)>>,
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) {
/// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept.
pub fn push(&mut self, step: RecordedStep) -> bool {
// Once a step has been dropped, or the step count is full, the rest are counted without
// being serialized (a `file.open` step holds a whole file).
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
self.dropped = self.dropped.saturating_add(1);
return;
return false;
}
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.steps.push(step);
true
} else {
self.dropped = self.dropped.saturating_add(1);
false
}
}
/// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it,
/// the step stays and its script is read from the workbook when the recording is used.
pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) {
let Some(index) = self.steps.len().checked_sub(1) else { return };
let size = serde_json::to_vec(&steps).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.ran.insert(index, steps);
}
}
}
+23
View File
@@ -1372,6 +1372,29 @@ fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() {
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
}
#[test]
fn a_recorded_script_run_keeps_the_steps_that_ran() {
// One script is edited and another deleted before the recording stops: the macro holds the
// steps as they ran, not the scripts as they stand when the recording is saved.
let mut s = s();
let step = |input: &str| json!([{"command": "cell.set", "params": {"cell": "B1", "input": input}}]);
s.execute("automate.newScript", json!({"name": "Edited", "commands": step("first")})).unwrap();
s.execute("automate.newScript", json!({"name": "Gone", "commands": step("kept")})).unwrap();
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("automate.runScript", json!({"name": "Edited"})).unwrap();
s.execute("automate.runScript", json!({"name": "Gone"})).unwrap();
s.execute("automate.newScript", json!({"name": "Edited", "commands": step("changed")})).unwrap();
s.execute("automate.deleteScript", json!({"name": "Gone"})).unwrap();
let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
let steps = recorded(&mut s, "Rec");
assert_eq!(
steps.get(..2),
Some(&["cell.set {\"cell\":\"B1\",\"input\":\"first\"}".to_string(), "cell.set {\"cell\":\"B1\",\"input\":\"kept\"}".to_string()][..]),
"{steps:?}"
);
assert!(!r["skipped"].as_array().unwrap().contains(&json!("automate.runScript")), "{r}");
}
#[test]
fn a_recording_is_saved_in_the_workbook_where_it_started() {
let mut s = s();