diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs index c054676a..1fc4dd97 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -32,6 +32,7 @@ public static class FlowDebugCommands var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method or value-change flow end to end, or replay a past one" }; command.Add(BuildRunMethod(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildRunScript(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildRunValueChange(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildReplay(urlOption, formatOption, usernameOption, passwordOption)); @@ -82,13 +83,77 @@ public static class FlowDebugCommands return cmd; } + private static Command BuildRunScript(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; + var scriptOption = new Option("--script") + { + Description = "Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. Manual or Module.Script)", + Required = true, + }; + var paramsOption = new Option("--params") { Description = "Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script (validated against the script's ParameterDefinitions)" }; + var valueOption = new Option("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" }; + var waitOption = new Option("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; + var timeoutOption = new Option("--timeout-seconds") + { + Description = "Client-side wait for the run; raise above the script's ExecutionTimeoutSeconds + 25 for a long script", + DefaultValueFactory = _ => 120, + }; + var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" }; + cmd.Add(instanceIdOption); + cmd.Add(scriptOption); + cmd.Add(paramsOption); + cmd.Add(valueOption); + cmd.Add(waitOption); + cmd.Add(timeoutOption); + cmd.SetAction(async (ParseResult result) => + { + var waitSeconds = result.GetValue(waitOption); + var timeoutSeconds = result.GetValue(timeoutOption); + if (waitSeconds <= 0) + { + OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT"); + return 1; + } + + if (timeoutSeconds <= 0) + { + OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT"); + return 1; + } + + JsonElement? parameters; + try + { + parameters = ParseParameters(result.GetValue(paramsOption)); + } + catch (JsonException ex) + { + OutputFormatter.WriteError($"--params is not valid JSON: {ex.Message}", "INVALID_PARAMS"); + return 1; + } + + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new FlowDebugRunScriptCommand( + result.GetValue(instanceIdOption), + result.GetValue(scriptOption)!, + parameters, + result.GetValue(valueOption), + waitSeconds), + timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)), + tableProjector: FlowDebugTableProjection.ProjectEvents); + }); + return cmd; + } + private static Command BuildRunValueChange(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var attributeOption = new Option("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; - var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers" }; + var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" }; cmd.Add(instanceIdOption); cmd.Add(attributeOption); cmd.Add(valueOption); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index c69e28a7..5e4247b9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -2270,16 +2270,18 @@ scadabridge --url cached-call discard --site-id --tracked-operati --- -### `flow-debug` — Trace an inbound API or value-change flow end to end +### `flow-debug` — Trace an inbound API, script, or value-change flow end to end -The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`). -A Live run executes the real thing — the method script on the active central node, or a -real attribute write at the site — with tracing switched on for the whole chain, so every -routed site script run, attribute write and attribute wait it causes is recorded as a -`ScriptTrace` audit row and returned inline. Replay reads those rows back for any past -execution. All three sub-commands require the **Deployer** role. +The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`, +script entry point `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). +A Live run executes the real thing — the method script on the active central node, a named +site script (called directly, or fired by writing its trigger attribute), or a real attribute +write at the site — with tracing switched on for the whole chain, so every routed site script +run, attribute write and attribute wait it causes is recorded as a `ScriptTrace` audit row and +returned inline. Replay reads those rows back for any past execution. All four sub-commands +require the **Deployer** role. -The two run commands are answered by the node the CLI is pointed at. Through the Traefik +The three run commands are answered by the node the CLI is pointed at. Through the Traefik load balancer that is the active node; pointed at a standby node directly they refuse with error code `STANDBY_NODE` rather than run the script somewhere it would not run in production. `replay` is served from any node. @@ -2317,10 +2319,38 @@ scadabridge --url flow-debug run-method --method-id [--params Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running the script. +#### `flow-debug run-script` + +Run one site script by its canonical name with tracing. A `ValueChange`/`Conditional` script +is fired by writing its trigger attribute (`--value`) and waiting for the run it spawns, +exactly like `run-value-change`; every other script (`Call`, `Interval`, `Expression`, or no +trigger) is called directly with `--params`, the same way `run-method` calls an inbound API +method. The runner resolves the script from the instance's flattened configuration and picks +the branch itself — the client does not need to know the script's trigger kind ahead of time. + +```sh +scadabridge --url flow-debug run-script --instance-id --script [--params ] [--value ] [--wait-seconds 45] [--timeout-seconds 120] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--instance-id` | yes | Instance ID | +| `--script` | yes | Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. `Manual` or `Module.Script`) | +| `--params` | no | Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script, validated against the script's `ParameterDefinitions` | +| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form | +| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) | +| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. | + +A missing `--value` on a write-kind script, or `--params` that fail schema validation on a +call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown +`--script` is reported as `NOT_FOUND`. + #### `flow-debug run-value-change` Write an attribute on a deployed instance through the traced routed-write path, then wait -for the script run(s) the change triggers at the site and print them. +for the script run(s) the change triggers at the site and print them. This is the +attribute-level path (it is also the way to fire an `Expression` trigger, which names no +attribute of its own); prefer `run-script` to run a script by name. ```sh scadabridge --url flow-debug run-value-change --instance-id --attribute --value [--wait-seconds 45] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index b548e442..836f4e44 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -24,13 +24,13 @@ public class FlowDebugCommandsTests private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; [Fact] - public void Build_RegistersTheThreeSubcommands_WithTheirRequiredOptions() + public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); Assert.Equal("flow-debug", group.Name); var names = group.Subcommands.Select(c => c.Name).ToList(); - Assert.Equal(["run-method", "run-value-change", "replay"], names); + Assert.Equal(["run-method", "run-script", "run-value-change", "replay"], names); var runMethod = group.Subcommands.Single(c => c.Name == "run-method"); Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required); @@ -38,6 +38,14 @@ public class FlowDebugCommandsTests Assert.Contains(runMethod.Options, o => o.Name == "--timeout-seconds" && !o.Required); Assert.DoesNotContain(runMethod.Options, o => o.Name == "--wait-seconds"); + var runScript = group.Subcommands.Single(c => c.Name == "run-script"); + Assert.Contains(runScript.Options, o => o.Name == "--instance-id" && o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--script" && o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--params" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--value" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--wait-seconds" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--timeout-seconds" && !o.Required); + var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change"); Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required); Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required); @@ -49,6 +57,17 @@ public class FlowDebugCommandsTests Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required); } + [Fact] + public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}"); + Assert.Empty(parse.Errors); + var cmd = group.Subcommands.Single(c => c.Name == "run-script"); + Assert.Equal(45, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--wait-seconds"))); + Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); + } + [Fact] public void RunMethod_TimeoutSecondsDefaultsTo120() { @@ -87,6 +106,7 @@ public class FlowDebugCommandsTests // The CLI derives the wire command name from the record type; a rename on either // side would throw here rather than at runtime. Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand)))); + Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunScriptCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand)))); }