feat(cli): flow-debug run-method|run-value-change|replay

New top-level command group for the Flow Debugger (design 2026-09-05 §7.1). run-method /
run-value-change post the FlowDebugRun* management commands with a 2-minute client wait;
replay posts FlowDebugReplayCommand. A table projection unwraps the FlowDebugRunResult
envelope to one row per step (occurred | kind | lane | target | durationMs | status |
error) using the Commons FlowStepProjector's kind/lane resolution, with a trailing note
row for an incomplete run (error code verbatim, e.g. STANDBY_NODE) or a truncated event
list. --params must be a JSON object (INVALID_PARAMS otherwise). README section added;
command-tree test bumped to 18 groups.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:11:42 -04:00
parent f783d4efc4
commit 0fb7e16ced
6 changed files with 534 additions and 3 deletions
@@ -0,0 +1,130 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1): a Live run of
/// an inbound API method or a value-change trigger with every routed site run traced, and a
/// Replay of a past execution chain from the central audit store. All three are Deployer
/// commands answered by the node the CLI is pointed at; a standby central refuses the two
/// run commands with <c>STANDBY_NODE</c> (Replay is served from any node).
/// </summary>
public static class FlowDebugCommands
{
/// <summary>Client-side wait for the run commands — must exceed the server's run grace + trigger window.</summary>
internal static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(2);
/// <summary>Builds the <c>flow-debug</c> command with its subcommands using the given shared CLI options.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
/// <returns>The configured <c>flow-debug</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
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(BuildRunValueChange(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildReplay(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildRunMethod(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var methodIdOption = new Option<int>("--method-id") { Description = "Inbound API method ID", Required = true };
var paramsOption = new Option<string?>("--params") { Description = "Flat JSON object of method parameters (validated against the method's schema)" };
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
cmd.Add(methodIdOption);
cmd.Add(paramsOption);
cmd.SetAction(async (ParseResult result) =>
{
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 FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
timeout: RunTimeout,
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" };
cmd.Add(instanceIdOption);
cmd.Add(attributeOption);
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugRunValueChangeCommand(
result.GetValue(instanceIdOption),
result.GetValue(attributeOption)!,
result.GetValue(valueOption)!,
waitSeconds),
timeout: RunTimeout,
tableProjector: FlowDebugTableProjection.ProjectEvents);
});
return cmd;
}
private static Command BuildReplay(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var executionIdOption = new Option<Guid>("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true };
var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
cmd.Add(executionIdOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
});
return cmd;
}
/// <summary>
/// Parses the <c>--params</c> value into a detached <see cref="JsonElement"/>; null/blank
/// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a
/// non-object root (the inbound API body is always a flat JSON object).
/// </summary>
internal static JsonElement? ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
throw new JsonException("expected a JSON object at the root");
return doc.RootElement.Clone();
}
}
@@ -0,0 +1,136 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Compact table projection for the <c>flow-debug</c> commands.
///
/// <para>
/// The handler returns a <c>FlowDebugRunResult</c> — <c>{ rootExecutionId, completed,
/// error, errorCode, events: [ … ], truncated, … }</c> whose <c>events</c> are canonical
/// audit events (each carrying its domain fields inside a <c>detailsJson</c> string). Handed
/// to the generic renderer unprojected, the object takes the Property/Value branch and the
/// whole event array lands in one cell as raw JSON. This unwraps it to one row per step —
/// <c>occurred | kind | lane | target | durationMs | status | error</c> — in the same
/// chronological order the Flow Debugger page draws them, so a terminal user can read the
/// swimlane top-to-bottom. A trailing <c>note</c> row reports a failed / incomplete run
/// (with the error code verbatim, e.g. <c>STANDBY_NODE</c>) or a truncated event list.
/// </para>
///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
/// </summary>
internal static class FlowDebugTableProjection
{
/// <summary>
/// Projects a <c>FlowDebugRunResult</c> body to the row array the table renderer
/// consumes. Returns the input unchanged when it is not JSON or not that shape, so the
/// renderer's own fallbacks still apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <returns>Compact JSON suitable for table rendering.</returns>
internal static string ProjectEvents(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
return json;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !TryGetPropertyCI(root, "events", out var events)
|| events.ValueKind != JsonValueKind.Array)
{
return json;
}
var rows = new JsonArray();
foreach (var evt in events.EnumerateArray())
rows.Add(ProjectEvent(evt));
var completed = TryGetPropertyCI(root, "completed", out var c) && c.ValueKind == JsonValueKind.True;
var errorCode = StrValue(root, "errorCode");
var error = StrValue(root, "error");
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
var notes = new List<string>();
if (!completed || errorCode != null || error != null)
{
notes.Add(errorCode != null
? $"{errorCode}: {error ?? "run did not complete"}"
: error ?? "run did not complete");
}
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
return rows.ToJsonString();
}
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row.</summary>
private static JsonNode ProjectEvent(JsonElement evt)
{
if (evt.ValueKind != JsonValueKind.Object)
return JsonValue.Create(evt.ToString())!;
var details = AuditDetailsCodec.Deserialize(StrValue(evt, "detailsJson"));
var kind = FlowStepProjector.ResolveKind(details.Channel, details.Kind);
var lane = FlowStepProjector.ResolveLane(kind, details.SourceSiteId);
string? occurred = null;
if (TryGetPropertyCI(evt, "occurredAtUtc", out var o) && o.ValueKind == JsonValueKind.String
&& o.TryGetDateTimeOffset(out var when))
{
occurred = when.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
}
var target = StrValue(evt, "target");
var instance = details.SourceInstanceId;
var shownTarget = target ?? details.SourceScript;
if (instance != null && shownTarget != null && !shownTarget.StartsWith(instance, StringComparison.Ordinal))
shownTarget = $"{instance}.{shownTarget}";
return new JsonObject
{
["occurred"] = occurred,
["kind"] = kind.ToString(),
["lane"] = lane.ToString(),
["target"] = shownTarget ?? instance,
["durationMs"] = details.DurationMs,
["status"] = details.Status,
["error"] = details.ErrorMessage,
};
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
private static string? StrValue(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
+1
View File
@@ -39,6 +39,7 @@ rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption,
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
+79
View File
@@ -2270,6 +2270,85 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
---
### `flow-debug` — Trace an inbound API 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 two 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.
Trace steps are ordinary audit rows: a run leaves them in the central `AuditLog`
(channel `ScriptTrace`), so `audit tree --execution-id <rootExecutionId>` shows the same
chain after the fact.
Table output prints one row per step in chronological order —
`occurred | kind | lane | target | durationMs | status | error` — with a trailing `note`
row when the run did not complete (the error code verbatim) or the inline event list was
truncated. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`).
#### `flow-debug run-method`
Run an inbound API method with the given parameters, exactly as an external caller would,
and print the traced flow.
```sh
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--method-id` | yes | Inbound API method ID (`api-method list`) |
| `--params` | no | Flat JSON object of parameters, validated against the method's parameter schema. Omit for a parameterless method. |
The client waits up to 2 minutes. Parameters that fail schema validation are reported as
`VALIDATION_FAILED` without running the script.
#### `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.
```sh
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--instance-id` | yes | Instance ID |
| `--attribute` | yes | Canonical attribute name to write |
| `--value` | yes | Value to write, in canonical string form |
| `--wait-seconds` | no | How long to wait for a triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
The write itself is real — the attribute changes — and is the root step of the flow.
A static attribute triggers directly; a data-sourced attribute triggers when the device
echoes the write back, which the site's trace arm (`SiteRuntime:TraceArmWindowSeconds`,
default 30) correlates back to this run.
#### `flow-debug replay`
Rebuild a past flow from the central audit store.
```sh
scadabridge --url <url> flow-debug replay --execution-id <guid>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side |
Only flows whose site legs were traced (a Live run, or a method with `--trace-all-calls`
set) have site steps to replay; an untraced call shows its central `RouteCall` step alone.
---
## Architecture Notes
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.