From fefa3ffa77b295561ab2a1ff9b9644b9e5b0fa8d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 01:02:10 -0400 Subject: [PATCH] =?UTF-8?q?docs(plans):=20flow=20debugger=20script=20entry?= =?UTF-8?q?=20=E2=80=94=20implementation=20plan=20(13=20tasks,=206=20waves?= =?UTF-8?q?)?= 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" +}