diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json index a13dbd93..5924f484 100644 --- a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json @@ -26,7 +26,7 @@ { "id": 3, "subject": "Task 3: Site runtime \u2014 outbound rows flow through the trace collector", - "status": "in_progress", + "status": "completed", "blockedBy": [ 0 ] @@ -50,7 +50,7 @@ { "id": 6, "subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges", - "status": "pending", + "status": "in_progress", "blockedBy": [ 0 ] @@ -58,7 +58,7 @@ { "id": 7, "subject": "Task 7: FlowGraphView \u2014 External lane, status pills, async/spawn edge styles, pending state", - "status": "pending", + "status": "in_progress", "blockedBy": [ 0, 1, @@ -96,5 +96,5 @@ ] } ], - "lastUpdated": "2026-09-06T05:47:05.104042+00:00" + "lastUpdated": "2026-09-06T05:51:19.355720+00:00" } \ No newline at end of file diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs index 2e6468b6..9aa53e71 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs @@ -19,7 +19,12 @@ public sealed record PredictedNode(string Id, FlowStepKind Kind, FlowLane Lane, /// Source node id. /// Target node id. /// True when exactly one end sits in the Central lane (a central↔site hop). -public sealed record PredictedEdge(string From, string To, bool CrossesBoundary); +/// +/// True when the target is a fire-and-forget branch (a cached call or a notification, D4): the edge +/// hangs off the chain step before it and the chain continues from that same step, mirroring +/// on a real run. +/// +public sealed record PredictedEdge(string From, string To, bool CrossesBoundary, bool Async = false); /// The predicted shape of a flow: nodes in source order plus the chain edges between them. public sealed record PredictedFlow(IReadOnlyList Nodes, IReadOnlyList Edges) @@ -30,8 +35,10 @@ public sealed record PredictedFlow(IReadOnlyList Nodes, IReadOnly /// /// Predicts the shape of an inbound-API or site-script flow from script syntax alone -/// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes -/// and attribute waits the script would produce, in source order, before anything runs. +/// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes, +/// attribute waits and outbound calls (API, database, notification — on the +/// lane, plan 2026-09-06 D8) the script would produce, in source +/// order, before anything runs. /// /// /// This is a syntax walk, not a semantic one — it matches the same member-access chains the @@ -145,8 +152,13 @@ public static class FlowShapePredictor internal PredictedFlow Build() => new(_nodes.ToArray(), _edges.ToArray()); - /// Adds a node, chains it to the previous one, and consumes any pending early-return note. - internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note) + /// + /// Adds a node, chains it to the previous one, and consumes any pending early-return note. + /// An node hangs off the previous node on an + /// edge and does NOT become the new chain head — the next node still links from the previous + /// non-async node (D4: a cached call or notification returns immediately, so the run continues without it). + /// + internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note, bool async = false) { var id = "n" + (_nodes.Count + 1).ToString(CultureInfo.InvariantCulture); var mergedNote = note ?? _pendingNote; @@ -158,10 +170,11 @@ public static class FlowShapePredictor if (_previous != null) { var fromLane = _nodes.First(n => n.Id == _previous).Lane; - _edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central))); + _edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central), async)); } - _previous = id; + if (!async) + _previous = id; return id; } @@ -381,25 +394,60 @@ public static class FlowShapePredictor } } - // ExternalSystem.Call / CachedCall, Database.*, Notify.Send — shown, never chained across. - if (receiverText == "ExternalSystem" && member is "Call" or "CachedCall") + // Outbound calls (plan 2026-09-06 D8): ApiCall / DbCall / Notify on the External lane. A sync + // call blocks the run and sits in the chain; CachedCall / CachedWrite / Notify.Send return at + // once and branch off it on an Async edge (D4). "…Async"-suffixed members are the same calls. + var op = TrimAsyncSuffix(member); + + // ExternalSystem.Call(system, method, …) / CachedCall(system, method, …) + if (receiverText == "ExternalSystem" && op is "Call" or "CachedCall") { - var (system, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression); - _b.AddNode(FlowStepKind.ApiCall, _lane, $"ExternalSystem.{member}({system})", dependent, null); + var (system, systemDependent) = LiteralOrExpr(args.ElementAtOrDefault(0)?.Expression); + var (method, methodDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression); + var dependent = systemDependent || methodDependent; + var title = dependent ? $"ExternalSystem.{op}({system}, {method})" : $"{system}.{method}"; + var cached = op == "CachedCall"; + _b.AddNode(FlowStepKind.ApiCall, FlowLane.External, title, dependent, cached ? CachedNote : null, async: cached); return true; } - if (receiverText == "Database") + // Database.Execute/Query/QuerySingle(connection, sql, …) — sync, in chain; + // Database.CachedWrite(connection, sql, …) — store-and-forward, async branch. + if (receiverText == "Database" && op is "Execute" or "Query" or "QuerySingle" or "CachedWrite") { var (conn, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression); - _b.AddNode(FlowStepKind.DbCall, _lane, $"Database.{member}({conn})", dependent, null); + var title = dependent ? $"Database.{op}({conn})" : $"{conn} · {op}"; + var cached = op == "CachedWrite"; + _b.AddNode(FlowStepKind.DbCall, FlowLane.External, title, dependent, cached ? CachedNote : null, async: cached); return true; } - if (receiverText == "Notify" && member == "Send") + // Notify.To(list).Send(subject, message) — the site script API's shape — and the flat + // Notify.Send(list, …) form. Enqueue-only, so always an async branch. + if (op == "Send") { - _b.AddNode(FlowStepKind.Notify, _lane, "Notify.Send", false, null); - return true; + ExpressionSyntax? listExpr = null; + var isNotify = false; + if (receiverText == "Notify") + { + isNotify = true; + listExpr = args.FirstOrDefault()?.Expression; + } + else if (ma.Expression is InvocationExpressionSyntax to + && to.Expression is MemberAccessExpressionSyntax toMa + && Strip(toMa.ToString()) == "Notify.To") + { + isNotify = true; + listExpr = to.ArgumentList.Arguments.FirstOrDefault()?.Expression; + } + + if (isNotify) + { + var (list, dependent) = LiteralOrExpr(listExpr); + var title = dependent ? $"Notify.Send({list})" : $"Notify {list}"; + _b.AddNode(FlowStepKind.Notify, FlowLane.External, title, dependent, null, async: true); + return true; + } } return false; @@ -408,6 +456,13 @@ public static class FlowShapePredictor private string Qualify(string name) => _instanceCode == null ? name : $"{_instanceCode}.{name}"; + /// Note on a store-and-forward node (CachedCall / CachedWrite) — mirrors the projector's "cached" subtitle. + private const string CachedNote = "cached"; + + /// CallAsync → Call; the script API exposes both spellings for the same operation. + private static string TrimAsyncSuffix(string member) + => member.Length > 5 && member.EndsWith("Async", StringComparison.Ordinal) ? member[..^5] : member; + /// A string literal resolves to its value; anything else is input-dependent and shown as its source text. private static (string Text, bool Dependent) LiteralOrExpr(ExpressionSyntax? expr) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs index 1fa20161..572df0a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs @@ -199,11 +199,109 @@ public class FlowShapePredictorTests (FlowStepKind.ScriptRun, "Handshake"), (FlowStepKind.AttributeWrite, "Cmd"), (FlowStepKind.AttributeWait, "Ack"), - (FlowStepKind.ApiCall, "ExternalSystem.Call(Mes)"), - (FlowStepKind.Notify, "Notify.Send"), + (FlowStepKind.ApiCall, "Mes.Post"), + (FlowStepKind.Notify, "Notify ops"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); Assert.DoesNotContain(flow.Nodes, n => n.Lane == FlowLane.Central); Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary)); + Assert.Equal(FlowLane.External, flow.Nodes[3].Lane); + Assert.Equal(FlowLane.External, flow.Nodes[4].Lane); + } + + [Fact] + public void OutboundCalls_SitOnTheExternalLane_SyncInChain_CachedAndNotifyBranchAsync() + { + // D4/D8: a sync call blocks the run (in-chain); CachedCall/CachedWrite/Notify return at once, so + // they hang off the chain on Async edges and the chain continues from the last sync node. + const string body = """ + var r = await ExternalSystem.CallAsync("Delmia", "AckRecipe", new { A = 1 }); + await Database.CachedWriteAsync("MesDb", "INSERT INTO t VALUES (1)"); + await Notify.SendAsync("Planners", "Recipe acked", "done"); + await Database.ExecuteAsync("MesDb", "UPDATE t SET x = 1"); + """; + + var flow = FlowShapePredictor.PredictSiteScript("Handshake", body); + + Assert.Equal( + [ + (FlowStepKind.ScriptRun, FlowLane.Site, "Handshake"), + (FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe"), + (FlowStepKind.DbCall, FlowLane.External, "MesDb · CachedWrite"), + (FlowStepKind.Notify, FlowLane.External, "Notify Planners"), + (FlowStepKind.DbCall, FlowLane.External, "MesDb · Execute"), + ], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList()); + Assert.All(flow.Nodes, n => Assert.False(n.InputDependent)); + Assert.Null(flow.Nodes[1].Note); + Assert.Equal("cached", flow.Nodes[2].Note); + Assert.Null(flow.Nodes[3].Note); + + var (run, api, cached, notify, exec) = (flow.Nodes[0].Id, flow.Nodes[1].Id, flow.Nodes[2].Id, flow.Nodes[3].Id, flow.Nodes[4].Id); + Assert.Equal( + [ + (run, api, false), + (api, cached, true), + (api, notify, true), + (api, exec, false), + ], flow.Edges.Select(e => (e.From, e.To, e.Async)).ToList()); + Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary)); + } + + [Fact] + public void CachedCall_IsAnAsyncApiCall_AndNotifyTo_ResolvesTheList() + { + // The site script API's real Notify shape is Notify.To(list).Send(subject, message). + const string body = """ + await ExternalSystem.CachedCall("Delmia", "AckRecipe"); + await Notify.To("Planners").Send("s", "m"); + """; + + var flow = FlowShapePredictor.PredictSiteScript("S", body); + + Assert.Equal( + [ + (FlowStepKind.ScriptRun, "S", (string?)null), + (FlowStepKind.ApiCall, "Delmia.AckRecipe", "cached"), + (FlowStepKind.Notify, "Notify Planners", null), + ], flow.Nodes.Select(n => (n.Kind, n.Title, n.Note)).ToList()); + Assert.All(flow.Nodes.Skip(1), n => Assert.Equal(FlowLane.External, n.Lane)); + // Both branch off the run: neither advances the chain. + Assert.Equal([(flow.Nodes[0].Id, true), (flow.Nodes[0].Id, true)], flow.Edges.Select(e => (e.From, e.Async)).ToList()); + } + + [Fact] + public void OutboundCalls_WithComputedTargets_AreInputDependent() + { + const string body = """ + await ExternalSystem.Call(sys, "M"); + await Database.QueryAsync(conn, "SELECT 1"); + await Notify.Send(list, "s"); + """; + + var flow = FlowShapePredictor.PredictSiteScript("S", body); + + var outbound = flow.Nodes.Skip(1).ToList(); + Assert.All(outbound, n => Assert.True(n.InputDependent)); + Assert.Equal("ExternalSystem.Call(sys, M)", outbound[0].Title); + Assert.Equal("Database.Query(conn)", outbound[1].Title); + Assert.Equal("Notify.Send(list)", outbound[2].Title); + } + + [Fact] + public void InboundMethod_DatabaseCall_CrossesFromCentralToExternal() + { + const string script = """ + var rows = await Database.QuerySingleAsync("ConfigDb", "SELECT 1"); + return rows; + """; + + var flow = FlowShapePredictor.PredictInboundMethod("M", script, AnySiteScript); + + var db = flow.Nodes.Single(n => n.Kind == FlowStepKind.DbCall); + Assert.Equal(FlowLane.External, db.Lane); + Assert.Equal("ConfigDb · QuerySingle", db.Title); + var into = flow.Edges.Single(e => e.To == db.Id); + Assert.True(into.CrossesBoundary); + Assert.False(into.Async); } [Fact]