Merge feat/flow-debugger-script-entry: Flow Debugger script entry point (site → instance → script)

ValueChange/Conditional scripts are fired by writing the trigger's attribute; every other
kind is a direct traced RouteToCall with a ParameterValueForm from the script's
ParameterDefinitions. New FlowDebugRunScriptCommand (Deployer-only, long-running),
IFlowDebugService script surface over the flattening pipeline, CLI flow-debug run-script,
verify-delmia-notifier step H2, docs. Design: docs/plans/2026-09-08-flow-debugger-script-entry-design.md.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 08:51:51 -04:00
31 changed files with 3388 additions and 228 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+31 -2
View File
@@ -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).
@@ -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
@@ -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<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken ct = default);
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken ct = default);
Task<FlowDebugOutcome> 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 `<attribute>`* 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<string, object?>`, 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 <id> --script <canonical name> [--params '<json>'] [--value <v>]
[--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.
File diff suppressed because it is too large Load Diff
@@ -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"
}
+4 -4
View File
@@ -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&amp;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=<id>`** / **`/notifications/report?correlationId=<id>`** (*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 `<attribute>`* 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`).
+1 -1
View File
@@ -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<string>? 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.
@@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>flow-debug</c> 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 <c>STANDBY_NODE</c> (Replay is served from any node).
/// The <c>flow-debug</c> 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
/// <c>STANDBY_NODE</c>. Replay is served from any node and accepts a wider role set.
/// </summary>
public static class FlowDebugCommands
{
@@ -29,9 +30,10 @@ public static class FlowDebugCommands
/// <returns>The configured <c>flow-debug</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var scriptOption = new Option<string>("--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<string?>("--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<string?>("--value") { Description = "Value to write to the trigger attribute of a ValueChange/Conditional script (canonical string form)" };
var waitOption = new Option<int>("--wait-seconds") { Description = "Write kinds: how long to wait for the triggered run before NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var timeoutOption = new Option<int>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
var waitOption = new Option<int>("--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);
+41 -9
View File
@@ -2270,16 +2270,20 @@ scadabridge --url <url> cached-call discard --site-id <string> --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 <url> flow-debug run-method --method-id <int> [--params <json>
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 <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--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 <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
@@ -24,8 +24,8 @@
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
</div>
</div>
}
@@ -70,14 +70,14 @@
}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm">Attribute</label>
<select class="form-select form-select-sm" data-test="flow-attribute-select"
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<div class="col-md-3">
<label class="form-label form-label-sm">Script</label>
<select class="form-select form-select-sm" data-test="flow-script-select"
@bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<option value="">— choose —</option>
@foreach (var a in _attributes)
@foreach (var s in _scripts)
{
<option value="@a">@a</option>
<option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</option>
}
</select>
</div>
@@ -193,7 +193,7 @@
<div class="row g-3">
@* ── 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))
{
<div class="col-lg-3">
<div class="card fd-inputs" data-test="flow-inputs">
@@ -214,13 +214,38 @@
</div>
}
}
else
else if (SelectedScript is { } script)
{
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
</div>
@* 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)
{
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
<div class="form-text">@script.TriggerType trigger — writing the attribute fires the script.</div>
</div>
}
else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
<div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
}
else
{
<div data-test="flow-script-params">
@* 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). *@
<ParameterValueForm @key="@script.CanonicalName"
ParameterDefinitions="@script.ParameterDefinitions"
Values="_scriptParams"
ValuesChanged="@(v => _scriptParams = v)"
ValidChanged="@(ok => _scriptParamsValid = ok)" />
</div>
}
}
</div>
</div>
@@ -228,7 +253,7 @@
}
@* ── Flow panel ────────────────────────────────────────────────────── *@
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
<div class="fd-flow-tools">
<div class="small text-muted">
@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). *@
<FlowStepDetail Step="_selected" Graph="_graph"
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" />
</div>
@@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// 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
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
/// </summary>
@@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{
/// <summary>An inbound API method.</summary>
Method,
/// <summary>A value-change trigger on an instance attribute.</summary>
ValueChange,
/// <summary>
/// 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.
/// </summary>
Script,
}
/// <summary>Live vs Replay.</summary>
@@ -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
/// <summary>Drill-in: pre-select an inbound method.</summary>
[SupplyParameterFromQuery] public int? MethodId { get; set; }
/// <summary>Drill-in: pre-select an instance (value-change entry).</summary>
/// <summary>Drill-in: pre-select an instance (script entry).</summary>
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
/// <summary>Drill-in: pre-select the trigger attribute (with <see cref="InstanceId"/>).</summary>
[SupplyParameterFromQuery] public string? Attribute { get; set; }
/// <summary>Drill-in: pre-select the script (with <see cref="InstanceId"/>).</summary>
[SupplyParameterFromQuery] public string? Script { get; set; }
/// <summary>Drill-in: replay this execution on first render.</summary>
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
@@ -109,15 +112,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private List<ApiMethod> _methods = [];
private List<Site> _sites = [];
private List<Instance> _instances = [];
private List<string> _attributes = [];
private List<FlowScriptOption> _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;
/// <summary>The chosen script's option row, or null when nothing (or something stale) is selected.</summary>
private FlowScriptOption? SelectedScript
=> _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
/// <summary>Call-kind parameter values from <c>ParameterValueForm</c>, and whether it currently parses.</summary>
private Dictionary<string, object?> _scriptParams = new();
private bool _scriptParamsValid;
private List<InboundApiSchemaField> _inputFields = [];
private readonly Dictionary<string, string> _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<string?>("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<string?>("localStorage.getItem", StorageSite);
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
var attr = await JS.InvokeAsync<string?>("localStorage.getItem", StorageAttribute);
var script = await JS.InvokeAsync<string?>("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()
/// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
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);
}
/// <summary>
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary>
internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
private static JsonNode? Coerce(string? type, string raw)
{
switch (InboundApiSchema.NormalizeType(type))
@@ -48,6 +48,13 @@ else
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
/// <summary>
/// Raised after every edit with <c>true</c> 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.
/// </summary>
[Parameter] public EventCallback<bool> ValidChanged { get; set; }
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
private IReadOnlyList<InboundApiSchemaField> _topLevelFields = Array.Empty<InboundApiSchemaField>();
private InboundApiSchema? _rootSchema;
@@ -70,6 +77,44 @@ else
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
_rootSchema = await ParseRootAsync();
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
// 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<string>();
_rootSchema.Validate(element, string.Empty, errors);
return errors.Count == 0;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
@@ -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
@@ -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;
/// <summary>
/// Default <see cref="IFlowDebugService"/>: 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 <see cref="IFlatteningPipeline"/> 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 <c>ManagementActor</c> through
/// <see cref="ManagementActorHolder"/>, exactly as <see cref="DataConnectionMoveService"/>
/// does, with the current Blazor principal projected to an <see cref="AuthenticatedUser"/>
@@ -58,8 +66,8 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <summary>
/// Client-side Ask wait for <see cref="RunValueChangeAsync"/>: the trigger-wait window
/// plus <see cref="ClientSlack"/>.
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
/// trigger-wait window plus <see cref="ClientSlack"/>.
/// </summary>
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<FlowDebugService> _logger;
/// <summary>
/// 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 <see cref="GetInstanceScriptsAsync"/> 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
/// <c>VALIDATION_FAILED</c>) and a stale Ask bound against a runner that re-flattens on every
/// run. The raw <see cref="ResolvedScript"/>s ride along because prediction needs the code
/// bodies, which the picker's projection deliberately does not carry.
/// </summary>
private readonly Dictionary<int, InstanceScripts> _scriptCache = new();
/// <summary>One instance's flattened scripts: the unique name the site keys on, the raw resolved scripts, and the picker projection.</summary>
private sealed record InstanceScripts(
string UniqueName,
IReadOnlyList<ResolvedScript> Scripts,
IReadOnlyList<FlowScriptOption> Options);
/// <summary>Initializes the service.</summary>
public FlowDebugService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
IInboundApiRepository inboundApi,
ITemplateEngineRepository templates,
IFlatteningPipeline pipeline,
CommunicationService comms,
ILogger<FlowDebugService> 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
}
/// <inheritdoc/>
public async Task<PredictedFlow> PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default)
=> (await FlattenScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
/// <inheritdoc/>
public async Task<PredictedFlow> 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<PredictedNode>
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<PredictedEdge>();
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<string, string>(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<PredictedNode>
{
new("n1", FlowStepKind.RouteCall, FlowLane.Central, title, false, null),
};
var edges = new List<PredictedEdge>();
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
}
/// <inheritdoc/>
public Task<FlowDebugOutcome> RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default)
public async Task<FlowDebugOutcome> 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);
}
/// <inheritdoc/>
@@ -366,38 +412,108 @@ public sealed class FlowDebugService : IFlowDebugService
return result;
}
/// <summary>True when the script's trigger is a value-change on <paramref name="attributeName"/> (case-insensitive).</summary>
private static bool IsValueChangeOn(TemplateScript script, string attributeName)
/// <summary>
/// The instance's scripts as the current picker selection knows them: the cached entry a
/// <see cref="GetInstanceScriptsAsync"/> call just refreshed, or a fresh flatten when the
/// prediction / run is reached without one (a deep link straight into a run).
/// </summary>
private async Task<InstanceScripts?> CachedScriptsAsync(int instanceId, CancellationToken ct)
=> _scriptCache.TryGetValue(instanceId, out var cached) ? cached : await FlattenScriptsAsync(instanceId, ct);
/// <summary>
/// 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 <c>DeploymentService</c> 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.
/// </summary>
private async Task<InstanceScripts?> 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;
}
/// <summary>
/// Predicts the flow a write to <paramref name="attributeName"/> 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.
/// </summary>
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<PredictedNode>
{
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<PredictedEdge>();
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);
}
/// <summary>
/// Appends a predicted sub-flow to <paramref name="nodes"/>/<paramref name="edges"/>,
/// renumbering its node ids into the outer sequence and linking its first node to the root
/// (<c>n1</c>).
/// </summary>
private static void AppendSubFlow(PredictedFlow sub, List<PredictedNode> nodes, List<PredictedEdge> edges, bool crossesBoundary)
{
var offset = nodes.Count;
var map = new Dictionary<string, string>(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<FlowDebugOutcome> SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct)
@@ -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);
/// <summary>
/// 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 <c>ManagementActor</c>
/// 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).
/// </summary>
/// <param name="CanonicalName">Path-qualified script name from the flattened configuration (<c>Module.Script</c> for a composed member).</param>
/// <param name="TriggerType">The effective trigger type, or null when the script has none.</param>
/// <param name="TriggerAttribute">The monitored attribute of a write-kind trigger; null for every other kind.</param>
/// <param name="ParameterDefinitions">JSON parameter definitions the Inputs card renders for a call kind; null = none.</param>
/// <param name="ExecutionTimeoutSeconds">The script's own execution timeout, or null to use the site default.</param>
public sealed record FlowScriptOption(
string CanonicalName, string? TriggerType, string? TriggerAttribute,
string? ParameterDefinitions, int? ExecutionTimeoutSeconds)
{
/// <summary>True when the debugger fires this script by writing <see cref="TriggerAttribute"/>.</summary>
public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType);
/// <summary>Picker label suffix: <c>ValueChange: Level</c>, <c>Call</c>, <c>no trigger</c>…</summary>
public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger"
: TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!;
}
/// <summary>
/// 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 <c>ManagementActor</c>
/// through the in-process <c>ManagementActorHolder</c> — the SAME Ask path the HTTP
/// <c>/management</c> endpoint uses, so the server stays the single enforcer of the Deployer
/// gate and the active-node refusal (<c>STANDBY_NODE</c>).
@@ -49,15 +73,16 @@ public interface IFlowDebugService
/// <returns>The predicted flow; <see cref="PredictedFlow.Empty"/> when the method does not exist.</returns>
Task<PredictedFlow> PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default);
/// <summary>
/// Predicts the flow a value change on <paramref name="attributeName"/> would trigger: the
/// instance's template scripts whose value-change trigger names that attribute, each predicted
/// as a site script.
/// </summary>
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="attributeName">Canonical attribute name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictedFlow> PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
/// <param name="apiMethodId">The method id.</param>
@@ -65,12 +90,13 @@ public interface IFlowDebugService
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
/// <summary>Writes an attribute through the traced path and returns the flow the change triggered.</summary>
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="attributeName">Canonical attribute name.</param>
/// <param name="value">Value in canonical string form.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowDebugOutcome> RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default);
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
/// <summary>Rebuilds a past flow from the central audit store.</summary>
/// <param name="executionId">Any execution id in the chain.</param>
@@ -24,6 +24,27 @@ public record FlowDebugRunMethodCommand(int ApiMethodId, JsonElement? Parameters
/// <param name="WaitSeconds">How long to wait for a spawned run before reporting <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunValueChangeCommand(int InstanceId, string AttributeName, string Value, int WaitSeconds = 45);
/// <summary>
/// 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 <c>ValueChange</c>/<c>Conditional</c> script is fired by WRITING its monitored
/// attribute (<paramref name="Value"/>) and polling for the spawned run, exactly like
/// <see cref="FlowDebugRunValueChangeCommand"/>; every other kind is invoked DIRECTLY through the
/// traced routed-call path with <paramref name="Parameters"/>. Deployer role; refuses on a standby
/// with <c>STANDBY_NODE</c>.
/// </summary>
/// <param name="InstanceId">The instance the script belongs to.</param>
/// <param name="ScriptName">Canonical script name from the flattened configuration (case-insensitive).</param>
/// <param name="Parameters">Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none.</param>
/// <param name="Value">Write kinds: canonical string value to write to the trigger's attribute.</param>
/// <param name="WaitSeconds">Write kinds: how long to wait for a spawned run before <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunScriptCommand(
int InstanceId,
string ScriptName,
JsonElement? Parameters,
string? Value,
int WaitSeconds = 45);
/// <summary>
/// Flow Debugger Replay (design 2026-09-05 §7.1): returns the audit events of the execution
/// tree that <paramref name="ExecutionId"/> belongs to, from the central store.
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// <summary>
/// Reads the single monitored attribute of a <c>ValueChange</c> or <c>Conditional</c> script
/// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the
/// Central UI's <c>ScriptTriggerConfigCodec</c> writes (<c>attributeName</c>) and SiteRuntime's
/// <c>TriggerRouting</c> also accepts (<c>attribute</c>). Pure; never throws on bad JSON.
/// </summary>
public static class ScriptTriggerAttribute
{
/// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
public static bool IsWriteKind(string? triggerType) =>
string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
|| string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
@@ -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
/// <summary>Error code when the method / instance / site does not exist.</summary>
public const string NotFoundCode = "NOT_FOUND";
/// <summary>Error code when a direct script call (design 2026-09-08 §4.4) fails at the site.</summary>
public const string CallFailedCode = "CALL_FAILED";
/// <summary>Error code when a Live run exceeds the method's / script's timeout plus <see cref="RunGrace"/>.</summary>
public const string TimeoutCode = "TIMEOUT";
/// <summary>
/// Timeout for a direct script call whose script declares none — mirrors
/// <c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>'s default (the runner has no site options).
/// </summary>
public const int DefaultScriptTimeoutSeconds = 30;
/// <summary>
/// Ceiling on a write-kind run's spawned-run wait, whatever the command asks for. It must stay
/// UNDER <c>ManagementServiceOptions.LongRunningCommandTimeout</c> (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.
/// </summary>
public const int MaxWaitSeconds = 300;
/// <summary>
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'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);
}
/// <summary>
/// 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 <see cref="WriteAndPollAsync"/>, every other kind is a traced
/// <see cref="IInstanceRouter.RouteToCallAsync"/> with one operator-attributed
/// <see cref="AuditKind.RouteCall"/> root row. Calling <see cref="IInstanceRouter"/> directly
/// rather than through <see cref="RouteHelper"/> is deliberate: the helper emits its own
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
/// </summary>
public async Task<FlowDebugRunResult> 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<ITemplateEngineRepository>()
.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<ISiteRepository>()
.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<IFlatteningPipeline>()
.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<ISharedSchemaRepository>(), [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<IAuditRedactor>();
var collector = new FlowTraceCollector(redactor: redactor);
var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds));
var router = sp.GetRequiredService<IInstanceRouter>();
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<INodeIdentityProvider>()?.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<AuditEvent>(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);
}
/// <summary>
/// The traced attribute write + spawned-run poll shared by <see cref="RunValueChangeAsync"/> and
/// the write-kind branch of the script entry's <c>RunScriptAsync</c> (design 2026-09-08 §4.3). The
/// caller has already gated the active node, loaded and scope-checked the instance, and loaded the
/// site. <paramref name="triggerScript"/> is null for the attribute-level command and the script's
/// canonical name for the script entry — it only changes the NO_TRIGGER message.
/// </summary>
private async Task<FlowDebugRunResult> 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<string, string> { [cmd.AttributeName] = cmd.Value ?? string.Empty },
new Dictionary<string, string> { [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<INodeIdentityProvider>()?.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<IPullTraceEventsClient>();
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<Guid, AuditEvent>();
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<AuditEvent> { 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);
}
/// <summary>
/// The timed-out message shared by both Live entry points, so the two paths never drift.
/// </summary>
private static string TimeoutMessage(TimeSpan timeout) =>
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace";
/// <summary>
/// Serializes a routed call's return value for the root row's <c>ResponseSummary</c>, NEVER
/// throwing: <see cref="ScriptTraceExtra.JsonOptions"/> rejects named floating-point literals,
/// so a script returning <c>double.NaN</c> / 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.
///
/// <para>
/// The catch is deliberately broad (as <see cref="WriteCentralAsync"/>'s already is): the value
/// crossing this seam came from a user-authored script, and the serializer's failure mode varies
/// by value — <c>ArgumentException</c> for a non-finite number, <see cref="JsonException"/> for a
/// cycle, <see cref="NotSupportedException"/> for an unmappable type. A best-effort audit summary
/// must not be the thing that fails a call the site already ran.
/// </para>
/// </summary>
private static string? SerializeReturnValue(object? returnValue)
{
if (returnValue is null) return null;
try
{
return JsonSerializer.Serialize(returnValue, ScriptTraceExtra.JsonOptions);
}
catch (Exception ex)
{
return $"<unserializable return value: {ex.GetType().Name}>";
}
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService<IActiveNodeGate>();
@@ -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<FlowDebug.FlowDebugRunner>().RunMethodAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunValueChangeAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunScriptCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunScriptAsync(sp, cmd, user, CancellationToken.None),
FlowDebugReplayCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().ReplayAsync(sp, cmd, CancellationToken.None),
// External Systems
@@ -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),
];
/// <summary>
@@ -14,7 +14,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddManagementService(this IServiceCollection services)
{
services.AddSingleton<ManagementActorHolder>();
// 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<FlowDebug.FlowDebugRunner>();
services.AddOptions<ManagementServiceOptions>()
.BindConfiguration("ScadaBridge:ManagementService")
@@ -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 <see cref="RequireDeployment"/> in the
/// page, and <c>FlowDebugRunMethodCommand</c> /
/// <c>FlowDebugRunValueChangeCommand</c> stay Deployer-only in the
/// page, and <c>FlowDebugRunMethodCommand</c>,
/// <c>FlowDebugRunValueChangeCommand</c> and
/// <c>FlowDebugRunScriptCommand</c> stay Deployer-only in the
/// ManagementActor role matrix. Only <c>FlowDebugReplayCommand</c> accepts
/// <see cref="FlowDebuggerAccessRoles"/>.
/// </remarks>
@@ -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<int>)cmd.Options.Single(o => o.Name == "--wait-seconds")));
Assert.Equal(120, parse.GetValue((Option<int>)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))));
}
@@ -12,6 +12,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// prediction path that used to deadlock the circuit (see <see cref="FlowDebuggerFixture"/>) —
/// under that bug the state chip never reached <c>Predicted</c> and every later click was lost,
/// so these tests are the browser-level guard for that fix as well.
///
/// <para>
/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's
/// <c>OutboundDemo</c> Call script on <c>DelmiaSim01</c>, 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.
/// </para>
/// </summary>
[Collection("Playwright")]
public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
@@ -91,6 +98,175 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
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));
}
/// <summary>Rig instance the script-entry tests drive (<c>docker/seed-delmia-sim.sh</c>).</summary>
private const string RigInstanceName = "DelmiaSim01";
/// <summary>Call script on that instance (<c>docker/seed-flow-outbound-demo.sh</c>).</summary>
private const string RigCallScript = "OutboundDemo";
/// <summary>ValueChange script on that instance, and the attribute its trigger names.</summary>
private const string RigValueChangeScript = "DelmiaReceiverSimulator";
private const string RigValueChangeAttribute = "DelmiaReceiver.RecipeDownloadFlag";
/// <summary>
/// Walks the script entry's three pickers (site → instance → script) to the named script on the
/// rig's <see cref="RigInstanceName"/>, 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
/// <c>site-a</c>); the script is matched by option VALUE, which is its canonical name.
/// </summary>
private static async Task<ILocator> 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;
}
/// <summary>
/// Polls a <c>&lt;select&gt;</c>'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.
/// </summary>
private static async Task<string?> WaitForOptionLabelAsync(
IPage page, ILocator select, Func<string, bool> 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);
}
}
/// <summary>Polls until <paramref name="locator"/> matches at least one element, or the timeout expires.</summary>
private static async Task<bool> 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)
@@ -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<ITemplateEngineRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For<ISchemaLibraryQueryService>();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
@@ -91,6 +93,10 @@ public class FlowDebuggerPageTests : BunitContext
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).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<CancellationToken>()).Returns(new Dictionary<string, string>());
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<CancellationToken>()));
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<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
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<FlowDebuggerPage>();
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<JsonElement?>(), null, Arg.Any<CancellationToken>()).Returns(GraphOutcome(true, true));
var cut = Render<FlowDebuggerPage>();
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<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
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<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
}
/// <summary>
/// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes
/// <c>ParameterValueForm</c> skip its reparse (and its <c>ValidChanged</c>) and keeps the previous
/// script's raw text and parse errors. The page must remount the form per script instead.
/// </summary>
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<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, Manual2, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
private IRenderedComponent<FlowDebuggerPage> ChooseScriptWithAnInvalidParameter()
{
var cut = Render<FlowDebuggerPage>();
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"));
}
}
@@ -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<bool>();
var cut = Render<ParameterValueForm>(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<bool>();
var cut = Render<ParameterValueForm>(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<bool>();
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""")
.Add(x => x.ValidChanged, v => valid.Add(v)));
Assert.True(valid.Last());
}
}
@@ -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;
/// <summary>
/// Tests for <see cref="FlowDebugService"/> (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).
/// </summary>
/// <remarks>
/// <see cref="CommunicationService"/> 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<IInboundApiRepository>();
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly List<ManagementEnvelope> _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<FlowDebugService>.Instance);
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
}
/// <summary>
/// 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
/// <see cref="IFlatteningPipeline"/>, never the raw template chain).
/// </summary>
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
_templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
.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<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
@@ -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<FlowDebugRunValueChangeCommand>(_received[0].Command);
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_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<FlowDebugReplayCommand>(_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<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
[
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<CancellationToken>())
.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<FlowDebugRunScriptCommand>(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);
}
/// <summary>
/// One picker selection costs ONE flatten: the prediction and the run that follow it read the
/// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design
/// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain.
/// </summary>
[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<CancellationToken>(), false, null);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).Returns(new Template("Base") { Id = 10 });
static Result<FlatteningPipelineResult> Flattened(params ResolvedScript[] scripts)
=> Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(
new FlattenedConfiguration { InstanceUniqueName = "Sim01", TemplateId = 10, SiteId = 1, Scripts = scripts },
"h", new ValidationResult()));
_pipeline.FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), 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<CancellationToken>(), false, null);
}
private sealed class StubAuth : AuthenticationStateProvider
@@ -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<AuditEvent>
{
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);
}
}
@@ -0,0 +1,46 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts;
/// <summary>Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger.</summary>
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));
}
}
@@ -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.
/// <summary>Wires instance 5 / site-a like <see cref="WireValueChange"/> plus a flattened configuration holding the given scripts.</summary>
private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline)
WireScript(params ResolvedScript[] scripts)
{
var (templates, sites, puller) = WireValueChange();
var pipeline = Substitute.For<IFlatteningPipeline>();
var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts };
pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.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<RouteToSetAttributesRequest>(r => write = r), Arg.Any<CancellationToken>())
.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<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
calls++;
return calls >= 2
? (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
eventId: Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"),
executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)ci[1]).First(), target: "OnLevel")]
: Array.Empty<AuditEvent>();
});
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<ManagementCommandException>(() => 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<ManagementCommandException>(() => 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<ManagementCommandException>(() => 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<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
eventId: Guid.Parse("cccccccc-0000-0000-0000-000000000001"),
executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)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<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.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<ManagementCommandException>(() => 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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.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<ManagementCommandException>(() => 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<SiteScopeViolationException>(() => 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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<RouteToCallResponse>(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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<RouteToCallResponse>(
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);
}
/// <summary>A routed call that never answers until its token is cancelled — the timeout path.</summary>
private static async Task<RouteToCallResponse> 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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow));
_services.AddSingleton<IAuditRedactor>(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);
}
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
@@ -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 ----------------------------------------------