Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
2026-10-07 14:12:25 -04:00

232 lines
13 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; script entry
/// point 2026-09-08 §6): a Live run of an inbound API method, a site script, or a
/// value-change trigger with every routed site run traced, and a Replay of a past execution
/// chain from the central audit store. The three run commands are Deployer-only commands
/// answered by the node the CLI is pointed at; a standby central refuses them with
/// <c>STANDBY_NODE</c>. Replay is served from any node and accepts a wider role set.
/// </summary>
public static class FlowDebugCommands
{
/// <summary>
/// Slack the CLI's HTTP wait adds over the server's own bound (arch-review F4): the CLI
/// process is one more hop out from the management Ask than the Central UI's in-process
/// call, so it carries a larger fixed margin than <c>FlowDebugService.ClientSlack</c> (15s).
/// </summary>
private const int ClientSlackSeconds = 25;
/// <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, a site script, or a 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));
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 timeoutOption = new Option<int>("--timeout-seconds")
{
Description = "Client-side wait for the run; set to the method's TimeoutSeconds + 25 for long methods. "
+ "The server caps long-running commands at its own ManagementService:LongRunningCommandTimeout "
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
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.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
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 FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
timeout: TimeSpan.FromSeconds(timeoutSeconds),
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
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. "
+ "The server caps long-running commands at its own ManagementService:LongRunningCommandTimeout "
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
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.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
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: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
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 showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
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);
cmd.Add(waitOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var showLogs = result.GetValue(showLogsOption);
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: TimeSpan.FromSeconds(waitSeconds + ClientSlackSeconds),
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
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 showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
cmd.Add(executionIdOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var showLogs = result.GetValue(showLogsOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
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>
/// <param name="json">The raw <c>--params</c> JSON text, or null/blank for none.</param>
/// <returns>The parsed root object, or null when no parameters were supplied.</returns>
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();
}
}