feat(flow-debugger): outbound calls on the External lane — CLI projection, specs, rig demo + verification step I

CLI flow-debug table now projects through FlowGraphBuilder (one row per step,
outbound lifecycles folded, new 'lifecycle' column; per-event fallback kept).
Specs: Component-CentralUI (four lanes, node anatomy, pending refresh),
Component-AuditLog (central method-script Database.* now audited; replay reads
the three outbound channels), Component-InboundAPI (DbOutbound row per helper
call), Component-ScriptAnalysis (REQ-SA-7 kinds + Async edges), design doc
v1.1 status line, CLAUDE.md bullet, deferred-work register row 111 (Retry/
Discard + ?correlationId= deep links deferred). Rig: seed-flow-outbound-demo.sh
(RigRest/RigDb/FlowDemo + OutboundDemo script + FlowOutboundDemo method/key)
and verify-delmia-notifier.sh step I (live rows inline, replay until terminal).
Rig verification result follows in its own commit.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 02:10:50 -04:00
parent c3b4068de2
commit 3c9f9da818
14 changed files with 330 additions and 26 deletions
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
@@ -14,11 +16,15 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// 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.
/// whole event array lands in one cell as raw JSON. This projects the events through the same
/// <see cref="FlowGraphBuilder"/> the Flow Debugger page uses and prints one row per STEP —
/// <c>occurred | kind | lane | target | durationMs | status | lifecycle | error</c> — in the
/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a
/// notification's Send → Deliver) fold into ONE row whose <c>lifecycle</c> column counts them
/// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). 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>
@@ -54,14 +60,21 @@ internal static class FlowDebugTableProjection
return json;
}
var rows = new JsonArray();
foreach (var evt in events.EnumerateArray())
rows.Add(ProjectEvent(evt));
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
var rows = ProjectSteps(events, truncated);
if (rows == null)
{
// The event array did not deserialize as canonical audit events (a future wire
// shape, or a proxy body) — fall back to one row per raw event so nothing is hidden.
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)
@@ -81,7 +94,66 @@ internal static class FlowDebugTableProjection
}
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row.</summary>
private static readonly JsonSerializerOptions EventOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// Builds the flow graph from the canonical events and projects one row per step. Returns
/// null when the array is not a list of canonical audit events.
/// </summary>
private static JsonArray? ProjectSteps(JsonElement events, bool truncated)
{
List<AuditEvent>? list;
try
{
list = JsonSerializer.Deserialize<List<AuditEvent>>(events.GetRawText(), EventOptions);
}
catch (JsonException)
{
return null;
}
if (list == null || list.Any(e => e == null || e.EventId == Guid.Empty))
return null;
// Status per event: the graph's outcome is a coarser projection; the table keeps the
// audit status word (Delivered / Submitted / Parked …) the operator sees elsewhere.
var statusById = new Dictionary<Guid, string?>();
foreach (var e in list)
statusById[e.EventId] = AuditDetailsCodec.Deserialize(e.DetailsJson).Status;
var graph = FlowGraphBuilder.Build(list, truncated);
var rows = new JsonArray();
foreach (var step in graph.Steps)
{
var lifecycle = step.Lifecycle;
var status = lifecycle is { Count: > 0 }
? lifecycle[^1].Status
: statusById.TryGetValue(step.EventId, out var st) ? st : step.Outcome.ToString();
var target = step.Title;
if (step.Lane != FlowLane.External && step.Instance != null && !target.StartsWith(step.Instance, StringComparison.Ordinal))
target = $"{step.Instance}.{target}";
rows.Add(new JsonObject
{
["occurred"] = step.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture),
["kind"] = step.Kind.ToString(),
["lane"] = step.Lane.ToString(),
["target"] = target,
["durationMs"] = step.DurationMs,
["status"] = status,
["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null,
["error"] = step.ErrorMessage,
});
}
return rows;
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row (fallback path).</summary>
private static JsonNode ProjectEvent(JsonElement evt)
{
if (evt.ValueKind != JsonValueKind.Object)
+10 -4
View File
@@ -2289,9 +2289,14 @@ Trace steps are ordinary audit rows: a run leaves them in the central `AuditLog`
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`,
`occurred | kind | lane | target | durationMs | status | lifecycle | error` — the same
projection the Flow Debugger page draws (`FlowGraphBuilder`): an outbound `ExternalSystem.*`,
`Database.*` or `Notify` call is ONE row on the `External` lane (kinds `ApiCall` / `DbCall` /
`Notify`), and a cached call's or notification's later rows (Forwarded → Attempted → Delivered /
Parked) fold into it — `lifecycle` counts them and `status` is the latest row's (`Submitted`
while the operation is still in flight; re-run `replay` to see it settle). A trailing `note`
row reports a run that did not complete (the error code verbatim) or a truncated inline event
list. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`).
#### `flow-debug run-method`
@@ -2345,7 +2350,8 @@ scadabridge --url <url> flow-debug replay --execution-id <guid>
| `--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.
set) have site steps to replay; an untraced call shows its central `RouteCall` step plus the
run's outbound API / DB / notification rows, which are always audited.
---