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:
@@ -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"
|
||||
}
|
||||
@@ -216,13 +216,13 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
||||
- **Computed alarms render unchanged** from the prior flat-table style; the enrichment is purely additive for native rows.
|
||||
|
||||
### Flow Debugger (Flow Debugger policy; Live run = Deployment Role)
|
||||
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (value-change entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
|
||||
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (script entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
|
||||
- **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions).
|
||||
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a bolt marker. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<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`).
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
+43
-18
@@ -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>
|
||||
|
||||
+81
-40
@@ -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))));
|
||||
}
|
||||
|
||||
+176
@@ -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><select></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 ----------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user