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:
@@ -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);
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user