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