From 0fb7e16cedf594a800d33194505e73e1fffc0634 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 00:11:42 -0400 Subject: [PATCH] feat(cli): flow-debug run-method|run-value-change|replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/FlowDebugCommands.cs | 130 +++++++++++++ .../Commands/FlowDebugTableProjection.cs | 136 +++++++++++++ src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs | 1 + src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 79 ++++++++ .../CommandTreeTests.cs | 7 +- .../FlowDebugCommandsTests.cs | 184 ++++++++++++++++++ 6 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs new file mode 100644 index 00000000..c34d1fbd --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -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; + +/// +/// The flow-debug 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 STANDBY_NODE (Replay is served from any node). +/// +public static class FlowDebugCommands +{ + /// Client-side wait for the run commands — must exceed the server's run grace + trigger window. + internal static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(2); + + /// 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 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 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 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 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 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 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 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; + } + + /// + /// 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). + /// + 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(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs new file mode 100644 index 00000000..fd3f2641 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs @@ -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; + +/// +/// Compact table projection for the flow-debug commands. +/// +/// +/// The handler returns a FlowDebugRunResult — { rootExecutionId, completed, +/// error, errorCode, events: [ … ], truncated, … } whose events are canonical +/// audit events (each carrying its domain fields inside a detailsJson 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 — +/// occurred | kind | lane | target | durationMs | status | error — in the same +/// chronological order the Flow Debugger page draws them, so a terminal user can read the +/// swimlane top-to-bottom. A trailing note row reports a failed / incomplete run +/// (with the error code verbatim, e.g. STANDBY_NODE) or a truncated event list. +/// +/// +/// JSON output is never touched — --format json gets the full raw result. +/// +internal static class FlowDebugTableProjection +{ + /// + /// Projects a FlowDebugRunResult 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. + /// + /// The raw success JSON body from the management API. + /// Compact JSON suitable for table rendering. + 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(); + 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 )"); + + if (notes.Count > 0) + rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) }); + + return rows.ToJsonString(); + } + } + + /// Projects one canonical audit event (camelCase JSON) to its compact row. + 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; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs index 088f73cf..02322d06 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs @@ -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(_ => { diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index c0eedbbc..29d494bb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -2270,6 +2270,85 @@ scadabridge --url cached-call discard --site-id --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 ` 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 flow-debug run-method --method-id [--params ] +``` + +| 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 flow-debug run-value-change --instance-id --attribute --value [--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 flow-debug replay --execution-id +``` + +| 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`. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs index 8c019d56..ab601c45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs @@ -41,6 +41,7 @@ public class CommandTreeTests ApiMethodCommands.Build(Url, Format, Username, Password), BundleCommands.Build(Url, Format, Username, Password), CachedCallCommands.Build(Url, Format, Username, Password), + FlowDebugCommands.Build(Url, Format, Username, Password), }; private static IEnumerable LeafCommands(Command command) @@ -61,11 +62,11 @@ public class CommandTreeTests { var groups = AllCommandGroups().ToList(); // CLI-022: bump this count whenever a new top-level command group is - // registered in Program.cs. Current registered groups (17): + // registered in Program.cs. Current registered groups (18): // template, instance, site, deploy, data-connection, external-system, // notification, security, audit-config, audit, health, debug, - // shared-script, db-connection, api-method, bundle, cached-call. - Assert.Equal(17, groups.Count); + // shared-script, db-connection, api-method, bundle, cached-call, flow-debug. + Assert.Equal(18, groups.Count); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs new file mode 100644 index 00000000..af51ab99 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -0,0 +1,184 @@ +using System.CommandLine; +using System.Text.Json; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; + +/// +/// Tests for the flow-debug command group (Flow Debugger design 2026-09-05 §7.1): +/// the command tree shape, --params parsing, and the table projection that turns a +/// into one row per step. +/// +public class FlowDebugCommandsTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + [Fact] + public void Build_RegistersTheThreeSubcommands_WithTheirRequiredOptions() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + + Assert.Equal("flow-debug", group.Name); + var names = group.Subcommands.Select(c => c.Name).ToList(); + Assert.Equal(["run-method", "run-value-change", "replay"], names); + + var runMethod = group.Subcommands.Single(c => c.Name == "run-method"); + Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required); + Assert.Contains(runMethod.Options, o => o.Name == "--params" && !o.Required); + + var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change"); + Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required); + Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required); + Assert.Contains(runValueChange.Options, o => o.Name == "--value" && o.Required); + Assert.Contains(runValueChange.Options, o => o.Name == "--wait-seconds" && !o.Required); + + var replay = group.Subcommands.Single(c => c.Name == "replay"); + Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required); + } + + [Fact] + public void RunValueChange_WaitSecondsDefaultsTo45_MatchingTheCommandRecord() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + var parse = group.Parse("run-value-change --instance-id 7 --attribute Speed --value 42"); + + Assert.Empty(parse.Errors); + var waitOption = (Option)parse.CommandResult.Command.Options.Single(o => o.Name == "--wait-seconds"); + Assert.Equal(new FlowDebugRunValueChangeCommand(7, "Speed", "42").WaitSeconds, parse.GetValue(waitOption)); + } + + [Fact] + public void FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry() + { + // The CLI derives the wire command name from the record type; a rename on either + // side would throw here rather than at runtime. + Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand)))); + Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand)))); + Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand)))); + } + + [Fact] + public void ParseParameters_Blank_IsNull_AndObject_IsCloned() + { + Assert.Null(FlowDebugCommands.ParseParameters(null)); + Assert.Null(FlowDebugCommands.ParseParameters(" ")); + + var parsed = FlowDebugCommands.ParseParameters("""{"code":"Z28061","qty":3}"""); + Assert.NotNull(parsed); + Assert.Equal(JsonValueKind.Object, parsed!.Value.ValueKind); + Assert.Equal("Z28061", parsed.Value.GetProperty("code").GetString()); + } + + [Theory] + [InlineData("not json")] + [InlineData("[1,2]")] + [InlineData("\"scalar\"")] + public void ParseParameters_RejectsMalformedOrNonObjectInput(string input) + { + Assert.ThrowsAny(() => FlowDebugCommands.ParseParameters(input)); + } + + /// + /// The real server shape, serialised from the REAL + /// and canonical types with the server's camelCase policy, so + /// a rename on either fails this test instead of silently blanking a column. + /// + private static string RealPayload(bool completed, string? errorCode, string? error, bool truncated) + { + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var t0 = new DateTime(2026, 9, 5, 14, 3, 7, 120, DateTimeKind.Utc); + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, actor: "flow-debugger", target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 812), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(5), actor: "inbound-api", target: "DelmiaSim01.ProcessRecipeDownload", + executionId: root, durationMs: 790), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(20), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 700), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Failed, + occurredAtUtc: t0.AddMilliseconds(40), target: "DelmiaSim01.RecipeAck", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 600, errorMessage: "timed out after 600 ms"), + }; + + var result = new FlowDebugRunResult(root, completed, error, errorCode, events, truncated, + new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1))); + return JsonSerializer.Serialize(result, CamelCase); + } + + [Fact] + public void Projection_EmitsOneRowPerStep_WithKindLaneTargetDurationStatus() + { + var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: false)); + + using var doc = JsonDocument.Parse(projected); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + var rows = doc.RootElement.EnumerateArray().ToList(); + Assert.Equal(4, rows.Count); + + Assert.Equal("14:03:07.120", rows[0].GetProperty("occurred").GetString()); + Assert.Equal("InboundRequest", rows[0].GetProperty("kind").GetString()); + Assert.Equal("Central", rows[0].GetProperty("lane").GetString()); + Assert.Equal("POST /api/DelmiaRecipeDownload", rows[0].GetProperty("target").GetString()); + Assert.Equal(812, rows[0].GetProperty("durationMs").GetInt32()); + Assert.Equal("Delivered", rows[0].GetProperty("status").GetString()); + + Assert.Equal("RouteCall", rows[1].GetProperty("kind").GetString()); + Assert.Equal("Central", rows[1].GetProperty("lane").GetString()); + + Assert.Equal("ScriptRun", rows[2].GetProperty("kind").GetString()); + Assert.Equal("Site", rows[2].GetProperty("lane").GetString()); + Assert.Equal("DelmiaSim01.ProcessRecipeDownload", rows[2].GetProperty("target").GetString()); + + Assert.Equal("AttributeWait", rows[3].GetProperty("kind").GetString()); + Assert.Equal("Site", rows[3].GetProperty("lane").GetString()); // waits sit on the Site lane; writes on Tags + Assert.Equal("Failed", rows[3].GetProperty("status").GetString()); + Assert.Equal("timed out after 600 ms", rows[3].GetProperty("error").GetString()); + + // A completed, untruncated run has no trailing note row. + Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _))); + } + + [Fact] + public void Projection_IncompleteRun_AppendsANoteRowWithTheErrorCodeVerbatim() + { + var projected = FlowDebugTableProjection.ProjectEvents( + RealPayload(completed: false, "STANDBY_NODE", "This node is standby; run on the active central node.", truncated: false)); + + using var doc = JsonDocument.Parse(projected); + var last = doc.RootElement.EnumerateArray().Last(); + var note = last.GetProperty("note").GetString(); + Assert.StartsWith("STANDBY_NODE: ", note); + Assert.Contains("standby", note); + } + + [Fact] + public void Projection_TruncatedRun_NotesThatTheAuditRowsRemainComplete() + { + var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: true)); + + using var doc = JsonDocument.Parse(projected); + var last = doc.RootElement.EnumerateArray().Last(); + Assert.Contains("truncated", last.GetProperty("note").GetString()); + Assert.Contains("audit tree", last.GetProperty("note").GetString()); + } + + [Fact] + public void Projection_LeavesNonMatchingBodiesUntouched() + { + Assert.Equal("proxy", FlowDebugTableProjection.ProjectEvents("proxy")); + Assert.Equal("""{"error":"x"}""", FlowDebugTableProjection.ProjectEvents("""{"error":"x"}""")); + Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]")); + } +}