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; /// /// The flow-debug 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 /// STANDBY_NODE. Replay is served from any node and accepts a wider role set. /// public static class FlowDebugCommands { /// /// 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 FlowDebugService.ClientSlack (15s). /// private const int ClientSlackSeconds = 25; /// Builds the flow-debug command with its subcommands using the given shared CLI options. /// Shared management URL option. /// Shared output format option. /// Shared username option for authentication. /// Shared password option for authentication. /// The configured flow-debug command. public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var methodIdOption = new Option("--method-id") { Description = "Inbound API method ID", Required = true }; var paramsOption = new Option("--params") { Description = "Flat JSON object of method parameters (validated against the method's schema)" }; var timeoutOption = new Option("--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("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var scriptOption = new Option("--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("--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("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" }; var waitOption = new Option("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; var timeoutOption = new Option("--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("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var attributeOption = new Option("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; var showLogsOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var executionIdOption = new Option("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true }; var showLogsOption = new Option("--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; } /// /// Parses the --params value into a detached ; null/blank /// means "no parameters". Throws on malformed input or a /// non-object root (the inbound API body is always a flat JSON object). /// /// The raw --params JSON text, or null/blank for none. /// The parsed root object, or null when no parameters were supplied. 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(); } }