From ec7b5b2ea395096ff4002256caa0f95f914123de Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 5 Sep 2026 23:41:50 -0400 Subject: [PATCH] feat(commons): FlowStep/FlowGraph projection + FlowGraphBuilder for the Flow Debugger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Types/Debugging/FlowGraph.cs | 23 +++ .../Types/Debugging/FlowGraphBuilder.cs | 149 ++++++++++++++ .../Types/Debugging/FlowStep.cs | 73 +++++++ .../Types/Debugging/FlowStepProjector.cs | 110 +++++++++++ .../Types/Debugging/ScriptTraceExtra.cs | 81 ++++++++ .../Debugging/FlowGraphBuilderTests.cs | 182 ++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs new file mode 100644 index 00000000..b4a7bbf7 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +/// A directed edge between two flow steps. +/// Source step. +/// Target step. +/// True when the edge crosses the central ↔ site boundary (drawn in the accent colour). +/// True for an inferred relationship the system did not observe directly — a device echo satisfying a wait after a write (drawn dashed). +public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoundary, bool External); + +/// The projected flow of one execution tree (Flow Debugger design 2026-09-05 §7.2). +/// Steps in display order (time, then kind rank, then event id). +/// Edges between steps. +/// True when the graph came from static analysis rather than real events. +/// True when the inline event list was capped (the audit rows remain complete — Replay shows everything). +public sealed record FlowGraph( + IReadOnlyList Steps, + IReadOnlyList Edges, + bool Predicted, + bool Truncated) +{ + /// An empty, non-predicted graph. + public static FlowGraph Empty { get; } = new(Array.Empty(), Array.Empty(), false, false); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs new file mode 100644 index 00000000..152e1c7f --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs @@ -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; + +/// +/// Builds a from the audit events of one execution tree +/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out. +/// +/// +/// Rules: +/// +/// Steps are ordered by OccurredAtUtc, then a kind rank (inbound before route call +/// before script run before writes/waits), then EventId. +/// Within one ExecutionId consecutive steps are chained in that order. +/// The first step of an execution is linked from its spawner: a RouteCall whose +/// childExecutionId matches, else the latest step of the parent execution that occurred +/// no later than it. The edge CrossesBoundary when exactly one end is in the Central lane. +/// An AttributeWait that was satisfied gets an External (dashed) edge from the +/// preceding AttributeWrite in the same execution — the device echo the system inferred. +/// When an InboundRequest row carries a response summary, a Response step is +/// synthesized as the final Central node, linked from the last real step. +/// +/// +public static class FlowGraphBuilder +{ + /// Builds the graph. is passed through to . + public static FlowGraph Build(IReadOnlyList events, bool truncated = false) + { + ArgumentNullException.ThrowIfNull(events); + if (events.Count == 0) + return new FlowGraph(Array.Empty(), Array.Empty(), 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(); + 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); + } + + /// Display rank used to break timestamp ties: spawners before what they spawn. + 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); + + /// A stable id for the synthesized Response node so re-builds of the same events select the same node. + private static Guid DeriveResponseId(Guid inboundEventId) + { + var bytes = inboundEventId.ToByteArray(); + bytes[15] ^= 0x5A; + bytes[0] ^= 0xA5; + return new Guid(bytes); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs new file mode 100644 index 00000000..53be7d1e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs @@ -0,0 +1,73 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +/// Swimlane a flow step is drawn in (Flow Debugger design 2026-09-05 §4/§7.2). +public enum FlowLane +{ + /// Central node: inbound request, method script, route call, response. + Central, + /// Site node: script runs and attribute waits. + Site, + /// Attribute / tag layer: attribute writes and their device echoes. + Tags, +} + +/// What a flow step represents — the projection of an audit channel/kind pair. +public enum FlowStepKind +{ + /// ApiInbound.InboundRequest. + InboundRequest, + /// ScriptTrace.RouteCall. + RouteCall, + /// ScriptTrace.ScriptRun. + ScriptRun, + /// ScriptTrace.AttributeWrite. + AttributeWrite, + /// ScriptTrace.AttributeWait. + AttributeWait, + /// Synthesized final node — the inbound response (from the inbound row's response summary). + Response, + /// An outbound API / DB / notification row emitted by a traced run (shown, not chained across the boundary). + External, + /// A channel/kind this build does not recognise — rendered, never dropped. + Unknown, +} + +/// Per-step outcome. +public enum FlowStepOutcome +{ + /// Completed successfully. + Ok, + /// Failed (the step's error message says why). + Failed, + /// A script run or wait that hit its deadline. + TimedOut, + /// Predicted only — no real event yet. + Predicted, + /// No trace event exists for this position (e.g. site on an older build). + NotTraced, +} + +/// +/// One node of the flow graph — a central-side projection of a ScriptTrace / +/// ApiInbound.InboundRequest . Never serialized +/// to the wire; the wire carries the audit events themselves (design §5.4). +/// +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); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs new file mode 100644 index 00000000..723e0d4a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs @@ -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; + +/// +/// Projects one canonical onto a +/// (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 , +/// never throws. +/// +public static class FlowStepProjector +{ + /// Projects an audit event onto a flow step. + 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); + } + + /// Maps a channel/kind string pair onto a step kind. + 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; + } + + /// Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags. + 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, + }; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs new file mode 100644 index 00000000..09045637 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; + +/// +/// Kind-specific detail carried in AuditDetails.Extra as camelCase JSON on +/// ScriptTrace 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. +/// +public sealed record ScriptTraceExtra +{ + /// Emitting node (node-a / central-a) when not already on the event. + public string? Node { get; init; } + + /// Trigger kind of a ScriptRun: call | valuechange | … + public string? TriggerKind { get; init; } + + /// Attribute whose change fired a value-change run. + public string? TriggerAttribute { get; init; } + + /// Encoded value that fired a value-change run. + public string? TriggerValue { get; init; } + + /// Where the triggering change came from: value-change (the actor cannot tell a static write from a device echo). + public string? TriggerSource { get; init; } + + /// On a RouteCall row: the routed site run's ExecutionId. + public Guid? ChildExecutionId { get; init; } + + /// On an AttributeWrite row: each write's outcome. + public IReadOnlyList? Writes { get; init; } + + /// On an AttributeWait row: the awaited attribute. + public string? WaitAttribute { get; init; } + + /// On an AttributeWait row: the target value / predicate description. + public string? WaitTarget { get; init; } + + /// On an AttributeWait row: the timeout that applied. + public int? WaitTimeoutMs { get; init; } + + /// On an AttributeWait row: satisfied | timeout | cancelled | error. + public string? WaitOutcome { get; init; } + + /// On an AttributeWait row: the encoded value observed when the wait resolved. + public string? ObservedValue { get; init; } + + /// flow-debugger on rows the debugger synthesizes (the Live-run inbound row, the value-change root). + public string? Source { get; init; } + + /// Serializer options shared by every emitter and reader: camelCase, nulls omitted. + public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Serializes to the compact camelCase JSON stored in AuditDetails.Extra. + public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); + + /// Parses AuditDetails.Extra; returns null for null/blank/invalid JSON — never throws. + public static ScriptTraceExtra? TryParse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } +} + +/// One attribute write inside an AttributeWrite trace row. +/// Canonical attribute name. +/// Encoded value written. +/// Whether the instance actor accepted the write. +/// Error message when it did not. +public sealed record AttributeWriteDetail(string Attribute, string? Written, bool Success, string? Error); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs new file mode 100644 index 00000000..c4dced97 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs @@ -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; + +/// +/// 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. +/// +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(); + + /// Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write. + private static List 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(), truncated: true).Truncated); + Assert.Empty(FlowGraphBuilder.Build(Array.Empty()).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")); + } +}