diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html b/docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html new file mode 100644 index 00000000..f0f3eedb --- /dev/null +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html @@ -0,0 +1,328 @@ +Flow Debugger External Lane + + + +
+
+
Flow Debugger · design addendum · 2026-09-06
+

Outbound calls on a fourth lane

+

What the swimlane looks like once ExternalSystem, Database and Notify calls become nodes. The flow below is the rig demo the plan seeds (FlowOutboundDemo → DelmiaSim01.OutboundDemo): one synchronous REST call, one cached database write, one notification.

+
+ + +
+
+
Inbound API methodValue-change trigger
+
FlowOutboundDemo
+
LiveReplay
+
+ Refresh outcomes + Run +
+ +
+ Completed + Started05:31:02.118Z + Total371 ms + Steps9 + Boundaries2 + Writes2 + Pending1 + outcomes still arriving — refreshing every 5 s (1:40 left) +
+ +
+ + + + + + +
+ + +
+
+
+
DbCall · External
+

RigDb · CachedWrite

+
DelmiaSim01 · OutboundDemo
+
+
+ Delivered +
+
+
SQL + parametersResultLifecycleTiming
+ + + + + + + +
TimeRowStatusNodeError
05:31:02.301CachedSubmitSubmittedsite-a / node-a—
+0.9 sDbWriteCachedForwardednode-a—
+4.1 sCachedResolveDeliverednode-a—
+
+
+
Where this ran
+
+ Site / nodesite-a / node-a + InstanceDelmiaSim01 + ConnectionRigDb + Operation7c2e91d4… + Delivered bynode-a · +4.1 s +
+
Open in Debug ViewAudit rows (3)
+
+
+
+ + +
+
+

Connectors and pills

+
+ Executed edge — the run continued here. + Crossed the central ↔ site gRPC boundary. + New. Async branch — CachedCall / CachedWrite / Notify.Send returned at once; the run carried on without it. + Inferred device echo (a write that satisfied a wait). + New. Trigger spawn — this write fired a value-change script. + OkSync call answered, or cached operation delivered. + PendingSubmitted / forwarded, not yet resolved. The page keeps refreshing for up to two minutes after a Live run. + ParkedRetries exhausted or permanent failure downstream; retry or discard from Site Calls / Notifications. + FailedA sync call failed and the script received the error. +
+
+
+

What changed in the graph

+
+

External is a lane, not a caller-side node. Like Tags, it holds actors outside ScadaBridge — a REST endpoint, a database, the mail relay — so a hop that leaves the system reads as one. Its gutter lists the targets touched.

+

One node per operation. A cached call's four audit rows (Submitted → Forwarded → Attempted → Delivered) fold into one node; the pill is the latest status and the Lifecycle tab is the history. A notification folds the site's NotifySend with central's NotifyDeliver rows.

+

Sync calls sit in the chain; async ones branch. The script blocks on ExternalSystem.Call, so the chain runs through it (write → API call → write). It does not block on the cached write or the notification, so those hang off the script run on a dashed edge and the chain skips them.

+

The Response now starts from the route call, not from whatever step happened last — a cascaded write on another instance is not what answered the HTTP request.

+
+
+
+ + +
+

How audit rows become nodes

+
+ + + + + + + + + + +
Script callAudit rows already writtenNodeLive inlineArrives later
ExternalSystem.CallApiOutbound / ApiCall Delivered|Failed, request + response, durationAPI CALL · in chainwhole node—
ExternalSystem.CachedCallCachedSubmit → ApiCallCached Forwarded / Attempted ×n → CachedResolveAPI CALL · async branchSubmittedForwarded, attempts, Delivered / Parked
Database.Query / ExecuteDbOutbound / DbWrite, Extra {op, rows…}DATABASE · in chainwhole node—
Database.CachedWriteCachedSubmit → DbWriteCached → CachedResolveDATABASE · async branchSubmittedthe rest
Notify.Sendsite NotifySend Submitted → central NotifyDeliver Attempted / Delivered / ParkedNOTIFY · async branchSubmittedcentral outbox rows
central method script Database.*none today → gets DbOutbound / DbWrite (closes an audit gap)DATABASE · in chain, Central-side callerwhole node—
+
+
+ + +
+
+

Plan at a glance

+
    +
  1. Commons types — kinds ApiCall / DbCall / Notify, lane External, outcomes Pending / Parked, FlowLifecycleRow.
  2. +
  3. Projector maps the three outbound channels onto the External lane.
  4. +
  5. Graph builder folds lifecycles into one node, async branch edges, spawn edges, Response from the route call.
  6. +
  7. Site runtime routes outbound rows through the existing trace collector.
  8. +
  9. Central audits method-script Database.* and feeds the trace sink.
  10. +
  11. Replay filter widens to the three channels; site pull verified.
  12. +
  13. Predictor emits the three kinds with async edges.
  14. +
  15. FlowGraphView — fourth lane, pills, async/spawn connectors.
  16. +
  17. Step detail — Lifecycle tab, request/response labels.
  18. +
  19. Page — refresh pending outcomes after a Live run.
  20. +
  21. CLI, specs, rig demo + verification step I.
  22. +
+
+
+

Not in this slice

+

Retry / Discard on a parked node (Site Calls and the Notification report own those actions), deep links by operation id into those pages, and per-attempt request diffs. Each is a register row, not a blocker.

+

Repo plan: docs/plans/2026-09-06-flow-debugger-outbound-calls.md.

+
+
+
+ + diff --git a/docs/plans/2026-09-06-flow-debugger-outbound-calls.md b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md new file mode 100644 index 00000000..ab574d7f --- /dev/null +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md @@ -0,0 +1,235 @@ +# Flow Debugger — Outbound Calls (API · Database · Notification) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Show every `ExternalSystem.*`, `Database.*` and `Notify.Send` a traced flow makes as first-class nodes on a fourth **External** swimlane, in both Live and Replay, with a cached call's or notification's later delivery lifecycle folded into the node instead of drawn as four separate steps. + +**Architecture:** No new audit rows for the site side — the outbound rows already exist in the execution tree (`ApiOutbound` / `DbOutbound` / `Notification` channels, stamped with the run's `ExecutionId`); the gap is that neither the live `FlowTraceCollector` nor the replay query ever includes them. The site runtime routes those rows through the collector it already holds, the replay filter widens to the three channels, and `FlowGraphBuilder` groups a cached operation's rows by `CorrelationId` into one step carrying a `Lifecycle` list. The Flow Debugger page keeps a Live run's pending nodes fresh by re-reading the tree through the existing Replay command until every node is terminal or a bounded window closes. One real audit gap is closed on the way: central method scripts' `Database.*` calls (`InboundDatabaseHelper`) are unaudited today and get `DbOutbound` rows. + +**Tech Stack:** .NET 10, Akka.NET site runtime, `ZB.MOM.WW.Audit` `AuditEvent`, Blazor Server custom SVG (`FlowGraphView`), Roslyn syntax predictor, xunit + NSubstitute + bUnit. + +**Mockup:** `docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html` (published as a Claude artifact: https://claude.ai/code/artifact/712697e3-c9c9-42ce-80fc-d05065c183bb). Design origin: `docs/plans/2026-09-05-flow-debugger-design.md` §4 / §7.2; this plan is an addendum to it. + +--- + +## Why (the gap, verified 2026-09-06) + +- `FlowStepProjector.ResolveKind` already maps `ApiOutbound` / `DbOutbound` / `Notification` rows to `FlowStepKind.External`, and `FlowShapePredictor` already emits `External` nodes for `ExternalSystem.Call/CachedCall`, `Database.*`, `Notify.Send`. So the **prediction** shows them. +- **Live** never returns them: `ScriptRuntimeContext.EmitTrace` (`ScriptRuntimeContext.cs:363-392`) is the only feeder of `_traceCollector`, and it is called for `AttributeWrite` / `AttributeWait` only (the `ScriptRun` row is added by `ScriptRunLauncher`). The outbound rows are built at `ScriptRuntimeContext.cs:1286` (ES `CachedSubmit`), `:1404` (`ApiCallCached` Attempted), `:1472` (`CachedResolve`), `:1651` (sync `ApiCall`), `:2015` / `:2067` (DB cached rows), `:2518` (`NotifySend`) and in `AuditingDbCommand.cs:479` (sync `DbWrite`) — all go straight to the site `IAuditWriter`. +- **Replay** filters the tree to `Channel == ScriptTrace || (ApiInbound && InboundRequest)` (`AuditLogRepository.cs` `GetTraceEventsAsync`, the `.Where` right after the recursive CTE). +- Result today: after a run, every predicted outbound node is left as a **ghost** ("predicted, never happened") even when the call ran. Worse than omitting it. +- Central method scripts have a `Database` helper (`InboundScriptExecutor.cs:554`, `InboundDatabaseHelper.cs`) that writes **no audit row at all** — a real audit-scope gap (`Component-AuditLog.md` says outbound DB is in scope), independent of the debugger. + +## Row inventory (what the tree already holds per script call) + +| Script call | Rows (channel / kind / status) | Written by | `CorrelationId` | +|---|---|---|---| +| `ExternalSystem.Call` (sync) | `ApiOutbound` / `ApiCall` / `Delivered` or `Failed`; request + response summaries, `DurationMs` | site, inline | null | +| `ExternalSystem.CachedCall` | `CachedSubmit`/`Submitted` → `ApiCallCached`/`Forwarded` or `Attempted` (per retry) → `CachedResolve`/`Delivered`·`Failed`·`Parked`·`Discarded` | site: submit inline, the rest from the S&F forwarder **minutes later** | `TrackedOperationId` | +| `Database.Query/Execute` (sync) | `DbOutbound` / `DbWrite` / `Delivered`·`Failed`; `Extra = {op, rowsAffected|rowsReturned}` | site `AuditingDbCommand` | null | +| `Database.CachedWrite` | `CachedSubmit` → `DbWriteCached`/`Attempted` → `CachedResolve` | site, as above | `TrackedOperationId` | +| `Notify.Send` | site `Notification` / `NotifySend` / `Submitted` (target = list name) → central `NotifyDeliver` / `Attempted`, `Delivered`·`Parked`·`Discarded` | site inline, then `NotificationOutboxActor` (central, `executionId: notification.OriginExecutionId`) | `NotificationId` | +| central `Database.*` (method script) | **none today** → Task 4 adds `DbOutbound` / `DbWrite` | central `ICentralAuditWriter` | null | + +## Decisions + +| # | Decision | Rationale / rejected alternative | +|---|---|---| +| D1 | **Three kinds replace `External`:** `FlowStepKind.ApiCall`, `DbCall`, `Notify` (eyebrows `API CALL`, `DATABASE`, `NOTIFY`). `External` is removed (internal projection enum; nothing serializes `FlowStep`). | Distinct kinds make the predicted↔real overlay match by kind, and the eyebrow tells the operator what it is without reading the title. | +| D2 | **Fourth lane `FlowLane.External`** (index 3; sublabel = the distinct targets touched, e.g. `Delmia · MesDb · Planners`). | The lane *is* the boundary: like Tags, it holds actors outside ScadaBridge. Putting the node in the caller's lane would hide that a hop left the system. Design v1 said "three lanes fixed"; this is the v1.1 amendment. | +| D3 | **One node per operation.** Rows sharing `(Channel, CorrelationId)` fold into one `FlowStep`; a sync row (null correlation) is a group of one. `OccurredAtUtc` = first row; `DurationMs` = sync row's own, or submit→terminal wall time for cached; `Outcome` from the **latest** row: `Delivered`→`Ok`, `Failed`→`Failed`, `Parked`/`Discarded`→**new `FlowStepOutcome.Parked`**, anything non-terminal→**new `FlowStepOutcome.Pending`**. New additive `FlowStep.Lifecycle : IReadOnlyList?` (`EventId, Kind, Status, OccurredAtUtc, Node, ErrorMessage, DurationMs`) drives a detail-panel tab. | Four boxes per cached call would triple the graph width and say nothing the status pill doesn't. | +| D4 | **Sync calls sit in the execution chain; cached/notify branch off it.** The builder's chain edge skips a step whose kind is `Notify` or whose first row is `CachedSubmit`, and instead adds an edge from the preceding step to it flagged **`Async`** (new `FlowEdge.Async`, dashed). | A script blocks on a sync call, so in-chain is truthful; `CachedCall`/`Notify.Send` return immediately, so the run continues without them — same fork semantics the graph already uses for trigger spawns. | +| D5 | **Live returns what the run emitted; the page refreshes the rest.** Site: the outbound rows built in `ScriptRuntimeContext`/`AuditingDbCommand` pass through `_traceCollector.Add` before the audit writer (same as `EmitTrace`). Central: the inbound DB rows go through the `RouteHelper` trace sink already wired in `FlowDebugRunner.cs:106`. Later lifecycle rows (Forwarded/Attempted/Resolve/NotifyDeliver) are not in the reply; after a Live run whose graph has any `Pending` step, the page polls **the existing Replay command** every 5 s for up to 2 min and merges, and offers `Refresh outcomes` manually. | No new management command, no site push. Replay already returns the whole tree. `PollInterval`/`PendingRefreshWindow` are page constants, not options — nothing operational depends on them. | +| D6 | **Replay filter widens** to `ScriptTrace ∪ ApiOutbound ∪ DbOutbound ∪ Notification ∪ (ApiInbound & InboundRequest)`. Site-side `PullTraceEvents` (`SqliteAuditWriter.ReadByExecutionIdsAsync`) filters by execution id only — verify, no change expected. | Rows are already in the tree with the right ids; this is the one-line half of the fix. | +| D7 | **Central `Database.*` gets audited** — `InboundDatabaseHelper` takes an optional `Action` and an `ICentralAuditWriter`, emits `DbOutbound`/`DbWrite` with the request's `ExecutionId`, `Extra = {op, rows…}`, SQL as `RequestSummary` (post-redaction rules as the site's). | Closes an audit-scope gap; the debugger merely makes it visible. | +| D8 | **Predictor** emits the three kinds on the `External` lane with titles `Delmia.AckRecipe` / `MesDb · CachedWrite` / `Notify Planners`, and its edge to a cached/notify node is `Async`. | Keeps the overlay honest: a predicted async branch renders like a real one. | +| D9 | **Spawn edges get their own style, and Response links from the inbound execution's last step** (the question raised on 2026-09-06 about the "two parallel paths"). New `FlowEdge.Spawn` (edge from a parent step to a child execution's first step that is *not* a route call) renders with a small ⚡ marker; `Response` is linked from the last step whose `ExecutionId == inbound.ExecutionId` (the `RouteCall`), not from the globally last step. | The fork was real (a write both continued the run and fired a trigger) but unreadable; the Response edge from a cascaded write was simply wrong. | + +Out of scope (deferred, note in register): Retry/Discard actions on a parked node (Site Calls page owns that); a `?correlationId=` deep link on the Site Calls / Notification report pages; per-attempt request/response diff. + +## Node anatomy (all three kinds) + +``` +┌──────────────────────────────┐ +│ API CALL 212 ms │ eyebrow · duration (sync) or "+4.1 s" (submit→delivered) +│ Delmia.AckRecipe │ title = target (System.Method / Connection / List) +│ ● 200 · sync │ pill = latest status; "sync" | "cached" | "notify" +└──────────────────────────────┘ +pills: Ok (green) Pending (amber, pulsing ring while the page polls) Parked (amber, solid) Failed (red) +``` + +--- + +## Task 0: Commons — kinds, lane, outcomes, lifecycle row + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (everything depends on it) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs` (enums at lines 4-40; record `FlowStep` at ~56-73) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs:8` (`FlowEdge`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowStepProjectorTests.cs` (exists — extend) + +**Contract:** `FlowLane` gains `External = 3`. `FlowStepKind`: remove `External`, add `ApiCall`, `DbCall`, `Notify` (keep `Unknown`). `FlowStepOutcome` gains `Pending`, `Parked`. New `public sealed record FlowLifecycleRow(Guid EventId, string Kind, string Status, DateTimeOffset OccurredAtUtc, string? Node, string? ErrorMessage, int? DurationMs)`. `FlowStep` gains a trailing optional positional `IReadOnlyList? Lifecycle = null`. `FlowEdge` gains trailing `bool Async = false, bool Spawn = false`. Every existing constructor call compiles unchanged (defaults). + +**Steps:** write the failing tests (enum members exist; `new FlowStep(...)` without `Lifecycle` still compiles; `new FlowEdge(a, b, false, false)` has `Async == false`), run `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~FlowStep`, implement, re-run, commit — `git commit -am "feat(commons): Flow Debugger outbound kinds, External lane, Pending/Parked outcomes, FlowLifecycleRow"`. + +## Task 1: Commons — `FlowStepProjector` maps outbound rows + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs` (`ResolveKind` ~47-70, `ResolveLane` ~73-79, `BuildSubtitle` ~93-110) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowStepProjectorTests.cs` + +**Contract:** `ResolveKind`: `ApiOutbound` → `ApiCall`, `DbOutbound` → `DbCall`, `Notification` → `Notify` (any kind in those channels). `ResolveLane`: the three kinds → `FlowLane.External`. Title = `evt.Target` (unchanged rule). `BuildSubtitle`: `ApiCall` → `"{status} · sync"` for `AuditKind.ApiCall`, `"{status} · cached"` otherwise; `DbCall` → `"{op} · {rowsAffected|rowsReturned} rows"` from `Extra` when present, else `"{status} · cached"`; `Notify` → `"{status}"`. `ResolveOutcome` for the three kinds: `Delivered`→`Ok`, `Failed`→`Failed`, `Parked`/`Discarded`→`Parked`, `Submitted`/`Forwarded`/`Attempted`→`Pending`. `InputJson` = `RequestSummary`, `OutputJson` = `ResponseSummary` (already). + +**Tests (one per row shape, use `ScadaBridgeAuditEventFactory.Create`):** sync `ApiCall` Delivered → `ApiCall`/External/Ok/"Delivered · sync"; `CachedSubmit` in `DbOutbound` → `DbCall`/Pending/"Submitted · cached"; `DbWrite` with `extra {"op":"write","rowsAffected":3}` → "write · 3 rows"; `NotifySend` Submitted → `Notify`/Pending; `CachedResolve` Parked → `Parked`. Commit — `git commit -am "feat(commons): project outbound API/DB/notification rows onto the External lane"`. + +## Task 2: Commons — `FlowGraphBuilder` groups operations, async branches, spawn edges, Response origin + +**Classification:** high-risk (graph contract every surface reads) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs` (whole `Build`; rules doc-comment 10-24) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowGraphBuilderTests.cs` (exists — extend) + +**Contract (new rule order):** +0. **Fold.** Before ordering, group projected steps of kind `ApiCall`/`DbCall`/`Notify` by `(Channel-derived kind, CorrelationId)`; null correlation = singleton. The folded step takes the **first** row's `EventId`/`OccurredAtUtc`/`InputJson`/`Title`, the **latest** row's `Outcome`/`Subtitle`/`OutputJson`/`ErrorMessage`, `DurationMs` = first row's when sync, else `(latest.OccurredAt − first.OccurredAt)` when latest is terminal, else null; `Lifecycle` = all rows ordered by time. `ExecutionId` = first row's (the script's). +1. Chain within execution **skipping** async steps (kind `Notify`, or first lifecycle row kind `CachedSubmit`); for each skipped step add `FlowEdge(prev → step, Async: true)` where `prev` is the chain step immediately before it in time. +2. Spawner link unchanged, but the edge is `Spawn: true` when the spawner is not a `RouteCall`. +3. Satisfied-wait echo edge unchanged (`External: true`). +4. Response: linked from the last step with `ExecutionId == inbound.ExecutionId` and kind ≠ Response (falls back to global last when none). +`KindRank`: `ApiCall`/`DbCall`/`Notify` = 5 (same slot `External` had). + +**Tests:** (a) an ES cached call's 4 rows → one `ApiCall` step, `Lifecycle.Count == 4`, `Outcome == Ok`, `DurationMs == submit→resolve ms`, an `Async` edge from the preceding write, chain edge write→next write skips it; (b) a sync `ApiCall` row is in-chain (write → api → write) with no `Async` edge; (c) `NotifySend` + central `NotifyDeliver Parked` → one `Notify` step `Parked`; (d) submit-only → `Pending`, `DurationMs == null`; (e) Delmia fixture: spawn edge from the clearing write to the second simulator run is `Spawn: true`, the continuation edge is not; (f) Response edge starts at the `RouteCall`, not the last cascaded write; (g) existing builder tests still pass. Commit — `git commit -am "feat(commons): FlowGraphBuilder folds outbound lifecycles into one node; async/spawn edges; Response from the inbound execution"`. + +## Task 3: Site runtime — outbound rows flow through the trace collector + +**Classification:** high-risk (script runtime hot path) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (`EmitTrace` 363-392 — extract a `private void Collect(AuditEvent evt) => _traceCollector?.Add(evt);`; call it where the rows at 1286, 1404, 1472, 1651, 2015, 2067, 2518 are handed to the writer/telemetry) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbConnection.cs:147` and `AuditingDbCommand.cs:70-90, 479` — add an optional `Action? onAudit` ctor parameter threaded from the context's `Collect` +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (exists — extend) + +**Contract:** With a collector present, each of the seven emission sites adds the **same** `AuditEvent` instance to the collector that it hands to the audit writer / telemetry (no duplicate construction). With no collector (untraced run, the production default) behaviour is byte-identical. The collector cap (`MaxTraceEventsPerRun` 500) applies unchanged. Rows the S&F forwarder writes later (Forwarded/Attempted/Resolve on a *different* thread after the run returned) MUST NOT touch the collector: guard with the collector's existing `Completed` flag (add it if absent — `ScriptRunLauncher` sets it when it snapshots `TraceEvents`). + +**Tests:** sync `ExternalSystem.Call` (stub gateway) → collector holds `ScriptRun`+`ApiCall`; `CachedCall` → holds `CachedSubmit` only, and a simulated later resolve does not add; `Database.Execute` via `AuditingDbCommand` with a fake connection → `DbWrite` collected; `Notify.Send` → `NotifySend` collected; no collector → writer still receives every row. Commit — `git commit -am "feat(site-runtime): outbound API/DB/notification audit rows ride the flow-trace collector"`. + +## Task 4: Central — audit `InboundDatabaseHelper` (`DbOutbound`) and feed the trace sink + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs` (ctor :52, `QuerySingleAsync` :67, `QueryAsync` :83, `ExecuteAsync` :113) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:384` (construct the helper with `executionId`, `parentExecutionId`, the request's method name as actor context, `ICentralAuditWriter` from DI, and the route's trace sink) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs:163` (expose the sink so the executor can pass the same delegate) +- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs` (new) + +**Contract:** Each helper call writes one `AuditChannel.DbOutbound` / `AuditKind.DbWrite` row via `ICentralAuditWriter` (best-effort, never throws into the script), `Status` `Delivered`/`Failed`, `Target = connectionName`, `Actor = "api:{methodName}"`, `ExecutionId`/`ParentExecutionId` from the request, `DurationMs`, `RequestSummary` = SQL + parameters (same 8 KB cap and redaction as the site's `AuditingDbCommand` — reuse `AuditPayloadPolicy` if the site one is shared; otherwise mirror the cap constant), `Extra = {"op":"read"|"write","rows…":n}`, `SourceNode` from `INodeIdentityProvider`. When a trace sink is present the same instance is passed to it first. Spec line for `Component-AuditLog.md` is in Task 10. + +**Tests:** `ExecuteAsync` writes a `DbWrite` Delivered row with `rowsAffected`; a throwing gateway writes `Failed` with the error and rethrows to the script; audit writer faulting does not fail the call; sink receives the same instance. Commit — `git commit -am "feat(inbound-api): audit central method-script Database.* calls (DbOutbound) and feed the flow-trace sink"`. + +## Task 5: Central replay filter + site pull verification + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 4 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs` (`GetTraceEventsAsync`, the `.Where(r => r.Channel == AuditChannel.ScriptTrace || …)` after the CTE) +- Verify only: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs:861` `ReadByExecutionIdsAsync` (no channel predicate — add a test pinning that) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceTests.cs` (exists from Task 12 of the base plan — extend), `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionIdsTests.cs` (extend) + +**Contract:** replay returns rows whose channel ∈ {`ScriptTrace`, `ApiOutbound`, `DbOutbound`, `Notification`} plus the `InboundRequest` row, in `(OccurredAtUtc, EventId)` order; `SecuredWrite`/`Cluster`/`InboundAuthFailure` still excluded. Site pull returns an `ApiOutbound` row stored under the execution id. + +Commit — `git commit -am "feat(audit): replay/pull include outbound API, DB and notification rows"`. + +## Task 6: `FlowShapePredictor` — three kinds on the External lane, async edges + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 7, Task 8 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs:380-402` (the `ExternalSystem`/`Database`/`Notify` branches; `PredictedEdge` gains `Async`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs` + +**Contract:** `ExternalSystem.Call(s, m, …)` → `ApiCall`, lane External, title `"{s}.{m}"` (input-dependent when either is not a literal); `CachedCall` same kind, note `"cached"`, incoming edge `Async`; `Database.Execute/Query/QuerySingle(conn, …)` → `DbCall`, title `"{conn} · {member}"`; `Database.CachedWrite` → `DbCall`, note `"cached"`, `Async`; `Notify.Send(list, …)` → `Notify`, title `"Notify {list}"`, `Async`. `PredictedEdge(From, To, CrossesBoundary, Async = false)`; an `Async` edge does not advance the chain (the next node links from the previous non-async node), mirroring D4. + +**Tests:** a site script with `var r = await ExternalSystem.CallAsync("Delmia","AckRecipe",…); await Database.CachedWriteAsync("MesDb", …); await Notify.SendAsync("Planners", …);` predicts `ScriptRun → ApiCall(in chain) ; Async→ DbCall ; Async→ Notify`, all lane External. Commit — `git commit -am "feat(script-analysis): predict outbound calls as ApiCall/DbCall/Notify on the External lane"`. + +## Task 7: `FlowGraphView` — External lane, status pills, async/spawn edge styles, pending state + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 6, Task 8 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor` (lane loop `for lane < 3` → `LaneCount`; node body: add a status pill row for the three kinds; edge classes), `FlowGraphView.razor.cs` (`LaneCount = 4`; `Eyebrow` for the three kinds; sublabel for External = distinct `Target`s joined with ` · `, clipped; `EdgeClass` adds `async`, `spawn`; `NodeClass` adds `pending`, `parked`; `MarkerFor` returns `url(#fg-arrow-spawn)` for spawn edges), `FlowGraphView.razor.css` (`.fg-edge.async { stroke-dasharray: 5 4 }`, `.fg-edge.spawn` marker path with a small bolt, `.fg-pill` ok/pending/parked/failed colours from `--bs-success/warning/danger`, `.fg-node.pending .fg-pill { animation: fg-pulse 1.6s ease-in-out infinite }` guarded by `prefers-reduced-motion`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs` + +**Contract:** Height = `4 × LaneHeight + LanePad` (the de-DE test's pinned `242.2` at zoom 0.7 becomes `319.2`). A `Response` step is placed at `max(all other columns) + 1` regardless of its predecessor's column, so linking it from the route call (D9) does not drag it back to column 2 while the site work sits to its right. Existing `External` dashed style is kept for the echo edge; `async` shares the dash but not the class. The lane sublabel for External lists up to three targets then `+n`. + +**Tests:** four `g.fg-lane`; an `ApiCall` step lands in lane 3 with eyebrow `API CALL` and a pill `Ok`; a `Pending` step has classes `pending` and the pill text `Pending`; `Async` edge has class `async`, `Spawn` edge uses the spawn marker; de-DE height `319.2`. Commit — `git commit -am "feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors"`. + +## Task 8: `FlowStepDetail` — Lifecycle tab and outbound fields + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 6, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor` (tabs list; new `Lifecycle` tab rendered only when `Step.Lifecycle is { Count: > 0 }`; *Where this ran* gains `System / Connection / List` (= `Target`) and `Delivered by` (= the terminal row's `Node`); Input tab labels `Request` (API), `SQL + parameters` (DB), `Subject + body` (Notify)) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs` (exists — extend) + +**Contract:** Lifecycle table columns `time · kind · status · node · error`, newest last, `data-test="flow-step-lifecycle"`; a `Parked` step shows a hint line "Retry or discard from Site Calls / Notification report" (text only, links are deferred). + +Commit — `git commit -am "feat(central-ui): FlowStepDetail Lifecycle tab + outbound request/response labels"`. + +## Task 9: Flow Debugger page — pending refresh after a Live run + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on 7, 8) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (status strip: `Pending n` count + `Refresh outcomes` button `data-test="flow-refresh"`, a small "outcomes still arriving — refreshing" note `data-test="flow-pending-note"` while polling), `FlowDebugger.razor.cs` (`RunAsync` ~353: after a successful run with any `Outcome == Pending` step start `RefreshPendingAsync()` — loop: `IFlowDebugService.ReplayAsync(root)`; if the returned graph has no Pending step or `PendingRefreshWindow` (2 min) elapsed, stop; else delay `PendingRefreshInterval` (5 s); merges by replacing `_graph`/`_raw` and keeping `_selected` by `EventId`; cancelled by a new run, a mode switch or `Dispose`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` + +**Contract:** Polling never runs in Replay mode; the manual button works in both modes; the state chip stays `Completed` (pending outbound work is not a failed run); the run's own Steps count updates as rows fold in. + +**Tests (inject a fake delay via an internal `Func` as `FlowDebugRunner` does):** a Live outcome with one Pending step triggers `ReplayAsync(root)` calls until a graph with `Ok` returns; a graph with no Pending step never polls; `flow-refresh` click calls `ReplayAsync` once. Commit — `git commit -am "feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run"`. + +## Task 10: CLI projection, docs, rig demo + verification + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs:85-115` (fold rows by correlation like the builder — reuse `FlowGraphBuilder.Build` and project steps instead of raw events; add `lifecycle` column = `n rows`), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (`### flow-debug` table columns), test `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs` +- Modify: `docs/requirements/Component-CentralUI.md` (Flow Debugger bullet: four lanes, node anatomy, refresh), `Component-AuditLog.md` (scope table: central method-script DB calls now audited; Flow Debugger reads the three outbound channels), `Component-InboundAPI.md` (`Database.*` audit row), `Component-ScriptAnalysis.md` (REQ-SA-7 kinds), `docs/plans/2026-09-05-flow-debugger-design.md` (status line: "v1.1 outbound calls — see 2026-09-06 plan"), `CLAUDE.md` (Flow Debugger bullet: External lane + pending refresh), `docs/deferred-work-register.md` (row: Retry/Discard from the node + `?correlationId=` deep links deferred) +- Create: `docker/seed-flow-outbound-demo.sh` — a `Call` script `OutboundDemo` on `SimMachine` (site-a) that does `ExternalSystem.Call` against the rig's `scadabridge-restapi` (register an External System `RigRest` first), `Database.CachedWrite` to `scadabridge-mssql` (a `RigDb` connection, `CREATE TABLE IF NOT EXISTS FlowDemo`), `Notify.Send("FlowDemo")` (a list pointing at the rig SMTP), plus an inbound method `FlowOutboundDemo` routing to it and a scoped key +- Modify: `docker/verify-delmia-notifier.sh` — step **I**: `flow-debug run-method` on `FlowOutboundDemo` asserts kinds ⊇ {`ApiCall`,`DbCall`,`Notify`} inline and `Pending` for the two async ones; then `flow-debug replay` retried ≤ 90 s until both are terminal (`Delivered`); prints `I outbound live+replay -> YES`; `docker/README.md` one line + +**Steps:** implement, `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, per-project tests, `bash docker/deploy.sh`, seed the demo, run the drill, paste the I line into the commit — `git commit -am "feat(flow-debugger): outbound calls on the External lane — CLI projection, specs, rig demo + verification step I"`. + +--- + +## Verification (whole plan) + +1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx -nologo -v q` clean. +2. `dotnet test` per project green (Playwright excluded). +3. Rig: `DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh` → H and I both `YES`. +4. Browser: `/deployment/flow-debugger?executionId=` shows four lanes, the API call in-chain with `Ok · sync`, the DB write and notification hanging off the script run on dashed edges, both pills going amber→green within ~10 s of load (or after `Refresh outcomes`), and the Lifecycle tab listing Submitted → Forwarded → Delivered. 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 new file mode 100644 index 00000000..1279add0 --- /dev/null +++ b/docs/plans/2026-09-06-flow-debugger-outbound-calls.md.tasks.json @@ -0,0 +1,100 @@ +{ + "planPath": "docs/plans/2026-09-06-flow-debugger-outbound-calls.md", + "tasks": [ + { + "id": 0, + "subject": "Task 0: Commons \u2014 kinds, lane, outcomes, lifecycle row", + "status": "pending" + }, + { + "id": 1, + "subject": "Task 1: Commons \u2014 FlowStepProjector maps outbound rows", + "status": "pending", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 2: Commons \u2014 FlowGraphBuilder groups operations, async branches, spawn edges, Response origin", + "status": "pending", + "blockedBy": [ + 0, + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: Site runtime \u2014 outbound rows flow through the trace collector", + "status": "pending", + "blockedBy": [ + 0 + ] + }, + { + "id": 4, + "subject": "Task 4: Central \u2014 audit InboundDatabaseHelper (DbOutbound) and feed the trace sink", + "status": "pending", + "blockedBy": [ + 0 + ] + }, + { + "id": 5, + "subject": "Task 5: Central replay filter + site pull verification", + "status": "pending", + "blockedBy": [ + 0 + ] + }, + { + "id": 6, + "subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges", + "status": "pending", + "blockedBy": [ + 0 + ] + }, + { + "id": 7, + "subject": "Task 7: FlowGraphView \u2014 External lane, status pills, async/spawn edge styles, pending state", + "status": "pending", + "blockedBy": [ + 0, + 1, + 2 + ] + }, + { + "id": 8, + "subject": "Task 8: FlowStepDetail \u2014 Lifecycle tab and outbound fields", + "status": "pending", + "blockedBy": [ + 0, + 2 + ] + }, + { + "id": 9, + "subject": "Task 9: Flow Debugger page \u2014 pending refresh after a Live run", + "status": "pending", + "blockedBy": [ + 7, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: CLI projection, docs, rig demo + verification", + "status": "pending", + "blockedBy": [ + 3, + 4, + 5, + 6, + 9 + ] + } + ], + "lastUpdated": "2026-09-06T05:32:15.141670+00:00" +}