0fb7e16ced
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
131 lines
6.8 KiB
C#
131 lines
6.8 KiB
C#
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();
|
|
}
|
|
}
|