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();
}
}