feat(commons): FlowStep/FlowGraph projection + FlowGraphBuilder for the Flow Debugger

Central-side projection of ScriptTrace/ApiInbound audit events (design §7.2): lanes
Central/Site/Tags, in-execution chaining, spawner→child edges (RouteCall by
childExecutionId), inferred write→satisfied-wait echo edge, synthesized Response node.
ScriptTraceExtra is the camelCase Extra payload shared by every emitter.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:41:50 -04:00
parent 24e595d6d9
commit ec7b5b2ea3
6 changed files with 618 additions and 0 deletions
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>A directed edge between two flow steps.</summary>
/// <param name="FromEventId">Source step.</param>
/// <param name="ToEventId">Target step.</param>
/// <param name="CrossesBoundary">True when the edge crosses the central ↔ site boundary (drawn in the accent colour).</param>
/// <param name="External">True for an inferred relationship the system did not observe directly — a device echo satisfying a wait after a write (drawn dashed).</param>
public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoundary, bool External);
/// <summary>The projected flow of one execution tree (Flow Debugger design 2026-09-05 §7.2).</summary>
/// <param name="Steps">Steps in display order (time, then kind rank, then event id).</param>
/// <param name="Edges">Edges between steps.</param>
/// <param name="Predicted">True when the graph came from static analysis rather than real events.</param>
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete — Replay shows everything).</param>
public sealed record FlowGraph(
IReadOnlyList<FlowStep> Steps,
IReadOnlyList<FlowEdge> Edges,
bool Predicted,
bool Truncated)
{
/// <summary>An empty, non-predicted graph.</summary>
public static FlowGraph Empty { get; } = new(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), false, false);
}
@@ -0,0 +1,149 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Builds a <see cref="FlowGraph"/> from the audit events of one execution tree
/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out.
/// </summary>
/// <remarks>
/// Rules:
/// <list type="bullet">
/// <item>Steps are ordered by <c>OccurredAtUtc</c>, then a kind rank (inbound before route call
/// before script run before writes/waits), then <c>EventId</c>.</item>
/// <item>Within one <c>ExecutionId</c> consecutive steps are chained in that order.</item>
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
/// <c>childExecutionId</c> matches, else the latest step of the parent execution that occurred
/// no later than it. The edge <c>CrossesBoundary</c> when exactly one end is in the Central lane.</item>
/// <item>An <c>AttributeWait</c> that was satisfied gets an <c>External</c> (dashed) edge from the
/// preceding <c>AttributeWrite</c> in the same execution — the device echo the system inferred.</item>
/// <item>When an <c>InboundRequest</c> row carries a response summary, a <c>Response</c> step is
/// synthesized as the final Central node, linked from the last real step.</item>
/// </list>
/// </remarks>
public static class FlowGraphBuilder
{
/// <summary>Builds the graph. <paramref name="truncated"/> is passed through to <see cref="FlowGraph.Truncated"/>.</summary>
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false)
{
ArgumentNullException.ThrowIfNull(events);
if (events.Count == 0)
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated);
var steps = events
.Select(FlowStepProjector.Project)
.OrderBy(s => s.OccurredAtUtc)
.ThenBy(s => KindRank(s.Kind))
.ThenBy(s => s.EventId)
.ToList();
var edges = new List<FlowEdge>();
var byExecution = steps
.Where(s => s.ExecutionId is not null)
.GroupBy(s => s.ExecutionId!.Value)
.ToDictionary(g => g.Key, g => g.ToList());
// 1. Chain consecutive steps inside each execution.
foreach (var chain in byExecution.Values)
{
for (var i = 1; i < chain.Count; i++)
edges.Add(new FlowEdge(chain[i - 1].EventId, chain[i].EventId, CrossesBoundary(chain[i - 1], chain[i]), External: false));
}
// 2. Link each child execution's first step from its spawner.
var routeCallsByChild = steps
.Where(s => s.Kind == FlowStepKind.RouteCall && s.Extra?.ChildExecutionId is not null)
.GroupBy(s => s.Extra!.ChildExecutionId!.Value)
.ToDictionary(g => g.Key, g => g.First());
foreach (var (executionId, chain) in byExecution)
{
var first = chain[0];
if (first.ParentExecutionId is not { } parentId) continue;
FlowStep? spawner = null;
if (routeCallsByChild.TryGetValue(executionId, out var routeCall))
spawner = routeCall;
else if (byExecution.TryGetValue(parentId, out var parentChain))
spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response)
?? parentChain[0];
if (spawner is not null && spawner.EventId != first.EventId)
edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false));
}
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
foreach (var chain in byExecution.Values)
{
for (var i = 0; i < chain.Count; i++)
{
var wait = chain[i];
if (wait.Kind != FlowStepKind.AttributeWait) continue;
if (!string.Equals(wait.Extra?.WaitOutcome, "satisfied", StringComparison.OrdinalIgnoreCase)) continue;
for (var j = i - 1; j >= 0; j--)
{
if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
break;
}
}
}
// 4. Synthesize the Response node from the inbound row's response summary.
var inbound = steps.FirstOrDefault(s => s.Kind == FlowStepKind.InboundRequest);
if (inbound is not null && inbound.OutputJson is not null)
{
var last = steps[^1];
var response = new FlowStep(
EventId: DeriveResponseId(inbound.EventId),
ExecutionId: inbound.ExecutionId,
ParentExecutionId: null,
Kind: FlowStepKind.Response,
Lane: FlowLane.Central,
Title: "Response",
Subtitle: inbound.Outcome == FlowStepOutcome.Ok ? "200" : "error",
OccurredAtUtc: inbound.DurationMs is { } ms ? inbound.OccurredAtUtc.AddMilliseconds(ms) : last.OccurredAtUtc,
DurationMs: null,
Outcome: inbound.Outcome,
InputJson: null,
OutputJson: inbound.OutputJson,
ErrorMessage: inbound.ErrorMessage,
SiteId: null,
Node: inbound.Node,
Instance: null,
Target: inbound.Target,
Extra: null);
steps.Add(response);
edges.Add(new FlowEdge(last.EventId, response.EventId, CrossesBoundary(last, response), External: false));
}
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
}
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
public static int KindRank(FlowStepKind kind) => kind switch
{
FlowStepKind.InboundRequest => 0,
FlowStepKind.RouteCall => 1,
FlowStepKind.ScriptRun => 2,
FlowStepKind.AttributeWrite => 3,
FlowStepKind.AttributeWait => 4,
FlowStepKind.External => 5,
FlowStepKind.Unknown => 6,
FlowStepKind.Response => 9,
_ => 7,
};
private static bool CrossesBoundary(FlowStep a, FlowStep b)
=> (a.Lane == FlowLane.Central) != (b.Lane == FlowLane.Central);
/// <summary>A stable id for the synthesized Response node so re-builds of the same events select the same node.</summary>
private static Guid DeriveResponseId(Guid inboundEventId)
{
var bytes = inboundEventId.ToByteArray();
bytes[15] ^= 0x5A;
bytes[0] ^= 0xA5;
return new Guid(bytes);
}
}
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>Swimlane a flow step is drawn in (Flow Debugger design 2026-09-05 §4/§7.2).</summary>
public enum FlowLane
{
/// <summary>Central node: inbound request, method script, route call, response.</summary>
Central,
/// <summary>Site node: script runs and attribute waits.</summary>
Site,
/// <summary>Attribute / tag layer: attribute writes and their device echoes.</summary>
Tags,
}
/// <summary>What a flow step represents — the projection of an audit channel/kind pair.</summary>
public enum FlowStepKind
{
/// <summary><c>ApiInbound.InboundRequest</c>.</summary>
InboundRequest,
/// <summary><c>ScriptTrace.RouteCall</c>.</summary>
RouteCall,
/// <summary><c>ScriptTrace.ScriptRun</c>.</summary>
ScriptRun,
/// <summary><c>ScriptTrace.AttributeWrite</c>.</summary>
AttributeWrite,
/// <summary><c>ScriptTrace.AttributeWait</c>.</summary>
AttributeWait,
/// <summary>Synthesized final node — the inbound response (from the inbound row's response summary).</summary>
Response,
/// <summary>An outbound API / DB / notification row emitted by a traced run (shown, not chained across the boundary).</summary>
External,
/// <summary>A channel/kind this build does not recognise — rendered, never dropped.</summary>
Unknown,
}
/// <summary>Per-step outcome.</summary>
public enum FlowStepOutcome
{
/// <summary>Completed successfully.</summary>
Ok,
/// <summary>Failed (the step's error message says why).</summary>
Failed,
/// <summary>A script run or wait that hit its deadline.</summary>
TimedOut,
/// <summary>Predicted only — no real event yet.</summary>
Predicted,
/// <summary>No trace event exists for this position (e.g. site on an older build).</summary>
NotTraced,
}
/// <summary>
/// One node of the flow graph — a central-side projection of a <c>ScriptTrace</c> /
/// <c>ApiInbound.InboundRequest</c> <see cref="ZB.MOM.WW.Audit.AuditEvent"/>. Never serialized
/// to the wire; the wire carries the audit events themselves (design §5.4).
/// </summary>
public sealed record FlowStep(
Guid EventId,
Guid? ExecutionId,
Guid? ParentExecutionId,
FlowStepKind Kind,
FlowLane Lane,
string Title,
string? Subtitle,
DateTimeOffset OccurredAtUtc,
int? DurationMs,
FlowStepOutcome Outcome,
string? InputJson,
string? OutputJson,
string? ErrorMessage,
string? SiteId,
string? Node,
string? Instance,
string? Target,
ScriptTraceExtra? Extra);
@@ -0,0 +1,110 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Projects one canonical <see cref="AuditEvent"/> onto a <see cref="FlowStep"/>
/// (Flow Debugger design 2026-09-05 §5.4: trace steps ARE audit events; this is the read side).
/// Pure and total — an unrecognised channel/kind projects to <see cref="FlowStepKind.Unknown"/>,
/// never throws.
/// </summary>
public static class FlowStepProjector
{
/// <summary>Projects an audit event onto a flow step.</summary>
public static FlowStep Project(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
var extra = ScriptTraceExtra.TryParse(d.Extra);
var kind = ResolveKind(d.Channel, d.Kind);
var lane = ResolveLane(kind, d.SourceSiteId);
var outcome = ResolveOutcome(kind, d.Status, d.ErrorMessage);
var title = string.IsNullOrWhiteSpace(evt.Target) ? kind.ToString() : evt.Target!;
return new FlowStep(
EventId: evt.EventId,
ExecutionId: d.ExecutionId,
ParentExecutionId: d.ParentExecutionId,
Kind: kind,
Lane: lane,
Title: title,
Subtitle: BuildSubtitle(kind, evt, d, extra),
OccurredAtUtc: evt.OccurredAtUtc,
DurationMs: d.DurationMs,
Outcome: outcome,
InputJson: d.RequestSummary,
OutputJson: d.ResponseSummary,
ErrorMessage: d.ErrorMessage,
SiteId: d.SourceSiteId,
Node: evt.SourceNode ?? extra?.Node,
Instance: d.SourceInstanceId,
Target: evt.Target,
Extra: extra);
}
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
public static FlowStepKind ResolveKind(string? channel, string? kind)
{
if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
{
return kind switch
{
nameof(AuditKind.RouteCall) => FlowStepKind.RouteCall,
nameof(AuditKind.ScriptRun) => FlowStepKind.ScriptRun,
nameof(AuditKind.AttributeWrite) => FlowStepKind.AttributeWrite,
nameof(AuditKind.AttributeWait) => FlowStepKind.AttributeWait,
_ => FlowStepKind.Unknown,
};
}
if (string.Equals(channel, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal)
&& string.Equals(kind, nameof(AuditKind.InboundRequest), StringComparison.Ordinal))
return FlowStepKind.InboundRequest;
if (string.Equals(channel, nameof(AuditChannel.ApiOutbound), StringComparison.Ordinal)
|| string.Equals(channel, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
|| string.Equals(channel, nameof(AuditChannel.Notification), StringComparison.Ordinal))
return FlowStepKind.External;
return FlowStepKind.Unknown;
}
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags.</summary>
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
{
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
FlowStepKind.ScriptRun or FlowStepKind.AttributeWait => FlowLane.Site,
FlowStepKind.AttributeWrite => FlowLane.Tags,
_ => string.IsNullOrEmpty(sourceSiteId) ? FlowLane.Central : FlowLane.Site,
};
private static FlowStepOutcome ResolveOutcome(FlowStepKind kind, string? status, string? error)
{
if (!string.Equals(status, nameof(AuditStatus.Failed), StringComparison.Ordinal))
return FlowStepOutcome.Ok;
if ((kind == FlowStepKind.ScriptRun || kind == FlowStepKind.AttributeWait)
&& error is not null && error.Contains("timed out", StringComparison.OrdinalIgnoreCase))
return FlowStepOutcome.TimedOut;
return FlowStepOutcome.Failed;
}
private static string? BuildSubtitle(FlowStepKind kind, AuditEvent evt, AuditDetails d, ScriptTraceExtra? extra) => kind switch
{
FlowStepKind.InboundRequest => string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
FlowStepKind.RouteCall => extra?.ChildExecutionId is { } child ? $"→ {child.ToString("D")[..8]}" : null,
FlowStepKind.ScriptRun => d.SourceInstanceId is null
? extra?.TriggerKind
: extra?.TriggerKind is null ? d.SourceInstanceId : $"{d.SourceInstanceId} · {extra.TriggerKind}",
FlowStepKind.AttributeWrite => extra?.Writes is { Count: > 0 } w
? string.Join(", ", w.Select(x => x.Written is null ? x.Attribute : $"{x.Attribute} = {x.Written}"))
: null,
FlowStepKind.AttributeWait => extra is null
? null
: extra.WaitOutcome is null
? extra.ObservedValue
: extra.ObservedValue is null ? extra.WaitOutcome : $"{extra.WaitOutcome} · {extra.ObservedValue}",
_ => d.Kind,
};
}
@@ -0,0 +1,81 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Kind-specific detail carried in <c>AuditDetails.Extra</c> as camelCase JSON on
/// <c>ScriptTrace</c> rows (and on the Flow Debugger's synthesized inbound row). Every member is
/// optional; unknown members are ignored on read so older/newer emitters interoperate.
/// </summary>
public sealed record ScriptTraceExtra
{
/// <summary>Emitting node (<c>node-a</c> / <c>central-a</c>) when not already on the event.</summary>
public string? Node { get; init; }
/// <summary>Trigger kind of a <c>ScriptRun</c>: <c>call</c> | <c>valuechange</c> | …</summary>
public string? TriggerKind { get; init; }
/// <summary>Attribute whose change fired a value-change run.</summary>
public string? TriggerAttribute { get; init; }
/// <summary>Encoded value that fired a value-change run.</summary>
public string? TriggerValue { get; init; }
/// <summary>Where the triggering change came from: <c>value-change</c> (the actor cannot tell a static write from a device echo).</summary>
public string? TriggerSource { get; init; }
/// <summary>On a <c>RouteCall</c> row: the routed site run's <c>ExecutionId</c>.</summary>
public Guid? ChildExecutionId { get; init; }
/// <summary>On an <c>AttributeWrite</c> row: each write's outcome.</summary>
public IReadOnlyList<AttributeWriteDetail>? Writes { get; init; }
/// <summary>On an <c>AttributeWait</c> row: the awaited attribute.</summary>
public string? WaitAttribute { get; init; }
/// <summary>On an <c>AttributeWait</c> row: the target value / predicate description.</summary>
public string? WaitTarget { get; init; }
/// <summary>On an <c>AttributeWait</c> row: the timeout that applied.</summary>
public int? WaitTimeoutMs { get; init; }
/// <summary>On an <c>AttributeWait</c> row: <c>satisfied</c> | <c>timeout</c> | <c>cancelled</c> | <c>error</c>.</summary>
public string? WaitOutcome { get; init; }
/// <summary>On an <c>AttributeWait</c> row: the encoded value observed when the wait resolved.</summary>
public string? ObservedValue { get; init; }
/// <summary><c>flow-debugger</c> on rows the debugger synthesizes (the Live-run inbound row, the value-change root).</summary>
public string? Source { get; init; }
/// <summary>Serializer options shared by every emitter and reader: camelCase, nulls omitted.</summary>
public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary>
public static ScriptTraceExtra? TryParse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
return JsonSerializer.Deserialize<ScriptTraceExtra>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
/// <summary>One attribute write inside an <c>AttributeWrite</c> trace row.</summary>
/// <param name="Attribute">Canonical attribute name.</param>
/// <param name="Written">Encoded value written.</param>
/// <param name="Success">Whether the instance actor accepted the write.</param>
/// <param name="Error">Error message when it did not.</param>
public sealed record AttributeWriteDetail(string Attribute, string? Written, bool Success, string? Error);
@@ -0,0 +1,182 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Flow Debugger design 2026-09-05 §7.2: lane/edge/boundary rules over a Delmia-shaped
/// event list, truncation pass-through, and total-ness on unknown kinds.
/// </summary>
public class FlowGraphBuilderTests
{
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
private static readonly Guid Root = Guid.NewGuid();
private static readonly Guid SiteRun = Guid.NewGuid();
private static readonly Guid Cascade = Guid.NewGuid();
/// <summary>Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write.</summary>
private static List<AuditEvent> DelmiaShaped()
{
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0, actor: "delmia-key", target: "DelmiaRecipeDownload", sourceNode: "central-a",
executionId: Root, durationMs: 900, requestSummary: "{\"MachineCode\":\"M1\"}", responseSummary: "{\"ok\":true}");
var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(10), actor: "inbound-api", target: "M1.ProcessRecipeDownload", sourceNode: "central-a",
executionId: Root, durationMs: 850, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(20), actor: "ProcessRecipeDownload", target: "ProcessRecipeDownload", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800,
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
var write1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(30), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.RecipePath", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 5,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.RecipePath", "C:\\r.nc", true, null)] }.ToJson());
var wait = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(40), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Processed", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 700,
extra: new ScriptTraceExtra { WaitAttribute = "DelmiaReceiver.Processed", WaitOutcome = "satisfied", ObservedValue = "true" }.ToJson());
var write2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(750), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Trigger", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.Trigger", "false", true, null)] }.ToJson());
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(760), actor: "OnTriggerReset", target: "OnTriggerReset", sourceNode: "node-a",
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger", TriggerValue = "false" }.ToJson());
// Deliberately shuffled: the builder must order by time.
return [wait, cascade, inbound, write2, run, route, write1];
}
[Fact]
public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
{
var graph = FlowGraphBuilder.Build(DelmiaShaped());
Assert.False(graph.Predicted);
Assert.False(graph.Truncated);
Assert.Equal(8, graph.Steps.Count); // 7 events + synthesized Response
var kinds = graph.Steps.Select(s => s.Kind).ToArray();
Assert.Equal(
new[]
{
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite,
FlowStepKind.AttributeWait, FlowStepKind.AttributeWrite, FlowStepKind.ScriptRun, FlowStepKind.Response,
},
kinds);
Assert.Equal(FlowLane.Central, graph.Steps[0].Lane);
Assert.Equal(FlowLane.Central, graph.Steps[1].Lane);
Assert.Equal(FlowLane.Site, graph.Steps[2].Lane);
Assert.Equal(FlowLane.Tags, graph.Steps[3].Lane);
Assert.Equal(FlowLane.Site, graph.Steps[4].Lane);
Assert.Equal(FlowLane.Tags, graph.Steps[5].Lane);
Assert.Equal(FlowLane.Site, graph.Steps[6].Lane);
Assert.Equal(FlowLane.Central, graph.Steps[7].Lane);
// Exactly two boundary crossings: route → site run, and last site step → response.
var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList();
Assert.Equal(2, crossing.Count);
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId);
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
// One inferred echo edge: first write → satisfied wait.
var external = Assert.Single(graph.Edges, e => e.External);
Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
// The cascade run hangs off the write that spawned it (same site, no boundary).
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary);
// Inbound → route call chained inside the root execution.
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId);
// Projection detail.
Assert.Equal("→ " + SiteRun.ToString("D")[..8], graph.Steps[1].Subtitle);
Assert.Equal("M1 · call", graph.Steps[2].Subtitle);
Assert.Equal("DelmiaReceiver.RecipePath = C:\\r.nc", graph.Steps[3].Subtitle);
Assert.Equal("satisfied · true", graph.Steps[4].Subtitle);
Assert.Equal("{\"ok\":true}", graph.Steps[7].OutputJson);
Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome));
}
[Fact]
public void Build_IsDeterministic_ResponseIdIsStable()
{
var events = DelmiaShaped();
var a = FlowGraphBuilder.Build(events);
var b = FlowGraphBuilder.Build(events);
Assert.Equal(a.Steps.Select(s => s.EventId), b.Steps.Select(s => s.EventId));
Assert.Equal(a.Edges, b.Edges);
}
[Fact]
public void Truncated_PassesThrough()
{
Assert.True(FlowGraphBuilder.Build(DelmiaShaped(), truncated: true).Truncated);
Assert.True(FlowGraphBuilder.Build(Array.Empty<AuditEvent>(), truncated: true).Truncated);
Assert.Empty(FlowGraphBuilder.Build(Array.Empty<AuditEvent>()).Steps);
}
[Fact]
public void UnknownKind_ProjectsToUnknown_NeverThrows()
{
var weird = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = T0,
Actor = "x",
Action = "Future.Thing",
Outcome = AuditOutcome.Success,
DetailsJson = "{\"channel\":\"Future\",\"kind\":\"Thing\",\"extra\":\"not json at all\"}",
};
var garbage = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = T0,
Actor = "x",
Action = "?",
Outcome = AuditOutcome.Failure,
DetailsJson = "{{{",
};
var graph = FlowGraphBuilder.Build([weird, garbage]);
Assert.Equal(2, graph.Steps.Count);
Assert.All(graph.Steps, s => Assert.Equal(FlowStepKind.Unknown, s.Kind));
Assert.All(graph.Steps, s => Assert.Null(s.Extra));
Assert.Empty(graph.Edges);
}
[Fact]
public void FailedScriptRun_WithTimeoutMessage_IsTimedOut()
{
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
occurredAtUtc: T0, target: "S", executionId: SiteRun, errorMessage: "Script timed out after 30s");
var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
occurredAtUtc: T0.AddMilliseconds(1), target: "A", executionId: SiteRun, errorMessage: "rejected");
var graph = FlowGraphBuilder.Build([run, write]);
Assert.Equal(FlowStepOutcome.TimedOut, graph.Steps[0].Outcome);
Assert.Equal(FlowStepOutcome.Failed, graph.Steps[1].Outcome);
Assert.Single(graph.Edges);
}
[Fact]
public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers()
{
var extra = new ScriptTraceExtra { ChildExecutionId = SiteRun, WaitTimeoutMs = 1500, Source = "flow-debugger" };
var json = extra.ToJson();
Assert.Contains("\"childExecutionId\"", json);
Assert.DoesNotContain("triggerKind", json);
var back = ScriptTraceExtra.TryParse(json.Insert(1, "\"futureField\":1,"));
Assert.Equal(extra, back);
Assert.Null(ScriptTraceExtra.TryParse(null));
Assert.Null(ScriptTraceExtra.TryParse("nope"));
}
}