# Flow Debugger Script Entry Point Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Replace the Flow Debugger's value-change entry (site → instance → attribute) with a script entry (site → instance → script): ValueChange/Conditional scripts keep the write-and-poll path with the attribute derived from the trigger; every other script is invoked directly through the traced routed-call path with a parameter form from its `ParameterDefinitions`. **Architecture:** One new additive management command `FlowDebugRunScriptCommand` handled by `FlowDebugRunner.RunScriptAsync`, which resolves the script through `IFlatteningPipeline` (canonical names, effective trigger + parameters) and branches server-side on the trigger kind: write kinds reuse the extracted `WriteAndPollAsync` body of today's value-change run; call kinds route `RouteToCallRequest(Trace: true)` via `IInstanceRouter` and return one operator-attributed `RouteCall` root row plus the site's redacted/capped inline trace rows. The Central UI service exposes the instance's scripts (`FlowScriptOption`), a script prediction and a script run; the page swaps the attribute picker for a script picker and renders either the single value field or the shared `ParameterValueForm`. The CLI gains `flow-debug run-script`; `run-value-change` and its command record stay. **Tech Stack:** .NET 10, Blazor Server (bUnit tests), Akka.NET ManagementActor, System.CommandLine CLI, NSubstitute + xUnit, Playwright against the docker rig. **Design:** `docs/plans/2026-09-08-flow-debugger-script-entry-design.md` (approved 2026-09-08). **Branch:** `feat/flow-debugger-script-entry` (from `main` @ `789377ef`; design commit `47535f34`). **Execution notes for the orchestrator** - Worktree-isolated agents start at `main`: every agent prompt begins with `git reset --hard ` and only then confirms HEAD (see memory `agent-worktree-isolation-base`). - Agents commit on their own `worktree-agent-*` branch and report the sha; the orchestrator merges into the feature branch in the main checkout. Never commit in the main checkout from a parallel agent. - Commit trailer on every commit: `Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29`. - Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (0 warnings expected). Tests: `dotnet test --filter "FullyQualifiedName~"`. - Model per task: `opus` for high-risk (runner, actor/roles, page), `sonnet` for small/trivial; the merged diff gets an `opus` `code-reviewer` pass before the gate. **Waves (no file overlap inside a wave)** | Wave | Tasks | Notes | |---|---|---| | W1 | 1, 2, 3, 6, 7 | helper, command record, runner refactor, builder pin, form callback | | W2 | 4 | runner `RunScriptAsync` (needs 1, 2, 3) | | W3 | 5, 8, 10 | actor/roles/endpoints (needs 4); UI service (needs 1, 2); CLI (needs 2) | | W4 | 9 | page (needs 7, 8) | | W5 | 11, 12 | Playwright + verify script leg; docs | | W6 | 13 | gate: build, full tests, rig deploy, verify, browser | --- ### Task 1: `ScriptTriggerAttribute` helper (Commons) **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 2, Task 3, Task 6, Task 7 **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs` - Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs` (create; the `Scripts/` folder may not exist yet — create it) **Step 1: Write the failing tests** ```csharp using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts; /// Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger. public class ScriptTriggerAttributeTests { [Theory] [InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")] [InlineData("valuechange", """{"attribute":"Level"}""", "Level")] [InlineData("Conditional", """{"attributeName":"Level","operator":">","threshold":"5"}""", "Level")] [InlineData("CONDITIONAL", """{"attribute":"Flag"}""", "Flag")] public void WriteKinds_ReturnTheMonitoredAttribute(string kind, string config, string expected) { Assert.True(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); Assert.Equal(expected, attribute); Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); } [Theory] [InlineData("Call", """{"attributeName":"Level"}""")] [InlineData("Interval", """{"intervalMs":1000}""")] [InlineData("Expression", """{"expression":"true"}""")] [InlineData(null, null)] [InlineData("", "")] public void OtherKinds_ReturnFalse_EvenWhenTheConfigNamesAnAttribute(string? kind, string? config) { Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); Assert.Null(attribute); Assert.False(ScriptTriggerAttribute.IsWriteKind(kind)); } [Theory] [InlineData("ValueChange", null)] [InlineData("ValueChange", "")] [InlineData("ValueChange", "not json")] [InlineData("ValueChange", "[1,2]")] [InlineData("ValueChange", """{"attributeName":""}""")] [InlineData("ValueChange", """{"other":"x"}""")] public void WriteKind_WithoutAReadableAttribute_ReturnsFalse_ButIsStillAWriteKind(string kind, string? config) { Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute)); Assert.Null(attribute); Assert.True(ScriptTriggerAttribute.IsWriteKind(kind)); } } ``` **Step 2: Run to verify it fails** Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~ScriptTriggerAttributeTests"` Expected: build error `ScriptTriggerAttribute` not found. **Step 3: Implement** ```csharp using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; /// /// Reads the single monitored attribute of a ValueChange or Conditional script /// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the /// Central UI's ScriptTriggerConfigCodec writes (attributeName) and SiteRuntime's /// TriggerRouting also accepts (attribute). Pure; never throws on bad JSON. /// public static class ScriptTriggerAttribute { /// True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute. public static bool IsWriteKind(string? triggerType) => string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase) || string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase); /// /// Returns the monitored attribute for a write-kind trigger with a readable configuration; /// false (and null) for every other kind, and for a write kind whose config is missing, /// malformed, not an object, or names no attribute. /// public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute) { attribute = null; if (!IsWriteKind(triggerType) || string.IsNullOrWhiteSpace(triggerConfigurationJson)) return false; try { using var doc = JsonDocument.Parse(triggerConfigurationJson); var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return false; foreach (var key in new[] { "attributeName", "attribute" }) { if (root.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } name) { attribute = name; return true; } } return false; } catch (JsonException) { return false; } } } ``` **Step 4: Run to verify it passes** Run: the Step 2 command. Expected: all pass. **Step 5: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptTriggerAttribute.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Scripts/ScriptTriggerAttributeTests.cs git commit -m "feat(commons): ScriptTriggerAttribute — monitored attribute of a ValueChange/Conditional trigger (flow debugger script entry)" ``` --- ### Task 2: `FlowDebugRunScriptCommand` record **Classification:** trivial **Estimated implement time:** ~2 min **Parallelizable with:** Task 1, Task 3, Task 6, Task 7 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs` (after `FlowDebugRunValueChangeCommand`, line 25) **Step 1: Add the record** ```csharp /// /// Flow Debugger Live run of a site script by name (design 2026-09-08). The runner resolves the /// script's effective definition from the flattened instance configuration and branches on its /// trigger kind: a ValueChange/Conditional script is fired by WRITING its monitored /// attribute () and polling for the spawned run, exactly like /// ; every other kind is invoked DIRECTLY through the /// traced routed-call path with . Deployer role; refuses on a standby /// with STANDBY_NODE. /// /// The instance the script belongs to. /// Canonical script name from the flattened configuration (case-insensitive). /// Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none. /// Write kinds: canonical string value to write to the trigger's attribute. /// Write kinds: how long to wait for a spawned run before NO_TRIGGER_WITHIN_WINDOW. public record FlowDebugRunScriptCommand( int InstanceId, string ScriptName, JsonElement? Parameters, string? Value, int WaitSeconds = 45); ``` **Step 2: Build** Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons` Expected: success, 0 warnings. **Step 3: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs git commit -m "feat(commons): FlowDebugRunScriptCommand — additive management command for the flow debugger script entry" ``` --- ### Task 3: Extract `WriteAndPollAsync` from `RunValueChangeAsync` (runner refactor, no behaviour change) **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Task 1, Task 2, Task 6, Task 7 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs:198-303` (`RunValueChangeAsync`) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs` (existing `RunValueChange_*` tests must keep passing unchanged) **Step 1: Refactor** Split `RunValueChangeAsync` so that everything from the `root = Guid.NewGuid()` line through the final `return` moves into: ```csharp /// /// The traced attribute write + spawned-run poll shared by and /// the write-kind branch of (design 2026-09-08 §4.3). The caller has /// already gated the active node, loaded and scope-checked the instance, and loaded the site. /// is null for the attribute-level command and the script's /// canonical name for the script entry — it only changes the NO_TRIGGER message. /// private async Task WriteAndPollAsync( IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site, string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct) ``` Body: identical to today's code with `cmd.AttributeName` → `attributeName`, `cmd.Value` → `value`, `cmd.WaitSeconds` → `waitSeconds`. The final error message becomes: ```csharp completed ? null : triggerScript is null ? $"No traced run was spawned within {waitSeconds}s (no value-change trigger on '{attributeName}', or no device echo)." : $"No traced run of '{triggerScript}' was spawned within {waitSeconds}s after writing '{attributeName}' (trigger condition not met, or no device echo).", ``` `RunValueChangeAsync` keeps its argument checks, `EnsureActiveNode`, instance load + `EnforceSiteScope` + site load, then `return await WriteAndPollAsync(sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct)`. Add `using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;` and `using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;` if not present. **Step 2: Run the existing tests** Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~FlowDebugRunnerTests"` Expected: all pass (no test changes). **Step 3: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs git commit -m "refactor(management): FlowDebugRunner — extract WriteAndPollAsync from RunValueChangeAsync (no behaviour change)" ``` --- ### Task 4: `FlowDebugRunner.RunScriptAsync` **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (needs Tasks 1, 2, 3) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs` (new constants + method after `RunValueChangeAsync`) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs` **Step 1: Write the failing tests** (append to `FlowDebugRunnerTests`; add `using ZB.MOM.WW.ScadaBridge.DeploymentManager;`, `using ZB.MOM.WW.ScadaBridge.Commons.Types;`, `using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;`, `using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;` as needed) ```csharp /// Wires instance 5 / site-a like plus a flattened configuration holding the given scripts. private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline) WireScript(params ResolvedScript[] scripts) { var (templates, sites, puller) = WireValueChange(); var pipeline = Substitute.For(); var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts }; pipeline.FlattenAndValidateAsync(5, Arg.Any(), false, null) .Returns(Result.Success(new FlatteningPipelineResult(config, "hash", new ValidationResult()))); _services.AddSingleton(pipeline); return (templates, sites, puller, pipeline); } private static ResolvedScript CallScript(string name, string? parameterDefinitions = null, int? timeout = null) => new() { CanonicalName = name, Code = "return 1;", TriggerType = "Call", ParameterDefinitions = parameterDefinitions, ExecutionTimeoutSeconds = timeout }; private static ResolvedScript WriteScript(string name, string kind, string attribute) => new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" }; [Fact] public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls() { var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "Conditional", "Level")); RouteToSetAttributesRequest? write = null; _router.RouteToSetAttributesAsync("site-a", Arg.Do(r => write = r), Arg.Any()) .Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); puller.PullAsync("site-a", Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => (IReadOnlyList)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection)ci[1]).First(), target: "OnLevel")]); var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "onlevel", null, "7", WaitSeconds: 10), Deployer, CancellationToken.None); Assert.True(result.Completed, result.Error); Assert.Equal("7", write!.AttributeValues["Level"]); Assert.True(write.Trace); Assert.Equal(AuditKind.AttributeWrite, result.Events[0].AsRow().Kind); await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); } [Fact] public async Task RunScript_WriteKind_WithoutValue_FailsValidation_BeforeAnyWrite() { WireScript(WriteScript("OnLevel", "ValueChange", "Level")); var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "OnLevel", null, null), Deployer, CancellationToken.None)); Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default); } [Fact] public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows() { WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5)); var child = Guid.NewGuid(); var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: child, target: "Manual"); RouteToCallRequest? captured = null; _router.RouteToCallAsync("site-a", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow, child, [siteRun])); var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None); Assert.True(result.Completed, result.Error); Assert.Null(result.ErrorCode); Assert.True(captured!.Trace); Assert.Equal("Pump1", captured.InstanceUniqueName); Assert.Equal("Manual", captured.ScriptName); Assert.Equal(result.RootExecutionId, captured.ParentExecutionId); Assert.Equal(3L, Convert.ToInt64(captured.Parameters!["n"])); Assert.Equal(2, result.Events.Count); var root = result.Events[0].AsRow(); Assert.Equal(AuditKind.RouteCall, root.Kind); Assert.Equal(result.RootExecutionId, root.ExecutionId); Assert.Equal("ops", result.Events[0].Actor); Assert.Equal("Pump1.Manual", result.Events[0].Target); Assert.Equal("site-a", root.SourceSiteId); Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(root.Extra)!.Source); Assert.Equal(child, ScriptTraceExtra.TryParse(root.Extra)!.ChildExecutionId); Assert.Contains("\"n\":3", root.RequestSummary); Assert.Contains("42", root.ResponseSummary); Assert.Same(siteRun, result.Events[1]); Assert.Single(_central); // the root row was written centrally } [Fact] public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting() { WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""")); var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", Json("{}"), null), Deployer, CancellationToken.None)); Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code); await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default); Assert.Empty(_central); } [Fact] public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRedactedRootRow() { WireScript(CallScript("Manual")); _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "Script 'Manual' not found on instance 'Pump1'.", DateTimeOffset.UtcNow)); var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None); Assert.False(result.Completed); Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode); Assert.Contains("not found", result.Error); Assert.Single(result.Events); Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status); } [Fact] public async Task RunScript_UnknownScript_IsNotFound() { WireScript(CallScript("Manual")); var ex = await Assert.ThrowsAsync(() => Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Nope", null, null), Deployer, CancellationToken.None)); Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code); Assert.Contains("Nope", ex.Message); } [Fact] public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening() { var (_, _, _, pipeline) = WireScript(CallScript("Manual")); var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" }); await Assert.ThrowsAsync(() => Runner().RunScriptAsync( _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), scoped, CancellationToken.None)); await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default); } [Fact] public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered() { WireScript(CallScript("Manual")); var redactor = Substitute.For(); redactor.Apply(Arg.Any()).Returns(ci => ((AuditEvent)ci[0]) with { RequestSummary = "[redacted]" }); _services.AddSingleton(redactor); _router.RouteToCallAsync("site-a", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", Json("{\"secret\":\"x\"}"), null), Deployer, CancellationToken.None); Assert.Equal("[redacted]", result.Events[0].RequestSummary); } ``` Check how the existing `RunValueChange_SynthesizedRootRow_IsRedacted_WhenARedactorIsRegistered` test (line ~405) constructs its redactor substitute and mirror that exactly (the `with` expression above assumes `AuditEvent` is a record — if it is not, copy the existing test's approach). **Step 2: Run to verify they fail** Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~FlowDebugRunnerTests"` Expected: build error — `RunScriptAsync` / `CallFailedCode` missing. **Step 3: Implement** Add constants: ```csharp /// Error code when a direct script call (design 2026-09-08 §4.4) fails at the site. public const string CallFailedCode = "CALL_FAILED"; /// Error code when a Live run exceeds the method's / script's timeout plus . public const string TimeoutCode = "TIMEOUT"; /// /// Timeout for a direct script call whose script declares none — mirrors /// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds's default (the runner has no site options). /// public const int DefaultScriptTimeoutSeconds = 30; ``` (Use `TimeoutCode` in the existing method-path literal `"TIMEOUT"` too.) Method: ```csharp /// /// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective /// definition from the flattened instance configuration and branches on its trigger kind — /// write kinds go through , every other kind is a traced /// with one operator-attributed /// root row. /// public async Task RunScriptAsync( IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct) { ArgumentNullException.ThrowIfNull(sp); ArgumentNullException.ThrowIfNull(cmd); ArgumentNullException.ThrowIfNull(user); if (string.IsNullOrWhiteSpace(cmd.ScriptName)) throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode); EnsureActiveNode(sp); var instance = await sp.GetRequiredService() .GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false) ?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode); EnforceSiteScope(user, instance.SiteId); var site = await sp.GetRequiredService() .GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false) ?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode); // Design §2: the flattened configuration is the one place canonical names, effective triggers and // effective parameter definitions all live (composition + inheritance + instance overrides applied). var flattened = await sp.GetRequiredService() .FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false); if (flattened.IsFailure) throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode); var script = flattened.Value.Configuration.Scripts .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase)) ?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode); if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType)) { if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute)) throw new ManagementCommandException( $"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.", ValidationFailedCode); if (cmd.Value is null) throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode); return await WriteAndPollAsync(sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false); } var resolveRef = await SchemaRefResolver.BuildAsync( sp.GetService(), [script.ParameterDefinitions], ct).ConfigureAwait(false); var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef); if (!validation.IsValid) throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode); var root = Guid.NewGuid(); var startedAt = DateTimeOffset.UtcNow; var sw = Stopwatch.StartNew(); var redactor = sp.GetService(); var collector = new FlowTraceCollector(redactor: redactor); var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds)); var router = sp.GetRequiredService(); var request = new RouteToCallRequest( Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName, validation.Parameters.Count == 0 ? null : validation.Parameters, DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true); RouteToCallResponse response; using (var runCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) { runCts.CancelAfter(timeout + RunGrace); try { response = await router.RouteToCallAsync(site.SiteIdentifier, request, runCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { response = new RouteToCallResponse(request.CorrelationId, false, null, $"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", DateTimeOffset.UtcNow); } } var timedOut = response.ErrorMessage?.StartsWith("Flow Debugger run timed out", StringComparison.Ordinal) == true; foreach (var e in response.TraceEvents ?? []) collector.Add(e); var events = collector.Complete(); var rootRow = ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.RouteCall, response.Success ? AuditStatus.Delivered : AuditStatus.Failed, occurredAtUtc: startedAt.UtcDateTime, actor: user.Username, target: $"{instance.UniqueName}.{script.CanonicalName}", sourceNode: sp.GetService()?.NodeName, executionId: root, sourceSiteId: site.SiteIdentifier, sourceInstanceId: instance.UniqueName, durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue), errorMessage: response.ErrorMessage, requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null, responseSummary: response.ReturnValue is null ? null : JsonSerializer.Serialize(response.ReturnValue, ScriptTraceExtra.JsonOptions), extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson()); await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false); var ordered = new List(events.Count + 1) { redactor?.Apply(rootRow) ?? rootRow }; ordered.AddRange(events); return new FlowDebugRunResult( root, response.Success, response.ErrorMessage, response.Success ? null : timedOut ? TimeoutCode : CallFailedCode, ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow); } ``` Add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;`, `using ZB.MOM.WW.ScadaBridge.DeploymentManager;`. Check `ScadaBridgeAuditEventFactory.Create`'s actual parameter list in `Commons/Types/Audit/` (the method path uses `requestSummary`/`responseSummary`/`extra`; the value-change path uses `sourceSiteId`/`sourceInstanceId`) — all exist. Check `ParameterValidationResult.Parameters` is non-null on success (it is: defaults to an empty dictionary). **Step 4: Run to verify they pass** Run: the Step 2 command. Expected: all `FlowDebugRunnerTests` pass (old value-change tests included). **Step 5: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs git commit -m "feat(management): FlowDebugRunner.RunScriptAsync — write-kind vs direct traced call by trigger kind (flow debugger script entry)" ``` --- ### Task 5: ManagementActor dispatch, role matrix, long-running registration **Classification:** high-risk **Estimated implement time:** ~3 min **Parallelizable with:** Task 8, Task 10 (needs Task 4) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:318-319` (roles) and `:419-421` (dispatch) - Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs:58-60` (`LongRunningCommands`) - Modify: `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:~164` (XML remark listing the Deployer-only run commands) - Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs:152-154` - Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs:85` (`FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry` — add the new record to whatever list it asserts) **Step 1: Extend the role-matrix test** — add `["FlowDebugRunScript"] = [Roles.Deployer],` beside line 153, and the new type to the CLI registry test. Run both; expected: fail (unknown command / missing role entry). Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~RequiredRoleMatrixTests"` and `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~FlowDebugCommandsTests"`. **Step 2: Implement** - `ManagementActor.cs:318`: `FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugRunScriptCommand => DeployerOnly,` - `ManagementActor.cs:420`: add `FlowDebugRunScriptCommand cmd => await sp.GetRequiredService().RunScriptAsync(sp, cmd, user, CancellationToken.None),` - `ManagementEndpoints.cs:60`: add `typeof(FlowDebugRunScriptCommand)` with a comment `// script entry (design 2026-09-08): a direct call lasts up to the script's timeout (+ grace).` - `AuthorizationPolicies.cs` remark: list all three run commands. - If there is a management-command registry (grep `FlowDebugRunValueChangeCommand` across `src/` for any other list — e.g. a JSON polymorphic type map used by `/management`), add the new record there too; the CLI registry test in Step 1 is what catches a missing entry. **Step 3: Run tests** — both filters pass. **Step 4: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs git commit -m "feat(management): dispatch FlowDebugRunScriptCommand (Deployer-only, long-running) on the ManagementActor" ``` --- ### Task 6: `FlowGraphBuilder` — a `RouteCall` root builds Central → Site **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 1, Task 2, Task 3, Task 7 **Files:** - Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs` - Modify (only if the test fails): `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs` **Step 1: Write the test** ```csharp [Fact] public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse() { // Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an // operator-attributed RouteCall row — there is no InboundRequest and no Response. var root = Guid.NewGuid(); var run = Guid.NewGuid(); var events = new List { ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42", extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a", executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()), }; var graph = FlowGraphBuilder.Build(events); Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList()); Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList()); var edge = Assert.Single(graph.Edges); Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.From, edge.To, edge.CrossesBoundary)); Assert.DoesNotContain(graph.Steps, s => s.Kind == FlowStepKind.Response); } ``` **Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~FlowGraphBuilderTests"`. Expected: pass as-is (the builder keys the root on the first row of the root execution and only synthesizes a Response from an `InboundRequest`). If it fails, fix the minimal builder rule and keep the existing tests green. Check `FlowStepProjector` gives a `RouteCall` row `FlowLane.Central` and the boundary flag from a `RouteCall → ScriptRun` edge (it does for method runs; nothing here should differ). **Step 3: Commit** ```bash git add tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs # + builder if changed git commit -m "test(commons): FlowGraphBuilder — a RouteCall root (flow debugger direct script call) builds Central → Site" ``` --- ### Task 7: `ParameterValueForm.ValidChanged` (additive) **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 1, Task 2, Task 3, Task 6 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor:47-49` (parameters) and `:619-623` (`Emit`) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/ParameterValueFormTests.cs` (find the existing test class for this component with `grep -rl "ParameterValueForm" tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`; if none, create one in that folder with a minimal bUnit render) **Step 1: Write the failing test** ```csharp [Fact] public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed() { var valid = new List(); var cut = Render(p => p .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""") .Add(x => x.ValidChanged, v => valid.Add(v))); var input = cut.Find("input[data-test='param-n']"); // use the component's actual per-field selector; read the razor to confirm input.Input("abc"); Assert.Equal(false, valid.Last()); input.Input("5"); Assert.Equal(true, valid.Last()); } ``` Adjust the selector to the form's real markup (read the razor first; it keys inputs by canonical path). **Step 2: Run** — expected: build error `ValidChanged` missing. **Step 3: Implement** ```csharp /// /// Raised after every edit with true when the value tree has no parse or validation errors /// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing /// callers ignore it. /// [Parameter] public EventCallback ValidChanged { get; set; } ``` and in `Emit()` after `Validate()`: `await ValidChanged.InvokeAsync(IsValid);` where `IsValid` is `_parseErrors.Count == 0 && .Count == 0` (read `Validate()` to name the second dictionary; if `Validate()` only populates `_parseErrors`, use that alone). **Step 4: Run** — pass. Also run the whole `ParameterValueForm`/`ApiMethodForm`/`TemplateScriptDialog` test classes to confirm nothing else changed. **Step 5: Commit** ```bash git commit -am "feat(central-ui): ParameterValueForm.ValidChanged — additive validity callback" ``` --- ### Task 8: `IFlowDebugService` / `FlowDebugService` — script list, prediction, run **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 5, Task 10 (needs Tasks 1, 2) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs:44-80` - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs` (ctor `:84-98`, `PredictValueChangeAsync` `:164-205`, `RunValueChangeAsync` `:213-217`, `IsValueChangeOn` `:370-395`) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs` (`CreateService` `:~64-84`, `PredictValueChange_*` `:521-564`) **Step 1: Interface changes** Replace `PredictValueChangeAsync` and `RunValueChangeAsync` with: ```csharp /// /// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5): /// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter /// definitions for call kinds, and the script's own execution timeout (client Ask sizing). /// public sealed record FlowScriptOption( string CanonicalName, string? TriggerType, string? TriggerAttribute, string? ParameterDefinitions, int? ExecutionTimeoutSeconds) { /// True when the debugger fires this script by writing . public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType); /// Picker label suffix: ValueChange: Level, Call, no trigger… public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger" : TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!; } /// The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist. Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default); /// Predicts the flow of running : the write + spawned script for a write kind, a RouteCall root + the script for a call kind. Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default); /// Runs a script by name (write kinds take , call kinds ) and returns the flow. Task RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default); ``` **Step 2: Write the failing tests** (replace the two `PredictValueChange_*` tests; add `IFlatteningPipeline _pipeline = Substitute.For()` to the fixture and pass it to the ctor in `CreateService`) ```csharp private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts) { _templates.GetInstanceByIdAsync(instanceId, Arg.Any()) .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 }); var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts }; _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null) .Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult()))); } [Fact] public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 }, new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }, new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" }); var options = await CreateService(reply: null).GetInstanceScriptsAsync(9); Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList()); var onFlag = options.Single(o => o.CanonicalName == "OnFlag"); Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel); var manual = options.Single(o => o.CanonicalName == "Manual"); Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel); Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); } [Fact] public async Task GetInstanceScripts_UnknownInstance_IsEmpty() { Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); } [Fact] public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }); var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag"); Assert.Equal( [ (FlowStepKind.AttributeWrite, "Sim01.Flag"), (FlowStepKind.ScriptRun, "Sim01.OnFlag"), (FlowStepKind.AttributeWrite, "Ack"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); } [Fact] public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" }); var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual"); Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind); Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane); Assert.Equal("Sim01.Manual", flow.Nodes[0].Title); Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind); Assert.True(flow.Edges[0].CrossesBoundary); } [Fact] public async Task PredictScript_UnknownScript_IsEmpty() { WireFlattened(9, "Sim01"); Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); } [Fact] public async Task RunScript_SendsTheCommand_WithTheScriptTimeoutBasedAskForCallKinds() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); var svc = CreateService(reply: raw); var outcome = await svc.RunScriptAsync(9, "Manual", null, null); var env = Assert.Single(_received); var cmd = Assert.IsType(env.Command); // adjust to the envelope's real property name Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); // Existing tests read the Ask timeout the same way the RunMethod timeout test does — copy that assertion pattern: // expected = FlowDebugService.RunAskTimeoutFor(40). } ``` Read the existing `RunMethod_*` service tests to copy how they assert the chosen Ask timeout (the scripted actor may capture it, or `SendAndBuildAsync` may be observable another way); if there is no such seam, assert only the command shape. **Step 3: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~FlowDebugServiceTests"` — expected: build errors. **Step 4: Implement** - Ctor: add `IFlatteningPipeline pipeline` (after `templates`); field `_pipeline`. Add a per-service cache `Dictionary> _scriptCache`. - `GetInstanceScriptsAsync`: instance null → `[]`; flatten with `validateScripts: false`; failure → log Debug + `[]`; project each `ResolvedScript` → `FlowScriptOption(CanonicalName, TriggerType, ScriptTriggerAttribute.TryRead(...) ? attr : null, ParameterDefinitions, ExecutionTimeoutSeconds)`, ordered by `CanonicalName` (`StringComparer.OrdinalIgnoreCase`); cache. - Keep a private `PredictValueChangeAsync(instance, attributeName, scripts)` body (today's code) but sourced from the **flattened** scripts rather than `CollectEffectiveScriptsAsync` — i.e. `IsValueChangeOn` becomes `ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) && a.Equals(attributeName, OrdinalIgnoreCase)` over `ResolvedScript`s. `CollectEffectiveScriptsAsync` stays for `PredictMethodAsync`'s route resolution (unchanged). - `PredictScriptAsync`: resolve the option; null → `PredictedFlow.Empty`. Write kind → the value-change prediction with `option.TriggerAttribute` (if the attribute is unreadable, return a single `ScriptRun` node with note `trigger names no attribute`). Call kind → nodes `[new("n1", FlowStepKind.RouteCall, FlowLane.Central, $"{instance.UniqueName}.{script.CanonicalName}", false, null)]` + `FlowShapePredictor.PredictSiteScript(...)` re-numbered as the value-change code does, with edge `("n1", first sub node, CrossesBoundary: true)`. - `RunScriptAsync`: `var option = (await GetInstanceScriptsAsync(...)).FirstOrDefault(name match)`; timeout = `option is { IsWriteKind: false } ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds) : ValueChangeAskTimeoutFor(45)`; `SendAndBuildAsync(new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value), timeout, ct)`. - Delete the public `PredictValueChangeAsync`/`RunValueChangeAsync` and the old `IsValueChangeOn`. - Add usings: `ZB.MOM.WW.ScadaBridge.DeploymentManager`, `ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening`, `ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts`. - Every other constructor call site of `FlowDebugService` (grep `new FlowDebugService(` in `tests/`) gets the pipeline argument. **Step 5: Run** — all `FlowDebugServiceTests` pass; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` still fails only in `FlowDebugger.razor.cs` (Task 9) — that is expected at this point and is why Task 9 follows immediately; do not touch the page in this task. **Step 6: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs git commit -m "feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration" ``` --- ### Task 9: Flow Debugger page — script picker + inputs card **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (needs Tasks 7, 8) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor:19-84` (entry buttons + pickers), `:194-222` (inputs card), `:250-256` (`FlowStepDetail` ids) - Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (enum `:33-40`, storage keys `:67-72`, query params `:84-93`, fields `:110-123`, `CanRun` `:162-164`, init `:221-236`, storage restore `:262-296`, `SetEntryAsync` `:309-321`, `OnSiteChangedAsync`…`PredictAsync` `:352-414`, `RunAsync` `:441-449`) - Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` - Test (Playwright, selector only): `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` — no value-change selectors are used there today; nothing to change unless grep says otherwise. **Step 1: Write the failing bUnit tests** (add to `FlowDebuggerPageTests`; register `_flow.GetInstanceScriptsAsync(7, …)` etc. per test) ```csharp private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null); private static readonly FlowScriptOption Manual = new("Manual", "Call", null, """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); private void WireInstance() { _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick])); _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); } [Fact] public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance() { RegisterServices(); WireInstance(); var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-instance-select']").Change("7"); var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); } [Fact] public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt() { RegisterServices(); WireInstance(); var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-instance-select']").Change("7"); cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); Assert.Empty(cut.FindAll("[data-test='flow-script-params']")); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); cut.Find("input[data-test='flow-input-value']").Input("true"); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } [Fact] public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters() { RegisterServices(); WireInstance(); _flow.RunScriptAsync(7, "Manual", Arg.Any(), null, Arg.Any()).Returns(GraphOutcome(true, true)); var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-instance-select']").Change("7"); cut.Find("select[data-test='flow-script-select']").Change("Manual"); Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); Assert.Empty(cut.FindAll("input[data-test='flow-input-value']")); // fill the form's n field (selector per ParameterValueForm's markup), then Run cut.Find("[data-test='flow-script-params'] input").Input("3"); cut.Find("button[data-test='flow-run']").Click(); cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); _ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any()); } [Fact] public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun() { RegisterServices(); WireInstance(); var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); cut.Find("select[data-test='flow-instance-select']").Change("7"); cut.Find("select[data-test='flow-script-select']").Change("Tick"); Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } [Fact] public void DeepLink_InstanceAndScript_PreselectsAndPredicts() { RegisterServices(); WireInstance(); var nav = Services.GetRequiredService(); nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual"); var cut = Render(); cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value"))); _ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any()); } ``` Also update line 148's selector (`flow-entry-value-change` → `flow-entry-script`). Read how the existing deep-link test (`?methodId=`/`?executionId=`) drives `NavigationManager` and copy it. **Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~FlowDebuggerPageTests"` — expected: build errors. **Step 3: Implement** Code-behind: - `EntryKind.ValueChange` → `EntryKind.Script` (update the enum doc). - Storage: `StorageAttribute` → `StorageScript = "flowDebugger.script"`; `StorageInputsPrefix` stays for methods; add `StorageScriptInputsPrefix = "flowDebugger.scriptInputs."` keyed `{instanceId}.{script}`. - Query: `[SupplyParameterFromQuery] public string? Script { get; set; }` replaces `Attribute`. - Fields: remove `_attributes`, `_selectedAttribute`; add `List _scripts = []`, `string _selectedScript = ""`, `FlowScriptOption? SelectedScript => _scripts.FirstOrDefault(s => s.CanonicalName == _selectedScript)`, `Dictionary _scriptParams = new()`, `bool _scriptParamsValid = true`. Keep `_valueChangeValue`. - `CanRun`: `_entry == EntryKind.Method ? _selectedMethodId > 0 : SelectedScript is { } s && (s.IsWriteKind ? !string.IsNullOrWhiteSpace(_valueChangeValue) : _scriptParamsValid)`. - `LoadAttributesAsync` → `LoadScriptsAsync()`: `_scripts = _selectedInstanceId > 0 ? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList() : []`. - `OnAttributeChangedAsync` → `OnScriptChangedAsync()`: `ResetResult(); _scriptParams = new(); _scriptParamsValid = SelectedScript?.ParameterDefinitions is null or "" ? true : true /* form reports */; await PersistAsync(StorageEntry, nameof(EntryKind.Script)); await PersistAsync(StorageScript, _selectedScript); await PredictAsync();` - `PredictAsync`: `FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript)`. - `RunAsync`: script branch → `var s = SelectedScript!; outcome = s.IsWriteKind ? await FlowDebug.RunScriptAsync(_selectedInstanceId, s.CanonicalName, null, _valueChangeValue) : await FlowDebug.RunScriptAsync(_selectedInstanceId, s.CanonicalName, BuildScriptParameters(), null);` where `BuildScriptParameters()` returns `null` when `_scriptParams.Count == 0` else `JsonSerializer.SerializeToElement(_scriptParams)` (values are the form's objects; `JsonElement` values serialize as-is). - Init/deep-link and localStorage restore: replace attribute logic with `Script`/`StorageScript` (`_scripts.Any(s => s.CanonicalName == script)`). - `SetEntryAsync`: `EntryKind.Script && _selectedInstanceId > 0 && _selectedScript != ""` → `PredictAsync`. Markup: - Entry button: `data-test="flow-entry-script"`, label **Site script**, `EntryKind.Script`. - Replace the Attribute ` @foreach (var s in _scripts) { } ``` - Inputs card condition: `_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0)` (both places, lines 196 and 226). - Inputs card script branch: ```razor else if (SelectedScript is { } script) { @if (script.IsWriteKind) {
@script.TriggerType trigger — the write fires the script.
} else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions)) {
This script takes no parameters.
} else {
} } ``` - `FlowStepDetail`: `_entry == EntryKind.Script && …` for both ids. - Update the "Value-change" wording anywhere else in the razor/code-behind comments. **Step 4: Run** — page tests pass; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean; run the whole `CentralUI.Tests` project once. **Step 5: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs git commit -m "feat(central-ui): Flow Debugger script entry — site/instance/script picker, value field or ParameterValueForm by trigger kind" ``` --- ### Task 10: CLI `flow-debug run-script` **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 5, Task 8 (needs Task 2) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs:30-37` (registration), new `BuildRunScript` after `BuildRunValueChange` (`:85-116`), `run-value-change` description text - Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md:2273-2345` (`flow-debug` section: intro sentence, new `run-script` subsection before `run-value-change`, `run-value-change` note) - Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs:27-50, 74-83` **Step 1: Failing tests** — in `Build_RegistersTheThreeSubcommands…` (rename to `…FourSubcommands…`) expect `["run-method", "run-script", "run-value-change", "replay"]` and assert `run-script` has `--instance-id` (required), `--script` (required), `--params`, `--value`, `--wait-seconds`, `--timeout-seconds` (all optional). Add: ```csharp [Fact] public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120() { var group = FlowDebugCommands.Build(Url, Format, Username, Password); var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}"); Assert.Empty(parse.Errors); var cmd = group.Subcommands.Single(c => c.Name == "run-script"); Assert.Equal(45, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--wait-seconds"))); Assert.Equal(120, parse.GetValue((Option)cmd.Options.Single(o => o.Name == "--timeout-seconds"))); } ``` Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~FlowDebugCommandsTests"` — fails. **Step 2: Implement** `BuildRunScript` modelled on `BuildRunMethod` + `BuildRunValueChange`: options `--instance-id` (int, required), `--script` (string, required, "Canonical script name (as shown by the Flow Debugger picker / the flattened configuration)"), `--params` (json, call kinds), `--value` (string, write kinds), `--wait-seconds` (45, write kinds), `--timeout-seconds` (120). Validation: both `<= 0` → `INVALID_ARGUMENT`; `--params` parse via `ParseParameters` with the same error handling as `run-method`. Client timeout: `TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds))`. Command description: "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value), any other script is called directly (--params)". Register it between `run-method` and `run-value-change`. `run-value-change` description gains ": the attribute-level path (also fires Expression triggers); prefer run-script to run a named script". README: document exactly those options in the existing table style, with an example using the rig (`--instance-id --script OutboundDemo --params '{...}'` — read `docker/seed-flow-outbound-demo.sh` for the script's real parameters, if any). **Step 3: Run** — tests pass. **Step 4: Commit** ```bash git add src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs src/ZB.MOM.WW.ScadaBridge.CLI/README.md tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs git commit -m "feat(cli): flow-debug run-script — run a site script by name through the flow debugger" ``` --- ### Task 11: Playwright script run + verify script leg **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 12 (needs Tasks 9, 10) **Files:** - Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` (new test) - Modify: `docker/verify-delmia-notifier.sh` (header comment `:33-34`, step H `:129-146`) **Step 1: Playwright test** (runs only against the live rig; follow the fixture's `Available` guard pattern) ```csharp [Fact] public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes() { // Rig objects: DelmiaSim01 (SimMachine) carries the OutboundDemo Call script seeded by // docker/seed-flow-outbound-demo.sh; skip when it is not seeded. ... login via fixture; navigate; click flow-entry-script; select site "Site A"/instance DelmiaSim01/script OutboundDemo ... fill the parameter form if the script declares parameters (read seed-flow-outbound-demo.sh), click flow-run await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Completed", new() { Timeout = 60_000 }); Assert.True(int.Parse(await page.Locator("[data-test='flow-step-count']").InnerTextAsync()) >= 2); } ``` Use `select.SelectOptionAsync(new SelectOptionValue { Label = "…" })` for the site/instance selects (ids differ per rig) and `Value = "OutboundDemo"` for the script. **Step 2: verify script** — after the existing H replay check, add an `H2` leg: ```bash INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next(i["id"] for i in xs if i["uniqueName"]=="DelmiaSim01"))') set +e; H2_RUN=$("$CLI" flow-debug run-script --instance-id "$INSTANCE_ID" --script OutboundDemo "${H_ARGS[@]}"); H2_RC=$?; set -e [ "$H2_RC" -eq 0 ] || { echo "FAIL: flow-debug run-script exited $H2_RC: $H2_RUN"; exit 1; } H2_OK=$(echo "$H2_RUN" | python3 -c 'import json,sys; r=json.load(sys.stdin); kinds=[json.loads(e["detailsJson"]).get("kind") for e in r.get("events",[])]; ok=r.get("completed") is True and kinds[:1]==["RouteCall"] and "ScriptRun" in kinds; print(("YES" if ok else "NO")+" kinds="+",".join(kinds)); sys.exit(0 if ok else 1)') || { echo "FAIL: run-script incomplete: $H2_OK"; exit 1; } echo "H2 script entry (direct call) -> $H2_OK" ``` Confirm the JSON property names (`uniqueName`, `detailsJson`, `kind`) against the existing H check and `instance list` output; adjust `--params` if `OutboundDemo` declares parameters. Update the header comment to list H2. **Step 3: Run** — deferred to Task 13 (needs the rebuilt rig). Build the Playwright project: `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`; `bash -n docker/verify-delmia-notifier.sh`. **Step 4: Commit** ```bash git add tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs docker/verify-delmia-notifier.sh git commit -m "test(rig): flow debugger script entry — Playwright direct-call run + verify-delmia-notifier step H2" ``` --- ### Task 12: Documentation **Classification:** trivial **Estimated implement time:** ~4 min **Parallelizable with:** Task 11 **Files:** - Modify: `docs/plans/2026-09-05-flow-debugger-design.md:26,56-58,76-79` (D2 row + §4 toolbar/inputs: add "superseded by `2026-09-08-flow-debugger-script-entry-design.md`" pointers, do not rewrite history) - Modify: `docs/requirements/Component-CentralUI.md:219-225` (entry points, execution path, authorization lists → three run commands, `run-script`) - Modify: `docs/requirements/Component-InboundAPI.md:306` (add `FlowDebugRunScriptCommand` to the paragraph) - Modify: `CLAUDE.md:199` (Flow Debugger bullet: CLI list `run-method|run-script|run-value-change|replay`; one added sentence "**v1.3 (2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`):** the second entry point is site → instance → **script**; ValueChange/Conditional scripts are fired by writing the trigger's attribute (derived, not picked), every other kind is a direct traced `RouteToCall` with a `ParameterValueForm` from the script's `ParameterDefinitions`, one operator-attributed `RouteCall` root row; `run-value-change` stays as the attribute-level CLI escape hatch.") - Modify: `../scadaproj/CLAUDE.md` ScadaBridge row — extend the Flow Debugger sentence with the script entry (keep the row short). Commit that repo separately. **Step: Edit, `git diff`, commit** ```bash git commit -am "docs: flow debugger script entry — component specs, CLAUDE.md, design pointers" ``` --- ### Task 13: Gate — build, full tests, rig, browser **Classification:** standard **Estimated implement time:** ~5 min of agent time + rig time **Parallelizable with:** none **Files:** none new (fixes go to the owning task's files and are committed with `fix:` subjects). 1. Orchestrator: merged-diff `code-reviewer` (opus) over `git diff main...HEAD`; fix Mediums+ before proceeding. 2. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings. 3. `dotnet test ZB.MOM.WW.ScadaBridge.slnx` → only the 11 known Playwright role-negative failures (see `scadabridge-cluster-ops` skill). 4. `bash docker/deploy.sh` (main checkout, not a worktree). 5. Mint a key: `scadabridge security api-key create --url http://localhost:9001 --username multi-role --password password --name flowdbg-0908 --methods DelmiaRecipeDownload` → `FLOW_ONLY=1 DELMIA_API_KEY= bash docker/verify-delmia-notifier.sh` → H, H2, I all YES. 6. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~FlowDebuggerTests"` → all pass. 7. Browser (Claude in Chrome): open `http://localhost:9000/deployment/flow-debugger`, pick Site script → site-a → DelmiaSim01 → `OutboundDemo`, confirm the parameter form / no-params note, Run, graph completes; then pick a ValueChange script on the same instance (if seeded) and confirm the value field names its attribute. 8. Record outcomes in the plan ledger; hand back to the user for merge/push (finishing-a-development-branch). --- ## Gate evidence (2026-09-08, branch head `7792b76b` + text fixes) - **Build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — 0 warnings, 0 errors. - **Full test run** (`dotnet test` on the slnx, 30 projects): all pass except the 11 known role-negative Playwright tests on the login-disabled rig, the 2 new script-entry Playwright tests (run against the pre-rebuild image; pass after the rebuild, below), and `SiteAlarmAggregatorActorTests.Stream_GivenUp_After_MaxRetries_Reopens_On_Reconcile_Tick`, which passes in isolation (30/30) and is untouched by this branch (load-induced flake). - **Targeted suites after the last fixes:** ManagementService.Tests 631/631 (FlowDebugRunnerTests 34), CentralUI.Tests 1280/1280 (FlowDebuggerPageTests 24, FlowDebugServiceTests 31), CLI.Tests 465/465, Commons.Tests (ScriptTriggerAttributeTests 15, FlowGraphBuilderTests 24). - **Reviews:** per-task spec + code reviews per classification; a whole-branch review (opus) found I1 (transport faults escaped without a root row), I2 (write-kind completion not script-specific) and I3 (circuit-lifetime script cache) — all fixed (`ca9be68d`, `7197c398`) and re-reviewed (approved; two Minor test gaps left: `MaxWaitSeconds` clamp and the unresolved-option Ask fallback are untested). - **Rig:** `bash docker/deploy.sh` rebuilt all 8 nodes; `FLOW_ONLY=1 DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh` → H YES (live 11 / replay 17), **H2 YES** (`run-script` on `DelmiaSim01`/`OutboundDemo`: kinds RouteCall,ApiCall,CachedSubmit,DbWriteCached,CachedResolve,NotifySend,ScriptRun), I YES. - **Playwright** `FlowDebuggerTests` on the rebuilt rig: 4/4 (incl. `ScriptEntry_LiveRunOfACallScript_InBrowser_Completes`, `ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute`). - **Browser (Claude in Chrome, `localhost:9000`):** Site script → Test Plant A → DelmiaSim01 → picker lists `DelmiaReceiverSimulator (ValueChange: DelmiaReceiver.RecipeDownloadFlag)`, `OutboundDemo (Call)`, `ProcessRecipeDownload (Call)`; `ProcessRecipeDownload` renders the five-field `ParameterValueForm`; `OutboundDemo` shows *This script takes no parameters* and a Live run completed in 21 ms, 5 steps, 1 boundary, all External nodes Ok; `DelmiaReceiverSimulator` shows *New value for DelmiaReceiver.RecipeDownloadFlag* with Run disabled until a value is typed.