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]"));
+ }
+}