feat(cli): flow-debug run-script — run a site script by name through the flow debugger

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 01:13:15 -04:00
parent 001123d930
commit 067a858ce9
3 changed files with 127 additions and 12 deletions
@@ -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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var scriptOption = new Option<string>("--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<string?>("--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<string?>("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" };
var waitOption = new Option<int>("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var timeoutOption = new Option<int>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
var waitOption = new Option<int>("--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);
+39 -9
View File
@@ -2270,16 +2270,18 @@ scadabridge --url <url> cached-call discard --site-id <string> --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 <url> flow-debug run-method --method-id <int> [--params <json>
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 <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--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 <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
@@ -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<int>)cmd.Options.Single(o => o.Name == "--wait-seconds")));
Assert.Equal(120, parse.GetValue((Option<int>)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))));
}