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