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:
@@ -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;
|
||||
}
|
||||
@@ -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(_ =>
|
||||
{
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user