diff --git a/CLAUDE.md b/CLAUDE.md index 987bb300..1dbb4cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,7 +196,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc. - UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components. - Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via `SiteStreamGrpcClient`, snapshot via the `SiteCommandService` query surface — `QueryReply.DebugViewSnapshot`). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR. -- **Flow Debugger (2026-09-05, Deployment → `/deployment/flow-debugger` + CLI `flow-debug run-method|run-value-change|replay`).** Runs one entry point (an inbound API method, or a value change on an instance attribute) and draws every step as a Central / Site / Tags / **External** swimlane; *Live* really runs it, *Replay* rebuilds a past execution. **v1.1 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`):** every `ExternalSystem.*` / `Database.*` / `Notify.Send` a traced run makes is an `ApiCall` / `DbCall` / `Notify` node on the External lane — the site routes those already-existing `ApiOutbound` / `DbOutbound` / `Notification` rows through the same inline trace collector, the replay query widens to those channels, and `FlowGraphBuilder` folds a cached operation's rows by `(kind, CorrelationId)` into ONE node with a `Lifecycle` (sync calls in-chain, cached/notify on dashed `Async` edges, trigger cascades on `Spawn` edges, the Response linked from the inbound execution's last step). Later lifecycle rows are not inline, so after a Live run the page polls the Replay command (5 s, ≤ 2 min) while any node is `Pending`, plus a *Refresh outcomes* button. **A refresh MERGES, never replaces (2026-09-06, found live in the browser):** the page unions the re-read rows into the run's own rows by `EventId` and rebuilds the graph, because a Live run carries its site rows inline while the same rows reach central's audit store by telemetry ~5–30 s later — a Replay read taken in between lacks them, and the old wholesale swap made an already-drawn node (a resolved cached DB write) vanish until the next manual refresh. The inline rows ARE the audited rows (`FlowTraceCollector` sinks the same `AuditEvent` objects the site writer persists), so the union is exact. Central method-script `Database.*` calls (`InboundDatabaseHelper`) are now audited (`DbOutbound`/`DbWrite`) — they were not. Rig demo: `docker/seed-flow-outbound-demo.sh` + `verify-delmia-notifier.sh` step I. **Trace steps ARE audit events** — `AuditChannel.ScriptTrace` with kinds `RouteCall` (central, **always-on** per `Route.To().Call`, joins the two halves of a Replay) and `ScriptRun` / `AttributeWrite` / `AttributeWait` (site, **gated** by a `bool Trace` riding beside `ParentExecutionId` on `RouteToCallRequest` / `ScriptCallRequest` / `SetStaticAttributeCommand` / `AttributeValueChanged`; proto fields additive, `site_command.proto` imports `sitestream.proto`). A Live run gets the site rows **inline** on `RouteToCallResponse.TraceEvents` (+ `ChildExecutionId`) because the 5 s telemetry cadence is too slow; Replay reads them from `AuditLog` (`GetTraceEventsAsync`). A value-change on a data-sourced attribute is a REAL tag write; the Instance Actor's **trace arm** (`SiteRuntime:TraceArmWindowSeconds`, 30) stamps the device echo with the writer's execution id so the triggered run is parented and traced, and central polls the site's new `PullTraceEvents` RPC (point read, not reconciliation) for it. Execution path is three management commands on the **per-node `ManagementActor`** (`FlowDebug*`, Deployer role) — a standby refuses the two run commands with **`STANDBY_NODE`** (shown verbatim); `ApiMethod.TraceAllCalls` (migration `AddApiMethodTraceAllCalls`, CLI `--trace-all-calls`) traces every real call to a method for later Replay (7–10 rows/call). Prediction before a run is a Roslyn syntax walk (`FlowShapePredictor`, REQ-SA-7, DB-free); the graph (`FlowStep`/`FlowGraphBuilder`, Commons) is a central projection, never a wire type; the renderer is custom SVG (`FlowGraphView`). Rig: `AuditLog:PerChannelRetentionDays:ScriptTrace=30` on both centrals; `verify-delmia-notifier.sh` step H proves live + replay. **Mixed-version caveat:** an older site ignores the flag → central `RouteCall` only, page says *site not traced*. **v1.2 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-followups.md`):** a `Parked` External node offers **Retry / Discard in place** (same site-scope check, confirm and relay as the report pages, via `IFlowDebugService.RetryOutboundAsync`/`DiscardOutboundAsync`, then the page re-polls Replay until the node leaves Parked) and every folded node deep-links to `/site-calls/report?correlationId=` / `/notifications/report?correlationId=` (both pages accept it through additive `TrackedOperationIdFilter` / `NotificationIdFilter` query params and auto-open the row); register row 111 closed. **Trap closed the same day:** `FlowDebugService.PredictMethodAsync` used to resolve route targets with `GetAwaiter().GetResult()` inside the synchronous predictor walk — on the Blazor renderer sync context that DEADLOCKED the whole circuit (page inert, healthy websocket, no error) whenever a method routing to a site script was chosen; now a resolve-and-rewalk loop, pinned by a single-thread-pump test. Never block on a task from a Blazor event/lifecycle path. Browser checks of the page go through Playwright (`FlowDebuggerTests`, fixture login + an ephemeral method), not the login-disabled override. **Arch-reviewed 2026-09-06** (`docs/plans/2026-09-06-flow-debugger-arch-review.md`, remediation `docs/plans/2026-09-06-flow-debugger-arch-review-remediation.md`): the inline Live trace copy is now redacted and capped by the shared `FlowTraceCollector` (500 events **and** 1 MB of `DetailsJson`, F1), a method-entry Live run enforces the operator's site scope at route time (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED` → `ManagementUnauthorized`, F2), and the page/Replay opened to the `OperationalAudit` roles under the new `RequireFlowDebugger` policy while everything that executes stays Deployer-only (F3). Design `docs/plans/2026-09-05-flow-debugger-design.md`; plan `docs/plans/2026-09-05-flow-debugger.md`. +- **Flow Debugger (2026-09-05, Deployment → `/deployment/flow-debugger` + CLI `flow-debug run-method|run-script|run-value-change|replay`).** Runs one entry point (an inbound API method, or a site script) and draws every step as a Central / Site / Tags / **External** swimlane; *Live* really runs it, *Replay* rebuilds a past execution. **v1.1 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`):** every `ExternalSystem.*` / `Database.*` / `Notify.Send` a traced run makes is an `ApiCall` / `DbCall` / `Notify` node on the External lane — the site routes those already-existing `ApiOutbound` / `DbOutbound` / `Notification` rows through the same inline trace collector, the replay query widens to those channels, and `FlowGraphBuilder` folds a cached operation's rows by `(kind, CorrelationId)` into ONE node with a `Lifecycle` (sync calls in-chain, cached/notify on dashed `Async` edges, trigger cascades on `Spawn` edges, the Response linked from the inbound execution's last step). Later lifecycle rows are not inline, so after a Live run the page polls the Replay command (5 s, ≤ 2 min) while any node is `Pending`, plus a *Refresh outcomes* button. **A refresh MERGES, never replaces (2026-09-06, found live in the browser):** the page unions the re-read rows into the run's own rows by `EventId` and rebuilds the graph, because a Live run carries its site rows inline while the same rows reach central's audit store by telemetry ~5–30 s later — a Replay read taken in between lacks them, and the old wholesale swap made an already-drawn node (a resolved cached DB write) vanish until the next manual refresh. The inline rows ARE the audited rows (`FlowTraceCollector` sinks the same `AuditEvent` objects the site writer persists), so the union is exact. Central method-script `Database.*` calls (`InboundDatabaseHelper`) are now audited (`DbOutbound`/`DbWrite`) — they were not. Rig demo: `docker/seed-flow-outbound-demo.sh` + `verify-delmia-notifier.sh` step I. **Trace steps ARE audit events** — `AuditChannel.ScriptTrace` with kinds `RouteCall` (central, **always-on** per `Route.To().Call`, joins the two halves of a Replay) and `ScriptRun` / `AttributeWrite` / `AttributeWait` (site, **gated** by a `bool Trace` riding beside `ParentExecutionId` on `RouteToCallRequest` / `ScriptCallRequest` / `SetStaticAttributeCommand` / `AttributeValueChanged`; proto fields additive, `site_command.proto` imports `sitestream.proto`). A Live run gets the site rows **inline** on `RouteToCallResponse.TraceEvents` (+ `ChildExecutionId`) because the 5 s telemetry cadence is too slow; Replay reads them from `AuditLog` (`GetTraceEventsAsync`). A value-change on a data-sourced attribute is a REAL tag write; the Instance Actor's **trace arm** (`SiteRuntime:TraceArmWindowSeconds`, 30) stamps the device echo with the writer's execution id so the triggered run is parented and traced, and central polls the site's new `PullTraceEvents` RPC (point read, not reconciliation) for it. Execution path is four management commands on the **per-node `ManagementActor`** (`FlowDebug*`; the three run commands are Deployer-only, Replay is wider) — a standby refuses the three run commands with **`STANDBY_NODE`** (shown verbatim); `ApiMethod.TraceAllCalls` (migration `AddApiMethodTraceAllCalls`, CLI `--trace-all-calls`) traces every real call to a method for later Replay (7–10 rows/call). Prediction before a run is a Roslyn syntax walk (`FlowShapePredictor`, REQ-SA-7, DB-free); the graph (`FlowStep`/`FlowGraphBuilder`, Commons) is a central projection, never a wire type; the renderer is custom SVG (`FlowGraphView`). Rig: `AuditLog:PerChannelRetentionDays:ScriptTrace=30` on both centrals; `verify-delmia-notifier.sh` step H proves live + replay. **Mixed-version caveat:** an older site ignores the flag → central `RouteCall` only, page says *site not traced*. **v1.2 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-followups.md`):** a `Parked` External node offers **Retry / Discard in place** (same site-scope check, confirm and relay as the report pages, via `IFlowDebugService.RetryOutboundAsync`/`DiscardOutboundAsync`, then the page re-polls Replay until the node leaves Parked) and every folded node deep-links to `/site-calls/report?correlationId=` / `/notifications/report?correlationId=` (both pages accept it through additive `TrackedOperationIdFilter` / `NotificationIdFilter` query params and auto-open the row); register row 111 closed. **Trap closed the same day:** `FlowDebugService.PredictMethodAsync` used to resolve route targets with `GetAwaiter().GetResult()` inside the synchronous predictor walk — on the Blazor renderer sync context that DEADLOCKED the whole circuit (page inert, healthy websocket, no error) whenever a method routing to a site script was chosen; now a resolve-and-rewalk loop, pinned by a single-thread-pump test. Never block on a task from a Blazor event/lifecycle path. Browser checks of the page go through Playwright (`FlowDebuggerTests`, fixture login + an ephemeral method), not the login-disabled override. **Arch-reviewed 2026-09-06** (`docs/plans/2026-09-06-flow-debugger-arch-review.md`, remediation `docs/plans/2026-09-06-flow-debugger-arch-review-remediation.md`): the inline Live trace copy is now redacted and capped by the shared `FlowTraceCollector` (500 events **and** 1 MB of `DetailsJson`, F1), a method-entry Live run enforces the operator's site scope at route time (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED` → `ManagementUnauthorized`, F2), and the page/Replay opened to the `OperationalAudit` roles under the new `RequireFlowDebugger` policy while everything that executes stays Deployer-only (F3). Design `docs/plans/2026-09-05-flow-debugger-design.md`; plan `docs/plans/2026-09-05-flow-debugger.md`. **v1.3 (2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`):** the second entry point is site → instance → **script**; ValueChange/Conditional scripts are fired by writing the trigger's attribute (derived from the trigger config, not picked), every other kind is a direct traced `RouteToCall` with a `ParameterValueForm` from the script's `ParameterDefinitions` and one operator-attributed `RouteCall` root row (`FlowDebugRunScriptCommand`, Deployer-only, long-running); `run-value-change` stays as the attribute-level CLI escape hatch (fires Expression triggers). - Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval. - Dead letter monitoring as a health metric. - Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search. **The cap measures `site_events` + its four indexes, NOT the consolidated LocalDb file** (register row 81, 2026-08-17): whole-file measurement made the trim unwinnable on a replicated node — each batch freed event pages while adding an equal count of oplog rows + tombstones to the same file, so it stopped 2.5x over a 64 MB cap and a node with a down peer could not enforce the cap at all. `dbstat` (the exact per-table measurement) is **NOT compiled into the bundled `SQLitePCLRaw.lib.e_sqlite3`** — probed and pinned by `SqliteCapabilityTests`, so don't reach for it — the footprint is modelled from sampled row width instead (−0.8%…+12.8%, biased high). Consequence: `MaxStorageMb` no longer bounds the data volume alone; LocalDb bookkeeping is separate overhead bounded by `MaxOplogRows`/`TombstoneRetention` and is deliberately not this component's job. Decision: `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`. diff --git a/docker/verify-delmia-notifier.sh b/docker/verify-delmia-notifier.sh index 219d7990..8fed7a74 100755 --- a/docker/verify-delmia-notifier.sh +++ b/docker/verify-delmia-notifier.sh @@ -5,7 +5,7 @@ set -euo pipefail # crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's # graceful handover) + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and # DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh). FLOW_ONLY=1 skips A-G (no -# notifier build, no docker kill) and runs the Flow Debugger steps H and I alone. +# notifier build, no docker kill) and runs the Flow Debugger steps H, H2 and I alone. # # HISTORY (2026-09-02): the first run of this script found that inbound API keys were a PER-NODE # SQLite store, so the key minted through the active node did not exist on the standby and every @@ -32,6 +32,10 @@ set -euo pipefail # G reject path (DownloadPath *.bad) -> NO + "Simulated reject" # H Flow Debugger live run + replay -> YES (flow-debug run-method returns the traced # site steps inline; flow-debug replay rebuilds the same chain from the central audit store) +# H2 Flow Debugger SCRIPT entry (2026-09-08) -> YES (flow-debug run-script calls $MACHINE's +# OutboundDemo Call script directly — no inbound method in front of it — and the reply leads with +# the operator-attributed RouteCall root, then the site's inline ScriptRun. Skipped with a note +# when the instance is not seeded.) # I Flow Debugger OUTBOUND calls (2026-09-06) -> YES (FlowOutboundDemo — seed-flow-outbound-demo.sh — # returns ApiOutbound + DbOutbound + Notification rows inline: the sync ApiCall Delivered, the cached # DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the @@ -77,7 +81,7 @@ for n in a b; do done if [ "${FLOW_ONLY:-}" = 1 ]; then - echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H and I only" + echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H, H2 and I only" else echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES @@ -142,6 +146,31 @@ done echo " replay: $H_REPLAY" echo "H live+replay -> YES" +# H2 — Flow Debugger SCRIPT entry (design 2026-09-08): flow-debug run-script invokes $MACHINE's +# OutboundDemo Call script directly through the traced RouteToCall path — no inbound method in front +# of it. The reply's first row is the operator-attributed RouteCall root written centrally, followed +# by the site's inline ScriptRun (and this script's outbound rows). OutboundDemo declares no +# ParameterDefinitions (seed-flow-outbound-demo.sh passes no --parameters; it reads an optional +# WorkOrder and defaults it), so no --params is needed. +echo "== H2 Flow Debugger script entry (direct call)" +H2_INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next((i["id"] for i in xs if i.get("uniqueName")==sys.argv[1]), ""))' "$MACHINE") +if [ -z "$H2_INSTANCE_ID" ]; then + echo "H2 script entry -> SKIPPED ($MACHINE not seeded — run docker/seed-delmia-sim.sh)" +else + set +e; H2_RUN=$("$CLI" flow-debug run-script --instance-id "$H2_INSTANCE_ID" --script OutboundDemo "${H_ARGS[@]}"); H2_RC=$?; set -e + [ "$H2_RC" -eq 0 ] || { echo "FAIL: flow-debug run-script exited $H2_RC: $H2_RUN"; exit 1; } + H2_OK=$(echo "$H2_RUN" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +kinds = [json.loads(e["detailsJson"]).get("kind") or "?" for e in r.get("events", [])] +ok = r.get("completed") is True and kinds[:1] == ["RouteCall"] and "ScriptRun" in kinds +print(("YES" if ok else "NO") + " root=" + str(r.get("rootExecutionId")) + " kinds=" + ",".join(kinds) + + (" error=" + str(r.get("errorCode")) if r.get("errorCode") else "")) +sys.exit(0 if ok else 1)') || { echo "FAIL: run-script incomplete: $H2_OK"; exit 1; } + echo " live: $H2_OK" + echo "H2 script entry (direct call) -> YES" +fi + # I — Flow Debugger outbound calls (plan 2026-09-06). The demo script makes a sync ExternalSystem.Call, # a Database.CachedWrite and a Notify.Send. Live: the site returns the rows it emitted inline — the # sync ApiCall is already Delivered, the cached write and the notification are Submitted (pending). diff --git a/docs/plans/2026-09-05-flow-debugger-design.md b/docs/plans/2026-09-05-flow-debugger-design.md index be5fb658..d1d0d22b 100644 --- a/docs/plans/2026-09-05-flow-debugger-design.md +++ b/docs/plans/2026-09-05-flow-debugger-design.md @@ -23,7 +23,7 @@ on the `DelmiaReceiver` child → seven attribute writes → wait for Galaxy to | # | Decision | Rejected | |---|----------|----------| | D1 | **Two modes: Live and Replay.** Live is a real run with real values, real writes, audited like any inbound call. Replay executes nothing — it rebuilds the flow from an existing ExecutionId's trace rows. | *Dry run* (execute scripts but capture writes/ES/DB/Notify instead of sending). Needs an interception seam in the site runtime that does not exist and would fork the semantics of every helper. | -| D2 | **Two entry points in v1:** an Inbound API method, and a value-change-triggered site script on a named instance. Both may land on a site script on instance X. | Alarm/timer trigger simulation, direct "call script X" — see §11. | +| D2 | **Two entry points in v1:** an Inbound API method, and a value-change-triggered site script on a named instance. Both may land on a site script on instance X. | Alarm/timer trigger simulation, direct "call script X" — see §11. Superseded by `2026-09-08-flow-debugger-script-entry-design.md` (site → instance → script) as the page's second entry point. | | D3 | **Value-change on a data-sourced attribute does a REAL tag write** through the instance actor's normal write path; the device echo fires the trigger. Correlation is by a short-lived *trace arm* on the instance actor (§5.3). | A debug-only "inject value" command that synthesises the change without touching the device — leaves the instance's cached value diverged from the device until the next real update. | | D4 | **Live gets its trace inline; Replay reads audit rows.** Site audit telemetry forwards on a 5 s busy / 30 s idle cadence (`AuditLog:SiteTelemetry`), too slow to drive an interactive run, so the routed reply carries the site steps back and the same steps are *also* written as audit rows for Replay. | Live waiting on the audit pipeline. | | D5 | **Tracing is gated, not always-on.** Sites run scripts at high rates from timers and value changes; a trace flag rides beside `ParentExecutionId` and only flagged runs emit trace rows. One exception is always-on: a central `RouteCall` row per `Route.To().Call` (inbound-driven volume, joins the two halves of a Replay). | Tracing every run (volume), or a purely time-window join. | @@ -55,6 +55,8 @@ Reference the mockup's two full-fidelity boards; this section only records what **Toolbar.** Entry point (`Inbound API method` | `Value-change trigger`) + a picker for the method or the instance + script; Mode (`Live` | `Replay`); `Edit inputs`, `Load from audit…`, `Run`. +Superseded by `2026-09-08-flow-debugger-script-entry-design.md`: the second entry point is +site → instance → script, not site → instance → attribute. **Status strip.** State chip (`Not run` / `Running` / `Completed` / `Failed` / `Timed out`), run number, wall-clock and node, total duration, counts (steps, boundaries crossed, attribute writes), @@ -77,6 +79,9 @@ instance, composed child, connection, attribute kind, source line) with `Open in `ParameterDefinitions` (type, required) or, for a value-change entry, one field for the trigger attribute's new value plus read-only current value/quality. `Fill from last real call` pre-fills from the most recent `InboundRequest` row for that method. +Superseded by `2026-09-08-flow-debugger-script-entry-design.md`: the script entry point's Inputs +card is a `ParameterValueForm` from the script's own `ParameterDefinitions` for call kinds, and the +single value field (attribute derived from the trigger, not picked) for write kinds. **Replay.** `Load from audit…` takes an ExecutionId (or arrives via a new `Open in Flow Debugger` action on the Audit Log drawer and the Execution Chain page). The graph is built from trace rows diff --git a/docs/plans/2026-09-08-flow-debugger-script-entry-design.md b/docs/plans/2026-09-08-flow-debugger-script-entry-design.md new file mode 100644 index 00000000..27a60c06 --- /dev/null +++ b/docs/plans/2026-09-08-flow-debugger-script-entry-design.md @@ -0,0 +1,194 @@ +# Flow Debugger — Script Entry Point (design) + +**Date:** 2026-09-08 +**Status:** approved (brainstorm 2026-09-08), implementation plan follows +**Supersedes:** the *Value-change trigger* entry of `2026-09-05-flow-debugger-design.md` §4/§5.3 as the +page's second entry point. §5.3's trace arm and the `FlowDebugRunValueChangeCommand` wire record are +unchanged. + +## 1. What changes + +The Flow Debugger's second entry point selects **site → instance → script** instead of +site → instance → attribute. + +- A script whose trigger is **ValueChange or Conditional** names one monitored attribute in its + trigger configuration. The debugger writes that attribute (the operator supplies only the new + value) and waits for the run the change spawns — exactly today's value-change behaviour, with the + attribute derived from the script rather than picked. +- Every other script (**Call, Interval, Expression, or no trigger**) is invoked **directly** through + the traced routed-call path that `Route.To(instance).Call(script, args)` already uses. The Inputs + card shows a parameter form generated from the script's own `ParameterDefinitions`, the same way + the inbound-API method entry does. + +The decision on trigger kinds was taken in the brainstorm: Conditional joins ValueChange on the +write side because both name a single attribute; Expression joins the call side because it names +none, and a direct call is the only honest single-step run of it. The attribute-level CLI command +`flow-debug run-value-change` stays as the escape hatch for firing an Expression trigger through a +real attribute write. + +## 2. Script resolution + +Both the page and the runner resolve an instance's scripts through the existing +`IFlatteningPipeline.FlattenAndValidateAsync(instanceId, validateScripts: false)` (DeploymentManager, +already referenced by both CentralUI and ManagementService — Create Instance's binding preview uses +the same pipeline). `FlattenedConfiguration.Scripts` gives `ResolvedScript` rows carrying the +**canonical name** (so composed-module scripts appear as `Module.Script`, the name the site's +`InstanceActor` keys on), the effective `TriggerType`/`TriggerConfiguration`, and the effective +`ParameterDefinitions`/`ExecutionTimeoutSeconds` after inheritance and instance overrides. + +The runner resolves the script itself and never trusts the client's claim of trigger kind: the +branch below is taken on the server-side `ResolvedScript`. + +A new pure Commons helper, `ScriptTriggerAttribute.TryRead(triggerType, configJson, out attribute)` +(`Commons/Types/Scripts/`), returns the monitored attribute for `ValueChange`/`Conditional` +(keys `attributeName` then `attribute`, matching `ScriptTriggerConfigCodec` and SiteRuntime's +`TriggerRouting.TryReadAttributeName`) and false for every other kind. `FlowDebugService.IsValueChangeOn` +is rewritten on top of it. SiteRuntime's private copy is left alone — it also serves alarms and +fail-open routing, a different contract. + +**Design vs deployed.** The site runs the last deployed snapshot. A script that exists in design but +was never deployed fails at the site with its existing `Script 'X' not found on instance 'Y'` error, +which the debugger surfaces verbatim; the page does not try to detect staleness (the Instances page +already shows it). + +## 3. Wire and command changes (additive) + +```csharp +/// Flow Debugger Live run of a site script by name (design 2026-09-08). +public record FlowDebugRunScriptCommand( + int InstanceId, + string ScriptName, // canonical name from the flattened configuration + JsonElement? Parameters, // call kinds: flat JSON object validated against the script's ParameterDefinitions + string? Value, // write kinds: the new value for the trigger's attribute + int WaitSeconds = 45); // write kinds: NO_TRIGGER_WITHIN_WINDOW window +``` + +- Registered in `ManagementEndpoints`' command type list beside the other two run commands. +- `ManagementActor.GetRequiredRoles`: `DeployerOnly`, same as `FlowDebugRunMethodCommand` / + `FlowDebugRunValueChangeCommand`. Replay roles unchanged. +- `FlowDebugRunValueChangeCommand` is **kept** (CLI `run-value-change`, additive-only contracts). + `FlowDebugRunResult` is unchanged. +- No proto change: the direct call rides the existing `RouteToCallRequest.Trace` / + `RouteToCallResponse.TraceEvents` fields. + +## 4. Runner (`FlowDebugRunner.RunScriptAsync`) + +1. `EnsureActiveNode`; load instance (`NOT_FOUND`); `EnforceSiteScope(user, instance.SiteId)`; load site. +2. Flatten; find `ResolvedScript` by canonical name, case-insensitive (`NOT_FOUND` with the message + `Script 'X' is not defined on instance 'Y'.`). +3. **Write kinds** (`ScriptTriggerAttribute.TryRead` succeeds): `Value` is required + (`VALIDATION_FAILED`); call the shared private helper `WriteAndPollAsync(sp, user, instance, site, + attribute, value, waitSeconds, ct)` — the body of today's `RunValueChangeAsync` from the traced + `RouteToSetAttributesAsync` onward, extracted unchanged. `RunValueChangeAsync` becomes a thin caller + of the same helper so both commands share one code path and one set of tests. +4. **Call kinds**: `ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef)` + with `SchemaRefResolver.BuildAsync` over the shared-schema repository, exactly as `RunMethodAsync` + (`VALIDATION_FAILED` on failure; null definitions + null parameters is valid and sends no + parameters). Then: + - `root = Guid.NewGuid()`; `timeout = script.ExecutionTimeoutSeconds ?? SiteRuntime default 30 s` + (the runner has no site options — it uses a `DefaultScriptTimeoutSeconds = 30` constant, documented + as mirroring `SiteRuntimeOptions.ScriptExecutionTimeoutSeconds`). + - `IInstanceRouter.RouteToCallAsync(site.SiteIdentifier, new RouteToCallRequest(correlationId, + instance.UniqueName, script.CanonicalName, validation.Parameters, now, ParentExecutionId: root, + Trace: true), linked token cancelled after timeout + RunGrace)`. A cancellation is reported as + `TIMEOUT` like the method path. + - Synthesize the **root row**: `AuditChannel.ScriptTrace` / `AuditKind.RouteCall`, status by + `response.Success`, actor = operator, target = `{instance}.{script}`, `executionId = root`, + `sourceSiteId`/`sourceInstanceId`, `durationMs`, `requestSummary` = the parameters JSON, + `responseSummary` = the return value serialized with `ScriptTraceExtra.JsonOptions`, + `errorMessage`, `extra = ScriptTraceExtra { Source = "flow-debugger" }`. Written through + `WriteCentralAsync`; the returned copy is `redactor.Apply`'d (review follow-up rule). + - The site's `response.TraceEvents` are pushed through a `FlowTraceCollector(redactor:)` so the + inline copy is redacted and capped (arch-review F1), then `Complete()`d. + - Result: `[redactedRoot] + collector events`, in **emission order** (F5: no re-sort across clocks), + `Completed = response.Success`, `ErrorCode = response.Success ? null : "CALL_FAILED"`, + `Truncated = collector.Truncated`. + +Not using `RouteHelper` here is deliberate: it would emit its own always-on `RouteCall` row with no +operator actor, giving the graph two roots. Calling `IInstanceRouter` directly mirrors how the +write path calls `RouteToSetAttributesAsync` and keeps one synthesized, operator-attributed root. + +`FlowGraphBuilder` already treats the first row of the root execution as the chain root; a +`RouteCall`-kind root with `ScriptRun` children parented to it renders Central → Site exactly like the +routed leg of a method run. The plan pins this with a builder test. + +## 5. Central UI + +**`IFlowDebugService`** (UI-internal, not a wire contract): + +```csharp +sealed record FlowScriptOption(string CanonicalName, string? TriggerType, string? TriggerAttribute, + string? ParameterDefinitions, bool IsWriteKind); +Task> GetInstanceScriptsAsync(int instanceId, CancellationToken ct = default); +Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken ct = default); +Task RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, + string? value, CancellationToken ct = default); +``` + +`PredictValueChangeAsync` / `RunValueChangeAsync` are removed from the UI service (their only caller +was the page); `PredictScriptAsync` delegates to the value-change prediction for write kinds (with +the trigger's attribute) and, for call kinds, builds a `RouteCall` root on the Central lane +(`{instance}.{script}`) followed by `FlowShapePredictor.PredictSiteScript`. `RunScriptAsync`'s Ask +timeout is `RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30)` for call kinds and +`ValueChangeAskTimeoutFor(45)` for write kinds. The script list is cached per instance for the life +of the scoped service (one flatten per selection). + +**Page (`FlowDebugger.razor(.cs)`).** + +- Entry button label: **Site script** (`data-test="flow-entry-script"`, replacing + `flow-entry-value-change`; `EntryKind.ValueChange` → `EntryKind.Script`). +- Pickers: Site, Instance, **Script** (`data-test="flow-script-select"`); each option renders + `Name (ValueChange: Level)` / `(Conditional: Level)` / `(Call)` / `(Interval)` / `(Expression)` / + `(no trigger)`. +- Inputs card: write kinds → the existing single field, labelled *New value for ``* with + the attribute read-only (`flow-input-value`); call kinds → the shared `ParameterValueForm` bound to + the option's `ParameterDefinitions` (`data-test="flow-script-params"`), or the note *This script + takes no parameters* when the definitions are empty. `ParameterValueForm` hands back + `Dictionary`, serialized to a `JsonElement` for the command. +- `CanRun`: script chosen and (write kind → any value entered; call kind → form has no parse errors). +- Query parameters: `?instanceId=` and `?script=`; `?attribute=` is removed (nothing outside the page + links with it). Inputs persistence keys become `flow-debugger.script.{instanceId}.{script}`. +- `FlowStepDetail`'s `SiteDbId`/`InstanceDbId` pass-through keys on `EntryKind.Script`. +- The status strip, refresh loop, Retry/Discard and Replay are untouched. + +## 6. CLI + +`flow-debug run-script --instance-id --script [--params ''] [--value ] +[--wait-seconds 45]` → `FlowDebugRunScriptCommand`, same client timeout rule as `run-method` +(`--timeout-seconds`, default 120) because the server may run a call script up to its execution +timeout + grace. `run-value-change` stays, its help text noting it is the attribute-level path (fires +Expression triggers) and that `run-script` is the script-level one. + +## 7. Security and audit + +Unchanged posture: Deployer-only, active node only, site scope enforced from the instance before any +site traffic, the root row is written centrally against the operator's name whether the call +succeeded or not, inline trace copies are redacted and capped. A direct call of a script is a real +run with real side effects — the page's existing Live-run wording already says so. + +## 8. Tests + +- `FlowDebugRunnerTests`: write-kind script → `RouteToSetAttributesAsync` with the trigger's attribute; + call-kind script → `RouteToCallAsync` with `Trace: true`, parent = root, parameters validated; + unknown script → `NOT_FOUND`; missing `Value` on a write kind → `VALIDATION_FAILED`; bad parameters + → `VALIDATION_FAILED`; out-of-scope site refused; root row redacted; `CALL_FAILED` on a site + failure; `TIMEOUT` on cancellation. Existing `RunValueChange_*` tests keep passing through the + shared helper. +- `ScriptTriggerAttributeTests` (Commons): both key spellings, both kinds, case-insensitive type, + malformed JSON → false. +- `FlowGraphBuilderTests`: a `RouteCall` root with a `ScriptRun` child builds Central → Site. +- `RequiredRoleMatrixTests` + `ManagementEndpoints` registration test for the new command. +- `FlowDebugServiceTests`: script list from a flattened configuration, write/call classification, + prediction shapes, Ask timeout selection. +- `FlowDebuggerPageTests` (bUnit): script picker renders options with trigger annotations; Inputs card + switches between value field and `ParameterValueForm`; `CanRun` rules; `?script=` deep link. +- `FlowDebugCommandsTests` (CLI): `run-script` parsing incl. `--params` JSON errors. +- Playwright `FlowDebuggerTests`: Live run of the rig's `OutboundDemo` Call script on `DelmiaSim01`. +- `docker/verify-delmia-notifier.sh` step H gains a `run-script` leg against `OutboundDemo`. + +## 9. Documents to update + +`2026-09-05-flow-debugger-design.md` §4 (pointer to this doc), `Component-CentralUI.md` (Flow +Debugger entry points), `Component-InboundAPI.md` (FlowDebug commands table), +`src/ZB.MOM.WW.ScadaBridge.CLI/README.md`, CLAUDE.md Flow Debugger bullet, and the umbrella index +row's Flow Debugger sentence. diff --git a/docs/plans/2026-09-08-flow-debugger-script-entry.md b/docs/plans/2026-09-08-flow-debugger-script-entry.md new file mode 100644 index 00000000..bbfd0f2e --- /dev/null +++ b/docs/plans/2026-09-08-flow-debugger-script-entry.md @@ -0,0 +1,1218 @@ +# Flow Debugger Script Entry Point Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the Flow Debugger's value-change entry (site → instance → attribute) with a script entry (site → instance → script): ValueChange/Conditional scripts keep the write-and-poll path with the attribute derived from the trigger; every other script is invoked directly through the traced routed-call path with a parameter form from its `ParameterDefinitions`. + +**Architecture:** One new additive management command `FlowDebugRunScriptCommand` handled by `FlowDebugRunner.RunScriptAsync`, which resolves the script through `IFlatteningPipeline` (canonical names, effective trigger + parameters) and branches server-side on the trigger kind: write kinds reuse the extracted `WriteAndPollAsync` body of today's value-change run; call kinds route `RouteToCallRequest(Trace: true)` via `IInstanceRouter` and return one operator-attributed `RouteCall` root row plus the site's redacted/capped inline trace rows. The Central UI service exposes the instance's scripts (`FlowScriptOption`), a script prediction and a script run; the page swaps the attribute picker for a script picker and renders either the single value field or the shared `ParameterValueForm`. The CLI gains `flow-debug run-script`; `run-value-change` and its command record stay. + +**Tech Stack:** .NET 10, Blazor Server (bUnit tests), Akka.NET ManagementActor, System.CommandLine CLI, NSubstitute + xUnit, Playwright against the docker rig. + +**Design:** `docs/plans/2026-09-08-flow-debugger-script-entry-design.md` (approved 2026-09-08). + +**Branch:** `feat/flow-debugger-script-entry` (from `main` @ `789377ef`; design commit `47535f34`). + +**Execution notes for the orchestrator** + +- Worktree-isolated agents start at `main`: every agent prompt begins with `git reset --hard ` and only then confirms HEAD (see memory `agent-worktree-isolation-base`). +- Agents commit on their own `worktree-agent-*` branch and report the sha; the orchestrator merges into the feature branch in the main checkout. Never commit in the main checkout from a parallel agent. +- Commit trailer on every commit: `Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29`. +- Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (0 warnings expected). Tests: `dotnet test --filter "FullyQualifiedName~"`. +- Model per task: `opus` for high-risk (runner, actor/roles, page), `sonnet` for small/trivial; the merged diff gets an `opus` `code-reviewer` pass before the gate. + +**Waves (no file overlap inside a wave)** + +| Wave | Tasks | Notes | +|---|---|---| +| W1 | 1, 2, 3, 6, 7 | helper, command record, runner refactor, builder pin, form callback | +| W2 | 4 | runner `RunScriptAsync` (needs 1, 2, 3) | +| W3 | 5, 8, 10 | actor/roles/endpoints (needs 4); UI service (needs 1, 2); CLI (needs 2) | +| W4 | 9 | page (needs 7, 8) | +| W5 | 11, 12 | Playwright + verify script leg; docs | +| W6 | 13 | gate: build, full tests, rig deploy, verify, browser | + +--- + +### Task 1: `ScriptTriggerAttribute` helper (Commons) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 2, Task 3, Task 6, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs` (create; the `Scripts/` folder may not exist yet — create it) + +**Step 1: Write the failing tests** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts; + +/// Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger. +public class ScriptTriggerAttributeTests +{ + [Theory] + [InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")] + [InlineData("valuechange", """{"attribute":"Level"}""", "Level")] + [InlineData("Conditional", """{"attributeName":"Level","operator":">","threshold":"5"}""", "Level")] + [InlineData("CONDITIONAL", """{"attribute":"Flag"}""", "Flag")] + public void WriteKinds_ReturnTheMonitoredAttribute(string kind, string config, string expected) + { + Assert.True(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Equal(expected, attribute); + Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); + } + + [Theory] + [InlineData("Call", """{"attributeName":"Level"}""")] + [InlineData("Interval", """{"intervalMs":1000}""")] + [InlineData("Expression", """{"expression":"true"}""")] + [InlineData(null, null)] + [InlineData("", "")] + public void OtherKinds_ReturnFalse_EvenWhenTheConfigNamesAnAttribute(string? kind, string? config) + { + Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Null(attribute); + Assert.False(ScriptTriggerAttribute.IsWriteKind(kind)); + } + + [Theory] + [InlineData("ValueChange", null)] + [InlineData("ValueChange", "")] + [InlineData("ValueChange", "not json")] + [InlineData("ValueChange", "[1,2]")] + [InlineData("ValueChange", """{"attributeName":""}""")] + [InlineData("ValueChange", """{"other":"x"}""")] + public void WriteKind_WithoutAReadableAttribute_ReturnsFalse_ButIsStillAWriteKind(string kind, string? config) + { + Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Null(attribute); + Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); + } +} +``` + +**Step 2: Run to verify it fails** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~ScriptTriggerAttributeTests"` +Expected: build error `ScriptTriggerAttribute` not found. + +**Step 3: Implement** + +```csharp +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +/// +/// Reads the single monitored attribute of a ValueChange or Conditional script +/// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the +/// Central UI's ScriptTriggerConfigCodec writes (attributeName) and SiteRuntime's +/// TriggerRouting also accepts (attribute). Pure; never throws on bad JSON. +/// +public static class ScriptTriggerAttribute +{ + /// True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute. + public static bool IsWriteKind(string? triggerType) => + string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase) + || string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase); + + /// + /// Returns the monitored attribute for a write-kind trigger with a readable configuration; + /// false (and null) for every other kind, and for a write kind whose config is missing, + /// malformed, not an object, or names no attribute. + /// + public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute) + { + attribute = null; + if (!IsWriteKind(triggerType) || string.IsNullOrWhiteSpace(triggerConfigurationJson)) + return false; + try + { + using var doc = JsonDocument.Parse(triggerConfigurationJson); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + foreach (var key in new[] { "attributeName", "attribute" }) + { + if (root.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String + && v.GetString() is { Length: > 0 } name) + { + attribute = name; + return true; + } + } + return false; + } + catch (JsonException) + { + return false; + } + } +} +``` + +**Step 4: Run to verify it passes** + +Run: the Step 2 command. Expected: all pass. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs +git commit -m "feat(commons): ScriptTriggerAttribute — monitored attribute of a ValueChange/Conditional trigger (flow debugger script entry)" +``` + +--- + +### Task 2: `FlowDebugRunScriptCommand` record + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Task 1, Task 3, Task 6, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs` (after `FlowDebugRunValueChangeCommand`, line 25) + +**Step 1: Add the record** + +```csharp +/// +/// Flow Debugger Live run of a site script by name (design 2026-09-08). The runner resolves the +/// script's effective definition from the flattened instance configuration and branches on its +/// trigger kind: a ValueChange/Conditional script is fired by WRITING its monitored +/// attribute () and polling for the spawned run, exactly like +/// ; every other kind is invoked DIRECTLY through the +/// traced routed-call path with . Deployer role; refuses on a standby +/// with STANDBY_NODE. +/// +/// The instance the script belongs to. +/// Canonical script name from the flattened configuration (case-insensitive). +/// Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none. +/// Write kinds: canonical string value to write to the trigger's attribute. +/// Write kinds: how long to wait for a spawned run before NO_TRIGGER_WITHIN_WINDOW. +public record FlowDebugRunScriptCommand( + int InstanceId, + string ScriptName, + JsonElement? Parameters, + string? Value, + int WaitSeconds = 45); +``` + +**Step 2: Build** + +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons` +Expected: success, 0 warnings. + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs +git commit -m "feat(commons): FlowDebugRunScriptCommand — additive management command for the flow debugger script entry" +``` + +--- + +### Task 3: Extract `WriteAndPollAsync` from `RunValueChangeAsync` (runner refactor, no behaviour change) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 2, Task 6, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs:198-303` (`RunValueChangeAsync`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs` (existing `RunValueChange_*` tests must keep passing unchanged) + +**Step 1: Refactor** + +Split `RunValueChangeAsync` so that everything from the `root = Guid.NewGuid()` line through the final `return` moves into: + +```csharp +/// +/// The traced attribute write + spawned-run poll shared by and +/// the write-kind branch of (design 2026-09-08 §4.3). The caller has +/// already gated the active node, loaded and scope-checked the instance, and loaded the site. +/// is null for the attribute-level command and the script's +/// canonical name for the script entry — it only changes the NO_TRIGGER message. +/// +private async Task WriteAndPollAsync( + IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site, + string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct) +``` + +Body: identical to today's code with `cmd.AttributeName` → `attributeName`, `cmd.Value` → `value`, `cmd.WaitSeconds` → `waitSeconds`. The final error message becomes: + +```csharp +completed ? null : triggerScript is null + ? $"No traced run was spawned within {waitSeconds}s (no value-change trigger on '{attributeName}', or no device echo)." + : $"No traced run of '{triggerScript}' was spawned within {waitSeconds}s after writing '{attributeName}' (trigger condition not met, or no device echo).", +``` + +`RunValueChangeAsync` keeps its argument checks, `EnsureActiveNode`, instance load + `EnforceSiteScope` + site load, then `return await WriteAndPollAsync(sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct)`. + +Add `using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;` and `using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;` if not present. + +**Step 2: Run the existing tests** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~FlowDebugRunnerTests"` +Expected: all pass (no test changes). + +**Step 3: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs +git commit -m "refactor(management): FlowDebugRunner — extract WriteAndPollAsync from RunValueChangeAsync (no behaviour change)" +``` + +--- + +### Task 4: `FlowDebugRunner.RunScriptAsync` + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Tasks 1, 2, 3) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs` (new constants + method after `RunValueChangeAsync`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs` + +**Step 1: Write the failing tests** (append to `FlowDebugRunnerTests`; add `using ZB.MOM.WW.ScadaBridge.DeploymentManager;`, `using ZB.MOM.WW.ScadaBridge.Commons.Types;`, `using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;`, `using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;` as needed) + +```csharp +/// Wires instance 5 / site-a like plus a flattened configuration holding the given scripts. +private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline) + WireScript(params ResolvedScript[] scripts) +{ + var (templates, sites, puller) = WireValueChange(); + var pipeline = Substitute.For(); + var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts }; + pipeline.FlattenAndValidateAsync(5, Arg.Any(), false, null) + .Returns(Result.Success(new FlatteningPipelineResult(config, "hash", new ValidationResult()))); + _services.AddSingleton(pipeline); + return (templates, sites, puller, pipeline); +} + +private static ResolvedScript CallScript(string name, string? parameterDefinitions = null, int? timeout = null) => + new() { CanonicalName = name, Code = "return 1;", TriggerType = "Call", ParameterDefinitions = parameterDefinitions, ExecutionTimeoutSeconds = timeout }; + +private static ResolvedScript WriteScript(string name, string kind, string attribute) => + new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" }; + +[Fact] +public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls() +{ + var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "Conditional", "Level")); + RouteToSetAttributesRequest? write = null; + _router.RouteToSetAttributesAsync("site-a", Arg.Do(r => write = r), Arg.Any()) + .Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); + puller.PullAsync("site-a", Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => (IReadOnlyList)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection)ci[1]).First(), target: "OnLevel")]); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "onlevel", null, "7", WaitSeconds: 10), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.Equal("7", write!.AttributeValues["Level"]); + Assert.True(write.Trace); + Assert.Equal(AuditKind.AttributeWrite, result.Events[0].AsRow().Kind); + await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); +} + +[Fact] +public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite() +{ + WireScript(WriteScript("OnLevel", "ValueChange", "Level")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "OnLevel", null, null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); + await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); +} + +[Fact] +public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows() +{ + WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5)); + var child = Guid.NewGuid(); + var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + executionId: child, target: "Manual"); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("site-a", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow, child, [siteRun])); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.Null(result.ErrorCode); + Assert.True(captured!.Trace); + Assert.Equal("Pump1", captured.InstanceUniqueName); + Assert.Equal("Manual", captured.ScriptName); + Assert.Equal(result.RootExecutionId, captured.ParentExecutionId); + Assert.Equal(3L, Convert.ToInt64(captured.Parameters!["n"])); + + Assert.Equal(2, result.Events.Count); + var root = result.Events[0].AsRow(); + Assert.Equal(AuditKind.RouteCall, root.Kind); + Assert.Equal(result.RootExecutionId, root.ExecutionId); + Assert.Equal("ops", result.Events[0].Actor); + Assert.Equal("Pump1.Manual", result.Events[0].Target); + Assert.Equal("site-a", root.SourceSiteId); + Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(root.Extra)!.Source); + Assert.Equal(child, ScriptTraceExtra.TryParse(root.Extra)!.ChildExecutionId); + Assert.Contains("\"n\":3", root.RequestSummary); + Assert.Contains("42", root.ResponseSummary); + Assert.Same(siteRun, result.Events[1]); + Assert.Single(_central); // the root row was written centrally +} + +[Fact] +public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting() +{ + WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", Json("{}"), null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); + await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); + Assert.Empty(_central); +} + +[Fact] +public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRedactedRootRow() +{ + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "Script 'Manual' not found on instance 'Pump1'.", DateTimeOffset.UtcNow)); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode); + Assert.Contains("not found", result.Error); + Assert.Single(result.Events); + Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status); +} + +[Fact] +public async Task RunScript_UnknownScript_IsNotFound() +{ + WireScript(CallScript("Manual")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Nope", null, null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code); + Assert.Contains("Nope", ex.Message); +} + +[Fact] +public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening() +{ + var (_, _, _, pipeline) = WireScript(CallScript("Manual")); + var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" }); + await Assert.ThrowsAsync(() => Runner().RunScriptAsync( + _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), scoped, CancellationToken.None)); + await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); +} + +[Fact] +public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered() +{ + WireScript(CallScript("Manual")); + var redactor = Substitute.For(); + redactor.Apply(Arg.Any()).Returns(ci => ((AuditEvent)ci[0]) with { RequestSummary = "[redacted]" }); + _services.AddSingleton(redactor); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", Json("{\"secret\":\"x\"}"), null), Deployer, CancellationToken.None); + + Assert.Equal("[redacted]", result.Events[0].RequestSummary); +} +``` + +Check how the existing `RunValueChange_SynthesizedRootRow_IsRedacted_WhenARedactorIsRegistered` test (line ~405) constructs its redactor substitute and mirror that exactly (the `with` expression above assumes `AuditEvent` is a record — if it is not, copy the existing test's approach). + +**Step 2: Run to verify they fail** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~FlowDebugRunnerTests"` +Expected: build error — `RunScriptAsync` / `CallFailedCode` missing. + +**Step 3: Implement** + +Add constants: + +```csharp +/// Error code when a direct script call (design 2026-09-08 §4.4) fails at the site. +public const string CallFailedCode = "CALL_FAILED"; + +/// Error code when a Live run exceeds the method's / script's timeout plus . +public const string TimeoutCode = "TIMEOUT"; + +/// +/// Timeout for a direct script call whose script declares none — mirrors +/// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds's default (the runner has no site options). +/// +public const int DefaultScriptTimeoutSeconds = 30; +``` + +(Use `TimeoutCode` in the existing method-path literal `"TIMEOUT"` too.) + +Method: + +```csharp +/// +/// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective +/// definition from the flattened instance configuration and branches on its trigger kind — +/// write kinds go through , every other kind is a traced +/// with one operator-attributed +/// root row. +/// +public async Task RunScriptAsync( + IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct) +{ + ArgumentNullException.ThrowIfNull(sp); + ArgumentNullException.ThrowIfNull(cmd); + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(cmd.ScriptName)) + throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode); + EnsureActiveNode(sp); + + var instance = await sp.GetRequiredService() + .GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false) + ?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode); + EnforceSiteScope(user, instance.SiteId); + var site = await sp.GetRequiredService() + .GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false) + ?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode); + + // Design §2: the flattened configuration is the one place canonical names, effective triggers and + // effective parameter definitions all live (composition + inheritance + instance overrides applied). + var flattened = await sp.GetRequiredService() + .FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false); + if (flattened.IsFailure) + throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode); + var script = flattened.Value.Configuration.Scripts + .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode); + + if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType)) + { + if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute)) + throw new ManagementCommandException( + $"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.", ValidationFailedCode); + if (cmd.Value is null) + throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode); + return await WriteAndPollAsync(sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false); + } + + var resolveRef = await SchemaRefResolver.BuildAsync( + sp.GetService(), [script.ParameterDefinitions], ct).ConfigureAwait(false); + var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef); + if (!validation.IsValid) + throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode); + + var root = Guid.NewGuid(); + var startedAt = DateTimeOffset.UtcNow; + var sw = Stopwatch.StartNew(); + var redactor = sp.GetService(); + var collector = new FlowTraceCollector(redactor: redactor); + var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds)); + var router = sp.GetRequiredService(); + var request = new RouteToCallRequest( + Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName, + validation.Parameters.Count == 0 ? null : validation.Parameters, + DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true); + + RouteToCallResponse response; + using (var runCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + runCts.CancelAfter(timeout + RunGrace); + try + { + response = await router.RouteToCallAsync(site.SiteIdentifier, request, runCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + response = new RouteToCallResponse(request.CorrelationId, false, null, + $"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", DateTimeOffset.UtcNow); + } + } + var timedOut = response.ErrorMessage?.StartsWith("Flow Debugger run timed out", StringComparison.Ordinal) == true; + + foreach (var e in response.TraceEvents ?? []) + collector.Add(e); + var events = collector.Complete(); + + var rootRow = ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, AuditKind.RouteCall, + response.Success ? AuditStatus.Delivered : AuditStatus.Failed, + occurredAtUtc: startedAt.UtcDateTime, + actor: user.Username, + target: $"{instance.UniqueName}.{script.CanonicalName}", + sourceNode: sp.GetService()?.NodeName, + executionId: root, + sourceSiteId: site.SiteIdentifier, + sourceInstanceId: instance.UniqueName, + durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue), + errorMessage: response.ErrorMessage, + requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null, + responseSummary: response.ReturnValue is null ? null : JsonSerializer.Serialize(response.ReturnValue, ScriptTraceExtra.JsonOptions), + extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson()); + await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false); + + var ordered = new List(events.Count + 1) { redactor?.Apply(rootRow) ?? rootRow }; + ordered.AddRange(events); + return new FlowDebugRunResult( + root, response.Success, response.ErrorMessage, + response.Success ? null : timedOut ? TimeoutCode : CallFailedCode, + ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow); +} +``` + +Add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;`, `using ZB.MOM.WW.ScadaBridge.DeploymentManager;`. Check `ScadaBridgeAuditEventFactory.Create`'s actual parameter list in `Commons/Types/Audit/` (the method path uses `requestSummary`/`responseSummary`/`extra`; the value-change path uses `sourceSiteId`/`sourceInstanceId`) — all exist. Check `ParameterValidationResult.Parameters` is non-null on success (it is: defaults to an empty dictionary). + +**Step 4: Run to verify they pass** + +Run: the Step 2 command. Expected: all `FlowDebugRunnerTests` pass (old value-change tests included). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs +git commit -m "feat(management): FlowDebugRunner.RunScriptAsync — write-kind vs direct traced call by trigger kind (flow debugger script entry)" +``` + +--- + +### Task 5: ManagementActor dispatch, role matrix, long-running registration + +**Classification:** high-risk +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 8, Task 10 (needs Task 4) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:318-319` (roles) and `:419-421` (dispatch) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs:58-60` (`LongRunningCommands`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:~164` (XML remark listing the Deployer-only run commands) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs:152-154` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs:85` (`FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry` — add the new record to whatever list it asserts) + +**Step 1: Extend the role-matrix test** — add `["FlowDebugRunScript"] = [Roles.Deployer],` beside line 153, and the new type to the CLI registry test. Run both; expected: fail (unknown command / missing role entry). + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~RequiredRoleMatrixTests"` and `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~FlowDebugCommandsTests"`. + +**Step 2: Implement** + +- `ManagementActor.cs:318`: `FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugRunScriptCommand => DeployerOnly,` +- `ManagementActor.cs:420`: add `FlowDebugRunScriptCommand cmd => await sp.GetRequiredService().RunScriptAsync(sp, cmd, user, CancellationToken.None),` +- `ManagementEndpoints.cs:60`: add `typeof(FlowDebugRunScriptCommand)` with a comment `// script entry (design 2026-09-08): a direct call lasts up to the script's timeout (+ grace).` +- `AuthorizationPolicies.cs` remark: list all three run commands. +- If there is a management-command registry (grep `FlowDebugRunValueChangeCommand` across `src/` for any other list — e.g. a JSON polymorphic type map used by `/management`), add the new record there too; the CLI registry test in Step 1 is what catches a missing entry. + +**Step 3: Run tests** — both filters pass. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +git commit -m "feat(management): dispatch FlowDebugRunScriptCommand (Deployer-only, long-running) on the ManagementActor" +``` + +--- + +### Task 6: `FlowGraphBuilder` — a `RouteCall` root builds Central → Site + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 7 + +**Files:** +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs` +- Modify (only if the test fails): `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs` + +**Step 1: Write the test** + +```csharp +[Fact] +public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse() +{ + // Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an + // operator-attributed RouteCall row — there is no InboundRequest and no Response. + var root = Guid.NewGuid(); + var run = Guid.NewGuid(); + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root, + sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42", + extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a", + executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40, + extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()), + }; + + var graph = FlowGraphBuilder.Build(events); + + Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList()); + Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList()); + var edge = Assert.Single(graph.Edges); + Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.From, edge.To, edge.CrossesBoundary)); + Assert.DoesNotContain(graph.Steps, s => s.Kind == FlowStepKind.Response); +} +``` + +**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~FlowGraphBuilderTests"`. +Expected: pass as-is (the builder keys the root on the first row of the root execution and only synthesizes a Response from an `InboundRequest`). If it fails, fix the minimal builder rule and keep the existing tests green. Check `FlowStepProjector` gives a `RouteCall` row `FlowLane.Central` and the boundary flag from a `RouteCall → ScriptRun` edge (it does for method runs; nothing here should differ). + +**Step 3: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs # + builder if changed +git commit -m "test(commons): FlowGraphBuilder — a RouteCall root (flow debugger direct script call) builds Central → Site" +``` + +--- + +### Task 7: `ParameterValueForm.ValidChanged` (additive) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor:47-49` (parameters) and `:619-623` (`Emit`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ParameterValueFormTests.cs` (find the existing test class for this component with `grep -rl "ParameterValueForm" tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`; if none, create one in that folder with a minimal bUnit render) + +**Step 1: Write the failing test** + +```csharp +[Fact] +public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed() +{ + var valid = new List(); + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""") + .Add(x => x.ValidChanged, v => valid.Add(v))); + + var input = cut.Find("input[data-test='param-n']"); // use the component's actual per-field selector; read the razor to confirm + input.Input("abc"); + Assert.Equal(false, valid.Last()); + input.Input("5"); + Assert.Equal(true, valid.Last()); +} +``` + +Adjust the selector to the form's real markup (read the razor first; it keys inputs by canonical path). + +**Step 2: Run** — expected: build error `ValidChanged` missing. + +**Step 3: Implement** + +```csharp +/// +/// Raised after every edit with true when the value tree has no parse or validation errors +/// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing +/// callers ignore it. +/// +[Parameter] public EventCallback ValidChanged { get; set; } +``` + +and in `Emit()` after `Validate()`: `await ValidChanged.InvokeAsync(IsValid);` where `IsValid` is `_parseErrors.Count == 0 && .Count == 0` (read `Validate()` to name the second dictionary; if `Validate()` only populates `_parseErrors`, use that alone). + +**Step 4: Run** — pass. Also run the whole `ParameterValueForm`/`ApiMethodForm`/`TemplateScriptDialog` test classes to confirm nothing else changed. + +**Step 5: Commit** + +```bash +git commit -am "feat(central-ui): ParameterValueForm.ValidChanged — additive validity callback" +``` + +--- + +### Task 8: `IFlowDebugService` / `FlowDebugService` — script list, prediction, run + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5, Task 10 (needs Tasks 1, 2) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs:44-80` +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs` (ctor `:84-98`, `PredictValueChangeAsync` `:164-205`, `RunValueChangeAsync` `:213-217`, `IsValueChangeOn` `:370-395`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs` (`CreateService` `:~64-84`, `PredictValueChange_*` `:521-564`) + +**Step 1: Interface changes** + +Replace `PredictValueChangeAsync` and `RunValueChangeAsync` with: + +```csharp +/// +/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5): +/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter +/// definitions for call kinds, and the script's own execution timeout (client Ask sizing). +/// +public sealed record FlowScriptOption( + string CanonicalName, string? TriggerType, string? TriggerAttribute, + string? ParameterDefinitions, int? ExecutionTimeoutSeconds) +{ + /// True when the debugger fires this script by writing . + public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType); + + /// Picker label suffix: ValueChange: Level, Call, no trigger… + public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger" + : TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!; +} + +/// The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist. +Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default); + +/// Predicts the flow of running : the write + spawned script for a write kind, a RouteCall root + the script for a call kind. +Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default); + +/// Runs a script by name (write kinds take , call kinds ) and returns the flow. +Task RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default); +``` + +**Step 2: Write the failing tests** (replace the two `PredictValueChange_*` tests; add `IFlatteningPipeline _pipeline = Substitute.For()` to the fixture and pass it to the ctor in `CreateService`) + +```csharp +private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts) +{ + _templates.GetInstanceByIdAsync(instanceId, Arg.Any()) + .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 }); + var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts }; + _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null) + .Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult()))); +} + +[Fact] +public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() +{ + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 }, + new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }, + new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" }); + + var options = await CreateService(reply: null).GetInstanceScriptsAsync(9); + + Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList()); + var onFlag = options.Single(o => o.CanonicalName == "OnFlag"); + Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel); + var manual = options.Single(o => o.CanonicalName == "Manual"); + Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel); + Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); +} + +[Fact] +public async Task GetInstanceScripts_UnknownInstance_IsEmpty() +{ + Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); +} + +[Fact] +public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() +{ + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }); + + var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag"); + + Assert.Equal( + [ + (FlowStepKind.AttributeWrite, "Sim01.Flag"), + (FlowStepKind.ScriptRun, "Sim01.OnFlag"), + (FlowStepKind.AttributeWrite, "Ack"), + ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); +} + +[Fact] +public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() +{ + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" }); + + var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual"); + + Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind); + Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane); + Assert.Equal("Sim01.Manual", flow.Nodes[0].Title); + Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind); + Assert.True(flow.Edges[0].CrossesBoundary); +} + +[Fact] +public async Task PredictScript_UnknownScript_IsEmpty() +{ + WireFlattened(9, "Sim01"); + Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); +} + +[Fact] +public async Task RunScript_SendsTheCommand_WithTheScriptTimeoutBasedAskForCallKinds() +{ + WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); + var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + var svc = CreateService(reply: raw); + + var outcome = await svc.RunScriptAsync(9, "Manual", null, null); + + var env = Assert.Single(_received); + var cmd = Assert.IsType(env.Command); // adjust to the envelope's real property name + Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); + // Existing tests read the Ask timeout the same way the RunMethod timeout test does — copy that assertion pattern: + // expected = FlowDebugService.RunAskTimeoutFor(40). +} +``` + +Read the existing `RunMethod_*` service tests to copy how they assert the chosen Ask timeout (the scripted actor may capture it, or `SendAndBuildAsync` may be observable another way); if there is no such seam, assert only the command shape. + +**Step 3: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~FlowDebugServiceTests"` — expected: build errors. + +**Step 4: Implement** + +- Ctor: add `IFlatteningPipeline pipeline` (after `templates`); field `_pipeline`. Add a per-service cache `Dictionary> _scriptCache`. +- `GetInstanceScriptsAsync`: instance null → `[]`; flatten with `validateScripts: false`; failure → log Debug + `[]`; project each `ResolvedScript` → `FlowScriptOption(CanonicalName, TriggerType, ScriptTriggerAttribute.TryRead(...) ? attr : null, ParameterDefinitions, ExecutionTimeoutSeconds)`, ordered by `CanonicalName` (`StringComparer.OrdinalIgnoreCase`); cache. +- Keep a private `PredictValueChangeAsync(instance, attributeName, scripts)` body (today's code) but sourced from the **flattened** scripts rather than `CollectEffectiveScriptsAsync` — i.e. `IsValueChangeOn` becomes `ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) && a.Equals(attributeName, OrdinalIgnoreCase)` over `ResolvedScript`s. `CollectEffectiveScriptsAsync` stays for `PredictMethodAsync`'s route resolution (unchanged). +- `PredictScriptAsync`: resolve the option; null → `PredictedFlow.Empty`. Write kind → the value-change prediction with `option.TriggerAttribute` (if the attribute is unreadable, return a single `ScriptRun` node with note `trigger names no attribute`). Call kind → nodes `[new("n1", FlowStepKind.RouteCall, FlowLane.Central, $"{instance.UniqueName}.{script.CanonicalName}", false, null)]` + `FlowShapePredictor.PredictSiteScript(...)` re-numbered as the value-change code does, with edge `("n1", first sub node, CrossesBoundary: true)`. +- `RunScriptAsync`: `var option = (await GetInstanceScriptsAsync(...)).FirstOrDefault(name match)`; timeout = `option is { IsWriteKind: false } ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds) : ValueChangeAskTimeoutFor(45)`; `SendAndBuildAsync(new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value), timeout, ct)`. +- Delete the public `PredictValueChangeAsync`/`RunValueChangeAsync` and the old `IsValueChangeOn`. +- Add usings: `ZB.MOM.WW.ScadaBridge.DeploymentManager`, `ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening`, `ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts`. +- Every other constructor call site of `FlowDebugService` (grep `new FlowDebugService(` in `tests/`) gets the pipeline argument. + +**Step 5: Run** — all `FlowDebugServiceTests` pass; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` still fails only in `FlowDebugger.razor.cs` (Task 9) — that is expected at this point and is why Task 9 follows immediately; do not touch the page in this task. + +**Step 6: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +git commit -m "feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration" +``` + +--- + +### Task 9: Flow Debugger page — script picker + inputs card + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Tasks 7, 8) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor:19-84` (entry buttons + pickers), `:194-222` (inputs card), `:250-256` (`FlowStepDetail` ids) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (enum `:33-40`, storage keys `:67-72`, query params `:84-93`, fields `:110-123`, `CanRun` `:162-164`, init `:221-236`, storage restore `:262-296`, `SetEntryAsync` `:309-321`, `OnSiteChangedAsync`…`PredictAsync` `:352-414`, `RunAsync` `:441-449`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` +- Test (Playwright, selector only): `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` — no value-change selectors are used there today; nothing to change unless grep says otherwise. + +**Step 1: Write the failing bUnit tests** (add to `FlowDebuggerPageTests`; register `_flow.GetInstanceScriptsAsync(7, …)` etc. per test) + +```csharp +private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null); +private static readonly FlowScriptOption Manual = new("Manual", "Call", null, + """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); +private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); + +private void WireInstance() +{ + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); + _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick])); + _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); +} + +[Fact] +public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance() +{ + RegisterServices(); WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); + Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); + Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); +} + +[Fact] +public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt() +{ + RegisterServices(); WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); + + Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); + Assert.Empty(cut.FindAll("[data-test='flow-script-params']")); + Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + cut.Find("input[data-test='flow-input-value']").Input("true"); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); +} + +[Fact] +public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters() +{ + RegisterServices(); WireInstance(); + _flow.RunScriptAsync(7, "Manual", Arg.Any(), null, Arg.Any()).Returns(GraphOutcome(true, true)); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("Manual"); + + Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); + Assert.Empty(cut.FindAll("input[data-test='flow-input-value']")); + // fill the form's n field (selector per ParameterValueForm's markup), then Run + cut.Find("[data-test='flow-script-params'] input").Input("3"); + cut.Find("button[data-test='flow-run']").Click(); + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + _ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any()); +} + +[Fact] +public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun() +{ + RegisterServices(); WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("Tick"); + Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); +} + +[Fact] +public void DeepLink_InstanceAndScript_PreselectsAndPredicts() +{ + RegisterServices(); WireInstance(); + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual"); + var cut = Render(); + cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value"))); + _ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any()); +} +``` + +Also update line 148's selector (`flow-entry-value-change` → `flow-entry-script`). Read how the existing deep-link test (`?methodId=`/`?executionId=`) drives `NavigationManager` and copy it. + +**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~FlowDebuggerPageTests"` — expected: build errors. + +**Step 3: Implement** + +Code-behind: +- `EntryKind.ValueChange` → `EntryKind.Script` (update the enum doc). +- Storage: `StorageAttribute` → `StorageScript = "flowDebugger.script"`; `StorageInputsPrefix` stays for methods; add `StorageScriptInputsPrefix = "flowDebugger.scriptInputs."` keyed `{instanceId}.{script}`. +- Query: `[SupplyParameterFromQuery] public string? Script { get; set; }` replaces `Attribute`. +- Fields: remove `_attributes`, `_selectedAttribute`; add `List _scripts = []`, `string _selectedScript = ""`, `FlowScriptOption? SelectedScript => _scripts.FirstOrDefault(s => s.CanonicalName == _selectedScript)`, `Dictionary _scriptParams = new()`, `bool _scriptParamsValid = true`. Keep `_valueChangeValue`. +- `CanRun`: `_entry == EntryKind.Method ? _selectedMethodId > 0 : SelectedScript is { } s && (s.IsWriteKind ? !string.IsNullOrWhiteSpace(_valueChangeValue) : _scriptParamsValid)`. +- `LoadAttributesAsync` → `LoadScriptsAsync()`: `_scripts = _selectedInstanceId > 0 ? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList() : []`. +- `OnAttributeChangedAsync` → `OnScriptChangedAsync()`: `ResetResult(); _scriptParams = new(); _scriptParamsValid = SelectedScript?.ParameterDefinitions is null or "" ? true : true /* form reports */; await PersistAsync(StorageEntry, nameof(EntryKind.Script)); await PersistAsync(StorageScript, _selectedScript); await PredictAsync();` +- `PredictAsync`: `FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript)`. +- `RunAsync`: script branch → `var s = SelectedScript!; outcome = s.IsWriteKind ? await FlowDebug.RunScriptAsync(_selectedInstanceId, s.CanonicalName, null, _valueChangeValue) : await FlowDebug.RunScriptAsync(_selectedInstanceId, s.CanonicalName, BuildScriptParameters(), null);` where `BuildScriptParameters()` returns `null` when `_scriptParams.Count == 0` else `JsonSerializer.SerializeToElement(_scriptParams)` (values are the form's objects; `JsonElement` values serialize as-is). +- Init/deep-link and localStorage restore: replace attribute logic with `Script`/`StorageScript` (`_scripts.Any(s => s.CanonicalName == script)`). +- `SetEntryAsync`: `EntryKind.Script && _selectedInstanceId > 0 && _selectedScript != ""` → `PredictAsync`. + +Markup: +- Entry button: `data-test="flow-entry-script"`, label **Site script**, `EntryKind.Script`. +- Replace the Attribute ` + + @foreach (var s in _scripts) + { + + } + + +``` +- Inputs card condition: `_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0)` (both places, lines 196 and 226). +- Inputs card script branch: +```razor +else if (SelectedScript is { } script) +{ + @if (script.IsWriteKind) + { +
+ + +
@script.TriggerType trigger — the write fires the script.
+
+ } + else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions)) + { +
This script takes no parameters.
+ } + else + { +
+ +
+ } +} +``` +- `FlowStepDetail`: `_entry == EntryKind.Script && …` for both ids. +- Update the "Value-change" wording anywhere else in the razor/code-behind comments. + +**Step 4: Run** — page tests pass; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean; run the whole `CentralUI.Tests` project once. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +git commit -m "feat(central-ui): Flow Debugger script entry — site/instance/script picker, value field or ParameterValueForm by trigger kind" +``` + +--- + +### Task 10: CLI `flow-debug run-script` + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 8 (needs Task 2) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs:30-37` (registration), new `BuildRunScript` after `BuildRunValueChange` (`:85-116`), `run-value-change` description text +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md:2273-2345` (`flow-debug` section: intro sentence, new `run-script` subsection before `run-value-change`, `run-value-change` note) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs:27-50, 74-83` + +**Step 1: Failing tests** — in `Build_RegistersTheThreeSubcommands…` (rename to `…FourSubcommands…`) expect `["run-method", "run-script", "run-value-change", "replay"]` and assert `run-script` has `--instance-id` (required), `--script` (required), `--params`, `--value`, `--wait-seconds`, `--timeout-seconds` (all optional). Add: + +```csharp +[Fact] +public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() +{ + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}"); + Assert.Empty(parse.Errors); + var cmd = group.Subcommands.Single(c => c.Name == "run-script"); + Assert.Equal(45, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--wait-seconds"))); + Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); +} +``` + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~FlowDebugCommandsTests"` — fails. + +**Step 2: Implement** `BuildRunScript` modelled on `BuildRunMethod` + `BuildRunValueChange`: options `--instance-id` (int, required), `--script` (string, required, "Canonical script name (as shown by the Flow Debugger picker / the flattened configuration)"), `--params` (json, call kinds), `--value` (string, write kinds), `--wait-seconds` (45, write kinds), `--timeout-seconds` (120). Validation: both `<= 0` → `INVALID_ARGUMENT`; `--params` parse via `ParseParameters` with the same error handling as `run-method`. Client timeout: `TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds))`. Command description: "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value), any other script is called directly (--params)". Register it between `run-method` and `run-value-change`. `run-value-change` description gains ": the attribute-level path (also fires Expression triggers); prefer run-script to run a named script". + +README: document exactly those options in the existing table style, with an example using the rig (`--instance-id --script OutboundDemo --params '{...}'` — read `docker/seed-flow-outbound-demo.sh` for the script's real parameters, if any). + +**Step 3: Run** — tests pass. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs src/ZB.MOM.WW.ScadaBridge.CLI/README.md tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +git commit -m "feat(cli): flow-debug run-script — run a site script by name through the flow debugger" +``` + +--- + +### Task 11: Playwright script run + verify script leg + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 12 (needs Tasks 9, 10) + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` (new test) +- Modify: `docker/verify-delmia-notifier.sh` (header comment `:33-34`, step H `:129-146`) + +**Step 1: Playwright test** (runs only against the live rig; follow the fixture's `Available` guard pattern) + +```csharp +[Fact] +public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes() +{ + // Rig objects: DelmiaSim01 (SimMachine) carries the OutboundDemo Call script seeded by + // docker/seed-flow-outbound-demo.sh; skip when it is not seeded. + ... login via fixture; navigate; click flow-entry-script; select site "Site A"/instance DelmiaSim01/script OutboundDemo + ... fill the parameter form if the script declares parameters (read seed-flow-outbound-demo.sh), click flow-run + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Completed", new() { Timeout = 60_000 }); + Assert.True(int.Parse(await page.Locator("[data-test='flow-step-count']").InnerTextAsync()) >= 2); +} +``` + +Use `select.SelectOptionAsync(new SelectOptionValue { Label = "…" })` for the site/instance selects (ids differ per rig) and `Value = "OutboundDemo"` for the script. + +**Step 2: verify script** — after the existing H replay check, add an `H2` leg: + +```bash +INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next(i["id"] for i in xs if i["uniqueName"]=="DelmiaSim01"))') +set +e; H2_RUN=$("$CLI" flow-debug run-script --instance-id "$INSTANCE_ID" --script OutboundDemo "${H_ARGS[@]}"); H2_RC=$?; set -e +[ "$H2_RC" -eq 0 ] || { echo "FAIL: flow-debug run-script exited $H2_RC: $H2_RUN"; exit 1; } +H2_OK=$(echo "$H2_RUN" | python3 -c 'import json,sys; r=json.load(sys.stdin); kinds=[json.loads(e["detailsJson"]).get("kind") for e in r.get("events",[])]; ok=r.get("completed") is True and kinds[:1]==["RouteCall"] and "ScriptRun" in kinds; print(("YES" if ok else "NO")+" kinds="+",".join(kinds)); sys.exit(0 if ok else 1)') || { echo "FAIL: run-script incomplete: $H2_OK"; exit 1; } +echo "H2 script entry (direct call) -> $H2_OK" +``` + +Confirm the JSON property names (`uniqueName`, `detailsJson`, `kind`) against the existing H check and `instance list` output; adjust `--params` if `OutboundDemo` declares parameters. Update the header comment to list H2. + +**Step 3: Run** — deferred to Task 13 (needs the rebuilt rig). Build the Playwright project: `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`; `bash -n docker/verify-delmia-notifier.sh`. + +**Step 4: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs docker/verify-delmia-notifier.sh +git commit -m "test(rig): flow debugger script entry — Playwright direct-call run + verify-delmia-notifier step H2" +``` + +--- + +### Task 12: Documentation + +**Classification:** trivial +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 11 + +**Files:** +- Modify: `docs/plans/2026-09-05-flow-debugger-design.md:26,56-58,76-79` (D2 row + §4 toolbar/inputs: add "superseded by `2026-09-08-flow-debugger-script-entry-design.md`" pointers, do not rewrite history) +- Modify: `docs/requirements/Component-CentralUI.md:219-225` (entry points, execution path, authorization lists → three run commands, `run-script`) +- Modify: `docs/requirements/Component-InboundAPI.md:306` (add `FlowDebugRunScriptCommand` to the paragraph) +- Modify: `CLAUDE.md:199` (Flow Debugger bullet: CLI list `run-method|run-script|run-value-change|replay`; one added sentence "**v1.3 (2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`):** the second entry point is site → instance → **script**; ValueChange/Conditional scripts are fired by writing the trigger's attribute (derived, not picked), every other kind is a direct traced `RouteToCall` with a `ParameterValueForm` from the script's `ParameterDefinitions`, one operator-attributed `RouteCall` root row; `run-value-change` stays as the attribute-level CLI escape hatch.") +- Modify: `../scadaproj/CLAUDE.md` ScadaBridge row — extend the Flow Debugger sentence with the script entry (keep the row short). Commit that repo separately. + +**Step: Edit, `git diff`, commit** + +```bash +git commit -am "docs: flow debugger script entry — component specs, CLAUDE.md, design pointers" +``` + +--- + +### Task 13: Gate — build, full tests, rig, browser + +**Classification:** standard +**Estimated implement time:** ~5 min of agent time + rig time +**Parallelizable with:** none + +**Files:** none new (fixes go to the owning task's files and are committed with `fix:` subjects). + +1. Orchestrator: merged-diff `code-reviewer` (opus) over `git diff main...HEAD`; fix Mediums+ before proceeding. +2. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings. +3. `dotnet test ZB.MOM.WW.ScadaBridge.slnx` → only the 11 known Playwright role-negative failures (see `scadabridge-cluster-ops` skill). +4. `bash docker/deploy.sh` (main checkout, not a worktree). +5. Mint a key: `scadabridge security api-key create --url http://localhost:9001 --username multi-role --password password --name flowdbg-0908 --methods DelmiaRecipeDownload` → `FLOW_ONLY=1 DELMIA_API_KEY= bash docker/verify-delmia-notifier.sh` → H, H2, I all YES. +6. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~FlowDebuggerTests"` → all pass. +7. Browser (Claude in Chrome): open `http://localhost:9000/deployment/flow-debugger`, pick Site script → site-a → DelmiaSim01 → `OutboundDemo`, confirm the parameter form / no-params note, Run, graph completes; then pick a ValueChange script on the same instance (if seeded) and confirm the value field names its attribute. +8. Record outcomes in the plan ledger; hand back to the user for merge/push (finishing-a-development-branch). + +--- + +## Gate evidence (2026-09-08, branch head `7792b76b` + text fixes) + +- **Build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — 0 warnings, 0 errors. +- **Full test run** (`dotnet test` on the slnx, 30 projects): all pass except the 11 known role-negative Playwright tests on the login-disabled rig, the 2 new script-entry Playwright tests (run against the pre-rebuild image; pass after the rebuild, below), and `SiteAlarmAggregatorActorTests.Stream_GivenUp_After_MaxRetries_Reopens_On_Reconcile_Tick`, which passes in isolation (30/30) and is untouched by this branch (load-induced flake). +- **Targeted suites after the last fixes:** ManagementService.Tests 631/631 (FlowDebugRunnerTests 34), CentralUI.Tests 1280/1280 (FlowDebuggerPageTests 24, FlowDebugServiceTests 31), CLI.Tests 465/465, Commons.Tests (ScriptTriggerAttributeTests 15, FlowGraphBuilderTests 24). +- **Reviews:** per-task spec + code reviews per classification; a whole-branch review (opus) found I1 (transport faults escaped without a root row), I2 (write-kind completion not script-specific) and I3 (circuit-lifetime script cache) — all fixed (`ca9be68d`, `7197c398`) and re-reviewed (approved; two Minor test gaps left: `MaxWaitSeconds` clamp and the unresolved-option Ask fallback are untested). +- **Rig:** `bash docker/deploy.sh` rebuilt all 8 nodes; `FLOW_ONLY=1 DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh` → H YES (live 11 / replay 17), **H2 YES** (`run-script` on `DelmiaSim01`/`OutboundDemo`: kinds RouteCall,ApiCall,CachedSubmit,DbWriteCached,CachedResolve,NotifySend,ScriptRun), I YES. +- **Playwright** `FlowDebuggerTests` on the rebuilt rig: 4/4 (incl. `ScriptEntry_LiveRunOfACallScript_InBrowser_Completes`, `ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute`). +- **Browser (Claude in Chrome, `localhost:9000`):** Site script → Test Plant A → DelmiaSim01 → picker lists `DelmiaReceiverSimulator (ValueChange: DelmiaReceiver.RecipeDownloadFlag)`, `OutboundDemo (Call)`, `ProcessRecipeDownload (Call)`; `ProcessRecipeDownload` renders the five-field `ParameterValueForm`; `OutboundDemo` shows *This script takes no parameters* and a Live run completed in 21 ms, 5 steps, 1 boundary, all External nodes Ok; `DelmiaReceiverSimulator` shows *New value for DelmiaReceiver.RecipeDownloadFlag* with Run disabled until a value is typed. diff --git a/docs/plans/2026-09-08-flow-debugger-script-entry.md.tasks.json b/docs/plans/2026-09-08-flow-debugger-script-entry.md.tasks.json new file mode 100644 index 00000000..952a23b7 --- /dev/null +++ b/docs/plans/2026-09-08-flow-debugger-script-entry.md.tasks.json @@ -0,0 +1,103 @@ +{ + "planPath": "docs/plans/2026-09-08-flow-debugger-script-entry.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1: ScriptTriggerAttribute helper (Commons)", + "status": "completed" + }, + { + "id": 2, + "subject": "Task 2: FlowDebugRunScriptCommand record", + "status": "completed" + }, + { + "id": 3, + "subject": "Task 3: Extract WriteAndPollAsync from RunValueChangeAsync", + "status": "completed" + }, + { + "id": 4, + "subject": "Task 4: FlowDebugRunner.RunScriptAsync", + "status": "completed", + "blockedBy": [ + 1, + 2, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: ManagementActor dispatch, role matrix, long-running registration", + "status": "completed", + "blockedBy": [ + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: FlowGraphBuilder RouteCall-root pin", + "status": "completed" + }, + { + "id": 7, + "subject": "Task 7: ParameterValueForm.ValidChanged", + "status": "completed" + }, + { + "id": 8, + "subject": "Task 8: IFlowDebugService / FlowDebugService script entry", + "status": "completed", + "blockedBy": [ + 1, + 2 + ] + }, + { + "id": 9, + "subject": "Task 9: Flow Debugger page script picker + inputs card", + "status": "completed", + "blockedBy": [ + 7, + 8 + ] + }, + { + "id": 10, + "subject": "Task 10: CLI flow-debug run-script", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 11, + "subject": "Task 11: Playwright script run + verify script leg", + "status": "completed", + "blockedBy": [ + 9, + 10 + ] + }, + { + "id": 12, + "subject": "Task 12: Documentation", + "status": "completed", + "blockedBy": [ + 5, + 8, + 10 + ] + }, + { + "id": 13, + "subject": "Task 13: Gate \u2014 build, full tests, rig, browser", + "status": "completed", + "blockedBy": [ + 11, + 12 + ] + } + ], + "lastUpdated": "2026-09-08T05:47:49Z" +} diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index b0349b58..c139681f 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -216,13 +216,13 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **Computed alarms render unchanged** from the prior flat-table style; the enrichment is purely additive for native rows. ### Flow Debugger (Flow Debugger policy; Live run = Deployment Role) -- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (value-change entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins. +- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (script entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins. - **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions). - **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a bolt marker. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=`** / **`/notifications/report?correlationId=`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser). -- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the two run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` drill-ins; for a non-Deployer the page pins itself to Replay mode. -- **Two entry points.** An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a value-change trigger on a named instance attribute (Site → Instance → Attribute pickers, site-scope filtered). A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. +- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode. +- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for ``* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. - **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed. -- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the two run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same three commands back the CLI's `flow-debug run-method | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired. +- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the three run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same four commands back the CLI's `flow-debug run-method | run-script | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired. **The script entry point (2026-09-08) reuses the same two timeout functions by kind:** a call-kind script run waits `RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30)`, a write-kind script run waits `ValueChangeAskTimeoutFor(45)`; the page's `?instanceId=` + `?script=` drill-in replaces the former `?attribute=` query parameter. - State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`. - The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. Node text is HTML inside an SVG `foreignObject` so the browser measures it: the title wraps to two lines and ellipsizes by width (never by character count), the subtitle by width on one line, and the hover tooltip carries the full text. A lane whose real titles share a dotted head (Tags: `DelmiaReceiver.RecipeDownloadFlag`, `DelmiaReceiver.RecipeProcessedFlag`, …) shows that head once in the lane gutter (`DelmiaReceiver.*`) and each node shows only the tail that tells it apart (`FlowGraphView.CommonDottedPrefix`). diff --git a/docs/requirements/Component-InboundAPI.md b/docs/requirements/Component-InboundAPI.md index dcc67a7a..ce6163d2 100644 --- a/docs/requirements/Component-InboundAPI.md +++ b/docs/requirements/Component-InboundAPI.md @@ -303,7 +303,7 @@ For everything else, scripts interact with the system through the curated `Route ## Flow Debugger runs -The Flow Debugger (Central UI, design `docs/plans/2026-09-05-flow-debugger-design.md`) drives this component through three management commands handled by the per-node `ManagementActor` (Deployer role): `FlowDebugRunMethodCommand` executes a method exactly as an external caller would — `ParameterValidator` + `SchemaRefResolver`, then `InboundScriptExecutor.ExecuteAsync(..., trace: true)` with a `RouteHelper` trace sink collecting the `RouteCall` row and the site's inline events — and **synthesizes the `ApiInbound.InboundRequest` row itself** with the operator as actor (`flow-debugger` source tag) because no HTTP request passed through `AuditWriteMiddleware`; it refuses on a standby node with `STANDBY_NODE` (`IActiveNodeGate`), so the script runs only where a real call would. `FlowDebugRunValueChangeCommand` writes an attribute through the traced routed-write path and polls the site (`PullTraceEvents`) for the run the change spawned; `FlowDebugReplayCommand` reads the chain back from the central Audit Log and therefore accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer, Administrator, Viewer) while the two run commands stay Deployer-only (arch-review F3). The result (`FlowDebugRunResult`) carries the audit events inline — the graph is a central-side projection, never a wire type. +The Flow Debugger (Central UI, design `docs/plans/2026-09-05-flow-debugger-design.md`; script entry point `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`) drives this component through four management commands handled by the per-node `ManagementActor` (Deployer role): `FlowDebugRunMethodCommand` executes a method exactly as an external caller would — `ParameterValidator` + `SchemaRefResolver`, then `InboundScriptExecutor.ExecuteAsync(..., trace: true)` with a `RouteHelper` trace sink collecting the `RouteCall` row and the site's inline events — and **synthesizes the `ApiInbound.InboundRequest` row itself** with the operator as actor (`flow-debugger` source tag) because no HTTP request passed through `AuditWriteMiddleware`; it refuses on a standby node with `STANDBY_NODE` (`IActiveNodeGate`), so the script runs only where a real call would. `FlowDebugRunValueChangeCommand` writes an attribute through the traced routed-write path and polls the site (`PullTraceEvents`) for the run the change spawned. `FlowDebugRunScriptCommand` (2026-09-08) resolves the named script on the instance through the flattening pipeline and branches on its trigger kind: a write kind (ValueChange/Conditional) reuses the same traced routed-write + `PullTraceEvents` poll as `FlowDebugRunValueChangeCommand`, using the attribute derived from the script's own trigger configuration; a call kind (Call/Interval/Expression/no trigger) routes a `RouteToCallRequest(Trace: true)` through `IInstanceRouter` **directly** rather than through `RouteHelper` — this keeps exactly one operator-attributed `RouteCall` root row instead of `RouteHelper`'s own always-on, actor-less one — and returns the site's inline trace rows through `FlowTraceCollector`; its error codes are `NOT_FOUND` (unknown script or instance), `VALIDATION_FAILED` (missing write-kind value, or call-kind parameters that fail `ParameterValidator`), `CALL_FAILED` (the routed call completed but the site reported failure), and `TIMEOUT` (no response within the script's execution timeout). `FlowDebugReplayCommand` reads the chain back from the central Audit Log and therefore accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer, Administrator, Viewer) while the three run commands stay Deployer-only (arch-review F3). The result (`FlowDebugRunResult`) carries the audit events inline — the graph is a central-side projection, never a wire type. - **`SchemaRefResolver` is `public` for the runner's benefit.** It was internal to the HTTP path; the Flow Debugger's `FlowDebugRunMethodCommand` validates its operator-supplied parameters through exactly the same `ParameterValidator` + `SchemaRefResolver` pair a real HTTP call uses, so a Live run cannot accept an input the endpoint would reject. Keep the two paths on the one resolver — a second, debugger-local validator would silently fork the contract. - **Site scope is enforced at route time on a Live run** (arch-review F2). `RouteHelper.WithSiteScope(IReadOnlySet? permittedSiteIdentifiers)` carries the operator's permitted **site identifiers**; `RouteTarget.ResolveSiteAsync` throws `RouteSiteScopeException` before any site traffic when the resolved target site is outside that set, and `InboundScriptExecutor` maps it to error code **`SITE_SCOPE_DENIED`**. `FlowDebugRunner` translates that code into a `SiteScopeViolationException` (`ManagementUnauthorized`) *after* writing the inbound audit row, so the denied attempt is recorded under the operator's name. Administrators and unscoped users get a null scope (unrestricted), and the HTTP endpoint passes no scope at all — production routing is unchanged. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs index c054676a..20eac1b8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// -/// The flow-debug command group (Flow Debugger design 2026-09-05 §7.1): a Live run of -/// an inbound API method or a value-change trigger with every routed site run traced, and a -/// Replay of a past execution chain from the central audit store. All three are Deployer -/// commands answered by the node the CLI is pointed at; a standby central refuses the two -/// run commands with STANDBY_NODE (Replay is served from any node). +/// The flow-debug command group (Flow Debugger design 2026-09-05 §7.1; script entry +/// point 2026-09-08 §6): a Live run of an inbound API method, a site script, or a +/// value-change trigger with every routed site run traced, and a Replay of a past execution +/// chain from the central audit store. The three run commands are Deployer-only commands +/// answered by the node the CLI is pointed at; a standby central refuses them with +/// STANDBY_NODE. Replay is served from any node and accepts a wider role set. /// public static class FlowDebugCommands { @@ -29,9 +30,10 @@ public static class FlowDebugCommands /// The configured flow-debug command. public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { - var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method or value-change flow end to end, or replay a past one" }; + var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method, a site script, or a value-change flow end to end, or replay a past one" }; command.Add(BuildRunMethod(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildRunScript(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildRunValueChange(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildReplay(urlOption, formatOption, usernameOption, passwordOption)); @@ -82,13 +84,79 @@ public static class FlowDebugCommands return cmd; } + private static Command BuildRunScript(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; + var scriptOption = new Option("--script") + { + Description = "Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. Manual or Module.Script)", + Required = true, + }; + var paramsOption = new Option("--params") { Description = "Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script (validated against the script's ParameterDefinitions)" }; + var valueOption = new Option("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" }; + var waitOption = new Option("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; + var timeoutOption = new Option("--timeout-seconds") + { + Description = "Client-side wait for the run; raise above the script's ExecutionTimeoutSeconds + 25 for a long script. " + + "The server caps long-running commands at its own ManagementService:LongRunningCommandTimeout " + + "(default 5 min), so a larger value still 504s at that ceiling", + DefaultValueFactory = _ => 120, + }; + var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" }; + cmd.Add(instanceIdOption); + cmd.Add(scriptOption); + cmd.Add(paramsOption); + cmd.Add(valueOption); + cmd.Add(waitOption); + cmd.Add(timeoutOption); + cmd.SetAction(async (ParseResult result) => + { + var waitSeconds = result.GetValue(waitOption); + var timeoutSeconds = result.GetValue(timeoutOption); + if (waitSeconds <= 0) + { + OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT"); + return 1; + } + + if (timeoutSeconds <= 0) + { + OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT"); + return 1; + } + + JsonElement? parameters; + try + { + parameters = ParseParameters(result.GetValue(paramsOption)); + } + catch (JsonException ex) + { + OutputFormatter.WriteError($"--params is not valid JSON: {ex.Message}", "INVALID_PARAMS"); + return 1; + } + + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new FlowDebugRunScriptCommand( + result.GetValue(instanceIdOption), + result.GetValue(scriptOption)!, + parameters, + result.GetValue(valueOption), + waitSeconds), + timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)), + tableProjector: FlowDebugTableProjection.ProjectEvents); + }); + return cmd; + } + private static Command BuildRunValueChange(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var instanceIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var attributeOption = new Option("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; - var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers" }; + var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" }; cmd.Add(instanceIdOption); cmd.Add(attributeOption); cmd.Add(valueOption); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index c69e28a7..47d4a288 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -2270,16 +2270,20 @@ scadabridge --url cached-call discard --site-id --tracked-operati --- -### `flow-debug` — Trace an inbound API or value-change flow end to end +### `flow-debug` — Trace an inbound API, script, or value-change flow end to end -The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`). -A Live run executes the real thing — the method script on the active central node, or a -real attribute write at the site — with tracing switched on for the whole chain, so every -routed site script run, attribute write and attribute wait it causes is recorded as a -`ScriptTrace` audit row and returned inline. Replay reads those rows back for any past -execution. All three sub-commands require the **Deployer** role. +The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`, +script entry point `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). +A Live run executes the real thing — the method script on the active central node, a named +site script (called directly, or fired by writing its trigger attribute), or a real attribute +write at the site — with tracing switched on for the whole chain, so every routed site script +run, attribute write and attribute wait it causes is recorded as a `ScriptTrace` audit row and +returned inline. Replay reads those rows back for any past execution. The three run commands +(`run-method`, `run-script`, `run-value-change`) require the **Deployer** role; `replay` +accepts the wider `AuthorizationPolicies.FlowDebuggerAccessRoles` set (Deployer, +Administrator, Viewer — arch-review F3), since it only reads the audit chain. -The two run commands are answered by the node the CLI is pointed at. Through the Traefik +The three run commands are answered by the node the CLI is pointed at. Through the Traefik load balancer that is the active node; pointed at a standby node directly they refuse with error code `STANDBY_NODE` rather than run the script somewhere it would not run in production. `replay` is served from any node. @@ -2317,10 +2321,38 @@ scadabridge --url flow-debug run-method --method-id [--params Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running the script. +#### `flow-debug run-script` + +Run one site script by its canonical name with tracing. A `ValueChange`/`Conditional` script +is fired by writing its trigger attribute (`--value`) and waiting for the run it spawns, +exactly like `run-value-change`; every other script (`Call`, `Interval`, `Expression`, or no +trigger) is called directly with `--params`, the same way `run-method` calls an inbound API +method. The runner resolves the script from the instance's flattened configuration and picks +the branch itself — the client does not need to know the script's trigger kind ahead of time. + +```sh +scadabridge --url flow-debug run-script --instance-id --script [--params ] [--value ] [--wait-seconds 45] [--timeout-seconds 120] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--instance-id` | yes | Instance ID | +| `--script` | yes | Canonical script name, as shown by the Flow Debugger picker / the flattened configuration (e.g. `Manual` or `Module.Script`) | +| `--params` | no | Flat JSON object of parameters for a Call/Interval/Expression/no-trigger script, validated against the script's `ParameterDefinitions` | +| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form | +| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) | +| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. | + +A missing `--value` on a write-kind script, or `--params` that fail schema validation on a +call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown +`--script` is reported as `NOT_FOUND`. + #### `flow-debug run-value-change` Write an attribute on a deployed instance through the traced routed-write path, then wait -for the script run(s) the change triggers at the site and print them. +for the script run(s) the change triggers at the site and print them. This is the +attribute-level path (it is also the way to fire an `Expression` trigger, which names no +attribute of its own); prefer `run-script` to run a script by name. ```sh scadabridge --url flow-debug run-value-change --instance-id --attribute --value [--wait-seconds 45] diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index 91b7cac6..67469b3c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -24,8 +24,8 @@
- +
} @@ -70,14 +70,14 @@ } -
- - - @foreach (var a in _attributes) + @foreach (var s in _scripts) { - + }
@@ -193,7 +193,7 @@
@* ── Inputs (Live only) ────────────────────────────────────────────── *@ - @if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0)) + @if (_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0)) {
@@ -214,13 +214,38 @@
} } - else + else if (SelectedScript is { } script) { -
- - -
+ @* A write kind is fired by writing its own trigger attribute — the operator + supplies only the value. Every other kind is called directly with the + parameters its definitions declare. *@ + @if (script.IsWriteKind) + { +
+ + +
@script.TriggerType trigger — writing the attribute fires the script.
+
+ } + else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions)) + { +
This script takes no parameters.
+ } + else + { +
+ @* Keyed on the script so a change of script REMOUNTS the form: its raw + text and parse errors are per-instance state that must not survive + (and two scripts may even declare byte-identical definitions, which + would otherwise skip the reparse and its ValidChanged entirely). *@ + +
+ } }
@@ -228,7 +253,7 @@ } @* ── Flow panel ────────────────────────────────────────────────────── *@ -
+
@if (_graph == null && _predicted != null) @@ -254,8 +279,8 @@ @* Retry / Discard relay a command to the site — only wired for a Deployer, so the buttons never render for an auditor (arch-review F3). *@
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index 7fa894fa..b61cf3d0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; /// -/// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a -/// value-change trigger), see the predicted swimlane, run it live with tracing on the active -/// central node, or replay a past execution from the audit store. State machine +/// Flow Debugger page (design 2026-09-05 §4, script entry 2026-09-08 §5): pick an entry point +/// (inbound API method or a site script), see the predicted swimlane, run it live with tracing on +/// the active central node, or replay a past execution from the audit store. State machine /// Idle → Predicted → Running → Completed | Failed | TimedOut. The server's /// STANDBY_NODE refusal renders as an inline alert with the message verbatim. /// @@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable { /// An inbound API method. Method, - /// A value-change trigger on an instance attribute. - ValueChange, + /// + /// A script on a site instance: a write-kind trigger (ValueChange / Conditional) is fired + /// by writing its monitored attribute, every other kind is called directly. + /// + Script, } /// Live vs Replay. @@ -68,7 +71,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable private const string StorageMethod = "flowDebugger.methodId"; private const string StorageSite = "flowDebugger.siteId"; private const string StorageInstance = "flowDebugger.instanceId"; - private const string StorageAttribute = "flowDebugger.attribute"; + private const string StorageScript = "flowDebugger.script"; private const string StorageInputsPrefix = "flowDebugger.inputs."; [Inject] private IFlowDebugService FlowDebug { get; set; } = default!; @@ -83,11 +86,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable /// Drill-in: pre-select an inbound method. [SupplyParameterFromQuery] public int? MethodId { get; set; } - /// Drill-in: pre-select an instance (value-change entry). + /// Drill-in: pre-select an instance (script entry). [SupplyParameterFromQuery] public int? InstanceId { get; set; } - /// Drill-in: pre-select the trigger attribute (with ). - [SupplyParameterFromQuery] public string? Attribute { get; set; } + /// Drill-in: pre-select the script (with ). + [SupplyParameterFromQuery] public string? Script { get; set; } /// Drill-in: replay this execution on first render. [SupplyParameterFromQuery] public Guid? ExecutionId { get; set; } @@ -109,15 +112,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable private List _methods = []; private List _sites = []; private List _instances = []; - private List _attributes = []; + private List _scripts = []; private int _selectedMethodId; private int _selectedSiteId; private int _selectedInstanceId; - private string _selectedAttribute = string.Empty; + private string _selectedScript = string.Empty; private string _valueChangeValue = string.Empty; private string _replayExecutionIdText = string.Empty; + /// The chosen script's option row, or null when nothing (or something stale) is selected. + private FlowScriptOption? SelectedScript + => _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal)); + + /// Call-kind parameter values from ParameterValueForm, and whether it currently parses. + private Dictionary _scriptParams = new(); + private bool _scriptParamsValid; + private List _inputFields = []; private readonly Dictionary _inputs = new(StringComparer.Ordinal); @@ -159,9 +170,13 @@ public partial class FlowDebugger : ComponentBase, IDisposable private bool CanRefresh => !_running && _raw is not null; + // Script entry: a write kind needs the value to write; a call kind needs a parameter form + // that parses (a script declaring no parameters is runnable as soon as it is chosen). private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && ( _entry == EntryKind.Method ? _selectedMethodId > 0 - : _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute)); + : SelectedScript is { } s && (s.IsWriteKind + ? !string.IsNullOrWhiteSpace(_valueChangeValue) + : string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid)); private string StateLabel => _state switch { @@ -222,17 +237,19 @@ public partial class FlowDebugger : ComponentBase, IDisposable if (InstanceId is int iid && iid > 0) { - _entry = EntryKind.ValueChange; + _entry = EntryKind.Script; var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid); if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId)) { _selectedSiteId = inst.SiteId; await LoadInstancesAsync(); _selectedInstanceId = inst.Id; - await LoadAttributesAsync(); - if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute)) + await LoadScriptsAsync(); + // The link may spell the script in any case; the picker binds the canonical one. + var named = _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, Script, StringComparison.OrdinalIgnoreCase)); + if (named != null) { - _selectedAttribute = Attribute; + _selectedScript = named.CanonicalName; await PredictAsync(); } } @@ -266,12 +283,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable try { var entry = await JS.InvokeAsync("localStorage.getItem", StorageEntry); - if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal)) + if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal)) { - _entry = EntryKind.ValueChange; + _entry = EntryKind.Script; var site = await JS.InvokeAsync("localStorage.getItem", StorageSite); var inst = await JS.InvokeAsync("localStorage.getItem", StorageInstance); - var attr = await JS.InvokeAsync("localStorage.getItem", StorageAttribute); + var script = await JS.InvokeAsync("localStorage.getItem", StorageScript); if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid)) { _selectedSiteId = sid; @@ -279,10 +296,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid)) { _selectedInstanceId = iid; - await LoadAttributesAsync(); - if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr)) + await LoadScriptsAsync(); + if (_scripts.Any(s => string.Equals(s.CanonicalName, script, StringComparison.Ordinal))) { - _selectedAttribute = attr; + _selectedScript = script!; await PredictAsync(); } } @@ -313,10 +330,15 @@ public partial class FlowDebugger : ComponentBase, IDisposable ResetResult(); _predicted = null; _state = FlowState.Idle; + // Inputs are per-entry-point, as in OnScriptChangedAsync: never carry a script's value or + // parameter tree (or its validity) across a switch of entry kind. + _scriptParams = new(); + _scriptParamsValid = false; + _valueChangeValue = string.Empty; await PersistAsync(StorageEntry, entry.ToString()); if (_entry == EntryKind.Method && _selectedMethodId > 0) await OnMethodChangedAsync(); - else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty) + else if (_entry == EntryKind.Script && SelectedScript != null) await PredictAsync(); } @@ -355,8 +377,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable _predicted = null; _state = FlowState.Idle; _selectedInstanceId = 0; - _selectedAttribute = string.Empty; - _attributes = []; + _selectedScript = string.Empty; + _scripts = []; await LoadInstancesAsync(); await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture)); } @@ -366,17 +388,25 @@ public partial class FlowDebugger : ComponentBase, IDisposable ResetResult(); _predicted = null; _state = FlowState.Idle; - _selectedAttribute = string.Empty; - await LoadAttributesAsync(); + _selectedScript = string.Empty; + await LoadScriptsAsync(); await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture)); } - private async Task OnAttributeChangedAsync() + private async Task OnScriptChangedAsync() { ResetResult(); - await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString()); - await PersistAsync(StorageAttribute, _selectedAttribute); - await PredictAsync(); + _predicted = null; + _state = FlowState.Idle; + // Inputs belong to the chosen script: a write kind's value and a call kind's parameter + // tree are both dropped so nothing carries across a change of script. + _scriptParams = new(); + _scriptParamsValid = false; + _valueChangeValue = string.Empty; + await PersistAsync(StorageEntry, nameof(EntryKind.Script)); + await PersistAsync(StorageScript, _selectedScript); + if (SelectedScript != null) + await PredictAsync(); } private async Task LoadInstancesAsync() @@ -386,14 +416,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable : []; } - private async Task LoadAttributesAsync() + /// The instance's scripts, from its flattened configuration (canonical names, effective triggers). + private async Task LoadScriptsAsync() { - _attributes = []; - var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId); - if (inst == null) - return; - var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId); - _attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); + _scripts = _selectedInstanceId > 0 + ? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList() + : []; } private async Task PredictAsync() @@ -402,7 +430,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable { _predicted = _entry == EntryKind.Method ? await FlowDebug.PredictMethodAsync(_selectedMethodId) - : await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute); + : await FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript); _state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle; } catch (Exception ex) @@ -445,7 +473,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable } else { - outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue); + // A write kind is fired by writing its trigger attribute; every other kind is a + // direct traced call carrying the parameter form's values. + var script = SelectedScript!; + outcome = script.IsWriteKind + ? await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, null, _valueChangeValue) + : await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, BuildScriptParameters(), null); } ApplyOutcome(outcome); @@ -801,6 +834,14 @@ public partial class FlowDebugger : ComponentBase, IDisposable return JsonSerializer.SerializeToElement(obj); } + /// + /// The call-kind script's parameters as a flat JSON object, or null when the form produced + /// nothing. ParameterValueForm already emits the coerced value tree (scalars as + /// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is. + /// + internal JsonElement? BuildScriptParameters() + => _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams); + private static JsonNode? Coerce(string? type, string raw) { switch (InboundApiSchema.NormalizeType(type)) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor index 24ca8fe8..0d1ab5f3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor @@ -48,6 +48,13 @@ else [Parameter] public Dictionary Values { get; set; } = new(); [Parameter] public EventCallback> ValuesChanged { get; set; } + /// + /// Raised after every edit with true when the value tree has no parse or validation errors + /// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing + /// callers ignore it. + /// + [Parameter] public EventCallback ValidChanged { get; set; } + private IReadOnlyList _shapes = Array.Empty(); private IReadOnlyList _topLevelFields = Array.Empty(); private InboundApiSchema? _rootSchema; @@ -70,6 +77,44 @@ else _shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions); _rootSchema = await ParseRootAsync(); _topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty(); + + // Report validity for the freshly-(re)parsed schema so a caller gating a Run + // button on ValidChanged never starts (or carries over from a prior script) + // a stale "valid" reading when required fields are unset. This deliberately + // does NOT call Validate()/populate _parseErrors: the component has no + // "untouched field" concept -- RenderError paints whatever is in + // _parseErrors unconditionally -- so doing that here would immediately + // paint every required field red before the user has touched anything. The + // silent check below computes the same answer without that side effect; + // _parseErrors (and the visible red text) still only appears once editing + // drives it through Emit()/Validate() as before. + await ValidChanged.InvokeAsync(ComputeIsValid()); + } + + // Mirrors Validate()'s schema pass (plus any raw-text parse markers already + // parked in _parseErrors) WITHOUT writing to _parseErrors, so it can run on + // every schema (re)parse without painting untouched required fields red. + private bool ComputeIsValid() + { + if (_parseErrors.Count > 0) + { + return false; + } + if (_rootSchema is null) + { + return true; + } + try + { + var element = JsonSerializer.SerializeToElement(Values); + var errors = new List(); + _rootSchema.Validate(element, string.Empty, errors); + return errors.Count == 0; + } + catch (JsonException) + { + return false; + } } /// @@ -620,8 +665,11 @@ else { Validate(); await ValuesChanged.InvokeAsync(Values); + await ValidChanged.InvokeAsync(IsValid); } + private bool IsValid => _parseErrors.Count == 0; + // Validates the whole value tree against the parsed schema and projects each // path-qualified error onto the input that owns it. Raw-text parse failures // (numeric / JSON) are added directly by the setters; schema validation adds diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs index fa78b341..4d937cca 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs @@ -9,8 +9,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; @@ -20,7 +24,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// /// Default : prediction runs in-process over the repositories -/// (method script + instance → template chain → script bodies, DB-only, nothing executes); +/// (method script + instance → template chain → script bodies, DB-only, nothing executes), +/// while the script entry (design 2026-09-08 §2) resolves an instance's scripts through the +/// DeploymentManager's so the picker, the prediction and the +/// run all see the same canonical names, trigger kinds and effective parameter definitions the +/// runner will; /// the run/replay commands go to the per-node ManagementActor through /// , exactly as /// does, with the current Blazor principal projected to an @@ -58,8 +66,8 @@ public sealed class FlowDebugService : IFlowDebugService } /// - /// Client-side Ask wait for : the trigger-wait window - /// plus . + /// Client-side Ask wait for a WRITE-kind script run (): the + /// trigger-wait window plus . /// public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds) => TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack; @@ -77,15 +85,36 @@ public sealed class FlowDebugService : IFlowDebugService private readonly AuthenticationStateProvider _auth; private readonly IInboundApiRepository _inboundApi; private readonly ITemplateEngineRepository _templates; + private readonly IFlatteningPipeline _pipeline; private readonly CommunicationService _comms; private readonly ILogger _logger; + /// + /// Flattened script lists by instance id. A scoped service in Blazor Server lives for the + /// whole CIRCUIT, so this is emphatically not a per-interaction memo: it is refreshed by + /// every call (the page makes one per instance + /// selection), and only the prediction and the run read it back. That keeps one flatten per + /// picker selection while making a re-selection pick up a template edit or a redeploy — a + /// cache held for the session would otherwise serve a stale parameter schema (unexplained + /// VALIDATION_FAILED) and a stale Ask bound against a runner that re-flattens on every + /// run. The raw s ride along because prediction needs the code + /// bodies, which the picker's projection deliberately does not carry. + /// + private readonly Dictionary _scriptCache = new(); + + /// One instance's flattened scripts: the unique name the site keys on, the raw resolved scripts, and the picker projection. + private sealed record InstanceScripts( + string UniqueName, + IReadOnlyList Scripts, + IReadOnlyList Options); + /// Initializes the service. public FlowDebugService( ManagementActorHolder holder, AuthenticationStateProvider auth, IInboundApiRepository inboundApi, ITemplateEngineRepository templates, + IFlatteningPipeline pipeline, CommunicationService comms, ILogger logger) { @@ -93,6 +122,7 @@ public sealed class FlowDebugService : IFlowDebugService _auth = auth ?? throw new ArgumentNullException(nameof(auth)); _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi)); _templates = templates ?? throw new ArgumentNullException(nameof(templates)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); _comms = comms ?? throw new ArgumentNullException(nameof(comms)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -161,43 +191,39 @@ public sealed class FlowDebugService : IFlowDebugService } /// - public async Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default) + public async Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default) + => (await FlattenScriptsAsync(instanceId, cancellationToken))?.Options ?? []; + + /// + public async Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default) { - var instance = await _templates.GetInstanceByIdAsync(instanceId, cancellationToken); - if (instance == null) + var loaded = await CachedScriptsAsync(instanceId, cancellationToken); + var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); + if (loaded == null || script == null) return PredictedFlow.Empty; - var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, cancellationToken); - var triggered = scripts.Values - .Where(s => IsValueChangeOn(s, attributeName)) - .OrderBy(s => s.Name, StringComparer.Ordinal) - .ToList(); + var title = $"{loaded.UniqueName}.{script.CanonicalName}"; + if (ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute) && attribute != null) + return PredictValueChange(loaded, attribute); - // Root: the write itself (Tags lane), then each triggered script predicted as a site script. - var nodes = new List + if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType)) { - new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{instance.UniqueName}.{attributeName}", false, - triggered.Count == 0 ? "no value-change script on this attribute" : null), - }; - var edges = new List(); - foreach (var script in triggered) - { - var sub = FlowShapePredictor.PredictSiteScript($"{instance.UniqueName}.{script.Name}", script.Code ?? string.Empty); - var offset = nodes.Count; - var map = new Dictionary(StringComparer.Ordinal); - foreach (var n in sub.Nodes) - { - var id = "n" + (nodes.Count + 1); - map[n.Id] = id; - nodes.Add(n with { Id = id }); - } - - if (sub.Nodes.Count > 0) - edges.Add(new PredictedEdge("n1", nodes[offset].Id, false)); - foreach (var e in sub.Edges) - edges.Add(e with { From = map[e.From], To = map[e.To] }); + // A write-kind trigger whose configuration names no attribute: nothing to write, so + // the run cannot be predicted past the script itself. + return new PredictedFlow( + [new PredictedNode("n1", FlowStepKind.ScriptRun, FlowLane.Site, title, false, "trigger names no attribute")], + []); } + // Call kinds: the debugger invokes the script directly through the traced routed-call + // path, so the root is a Central-lane RouteCall and the site script hangs off it across + // the cluster boundary — the same shape a `Route.To(instance).Call(script)` renders. + var nodes = new List + { + new("n1", FlowStepKind.RouteCall, FlowLane.Central, title, false, null), + }; + var edges = new List(); + AppendSubFlow(FlowShapePredictor.PredictSiteScript(title, script.Code ?? string.Empty), nodes, edges, crossesBoundary: true); return new PredictedFlow(nodes, edges); } @@ -210,10 +236,30 @@ public sealed class FlowDebugService : IFlowDebugService } /// - public Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default) + public async Task RunScriptAsync( + int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default) { - var command = new FlowDebugRunValueChangeCommand(instanceId, attributeName, value); - return SendAndBuildAsync(command, ValueChangeAskTimeoutFor(command.WaitSeconds), cancellationToken); + // The server re-resolves the script and never trusts a client-supplied trigger kind + // (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind + // is bounded by the script's own execution timeout (falling back to the runner's own + // FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound), + // a write kind by the trigger-wait window. When the script cannot be resolved at all the + // server may still take EITHER branch, so the fallback is the longer of the two — a + // client Ask shorter than the server's bound would report a phantom timeout on a run + // that is still going. + var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value); + var option = (await CachedScriptsAsync(instanceId, cancellationToken))?.Options + .FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); + var writeKindWait = ValueChangeAskTimeoutFor(command.WaitSeconds); + var defaultCallWait = RunAskTimeoutFor(FlowDebugRunner.DefaultScriptTimeoutSeconds); + var timeout = option switch + { + { IsWriteKind: false } => RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds), + not null => writeKindWait, + null => writeKindWait > defaultCallWait ? writeKindWait : defaultCallWait, + }; + + return await SendAndBuildAsync(command, timeout, cancellationToken); } /// @@ -366,38 +412,108 @@ public sealed class FlowDebugService : IFlowDebugService return result; } - /// True when the script's trigger is a value-change on (case-insensitive). - private static bool IsValueChangeOn(TemplateScript script, string attributeName) + /// + /// The instance's scripts as the current picker selection knows them: the cached entry a + /// call just refreshed, or a fresh flatten when the + /// prediction / run is reached without one (a deep link straight into a run). + /// + private async Task CachedScriptsAsync(int instanceId, CancellationToken ct) + => _scriptCache.TryGetValue(instanceId, out var cached) ? cached : await FlattenScriptsAsync(instanceId, ct); + + /// + /// Flattens an instance's scripts (design 2026-09-08 §2) — canonical names, effective trigger + /// kinds and effective parameter definitions, i.e. exactly what the site's Instance Actor keys + /// on — and refreshes the cache entry, so a template edit or a redeploy is picked up on the + /// next picker selection. Returns null when the instance does not exist (Debug — an id from a + /// stale link) or the flatten failed (Warning — the same severity DeploymentService and + /// the Configure page give it, since the instance could not be deployed either); either way + /// the stale entry is dropped rather than served on. Neither is a page error: the picker + /// simply has no scripts to offer. + /// + private async Task FlattenScriptsAsync(int instanceId, CancellationToken ct) { - if (!string.Equals(script.TriggerType, "valuechange", StringComparison.OrdinalIgnoreCase) - && !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)) - return false; - - var cfg = script.TriggerConfiguration; - if (string.IsNullOrWhiteSpace(cfg)) - return false; - - try + var instance = await _templates.GetInstanceByIdAsync(instanceId, ct); + if (instance == null) { - using var doc = JsonDocument.Parse(cfg); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return false; - foreach (var prop in doc.RootElement.EnumerateObject()) - { - if (prop.NameEquals("attribute") || prop.NameEquals("Attribute") - || prop.NameEquals("attributeName") || prop.NameEquals("AttributeName")) - { - return prop.Value.ValueKind == JsonValueKind.String - && string.Equals(prop.Value.GetString(), attributeName, StringComparison.OrdinalIgnoreCase); - } - } - } - catch (JsonException) - { - // Malformed trigger config — not something prediction should fail over. + _scriptCache.Remove(instanceId); + return null; } - return false; + var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false); + if (flattened.IsFailure) + { + _logger.LogWarning( + "Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}", + instanceId, instance.UniqueName, flattened.Error); + _scriptCache.Remove(instanceId); + return null; + } + + var scripts = flattened.Value.Configuration.Scripts + .OrderBy(s => s.CanonicalName, StringComparer.OrdinalIgnoreCase) + .ToList(); + var options = scripts + .Select(s => new FlowScriptOption( + s.CanonicalName, + s.TriggerType, + ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) ? a : null, + s.ParameterDefinitions, + s.ExecutionTimeoutSeconds)) + .ToList(); + + var loaded = new InstanceScripts(instance.UniqueName, scripts, options); + _scriptCache[instanceId] = loaded; + return loaded; + } + + /// + /// Predicts the flow a write to would trigger: the write + /// itself on the Tags lane, then every script whose write-kind trigger names that attribute + /// predicted as a site script. Conditional triggers are included alongside ValueChange — + /// both name a single monitored attribute and both really fire on the write. + /// + private static PredictedFlow PredictValueChange(InstanceScripts loaded, string attributeName) + { + var triggered = loaded.Scripts + .Where(s => ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) + && string.Equals(a, attributeName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var nodes = new List + { + new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{loaded.UniqueName}.{attributeName}", false, + triggered.Count == 0 ? "no value-change script on this attribute" : null), + }; + var edges = new List(); + foreach (var script in triggered) + { + var sub = FlowShapePredictor.PredictSiteScript($"{loaded.UniqueName}.{script.CanonicalName}", script.Code ?? string.Empty); + AppendSubFlow(sub, nodes, edges, crossesBoundary: false); + } + + return new PredictedFlow(nodes, edges); + } + + /// + /// Appends a predicted sub-flow to /, + /// renumbering its node ids into the outer sequence and linking its first node to the root + /// (n1). + /// + private static void AppendSubFlow(PredictedFlow sub, List nodes, List edges, bool crossesBoundary) + { + var offset = nodes.Count; + var map = new Dictionary(StringComparer.Ordinal); + foreach (var n in sub.Nodes) + { + var id = "n" + (nodes.Count + 1); + map[n.Id] = id; + nodes.Add(n with { Id = id }); + } + + if (sub.Nodes.Count > 0) + edges.Add(new PredictedEdge("n1", nodes[offset].Id, crossesBoundary)); + foreach (var e in sub.Edges) + edges.Add(e with { From = map[e.From], To = map[e.To] }); } private async Task SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs index b055d0b2..238bba12 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; @@ -34,9 +35,32 @@ public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, public sealed record FlowOutboundActionResult(bool Success, string Message); /// -/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape -/// prediction from script syntax, and the three management commands (Live run of a method, -/// Live run of a value change, Replay) dispatched to the per-node ManagementActor +/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5): +/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter +/// definitions for call kinds, and the script's own execution timeout (client Ask sizing). +/// +/// Path-qualified script name from the flattened configuration (Module.Script for a composed member). +/// The effective trigger type, or null when the script has none. +/// The monitored attribute of a write-kind trigger; null for every other kind. +/// JSON parameter definitions the Inputs card renders for a call kind; null = none. +/// The script's own execution timeout, or null to use the site default. +public sealed record FlowScriptOption( + string CanonicalName, string? TriggerType, string? TriggerAttribute, + string? ParameterDefinitions, int? ExecutionTimeoutSeconds) +{ + /// True when the debugger fires this script by writing . + public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType); + + /// Picker label suffix: ValueChange: Level, Call, no trigger… + public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger" + : TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!; +} + +/// +/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1, script entry +/// 2026-09-08 §5): the instance's script list read from its flattened configuration, static +/// shape prediction from script syntax, and the four management commands (Live run of a +/// method, of a script, of a value change; Replay) dispatched to the per-node ManagementActor /// through the in-process ManagementActorHolder — the SAME Ask path the HTTP /// /management endpoint uses, so the server stays the single enforcer of the Deployer /// gate and the active-node refusal (STANDBY_NODE). @@ -49,15 +73,16 @@ public interface IFlowDebugService /// The predicted flow; when the method does not exist. Task PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default); - /// - /// Predicts the flow a value change on would trigger: the - /// instance's template scripts whose value-change trigger names that attribute, each predicted - /// as a site script. - /// + /// The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist. /// The instance id. - /// Canonical attribute name. /// Cancellation token. - Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default); + Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default); + + /// Predicts the flow of running : the write + spawned script for a write kind, a RouteCall root + the script for a call kind. + /// The instance id. + /// Canonical script name (case-insensitive). + /// Cancellation token. + Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default); /// Runs an inbound API method with tracing on the active central node and returns the flow. /// The method id. @@ -65,12 +90,13 @@ public interface IFlowDebugService /// Cancellation token. Task RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default); - /// Writes an attribute through the traced path and returns the flow the change triggered. + /// Runs a script by name (write kinds take , call kinds ) and returns the flow. /// The instance id. - /// Canonical attribute name. - /// Value in canonical string form. + /// Canonical script name (case-insensitive). + /// Call kinds: flat JSON object of parameters, or null. + /// Write kinds: the new value for the trigger's attribute, in canonical string form. /// Cancellation token. - Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default); + Task RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default); /// Rebuilds a past flow from the central audit store. /// Any execution id in the chain. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs index 7231b1e9..42e89448 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs @@ -24,6 +24,27 @@ public record FlowDebugRunMethodCommand(int ApiMethodId, JsonElement? Parameters /// How long to wait for a spawned run before reporting NO_TRIGGER_WITHIN_WINDOW. public record FlowDebugRunValueChangeCommand(int InstanceId, string AttributeName, string Value, int WaitSeconds = 45); +/// +/// Flow Debugger Live run of a site script by name (design 2026-09-08). The runner resolves the +/// script's effective definition from the flattened instance configuration and branches on its +/// trigger kind: a ValueChange/Conditional script is fired by WRITING its monitored +/// attribute () and polling for the spawned run, exactly like +/// ; every other kind is invoked DIRECTLY through the +/// traced routed-call path with . Deployer role; refuses on a standby +/// with STANDBY_NODE. +/// +/// The instance the script belongs to. +/// Canonical script name from the flattened configuration (case-insensitive). +/// Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none. +/// Write kinds: canonical string value to write to the trigger's attribute. +/// Write kinds: how long to wait for a spawned run before NO_TRIGGER_WITHIN_WINDOW. +public record FlowDebugRunScriptCommand( + int InstanceId, + string ScriptName, + JsonElement? Parameters, + string? Value, + int WaitSeconds = 45); + /// /// Flow Debugger Replay (design 2026-09-05 §7.1): returns the audit events of the execution /// tree that belongs to, from the central store. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs new file mode 100644 index 00000000..c9f371b6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +/// +/// Reads the single monitored attribute of a ValueChange or Conditional script +/// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the +/// Central UI's ScriptTriggerConfigCodec writes (attributeName) and SiteRuntime's +/// TriggerRouting also accepts (attribute). Pure; never throws on bad JSON. +/// +public static class ScriptTriggerAttribute +{ + /// True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute. + public static bool IsWriteKind(string? triggerType) => + string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase) + || string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase); + + /// + /// Returns the monitored attribute for a write-kind trigger with a readable configuration; + /// false (and null) for every other kind, and for a write kind whose config is missing, + /// malformed, not an object, or names no attribute. + /// + public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute) + { + attribute = null; + if (!IsWriteKind(triggerType) || string.IsNullOrWhiteSpace(triggerConfigurationJson)) + return false; + try + { + using var doc = JsonDocument.Parse(triggerConfigurationJson); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + foreach (var key in new[] { "attributeName", "attribute" }) + { + if (root.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String + && v.GetString() is { Length: > 0 } name) + { + attribute = name; + return true; + } + } + return false; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs index 26eca87a..4b6952d6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs @@ -3,6 +3,8 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; @@ -10,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.InboundAPI; using ZB.MOM.WW.ScadaBridge.Security; @@ -37,6 +41,27 @@ public sealed class FlowDebugRunner /// Error code when the method / instance / site does not exist. public const string NotFoundCode = "NOT_FOUND"; + /// Error code when a direct script call (design 2026-09-08 §4.4) fails at the site. + public const string CallFailedCode = "CALL_FAILED"; + + /// Error code when a Live run exceeds the method's / script's timeout plus . + public const string TimeoutCode = "TIMEOUT"; + + /// + /// Timeout for a direct script call whose script declares none — mirrors + /// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds's default (the runner has no site options). + /// + public const int DefaultScriptTimeoutSeconds = 30; + + /// + /// Ceiling on a write-kind run's spawned-run wait, whatever the command asks for. It must stay + /// UNDER ManagementServiceOptions.LongRunningCommandTimeout (5 minutes by default), which + /// is the Ask the management endpoint wraps this command in: a longer wait would leave the poll + /// loop running on the node after the caller's Ask has already expired, holding its scoped + /// provider and pulling from the site for a result nobody can receive. + /// + public const int MaxWaitSeconds = 300; + /// /// Arch-review F2: 's code for a routed call refused /// because the target site is outside the operator's site scope. InboundAPI cannot @@ -142,8 +167,7 @@ public sealed class FlowDebugRunner } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - result = new InboundScriptResult(false, null, - $"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", "TIMEOUT"); + result = new InboundScriptResult(false, null, TimeoutMessage(timeout), TimeoutCode); } } @@ -211,6 +235,180 @@ public sealed class FlowDebugRunner .GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false) ?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode); + return await WriteAndPollAsync( + sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct) + .ConfigureAwait(false); + } + + /// + /// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective + /// definition from the flattened instance configuration and branches on its trigger kind — + /// write kinds go through , every other kind is a traced + /// with one operator-attributed + /// root row. Calling directly + /// rather than through is deliberate: the helper emits its own + /// always-on RouteCall row with no operator actor, which would give the graph two roots. + /// + public async Task RunScriptAsync( + IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(sp); + ArgumentNullException.ThrowIfNull(cmd); + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(cmd.ScriptName)) + throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode); + EnsureActiveNode(sp); + + var instance = await sp.GetRequiredService() + .GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false) + ?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode); + EnforceSiteScope(user, instance.SiteId); + var site = await sp.GetRequiredService() + .GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false) + ?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode); + + // Design §2: the flattened configuration is the one place canonical names, effective triggers and + // effective parameter definitions all live (composition + inheritance + instance overrides applied). + var flattened = await sp.GetRequiredService() + .FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false); + if (flattened.IsFailure) + throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode); + var script = flattened.Value.Configuration.Scripts + .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode); + + if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType)) + { + if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute)) + { + throw new ManagementCommandException( + $"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.", + ValidationFailedCode); + } + + if (cmd.Value is null) + throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode); + return await WriteAndPollAsync( + sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false); + } + + var resolveRef = await SchemaRefResolver.BuildAsync( + sp.GetService(), [script.ParameterDefinitions], ct).ConfigureAwait(false); + var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef); + if (!validation.IsValid) + throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode); + + var root = Guid.NewGuid(); + var startedAt = DateTimeOffset.UtcNow; + var sw = Stopwatch.StartNew(); + // Review follow-up: the root row is synthesized here, so the copy handed back must be + // redacted by this method — WriteCentralAsync's writer only redacts its own private copy. + var redactor = sp.GetService(); + var collector = new FlowTraceCollector(redactor: redactor); + var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds)); + var router = sp.GetRequiredService(); + var request = new RouteToCallRequest( + Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName, + validation.Parameters.Count == 0 ? null : validation.Parameters, + DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true); + + RouteToCallResponse response; + // Null while the site ANSWERED (a success or a Success=false reply); otherwise the code to + // report for a routed Ask that threw instead of answering. + string? faultCode = null; + using (var runCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + runCts.CancelAfter(timeout + RunGrace); + try + { + response = await router.RouteToCallAsync(site.SiteIdentifier, request, runCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + faultCode = TimeoutCode; + response = new RouteToCallResponse( + request.CorrelationId, false, null, TimeoutMessage(timeout), DateTimeOffset.UtcNow); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // RouteToCallAsync is an Akka Ask bounded by CommunicationOptions.IntegrationTimeout, + // so it throws AskTimeoutException (NOT an OperationCanceledException) when the site + // never answers — and any transport fault throws too. "No answer" is NOT "did not + // happen": the site may already have run the script. Letting the exception escape + // would lose the root row entirely and show the operator MapFault's generic internal + // error for a call that ran, so it is converted into a failed response the same way + // RouteHelper.CallAsync types its Ask faults and WriteAndPollAsync reports a failed + // write. A cancellation of the CALLER's token still propagates (filtered out above). + faultCode = ex is Akka.Actor.AskTimeoutException ? TimeoutCode : CallFailedCode; + _logger.LogWarning(ex, + "Flow Debugger routed call to site {Site} for {Instance}.{Script} faulted", + site.SiteIdentifier, instance.UniqueName, script.CanonicalName); + response = new RouteToCallResponse( + request.CorrelationId, false, null, ex.Message, DateTimeOffset.UtcNow); + } + } + + // Everything from here to WriteCentralAsync runs AFTER the site already executed the + // script — a real side effect. Nothing in this stretch may throw, or the root row is never + // written and the operator sees ManagementActor.MapFault's generic "internal error" for a + // call that actually succeeded. So the trace drain skips a faulting event and the return + // value falls back to a placeholder, the same way InboundScriptExecutor turns a script's + // own faults into a captured error rather than an escaping exception. + foreach (var e in response.TraceEvents ?? []) + { + try + { + collector.Add(e); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Flow Debugger dropped an inline trace event from the run of {Script} on {Instance}", + script.CanonicalName, instance.UniqueName); + } + } + + var events = collector.Complete(); + + var rootRow = ScadaBridgeAuditEventFactory.Create( + AuditChannel.ScriptTrace, AuditKind.RouteCall, + response.Success ? AuditStatus.Delivered : AuditStatus.Failed, + occurredAtUtc: startedAt.UtcDateTime, + actor: user.Username, + target: $"{instance.UniqueName}.{script.CanonicalName}", + sourceNode: sp.GetService()?.NodeName, + executionId: root, + sourceSiteId: site.SiteIdentifier, + sourceInstanceId: instance.UniqueName, + durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue), + errorMessage: response.ErrorMessage, + requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null, + responseSummary: SerializeReturnValue(response.ReturnValue), + extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson()); + await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false); + + // Arch-review F5: emission order is authoritative — the root row carries central's clock and + // the site rows the site's, so re-sorting by OccurredAtUtc lets a skewed site clock float its + // steps ahead of the RouteCall that started them. + var ordered = new List(events.Count + 1) { redactor?.Apply(rootRow) ?? rootRow }; + ordered.AddRange(events); + return new FlowDebugRunResult( + root, response.Success, response.ErrorMessage, + response.Success ? null : faultCode ?? CallFailedCode, + ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow); + } + + /// + /// The traced attribute write + spawned-run poll shared by and + /// the write-kind branch of the script entry's RunScriptAsync (design 2026-09-08 §4.3). The + /// caller has already gated the active node, loaded and scope-checked the instance, and loaded the + /// site. is null for the attribute-level command and the script's + /// canonical name for the script entry — it only changes the NO_TRIGGER message. + /// + private async Task WriteAndPollAsync( + IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site, + string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct) + { var root = Guid.NewGuid(); var startedAt = DateTimeOffset.UtcNow; var sw = Stopwatch.StartNew(); @@ -223,7 +421,7 @@ public sealed class FlowDebugRunner site.SiteIdentifier, new RouteToSetAttributesRequest( Guid.NewGuid().ToString(), instance.UniqueName, - new Dictionary { [cmd.AttributeName] = cmd.Value ?? string.Empty }, + new Dictionary { [attributeName] = value ?? string.Empty }, DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true), ct).ConfigureAwait(false); @@ -232,18 +430,18 @@ public sealed class FlowDebugRunner write.Success ? AuditStatus.Delivered : AuditStatus.Failed, occurredAtUtc: startedAt.UtcDateTime, actor: user.Username, - target: cmd.AttributeName, + target: attributeName, sourceNode: sp.GetService()?.NodeName, executionId: root, sourceSiteId: site.SiteIdentifier, sourceInstanceId: instance.UniqueName, durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue), errorMessage: write.ErrorMessage, - requestSummary: JsonSerializer.Serialize(new { attribute = cmd.AttributeName, value = cmd.Value }, ScriptTraceExtra.JsonOptions), + requestSummary: JsonSerializer.Serialize(new { attribute = attributeName, value = value }, ScriptTraceExtra.JsonOptions), extra: new ScriptTraceExtra { Source = SourceTag, - Writes = [new AttributeWriteDetail(cmd.AttributeName, cmd.Value, write.Success, write.ErrorMessage)], + Writes = [new AttributeWriteDetail(attributeName, value, write.Success, write.ErrorMessage)], }.ToJson()); await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false); var redactedRoot = redactor?.Apply(rootRow) ?? rootRow; @@ -254,11 +452,26 @@ public sealed class FlowDebugRunner [redactedRoot], false, startedAt, DateTimeOffset.UtcNow); } + // The run that COUNTS as completion. For the script entry (design 2026-09-08 §4.3) that is + // the script the operator chose, matched on Target — the site stamps ScriptRunLauncher's + // `target: scriptName` on the row. Other scripts the same write happens to fire are still + // collected below (the graph draws the siblings) but must never report the CHOSEN script as + // having run. triggerScript == null is the attribute-level command, which expects no + // particular script, so any spawned run satisfies it. + bool IsAwaitedRun(AuditEvent e) + { + var d = AuditDetailsCodec.Deserialize(e.DetailsJson); + if (d.Kind != nameof(AuditKind.ScriptRun) || d.ParentExecutionId != root) return false; + return triggerScript is null + || string.Equals(e.Target, triggerScript, StringComparison.OrdinalIgnoreCase); + } + // Poll the site for the traced run(s) the write spawned — matched by ParentExecutionId - // == root, then their children — until a ScriptRun parented to root exists AND the - // set has been quiet for SettleWindow, or WaitSeconds elapses. + // == root, then their children — until the awaited ScriptRun exists AND the set has been + // quiet for SettleWindow, or the (clamped) wait elapses. var puller = sp.GetRequiredService(); - var deadline = _now() + TimeSpan.FromSeconds(Math.Max(1, cmd.WaitSeconds)); + var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds); + var deadline = _now() + TimeSpan.FromSeconds(wait); var known = new Dictionary(); DateTimeOffset? lastNew = null; var since = startedAt.UtcDateTime.AddSeconds(-5); @@ -278,11 +491,7 @@ public sealed class FlowDebugRunner } if (added) lastNew = _now(); - var spawned = known.Values.Any(e => - { - var d = AuditDetailsCodec.Deserialize(e.DetailsJson); - return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root; - }); + var spawned = known.Values.Any(IsAwaitedRun); var now = _now(); if (spawned && lastNew is { } ln && now - ln >= SettleWindow) break; if (now >= deadline) break; @@ -291,14 +500,12 @@ public sealed class FlowDebugRunner var ordered = new List { redactedRoot }; ordered.AddRange(known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId)); - var completed = known.Values.Any(e => - { - var d = AuditDetailsCodec.Deserialize(e.DetailsJson); - return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root; - }); + var completed = known.Values.Any(IsAwaitedRun); return new FlowDebugRunResult( root, completed, - completed ? null : $"No traced run was spawned within {cmd.WaitSeconds}s (no value-change trigger on '{cmd.AttributeName}', or no device echo).", + completed ? null : triggerScript is null + ? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)." + : $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).", completed ? null : NoTriggerCode, ordered, false, startedAt, DateTimeOffset.UtcNow); } @@ -322,6 +529,40 @@ public sealed class FlowDebugRunner return new FlowDebugRunResult(rootId, true, null, null, events, false, startedAt, DateTimeOffset.UtcNow); } + /// + /// The timed-out message shared by both Live entry points, so the two paths never drift. + /// + private static string TimeoutMessage(TimeSpan timeout) => + $"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace"; + + /// + /// Serializes a routed call's return value for the root row's ResponseSummary, NEVER + /// throwing: rejects named floating-point literals, + /// so a script returning double.NaN / an infinity — or any other value the serializer + /// cannot represent — would otherwise take down the audit write of a call that already ran. + /// The placeholder keeps the row (and the operator's Live result) intact. + /// + /// + /// The catch is deliberately broad (as 's already is): the value + /// crossing this seam came from a user-authored script, and the serializer's failure mode varies + /// by value — ArgumentException for a non-finite number, for a + /// cycle, for an unmappable type. A best-effort audit summary + /// must not be the thing that fails a call the site already ran. + /// + /// + private static string? SerializeReturnValue(object? returnValue) + { + if (returnValue is null) return null; + try + { + return JsonSerializer.Serialize(returnValue, ScriptTraceExtra.JsonOptions); + } + catch (Exception ex) + { + return $""; + } + } + private static void EnsureActiveNode(IServiceProvider sp) { var gate = sp.GetService(); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index fe8dc090..629b1ced 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -197,7 +197,7 @@ public class ManagementActor : ReceiveActor // arch-review F3: Flow Debugger Replay is an audit read, so it carries the same role // set as the RequireFlowDebugger page policy ({Deployer, Administrator, Viewer}). The - // two Live run commands stay DeployerOnly. + // three Live run commands stay DeployerOnly. private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles; // arch-review C6: area management is reconciled to any-of [Designer, Deployer]. @@ -310,12 +310,13 @@ public class ManagementActor : ReceiveActor or RetryParkedMessageCommand or DiscardParkedMessageCommand or DebugSnapshotCommand => DeployerOnly, - // Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3). A Live run executes - // scripts and writes attributes on operator input, so it stays Deployer-only, like - // the Debug View. Replay only reads the audit chain, so it additionally accepts the - // OperationalAudit roles — the Audit Log / Execution Chain drill-ins are the - // feature's main entry for a past incident and an auditor must be able to use them. - FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand => DeployerOnly, + // Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3; script entry added by + // design 2026-09-08). All three Live run commands execute scripts and write + // attributes on operator input, so they stay Deployer-only, like the Debug View. + // Replay only reads the audit chain, so it additionally accepts the OperationalAudit + // roles — the Audit Log / Execution Chain drill-ins are the feature's main entry for + // a past incident and an auditor must be able to use them. + FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugRunScriptCommand => DeployerOnly, FlowDebugReplayCommand => FlowDebuggerReplayRoles, // Two-person secured write. Submit is an Operator action; @@ -418,6 +419,7 @@ public class ManagementActor : ReceiveActor // Flow Debugger (design 2026-09-05 §7.1) — one execution path for the UI and the CLI. FlowDebugRunMethodCommand cmd => await sp.GetRequiredService().RunMethodAsync(sp, cmd, user, CancellationToken.None), FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService().RunValueChangeAsync(sp, cmd, user, CancellationToken.None), + FlowDebugRunScriptCommand cmd => await sp.GetRequiredService().RunScriptAsync(sp, cmd, user, CancellationToken.None), FlowDebugReplayCommand cmd => await sp.GetRequiredService().ReplayAsync(sp, cmd, CancellationToken.None), // External Systems diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs index 3a04aadc..9d8b4f22 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs @@ -58,6 +58,9 @@ public static class ManagementEndpoints // Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout // (+ grace) or the value-change wait window — never the ordinary Ask timeout. typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand), + // script entry (design 2026-09-08): a direct call lasts up to the script's timeout + // (+ grace), a write kind up to its wait window. + typeof(FlowDebugRunScriptCommand), ]; /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs index 5d727e2a..b89170e7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs @@ -14,7 +14,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddManagementService(this IServiceCollection services) { services.AddSingleton(); - // Flow Debugger (design 2026-09-05 §7.1): the runner behind the three FlowDebug* commands. + // Flow Debugger (design 2026-09-05 §7.1): the runner behind the four FlowDebug* commands (three Live runs + Replay). services.AddSingleton(); services.AddOptions() .BindConfiguration("ScadaBridge:ManagementService") diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs index c691fd24..caa3e5da 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs @@ -160,8 +160,9 @@ public static class AuthorizationPolicies /// This is page ACCESS only, and is deliberately NOT sufficient for a Live /// run: the Run controls, the Live entry-point pickers and the parked-node /// Retry/Discard actions are gated on in the - /// page, and FlowDebugRunMethodCommand / - /// FlowDebugRunValueChangeCommand stay Deployer-only in the + /// page, and FlowDebugRunMethodCommand, + /// FlowDebugRunValueChangeCommand and + /// FlowDebugRunScriptCommand stay Deployer-only in the /// ManagementActor role matrix. Only FlowDebugReplayCommand accepts /// . /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index b548e442..784636c0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -24,13 +24,13 @@ public class FlowDebugCommandsTests private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; [Fact] - public void Build_RegistersTheThreeSubcommands_WithTheirRequiredOptions() + public void Build_RegistersTheFourSubcommands_WithTheirRequiredOptions() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); Assert.Equal("flow-debug", group.Name); var names = group.Subcommands.Select(c => c.Name).ToList(); - Assert.Equal(["run-method", "run-value-change", "replay"], names); + Assert.Equal(["run-method", "run-script", "run-value-change", "replay"], names); var runMethod = group.Subcommands.Single(c => c.Name == "run-method"); Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required); @@ -38,6 +38,14 @@ public class FlowDebugCommandsTests Assert.Contains(runMethod.Options, o => o.Name == "--timeout-seconds" && !o.Required); Assert.DoesNotContain(runMethod.Options, o => o.Name == "--wait-seconds"); + var runScript = group.Subcommands.Single(c => c.Name == "run-script"); + Assert.Contains(runScript.Options, o => o.Name == "--instance-id" && o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--script" && o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--params" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--value" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--wait-seconds" && !o.Required); + Assert.Contains(runScript.Options, o => o.Name == "--timeout-seconds" && !o.Required); + var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change"); Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required); Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required); @@ -49,6 +57,37 @@ public class FlowDebugCommandsTests Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required); } + [Fact] + public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}"); + Assert.Empty(parse.Errors); + var cmd = group.Subcommands.Single(c => c.Name == "run-script"); + Assert.Equal(45, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--wait-seconds"))); + Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); + } + + [Fact] + public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + + var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --wait-seconds 0").InvokeAsync(); + + Assert.Equal(1, exitCode); + } + + [Fact] + public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument() + { + var group = FlowDebugCommands.Build(Url, Format, Username, Password); + + var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --timeout-seconds 0").InvokeAsync(); + + Assert.Equal(1, exitCode); + } + [Fact] public void RunMethod_TimeoutSecondsDefaultsTo120() { @@ -87,6 +126,7 @@ public class FlowDebugCommandsTests // The CLI derives the wire command name from the record type; a rename on either // side would throw here rather than at runtime. Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand)))); + Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunScriptCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand)))); Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand)))); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs index 10b9a374..c48ea0c1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -12,6 +12,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// prediction path that used to deadlock the circuit (see ) — /// under that bug the state chip never reached Predicted and every later click was lost, /// so these tests are the browser-level guard for that fix as well. +/// +/// +/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's +/// OutboundDemo Call script on DelmiaSim01, and the write-kind Inputs card naming the +/// trigger's own attribute. Unlike the method tests those drive RIG-SEEDED objects the fixture does +/// not provision, so each picker step skips with a "run docker/seed-…" message when it is absent. +/// /// [Collection("Playwright")] public class FlowDebuggerTests : IClassFixture @@ -91,6 +98,175 @@ public class FlowDebuggerTests : IClassFixture Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); } + [SkippableFact] + public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes() + { + // Flow Debugger script entry (design 2026-09-08 §5). Rig objects: DelmiaSim01 (SimMachine, site-a) + // carries the OutboundDemo Call script seeded by docker/seed-flow-outbound-demo.sh; skip when absent. + // A Call script is invoked DIRECTLY through the traced RouteToCall path, so this is the browser-level + // proof of the call-kind branch: picker -> prediction -> Inputs card -> real run -> graph. + Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + var consoleErrors = CaptureConsoleErrors(page); + await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl); + + await page.ClickAsync("button[data-test='flow-entry-script']"); + var scriptSelect = await SelectRigScriptAsync(page, RigCallScript); + + // The prediction ran server-side (a RouteCall root + the site script's predicted steps). + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 }); + + // Call kinds render the shared ParameterValueForm, or the no-parameters note when the script + // declares none — OutboundDemo declares none today (the seed passes no --parameters), but it + // reads an optional WorkOrder, so fill the field if a later seed starts declaring it. + var paramsCard = page.Locator("[data-test='flow-script-params']"); + var noParamsNote = page.Locator("[data-test='flow-script-no-params']"); + await Assertions.Expect(paramsCard.Or(noParamsNote)).ToBeVisibleAsync(new() { Timeout = 15_000 }); + if (await paramsCard.IsVisibleAsync()) + { + var workOrder = page.Locator("#param-WorkOrder"); + if (await workOrder.CountAsync() > 0) + { + await workOrder.FillAsync("WO-PW"); + await workOrder.BlurAsync(); + await page.WaitForTimeoutAsync(300); + } + } + + await page.ClickAsync("button[data-test='flow-run']"); + + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Completed", new() { Timeout = 60_000 }); + var stepCountText = await page.Locator("[data-test='flow-step-count']").InnerTextAsync(); + var stepCount = int.Parse(stepCountText.Trim(), CultureInfo.InvariantCulture); + Assert.True(stepCount >= 2, $"expected at least the RouteCall root + the site's ScriptRun, got {stepCount}"); + + // The picker kept the choice, and selecting a node fills the detail panel. + await Assertions.Expect(scriptSelect).ToHaveValueAsync(RigCallScript); + await page.Locator("g[data-test='flow-node']").Last.ClickAsync(); + await Assertions.Expect(page.Locator("[data-test='flow-step-title']")).ToBeVisibleAsync(); + + Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); + } + + [SkippableFact] + public async Task ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute() + { + // The write-kind branch of the same picker (design 2026-09-08 §5): a ValueChange/Conditional + // script is fired by writing ITS OWN trigger attribute, so the Inputs card offers one value + // field naming that attribute instead of a parameter form. DelmiaReceiverSimulator (ValueChange + // on DelmiaReceiver.RecipeDownloadFlag) is seeded by docker/seed-delmia-sim.sh. No run: the + // simulator has real side effects and the call-kind test above already covers execution. + Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + var consoleErrors = CaptureConsoleErrors(page); + await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl); + + await page.ClickAsync("button[data-test='flow-entry-script']"); + await SelectRigScriptAsync(page, RigValueChangeScript); + + var valueField = page.Locator("input[data-test='flow-input-value']"); + await Assertions.Expect(valueField).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await Assertions.Expect(page.Locator("[data-test='flow-script-params']")).ToHaveCountAsync(0); + + // The card names the attribute the debugger would write — that is what makes the derived + // attribute (rather than an operator-picked one) visible to the operator. + var inputsCard = valueField.Locator("xpath=ancestor::div[contains(@class,'card-body')][1]"); + await Assertions.Expect(inputsCard).ToContainTextAsync(RigValueChangeAttribute); + + Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); + } + + /// Rig instance the script-entry tests drive (docker/seed-delmia-sim.sh). + private const string RigInstanceName = "DelmiaSim01"; + + /// Call script on that instance (docker/seed-flow-outbound-demo.sh). + private const string RigCallScript = "OutboundDemo"; + + /// ValueChange script on that instance, and the attribute its trigger names. + private const string RigValueChangeScript = "DelmiaReceiverSimulator"; + private const string RigValueChangeAttribute = "DelmiaReceiver.RecipeDownloadFlag"; + + /// + /// Walks the script entry's three pickers (site → instance → script) to the named script on the + /// rig's , skipping the test with a rig-shaped message whenever an + /// option is missing — none of these objects is provisioned by the fixture. Site is matched by + /// LABEL because its id differs per rig (the seed names it "Test Plant A", identifier + /// site-a); the script is matched by option VALUE, which is its canonical name. + /// + private static async Task SelectRigScriptAsync(IPage page, string scriptName) + { + var sites = page.Locator("select[data-test='flow-site-select']"); + await Assertions.Expect(sites).ToBeVisibleAsync(new() { Timeout = 15_000 }); + var siteLabel = await WaitForOptionLabelAsync(page, sites, l => + l.Contains("Plant A", StringComparison.OrdinalIgnoreCase) + || l.Contains("Site A", StringComparison.OrdinalIgnoreCase) + || l.Contains("site-a", StringComparison.OrdinalIgnoreCase)); + Skip.If(siteLabel is null, "The rig's site-a ('Test Plant A') is not in the Flow Debugger site list — run docker/seed-sites.sh."); + await sites.SelectOptionAsync(new SelectOptionValue { Label = siteLabel! }); + + var instances = page.Locator("select[data-test='flow-instance-select']"); + var instanceLabel = await WaitForOptionLabelAsync( + page, instances, l => string.Equals(l, RigInstanceName, StringComparison.Ordinal)); + Skip.If(instanceLabel is null, $"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh."); + await instances.SelectOptionAsync(new SelectOptionValue { Label = instanceLabel! }); + + var scripts = page.Locator("select[data-test='flow-script-select']"); + var option = scripts.Locator($"option[value='{scriptName}']"); + Skip.IfNot( + await WaitForCountAsync(page, option), + $"Script {scriptName} is not on {RigInstanceName} — run docker/seed-delmia-sim.sh and docker/seed-flow-outbound-demo.sh."); + await scripts.SelectOptionAsync(scriptName); + return scripts; + } + + /// + /// Polls a <select>'s option labels until one matches, returning it (or null on + /// timeout). The instance and script lists are filled by a server round-trip after the previous + /// picker changes, so the options do not exist at first render. + /// + private static async Task WaitForOptionLabelAsync( + IPage page, ILocator select, Func match, int timeoutMs = 15_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (true) + { + foreach (var text in await select.Locator("option").AllTextContentsAsync()) + { + var label = text.Trim(); + if (label.Length > 0 && match(label)) + { + return label; + } + } + + if (DateTime.UtcNow >= deadline) + { + return null; + } + + await page.WaitForTimeoutAsync(250); + } + } + + /// Polls until matches at least one element, or the timeout expires. + private static async Task WaitForCountAsync(IPage page, ILocator locator, int timeoutMs = 15_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (await locator.CountAsync() == 0) + { + if (DateTime.UtcNow >= deadline) + { + return false; + } + + await page.WaitForTimeoutAsync(250); + } + + return true; + } + private string MethodIdText => _flow.MethodId.ToString(CultureInfo.InvariantCulture); private async Task SelectMethodAsync(IPage page) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index 55c974b7..ccc22fea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; @@ -35,6 +36,7 @@ public class FlowDebuggerPageTests : BunitContext private readonly ITemplateEngineRepository _templates = Substitute.For(); private readonly ISiteRepository _sites = Substitute.For(); private readonly IDialogService _dialog = Substitute.For(); + private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For(); private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;") { @@ -91,6 +93,10 @@ public class FlowDebuggerPageTests : BunitContext _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); Services.AddSingleton(_dialog); + // The script entry's Inputs card renders the shared ParameterValueForm, which injects + // the schema library to resolve {"$ref":"lib:Name"} nodes. Empty map = no refs. + _schemaLibrary.GetSchemaMapAsync(Arg.Any()).Returns(new Dictionary()); + Services.AddSingleton(_schemaLibrary); var identity = new ClaimsIdentity( [new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie"); @@ -145,7 +151,7 @@ public class FlowDebuggerPageTests : BunitContext Assert.Empty(cut.FindAll("button[data-test='flow-run']")); Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']")); Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']")); - Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']")); + Assert.Empty(cut.FindAll("button[data-test='flow-entry-script']")); Assert.Empty(cut.FindAll("select[data-test='flow-method-select']")); } @@ -589,4 +595,160 @@ public class FlowDebuggerPageTests : BunitContext cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any())); Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); } + + // ---- Script entry (design 2026-09-08 §5): site → instance → script ---------------------- + + private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null); + private static readonly FlowScriptOption Manual = new("Manual", "Call", null, + """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); + private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); + + private void WireInstance() + { + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); + _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick])); + _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); + } + + [Fact] + public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance() + { + RegisterServices(); + WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); + Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); + Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); + } + + [Fact] + public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt() + { + RegisterServices(); + WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); + + Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); + Assert.Empty(cut.FindAll("[data-test='flow-script-params']")); + Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + cut.Find("input[data-test='flow-input-value']").Input("true"); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } + + [Fact] + public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters() + { + RegisterServices(); + WireInstance(); + _flow.RunScriptAsync(7, "Manual", Arg.Any(), null, Arg.Any()).Returns(GraphOutcome(true, true)); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("Manual"); + + Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); + Assert.Empty(cut.FindAll("input[data-test='flow-input-value']")); + cut.Find("#param-n").Input("3"); + cut.Find("button[data-test='flow-run']").Click(); + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + _ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any()); + } + + [Fact] + public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun() + { + RegisterServices(); + WireInstance(); + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("Tick"); + Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } + + [Fact] + public void DeepLink_InstanceAndScript_PreselectsAndPredicts() + { + RegisterServices(); + WireInstance(); + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual"); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value"))); + _ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any()); + } + + /// + /// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes + /// ParameterValueForm skip its reparse (and its ValidChanged) and keeps the previous + /// script's raw text and parse errors. The page must remount the form per script instead. + /// + private static readonly FlowScriptOption Manual2 = new("Manual2", "Call", null, + """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); + + private void WireInstanceWithTwinScripts() + { + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + _flow.GetInstanceScriptsAsync(7, Arg.Any()) + .Returns(Task.FromResult>([Manual, Manual2, Tick])); + _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); + } + + private IRenderedComponent ChooseScriptWithAnInvalidParameter() + { + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("select[data-test='flow-instance-select']").Change("7"); + cut.Find("select[data-test='flow-script-select']").Change("Manual"); + cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error + Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + return cut; + } + + [Fact] + public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity() + { + RegisterServices(); + WireInstanceWithTwinScripts(); + var cut = ChooseScriptWithAnInvalidParameter(); + + cut.Find("select[data-test='flow-script-select']").Change("Manual2"); + + // A fresh form: no echo of the previous script's text, and no stale parse error keeping + // Run disabled invisibly — Run is disabled only because the required field is unset. + Assert.Equal(string.Empty, cut.Find("#param-n").GetAttribute("value")); + Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + + cut.Find("#param-n").Input("5"); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } + + [Fact] + public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun() + { + RegisterServices(); + WireInstanceWithTwinScripts(); + var cut = ChooseScriptWithAnInvalidParameter(); + + cut.Find("select[data-test='flow-script-select']").Change("Tick"); + + Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); + Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs index f968b3c8..c7b1f419 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs @@ -282,4 +282,47 @@ public class ParameterValueFormTests : BunitContext Assert.Single(cut.FindAll("button.param-list-remove")); Assert.NotEmpty(cut.FindAll(".text-danger")); } + + [Fact] + public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed() + { + var valid = new List(); + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""") + .Add(x => x.ValidChanged, v => valid.Add(v))); + + var input = cut.Find("#param-n"); + input.Input("abc"); + Assert.False(valid.Last()); + input.Input("5"); + Assert.True(valid.Last()); + } + + [Fact] + public void ValidChanged_ReportsFalseOnFirstRender_WhenARequiredFieldIsEmpty() + { + var valid = new List(); + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""") + .Add(x => x.ValidChanged, v => valid.Add(v))); + + // Reported without any input from the user... + Assert.False(valid.Last()); + + // ...and no red error text is painted for the untouched required field (the + // component has no "untouched" concept, so the validity check for + // ValidChanged deliberately does not populate _parseErrors on first render). + Assert.Empty(cut.FindAll(".text-danger")); + } + + [Fact] + public void ValidChanged_ReportsTrueOnFirstRender_WhenNothingIsRequired() + { + var valid = new List(); + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""") + .Add(x => x.ValidChanged, v => valid.Add(v))); + + Assert.True(valid.Last()); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs index 43b78400..21fa0eb6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -17,7 +17,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; using ZB.MOM.WW.ScadaBridge.Security; @@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1): /// the management-command bridge (holder null, error mapping, success → graph), the -/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay -/// for a parked outbound node (follow-ups plan 2026-09-06 D4). +/// prediction resolver wiring over substituted repositories, the script entry (design +/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and +/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4). /// /// /// is a concrete class with non-virtual methods, so the @@ -48,6 +52,7 @@ public sealed class FlowDebugServiceTests : IDisposable private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests"); private readonly IInboundApiRepository _inboundApi = Substitute.For(); private readonly ITemplateEngineRepository _templates = Substitute.For(); + private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly List _received = new(); // Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive. @@ -80,7 +85,21 @@ public sealed class FlowDebugServiceTests : IDisposable comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)))); comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)))); - return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger.Instance); + return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger.Instance); + } + + /// + /// Wires the instance row plus the flattened configuration its scripts are read from + /// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through + /// , never the raw template chain). + /// + private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts) + { + _templates.GetInstanceByIdAsync(instanceId, Arg.Any()) + .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 }); + var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts }; + _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null) + .Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult()))); } /// An External-lane step as FlowStepProjector would project it (positional per ). @@ -337,19 +356,23 @@ public sealed class FlowDebugServiceTests : IDisposable } [Fact] - public async Task RunValueChange_AndReplay_SendTheirCommands() + public async Task RunWriteKindScript_AndReplay_SendTheirCommands() { + WireFlattened(7, "Sim01", + new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" }); var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1))); var exec = Guid.NewGuid(); - var vc = await svc.RunValueChangeAsync(7, "Speed", "42"); + var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42"); var rp = await svc.ReplayAsync(exec); Assert.True(vc.Success); Assert.True(rp.Success); Assert.Equal(2, _received.Count); - var vcCmd = Assert.IsType(_received[0].Command); - Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value)); + var vcCmd = Assert.IsType(_received[0].Command); + Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value)); + // A write kind fires through the attribute, so it carries no parameters. + Assert.Null(vcCmd.Parameters); var rpCmd = Assert.IsType(_received[1].Command); Assert.Equal(exec, rpCmd.ExecutionId); } @@ -519,22 +542,36 @@ public sealed class FlowDebugServiceTests : IDisposable } [Fact] - public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript() + public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() { - _templates.GetInstanceByIdAsync(9, Arg.Any()) - .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); - _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) - .Returns(Task.FromResult>( - [ - new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" }, - new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" }, - new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" }, - ])); - _templates.GetTemplateByIdAsync(10, Arg.Any()) - .Returns(new Template("Base") { Id = 10 }); + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 }, + new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }, + new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" }); - var svc = CreateService(reply: null); - var flow = await svc.PredictValueChangeAsync(9, "Flag"); + var options = await CreateService(reply: null).GetInstanceScriptsAsync(9); + + Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList()); + var onFlag = options.Single(o => o.CanonicalName == "OnFlag"); + Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel); + var manual = options.Single(o => o.CanonicalName == "Manual"); + Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel); + Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); + } + + [Fact] + public async Task GetInstanceScripts_UnknownInstance_IsEmpty() + { + Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); + } + + [Fact] + public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() + { + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }); + + var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag"); Assert.Equal( [ @@ -542,25 +579,95 @@ public sealed class FlowDebugServiceTests : IDisposable (FlowStepKind.ScriptRun, "Sim01.OnFlag"), (FlowStepKind.AttributeWrite, "Ack"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); - Assert.Equal(2, flow.Edges.Count); - Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To)); - Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To)); } [Fact] - public async Task PredictValueChange_NoTriggeredScript_NotesIt() + public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() + { + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" }); + + var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual"); + + Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind); + Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane); + Assert.Equal("Sim01.Manual", flow.Nodes[0].Title); + Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind); + Assert.True(flow.Edges[0].CrossesBoundary); + } + + [Fact] + public async Task PredictScript_UnknownScript_IsEmpty() + { + WireFlattened(9, "Sim01"); + Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); + } + + [Fact] + public async Task RunScript_SendsTheCommand_WithTheScriptShape() + { + WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); + var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); + using var doc = JsonDocument.Parse("""{"n":3}"""); + + var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null); + + Assert.True(outcome.Success, outcome.Error); + var env = Assert.Single(_received); + var cmd = Assert.IsType(env.Command); + Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); + // A call kind carries its parameters verbatim and no value. + Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32()); + Assert.Null(cmd.Value); + } + + /// + /// One picker selection costs ONE flatten: the prediction and the run that follow it read the + /// entry just refreshed (design + /// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain. + /// + [Fact] + public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce() + { + WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }); + var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); + + await svc.GetInstanceScriptsAsync(9); + await svc.PredictScriptAsync(9, "Manual"); + await svc.RunScriptAsync(9, "Manual", null, null); + + await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any(), false, null); + } + + /// + /// A scoped service lives for the whole Blazor circuit, so re-selecting the instance must + /// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the + /// parameter form and the Ask sizing stale for the rest of the session. + /// + [Fact] + public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes() { _templates.GetInstanceByIdAsync(9, Arg.Any()) .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); - _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) - .Returns(Task.FromResult>([])); - _templates.GetTemplateByIdAsync(10, Arg.Any()).Returns(new Template("Base") { Id = 10 }); - + static Result Flattened(params ResolvedScript[] scripts) + => Result.Success(new FlatteningPipelineResult( + new FlattenedConfiguration { InstanceUniqueName = "Sim01", TemplateId = 10, SiteId = 1, Scripts = scripts }, + "h", new ValidationResult())); + _pipeline.FlattenAndValidateAsync(9, Arg.Any(), false, null).Returns( + Flattened(new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }), + Flattened( + new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }, + new ResolvedScript { CanonicalName = "Added", Code = "x", TriggerType = "Call" })); var svc = CreateService(reply: null); - var flow = await svc.PredictValueChangeAsync(9, "Flag"); - var root = Assert.Single(flow.Nodes); - Assert.Contains("no value-change script", root.Note); + var first = await svc.GetInstanceScriptsAsync(9); + var second = await svc.GetInstanceScriptsAsync(9); + + Assert.Equal(["Manual"], first.Select(o => o.CanonicalName).ToList()); + Assert.Equal(["Added", "Manual"], second.Select(o => o.CanonicalName).ToList()); + await _pipeline.Received(2).FlattenAndValidateAsync(9, Arg.Any(), false, null); } private sealed class StubAuth : AuthenticationStateProvider diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs index bfd3f478..9cf23c47 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs @@ -588,4 +588,34 @@ public class FlowGraphBuilderTests var into = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[2].EventId); Assert.Equal(graph.Steps[1].EventId, into.FromEventId); } + + // ---- Script entry point (2026-09-08 plan, Task 6) ----------------------------------------- + + [Fact] + public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse() + { + // Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an + // operator-attributed RouteCall row — there is no InboundRequest and no Response. + var root = Guid.NewGuid(); + var run = Guid.NewGuid(); + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root, + sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42", + extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a", + executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40, + extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()), + }; + + var graph = FlowGraphBuilder.Build(events); + + Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList()); + Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList()); + var edge = Assert.Single(graph.Edges); + Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.FromEventId, edge.ToEventId, edge.CrossesBoundary)); + Assert.DoesNotContain(graph.Steps, s => s.Kind == FlowStepKind.Response); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs new file mode 100644 index 00000000..2b2d14f2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs @@ -0,0 +1,46 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts; + +/// Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger. +public class ScriptTriggerAttributeTests +{ + [Theory] + [InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")] + [InlineData("valuechange", """{"attribute":"Level"}""", "Level")] + [InlineData("Conditional", """{"attributeName":"Level","operator":">","threshold":"5"}""", "Level")] + [InlineData("CONDITIONAL", """{"attribute":"Flag"}""", "Flag")] + public void WriteKinds_ReturnTheMonitoredAttribute(string kind, string config, string expected) + { + Assert.True(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Equal(expected, attribute); + Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); + } + + [Theory] + [InlineData("Call", """{"attributeName":"Level"}""")] + [InlineData("Interval", """{"intervalMs":1000}""")] + [InlineData("Expression", """{"expression":"true"}""")] + [InlineData(null, null)] + [InlineData("", "")] + public void OtherKinds_ReturnFalse_EvenWhenTheConfigNamesAnAttribute(string? kind, string? config) + { + Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Null(attribute); + Assert.False(ScriptTriggerAttribute.IsWriteKind(kind)); + } + + [Theory] + [InlineData("ValueChange", null)] + [InlineData("ValueChange", "")] + [InlineData("ValueChange", "not json")] + [InlineData("ValueChange", "[1,2]")] + [InlineData("ValueChange", """{"attributeName":""}""")] + [InlineData("ValueChange", """{"other":"x"}""")] + public void WriteKind_WithoutAReadableAttribute_ReturnsFalse_ButIsStillAWriteKind(string kind, string? config) + { + Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); + Assert.Null(attribute); + Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs index c7ce6c99..67d31354 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs @@ -10,9 +10,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.InboundAPI; using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug; @@ -340,10 +343,15 @@ public class FlowDebugRunnerTests } [Fact] - public void RoleGate_TheTwoRunCommands_AreDeployerOnly() + public void RoleGate_TheThreeRunCommands_AreDeployerOnly() { // arch-review F3: a Live run executes scripts and writes attributes, so it stays Deployer-only. - foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b") }) + foreach (var cmd in new object[] + { + new FlowDebugRunMethodCommand(1, null), + new FlowDebugRunValueChangeCommand(1, "a", "b"), + new FlowDebugRunScriptCommand(1, "Manual", null, null), + }) Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd)); } @@ -448,6 +456,331 @@ public class FlowDebugRunnerTests Assert.Equal(501, result.Events.Count); // inbound row + the 500 kept } + // --- Flow Debugger script entry (design 2026-09-08) --- + // + // The runner resolves the script from the FLATTENED configuration and branches on its + // server-side trigger kind: ValueChange/Conditional fire by WRITING the monitored attribute + // (the shared WriteAndPollAsync helper), every other kind is a direct traced routed call + // with ONE operator-attributed RouteCall root row. + + /// Wires instance 5 / site-a like plus a flattened configuration holding the given scripts. + private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline) + WireScript(params ResolvedScript[] scripts) + { + var (templates, sites, puller) = WireValueChange(); + var pipeline = Substitute.For(); + var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts }; + pipeline.FlattenAndValidateAsync(5, Arg.Any(), false, null) + .Returns(Result.Success(new FlatteningPipelineResult(config, "hash", new ValidationResult()))); + _services.AddSingleton(pipeline); + return (templates, sites, puller, pipeline); + } + + private static ResolvedScript CallScript(string name, string? parameterDefinitions = null, int? timeout = null) => + new() { CanonicalName = name, Code = "return 1;", TriggerType = "Call", ParameterDefinitions = parameterDefinitions, ExecutionTimeoutSeconds = timeout }; + + private static ResolvedScript WriteScript(string name, string kind, string attribute) => + new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" }; + + [Fact] + public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls() + { + var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "Conditional", "Level")); + RouteToSetAttributesRequest? write = null; + _router.RouteToSetAttributesAsync("site-a", Arg.Do(r => write = r), Arg.Any()) + .Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); + // Mirrors RunValueChange_TracedWrite_ThenPollsUntilSpawnedRunSettles: nothing on the first + // poll, then the SAME row (fixed event id) from the second on — a fresh id per poll would + // re-arm the settle window forever and the loop would only ever end on the deadline. + var calls = 0; + puller.PullAsync("site-a", Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + calls++; + return calls >= 2 + ? (IReadOnlyList)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + eventId: Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"), + executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection)ci[1]).First(), target: "OnLevel")] + : Array.Empty(); + }); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "onlevel", null, "7", WaitSeconds: 10), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.Equal("7", write!.AttributeValues["Level"]); + Assert.True(write.Trace); + Assert.Equal(result.RootExecutionId, write.ParentExecutionId); + Assert.Equal(2, result.Events.Count); + Assert.Equal(AuditKind.AttributeWrite, result.Events[0].AsRow().Kind); + Assert.Equal(AuditKind.ScriptRun, result.Events[1].AsRow().Kind); + // Settled: at least one quiet poll (2 s window) after the last new row, then stopped. + Assert.True(calls >= 3, $"expected settle polls, got {calls}"); + await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); + } + + [Fact] + public async Task RunScript_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheInstance() + { + var (templates, _, _, pipeline) = WireScript(CallScript("Manual")); + _gate.IsActiveNode.Returns(false); + + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync( + _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None)); + + Assert.Equal(FlowDebugRunner.StandbyNodeCode, ex.Code); + Assert.Contains("ACTIVE central node", ex.Message); + await templates.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default, default); + await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); + } + + [Fact] + public async Task RunScript_WriteKind_UnreadableTriggerConfig_FailsValidation() + { + // A write-kind trigger whose configuration names no attribute: there is nothing to write, + // so the run is refused rather than guessing an attribute. + WireScript(new ResolvedScript + { + CanonicalName = "OnLevel", Code = "return 1;", TriggerType = "ValueChange", TriggerConfiguration = "{}", + }); + + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync( + _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "OnLevel", null, "7"), Deployer, CancellationToken.None)); + + Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); + Assert.Contains("trigger configuration", ex.Message); + await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); + } + + [Fact] + public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite() + { + WireScript(WriteScript("OnLevel", "ValueChange", "Level")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "OnLevel", null, null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); + await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); + } + + [Fact] + public async Task RunScript_WriteKind_SiblingScriptRun_DoesNotCountAsCompletion() + { + // The write may fire several scripts. A sibling's run is still COLLECTED (the graph draws + // it) but must not report the script the operator chose as having run. + var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "ValueChange", "Level")); + puller.PullAsync("site-a", Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => (IReadOnlyList)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + eventId: Guid.Parse("cccccccc-0000-0000-0000-000000000001"), + executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection)ci[1]).First(), target: "OtherScript")]); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "OnLevel", null, "7", WaitSeconds: 3), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.NoTriggerCode, result.ErrorCode); + Assert.Contains("OnLevel", result.Error); + // ...but the sibling is in the events, so the operator can see what DID run. + Assert.Equal(2, result.Events.Count); + Assert.Equal("OtherScript", result.Events[1].Target); + } + + [Fact] + public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows() + { + WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5)); + var child = Guid.NewGuid(); + var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + executionId: child, target: "Manual"); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("site-a", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow, child, [siteRun])); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.Null(result.ErrorCode); + Assert.True(captured!.Trace); + Assert.Equal("Pump1", captured.InstanceUniqueName); + Assert.Equal("Manual", captured.ScriptName); + Assert.Equal(result.RootExecutionId, captured.ParentExecutionId); + Assert.Equal(3L, Convert.ToInt64(captured.Parameters!["n"])); + + Assert.Equal(2, result.Events.Count); + var root = result.Events[0].AsRow(); + Assert.Equal(AuditKind.RouteCall, root.Kind); + Assert.Equal(result.RootExecutionId, root.ExecutionId); + Assert.Equal("ops", result.Events[0].Actor); + Assert.Equal("Pump1.Manual", result.Events[0].Target); + Assert.Equal("site-a", root.SourceSiteId); + Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(root.Extra)!.Source); + Assert.Equal(child, ScriptTraceExtra.TryParse(root.Extra)!.ChildExecutionId); + Assert.Contains("\"n\":3", root.RequestSummary); + Assert.Contains("42", root.ResponseSummary); + Assert.Same(siteRun, result.Events[1]); + Assert.Single(_central); // the root row was written centrally + } + + [Fact] + public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting() + { + WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", Json("{}"), null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); + await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); + Assert.Empty(_central); + } + + [Fact] + public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRootRow() + { + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "Script 'Manual' not found on instance 'Pump1'.", DateTimeOffset.UtcNow)); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode); + Assert.Contains("not found", result.Error); + Assert.Single(result.Events); + Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status); + } + + [Fact] + public async Task RunScript_UnknownScript_IsNotFound() + { + WireScript(CallScript("Manual")); + var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Nope", null, null), Deployer, CancellationToken.None)); + Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code); + Assert.Contains("Nope", ex.Message); + } + + [Fact] + public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening() + { + var (_, _, _, pipeline) = WireScript(CallScript("Manual")); + var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" }); + await Assert.ThrowsAsync(() => Runner().RunScriptAsync( + _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), scoped, CancellationToken.None)); + await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); + } + + [Fact] + public async Task RunScript_CallKind_UnserializableReturnValue_StillWritesTheRootRow() + { + // The site already RAN the script by the time the root row is built, so a return value the + // serializer refuses (ScriptTraceExtra.JsonOptions rejects named floating-point literals) + // must not lose the audit row and surface as MapFault's generic "internal error". + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, double.NaN, null, DateTimeOffset.UtcNow)); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.True(result.Completed, result.Error); + Assert.Null(result.ErrorCode); + var root = Assert.Single(result.Events).AsRow(); + Assert.Equal(AuditKind.RouteCall, root.Kind); + Assert.Contains("unserializable", root.ResponseSummary); + var written = Assert.Single(_central); + Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind); + } + + [Fact] + public async Task RunScript_CallKind_TransportFault_StillWritesTheRootRow_AsCallFailed() + { + // A throwing Ask is NOT proof the script did not run — the site may have executed it and + // the reply been lost. The root row must still be written against the operator's name. + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromException(new InvalidOperationException("channel is broken"))); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode); + Assert.Contains("channel is broken", result.Error); + var root = Assert.Single(result.Events).AsRow(); + Assert.Equal(AuditKind.RouteCall, root.Kind); + Assert.Equal(AuditStatus.Failed, root.Status); + Assert.Equal("ops", result.Events[0].Actor); + var written = Assert.Single(_central); + Assert.Equal(AuditKind.RouteCall, written.AsRow().Kind); + } + + [Fact] + public async Task RunScript_CallKind_AskTimeout_ReportsTimeoutCode() + { + // The communication layer's own signal that the site never answered — an AskTimeoutException, + // NOT an OperationCanceledException, so it needs its own classification. + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromException( + new Akka.Actor.AskTimeoutException("no response from site-a"))); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.TimeoutCode, result.ErrorCode); + var root = Assert.Single(result.Events).AsRow(); + Assert.Equal(AuditStatus.Failed, root.Status); + Assert.Single(_central); + } + + /// A routed call that never answers until its token is cancelled — the timeout path. + private static async Task NeverAnswersAsync(CancellationToken ct) + { + await Task.Delay(Timeout.Infinite, ct); + throw new InvalidOperationException("unreachable"); + } + + [Fact] + public async Task RunScript_CallKind_Timeout_ReportsTimeoutCode() + { + // NOTE: this test really waits ExecutionTimeoutSeconds (1 s) + RunGrace (10 s) — the runner + // arms a real CancelAfter, which the injectable delay/clock does not drive. ~11 s is the + // price of the only coverage of this path. + WireScript(CallScript("Manual", timeout: 1)); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => NeverAnswersAsync((CancellationToken)ci[2])); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + Assert.False(result.Completed); + Assert.Equal(FlowDebugRunner.TimeoutCode, result.ErrorCode); + Assert.Contains("timed out", result.Error); + var root = Assert.Single(result.Events).AsRow(); + Assert.Equal(AuditKind.RouteCall, root.Kind); + Assert.Equal(AuditStatus.Failed, root.Status); + var written = Assert.Single(_central); + Assert.Equal(AuditStatus.Failed, written.AsRow().Status); + } + + [Fact] + public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered() + { + WireScript(CallScript("Manual")); + _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) + .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow)); + _services.AddSingleton(new UpperRedactor()); + + var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), + new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); + + var root = Assert.Single(result.Events); + var written = Assert.Single(_central); + Assert.Equal(written.DetailsJson!.ToUpperInvariant(), root.DetailsJson); + Assert.NotEqual(written.DetailsJson, root.DetailsJson); + } + /// Upper-cases DetailsJson so a test can see the filtered copy land. private sealed class UpperRedactor : IAuditRedactor { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index b060ad34..6f99b190 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -151,6 +151,7 @@ public class RequiredRoleMatrixTests // for a past incident and an auditor must be able to follow them. ["FlowDebugRunMethod"] = [Roles.Deployer], ["FlowDebugRunValueChange"] = [Roles.Deployer], + ["FlowDebugRunScript"] = [Roles.Deployer], ["FlowDebugReplay"] = FlowDebuggerReplayers, // ---- Two-person secured write ----------------------------------------------