feat(cli): flow-debug run-method|run-value-change|replay

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
This commit is contained in:
Joseph Doherty
2026-09-06 00:11:42 -04:00
parent f783d4efc4
commit 0fb7e16ced
6 changed files with 534 additions and 3 deletions
@@ -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;
/// <summary>
/// The <c>flow-debug</c> 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 <c>STANDBY_NODE</c> (Replay is served from any node).
/// </summary>
public static class FlowDebugCommands
{
/// <summary>Client-side wait for the run commands — must exceed the server's run grace + trigger window.</summary>
internal static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(2);
/// <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 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<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 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<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 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<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 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;
}
/// <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>
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();
}
}
@@ -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;
/// <summary>
/// Compact table projection for the <c>flow-debug</c> commands.
///
/// <para>
/// The handler returns a <c>FlowDebugRunResult</c> — <c>{ rootExecutionId, completed,
/// error, errorCode, events: [ … ], truncated, … }</c> whose <c>events</c> are canonical
/// audit events (each carrying its domain fields inside a <c>detailsJson</c> 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 —
/// <c>occurred | kind | lane | target | durationMs | status | error</c> — in the same
/// chronological order the Flow Debugger page draws them, so a terminal user can read the
/// swimlane top-to-bottom. A trailing <c>note</c> row reports a failed / incomplete run
/// (with the error code verbatim, e.g. <c>STANDBY_NODE</c>) or a truncated event list.
/// </para>
///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
/// </summary>
internal static class FlowDebugTableProjection
{
/// <summary>
/// Projects a <c>FlowDebugRunResult</c> 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.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <returns>Compact JSON suitable for table rendering.</returns>
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<string>();
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 <root>)");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
return rows.ToJsonString();
}
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row.</summary>
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;
}
+1
View File
@@ -39,6 +39,7 @@ rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption,
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ => rootCommand.SetAction(_ =>
{ {
+79
View File
@@ -2270,6 +2270,85 @@ scadabridge --url <url> cached-call discard --site-id <string> --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 <rootExecutionId>` 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 <url> flow-debug run-method --method-id <int> [--params <json>]
```
| 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 <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--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 <url> flow-debug replay --execution-id <guid>
```
| 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 ## 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`. 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`.
@@ -41,6 +41,7 @@ public class CommandTreeTests
ApiMethodCommands.Build(Url, Format, Username, Password), ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password), BundleCommands.Build(Url, Format, Username, Password),
CachedCallCommands.Build(Url, Format, Username, Password), CachedCallCommands.Build(Url, Format, Username, Password),
FlowDebugCommands.Build(Url, Format, Username, Password),
}; };
private static IEnumerable<Command> LeafCommands(Command command) private static IEnumerable<Command> LeafCommands(Command command)
@@ -61,11 +62,11 @@ public class CommandTreeTests
{ {
var groups = AllCommandGroups().ToList(); var groups = AllCommandGroups().ToList();
// CLI-022: bump this count whenever a new top-level command group is // 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, // template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug, // notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle, cached-call. // shared-script, db-connection, api-method, bundle, cached-call, flow-debug.
Assert.Equal(17, groups.Count); Assert.Equal(18, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
} }
@@ -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;
/// <summary>
/// Tests for the <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1):
/// the command tree shape, <c>--params</c> parsing, and the table projection that turns a
/// <see cref="FlowDebugRunResult"/> into one row per step.
/// </summary>
public class FlowDebugCommandsTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> 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<int>)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<JsonException>(() => FlowDebugCommands.ParseParameters(input));
}
/// <summary>
/// The real server shape, serialised from the REAL <see cref="FlowDebugRunResult"/>
/// and canonical <see cref="AuditEvent"/> types with the server's camelCase policy, so
/// a rename on either fails this test instead of silently blanking a column.
/// </summary>
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<AuditEvent>
{
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("<html>proxy</html>", FlowDebugTableProjection.ProjectEvents("<html>proxy</html>"));
Assert.Equal("""{"error":"x"}""", FlowDebugTableProjection.ProjectEvents("""{"error":"x"}"""));
Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]"));
}
}