Files
ScadaBridge/docs/plans/2026-09-08-flow-debugger-script-entry.md

71 KiB

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 <current feature-branch sha> 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 <project> --filter "FullyQualifiedName~<Class>".
  • 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

using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;

namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts;

/// <summary>Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger.</summary>
public class ScriptTriggerAttributeTests
{
    [Theory]
    [InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")]
    [InlineData("valuechange", """{"attribute":"Level"}""", "Level")]
    [InlineData("Conditional", """{"attributeName":"Level","operator":">","threshold":"5"}""", "Level")]
    [InlineData("CONDITIONAL", """{"attribute":"Flag"}""", "Flag")]
    public void WriteKinds_ReturnTheMonitoredAttribute(string kind, string config, string expected)
    {
        Assert.True(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
        Assert.Equal(expected, attribute);
        Assert.True(ScriptTriggerAttribute.IsWriteKind(kind));
    }

    [Theory]
    [InlineData("Call", """{"attributeName":"Level"}""")]
    [InlineData("Interval", """{"intervalMs":1000}""")]
    [InlineData("Expression", """{"expression":"true"}""")]
    [InlineData(null, null)]
    [InlineData("", "")]
    public void OtherKinds_ReturnFalse_EvenWhenTheConfigNamesAnAttribute(string? kind, string? config)
    {
        Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
        Assert.Null(attribute);
        Assert.False(ScriptTriggerAttribute.IsWriteKind(kind));
    }

    [Theory]
    [InlineData("ValueChange", null)]
    [InlineData("ValueChange", "")]
    [InlineData("ValueChange", "not json")]
    [InlineData("ValueChange", "[1,2]")]
    [InlineData("ValueChange", """{"attributeName":""}""")]
    [InlineData("ValueChange", """{"other":"x"}""")]
    public void WriteKind_WithoutAReadableAttribute_ReturnsFalse_ButIsStillAWriteKind(string kind, string? config)
    {
        Assert.False(ScriptTriggerAttribute.TryRead(kind, config, out var attribute));
        Assert.Null(attribute);
        Assert.True(ScriptTriggerAttribute.IsWriteKind(kind));
    }
}

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

using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;

/// <summary>
/// Reads the single monitored attribute of a <c>ValueChange</c> or <c>Conditional</c> script
/// trigger (Flow Debugger script entry, design 2026-09-08 §2). The config keys are the ones the
/// Central UI's <c>ScriptTriggerConfigCodec</c> writes (<c>attributeName</c>) and SiteRuntime's
/// <c>TriggerRouting</c> also accepts (<c>attribute</c>). Pure; never throws on bad JSON.
/// </summary>
public static class ScriptTriggerAttribute
{
    /// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
    public static bool IsWriteKind(string? triggerType) =>
        string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
        || string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);

    /// <summary>
    /// Returns the monitored attribute for a write-kind trigger with a readable configuration;
    /// false (and null) for every other kind, and for a write kind whose config is missing,
    /// malformed, not an object, or names no attribute.
    /// </summary>
    public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute)
    {
        attribute = null;
        if (!IsWriteKind(triggerType) || string.IsNullOrWhiteSpace(triggerConfigurationJson))
            return false;
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigurationJson);
            var root = doc.RootElement;
            if (root.ValueKind != JsonValueKind.Object) return false;
            foreach (var key in new[] { "attributeName", "attribute" })
            {
                if (root.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
                    && v.GetString() is { Length: > 0 } name)
                {
                    attribute = name;
                    return true;
                }
            }
            return false;
        }
        catch (JsonException)
        {
            return false;
        }
    }
}

Step 4: Run to verify it passes

Run: the Step 2 command. Expected: all pass.

Step 5: Commit

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

/// <summary>
/// Flow Debugger Live run of a site script by name (design 2026-09-08). The runner resolves the
/// script's effective definition from the flattened instance configuration and branches on its
/// trigger kind: a <c>ValueChange</c>/<c>Conditional</c> script is fired by WRITING its monitored
/// attribute (<paramref name="Value"/>) and polling for the spawned run, exactly like
/// <see cref="FlowDebugRunValueChangeCommand"/>; every other kind is invoked DIRECTLY through the
/// traced routed-call path with <paramref name="Parameters"/>. Deployer role; refuses on a standby
/// with <c>STANDBY_NODE</c>.
/// </summary>
/// <param name="InstanceId">The instance the script belongs to.</param>
/// <param name="ScriptName">Canonical script name from the flattened configuration (case-insensitive).</param>
/// <param name="Parameters">Call kinds: flat JSON object validated against the script's ParameterDefinitions; null = none.</param>
/// <param name="Value">Write kinds: canonical string value to write to the trigger's attribute.</param>
/// <param name="WaitSeconds">Write kinds: how long to wait for a spawned run before <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunScriptCommand(
    int InstanceId,
    string ScriptName,
    JsonElement? Parameters,
    string? Value,
    int WaitSeconds = 45);

Step 2: Build

Run: dotnet build src/ZB.MOM.WW.ScadaBridge.Commons Expected: success, 0 warnings.

Step 3: Commit

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:

/// <summary>
/// The traced attribute write + spawned-run poll shared by <see cref="RunValueChangeAsync"/> and
/// the write-kind branch of <see cref="RunScriptAsync"/> (design 2026-09-08 §4.3). The caller has
/// already gated the active node, loaded and scope-checked the instance, and loaded the site.
/// <paramref name="triggerScript"/> is null for the attribute-level command and the script's
/// canonical name for the script entry — it only changes the NO_TRIGGER message.
/// </summary>
private async Task<FlowDebugRunResult> WriteAndPollAsync(
    IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
    string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct)

Body: identical to today's code with cmd.AttributeName → attributeName, cmd.Value → value, cmd.WaitSeconds → waitSeconds. The final error message becomes:

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

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)

/// <summary>Wires instance 5 / site-a like <see cref="WireValueChange"/> plus a flattened configuration holding the given scripts.</summary>
private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller, IFlatteningPipeline Pipeline)
    WireScript(params ResolvedScript[] scripts)
{
    var (templates, sites, puller) = WireValueChange();
    var pipeline = Substitute.For<IFlatteningPipeline>();
    var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", TemplateId = 10, SiteId = 2, Scripts = scripts };
    pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, null)
        .Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(config, "hash", new ValidationResult())));
    _services.AddSingleton(pipeline);
    return (templates, sites, puller, pipeline);
}

private static ResolvedScript CallScript(string name, string? parameterDefinitions = null, int? timeout = null) =>
    new() { CanonicalName = name, Code = "return 1;", TriggerType = "Call", ParameterDefinitions = parameterDefinitions, ExecutionTimeoutSeconds = timeout };

private static ResolvedScript WriteScript(string name, string kind, string attribute) =>
    new() { CanonicalName = name, Code = "return 1;", TriggerType = kind, TriggerConfiguration = $$"""{"attributeName":"{{attribute}}"}""" };

[Fact]
public async Task RunScript_WriteKind_WritesTheTriggerAttribute_ThenPolls()
{
    var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "Conditional", "Level"));
    RouteToSetAttributesRequest? write = null;
    _router.RouteToSetAttributesAsync("site-a", Arg.Do<RouteToSetAttributesRequest>(r => write = r), Arg.Any<CancellationToken>())
        .Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
    puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
        .Returns(ci => (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
            executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)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<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
        new FlowDebugRunScriptCommand(5, "OnLevel", null, null), Deployer, CancellationToken.None));
    Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
    await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}

[Fact]
public async Task RunScript_CallKind_RoutesATracedCall_AndReturnsRootRouteCallRow_ThenSiteRows()
{
    WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5));
    var child = Guid.NewGuid();
    var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
        executionId: child, target: "Manual");
    RouteToCallRequest? captured = null;
    _router.RouteToCallAsync("site-a", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
        .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow, child, [siteRun]));

    var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
        new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None);

    Assert.True(result.Completed, result.Error);
    Assert.Null(result.ErrorCode);
    Assert.True(captured!.Trace);
    Assert.Equal("Pump1", captured.InstanceUniqueName);
    Assert.Equal("Manual", captured.ScriptName);
    Assert.Equal(result.RootExecutionId, captured.ParentExecutionId);
    Assert.Equal(3L, Convert.ToInt64(captured.Parameters!["n"]));

    Assert.Equal(2, result.Events.Count);
    var root = result.Events[0].AsRow();
    Assert.Equal(AuditKind.RouteCall, root.Kind);
    Assert.Equal(result.RootExecutionId, root.ExecutionId);
    Assert.Equal("ops", result.Events[0].Actor);
    Assert.Equal("Pump1.Manual", result.Events[0].Target);
    Assert.Equal("site-a", root.SourceSiteId);
    Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(root.Extra)!.Source);
    Assert.Equal(child, ScriptTraceExtra.TryParse(root.Extra)!.ChildExecutionId);
    Assert.Contains("\"n\":3", root.RequestSummary);
    Assert.Contains("42", root.ResponseSummary);
    Assert.Same(siteRun, result.Events[1]);
    Assert.Single(_central); // the root row was written centrally
}

[Fact]
public async Task RunScript_CallKind_InvalidParameters_FailsBeforeRouting()
{
    WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}"""));
    var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
        new FlowDebugRunScriptCommand(5, "Manual", Json("{}"), null), Deployer, CancellationToken.None));
    Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
    await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
    Assert.Empty(_central);
}

[Fact]
public async Task RunScript_CallKind_SiteFailure_ReportsCallFailed_WithTheRedactedRootRow()
{
    WireScript(CallScript("Manual"));
    _router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
        .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "Script 'Manual' not found on instance 'Pump1'.", DateTimeOffset.UtcNow));

    var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
        new FlowDebugRunScriptCommand(5, "Manual", null, null), Deployer, CancellationToken.None);

    Assert.False(result.Completed);
    Assert.Equal(FlowDebugRunner.CallFailedCode, result.ErrorCode);
    Assert.Contains("not found", result.Error);
    Assert.Single(result.Events);
    Assert.Equal(AuditStatus.Failed, result.Events[0].AsRow().Status);
}

[Fact]
public async Task RunScript_UnknownScript_IsNotFound()
{
    WireScript(CallScript("Manual"));
    var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunScriptAsync(_services.BuildServiceProvider(),
        new FlowDebugRunScriptCommand(5, "Nope", null, null), Deployer, CancellationToken.None));
    Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code);
    Assert.Contains("Nope", ex.Message);
}

[Fact]
public async Task RunScript_OutOfScopeSite_IsRefused_BeforeFlattening()
{
    var (_, _, _, pipeline) = WireScript(CallScript("Manual"));
    var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" });
    await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunScriptAsync(
        _services.BuildServiceProvider(), new FlowDebugRunScriptCommand(5, "Manual", null, null), scoped, CancellationToken.None));
    await pipeline.DidNotReceiveWithAnyArgs().FlattenAndValidateAsync(default, default, default, default);
}

[Fact]
public async Task RunScript_CallKind_RootRow_IsRedacted_WhenARedactorIsRegistered()
{
    WireScript(CallScript("Manual"));
    var redactor = Substitute.For<IAuditRedactor>();
    redactor.Apply(Arg.Any<AuditEvent>()).Returns(ci => ((AuditEvent)ci[0]) with { RequestSummary = "[redacted]" });
    _services.AddSingleton(redactor);
    _router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
        .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:

/// <summary>Error code when a direct script call (design 2026-09-08 §4.4) fails at the site.</summary>
public const string CallFailedCode = "CALL_FAILED";

/// <summary>Error code when a Live run exceeds the method's / script's timeout plus <see cref="RunGrace"/>.</summary>
public const string TimeoutCode = "TIMEOUT";

/// <summary>
/// Timeout for a direct script call whose script declares none — mirrors
/// <c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>'s default (the runner has no site options).
/// </summary>
public const int DefaultScriptTimeoutSeconds = 30;

(Use TimeoutCode in the existing method-path literal "TIMEOUT" too.)

Method:

/// <summary>
/// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective
/// definition from the flattened instance configuration and branches on its trigger kind —
/// write kinds go through <see cref="WriteAndPollAsync"/>, every other kind is a traced
/// <see cref="IInstanceRouter.RouteToCallAsync"/> with one operator-attributed
/// <see cref="AuditKind.RouteCall"/> root row.
/// </summary>
public async Task<FlowDebugRunResult> RunScriptAsync(
    IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
{
    ArgumentNullException.ThrowIfNull(sp);
    ArgumentNullException.ThrowIfNull(cmd);
    ArgumentNullException.ThrowIfNull(user);
    if (string.IsNullOrWhiteSpace(cmd.ScriptName))
        throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode);
    EnsureActiveNode(sp);

    var instance = await sp.GetRequiredService<ITemplateEngineRepository>()
        .GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false)
        ?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode);
    EnforceSiteScope(user, instance.SiteId);
    var site = await sp.GetRequiredService<ISiteRepository>()
        .GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false)
        ?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);

    // Design §2: the flattened configuration is the one place canonical names, effective triggers and
    // effective parameter definitions all live (composition + inheritance + instance overrides applied).
    var flattened = await sp.GetRequiredService<IFlatteningPipeline>()
        .FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false);
    if (flattened.IsFailure)
        throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode);
    var script = flattened.Value.Configuration.Scripts
        .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase))
        ?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode);

    if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType))
    {
        if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute))
            throw new ManagementCommandException(
                $"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.", ValidationFailedCode);
        if (cmd.Value is null)
            throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
        return await WriteAndPollAsync(sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false);
    }

    var resolveRef = await SchemaRefResolver.BuildAsync(
        sp.GetService<ISharedSchemaRepository>(), [script.ParameterDefinitions], ct).ConfigureAwait(false);
    var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef);
    if (!validation.IsValid)
        throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode);

    var root = Guid.NewGuid();
    var startedAt = DateTimeOffset.UtcNow;
    var sw = Stopwatch.StartNew();
    var redactor = sp.GetService<IAuditRedactor>();
    var collector = new FlowTraceCollector(redactor: redactor);
    var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds));
    var router = sp.GetRequiredService<IInstanceRouter>();
    var request = new RouteToCallRequest(
        Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName,
        validation.Parameters.Count == 0 ? null : validation.Parameters,
        DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true);

    RouteToCallResponse response;
    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<INodeIdentityProvider>()?.NodeName,
        executionId: root,
        sourceSiteId: site.SiteIdentifier,
        sourceInstanceId: instance.UniqueName,
        durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
        errorMessage: response.ErrorMessage,
        requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null,
        responseSummary: 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<AuditEvent>(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

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<FlowDebug.FlowDebugRunner>().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

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

[Fact]
public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse()
{
    // Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an
    // operator-attributed RouteCall row — there is no InboundRequest and no Response.
    var root = Guid.NewGuid();
    var run = Guid.NewGuid();
    var events = new List<AuditEvent>
    {
        ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
            occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root,
            sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42",
            extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()),
        ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
            occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a",
            executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40,
            extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()),
    };

    var graph = FlowGraphBuilder.Build(events);

    Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList());
    Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList());
    var edge = Assert.Single(graph.Edges);
    Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.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

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

[Fact]
public void ValidChanged_ReportsFalseOnAParseError_AndTrueOnceFixed()
{
    var valid = new List<bool>();
    var cut = Render<ParameterValueForm>(p => p
        .Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""")
        .Add(x => x.ValidChanged, v => valid.Add(v)));

    var input = cut.Find("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

/// <summary>
/// Raised after every edit with <c>true</c> when the value tree has no parse or validation errors
/// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing
/// callers ignore it.
/// </summary>
[Parameter] public EventCallback<bool> ValidChanged { get; set; }

and in Emit() after Validate(): await ValidChanged.InvokeAsync(IsValid); where IsValid is _parseErrors.Count == 0 && <the validation-error dictionary>.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

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:

/// <summary>
/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5):
/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter
/// definitions for call kinds, and the script's own execution timeout (client Ask sizing).
/// </summary>
public sealed record FlowScriptOption(
    string CanonicalName, string? TriggerType, string? TriggerAttribute,
    string? ParameterDefinitions, int? ExecutionTimeoutSeconds)
{
    /// <summary>True when the debugger fires this script by writing <see cref="TriggerAttribute"/>.</summary>
    public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType);

    /// <summary>Picker label suffix: <c>ValueChange: Level</c>, <c>Call</c>, <c>no trigger</c>…</summary>
    public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger"
        : TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!;
}

/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);

/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);

/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
Task<FlowDebugOutcome> 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<IFlatteningPipeline>() to the fixture and pass it to the ctor in CreateService)

private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
    _templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
        .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 });
    var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts };
    _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any<CancellationToken>(), false, null)
        .Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}

[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<FlowDebugRunScriptCommand>(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<int, IReadOnlyList<FlowScriptOption>> _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 ResolvedScripts. 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

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)

private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null);
private static readonly FlowScriptOption Manual = new("Manual", "Call", null,
    """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);

private void WireInstance()
{
    _templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
        .Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
    _templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
    _flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
    _flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}

[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
    RegisterServices(); WireInstance();
    var cut = Render<FlowDebugger>();
    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<FlowDebugger>();
    cut.Find("button[data-test='flow-entry-script']").Click();
    cut.Find("select[data-test='flow-site-select']").Change("1");
    cut.Find("select[data-test='flow-instance-select']").Change("7");
    cut.Find("select[data-test='flow-script-select']").Change("OnFlag");

    Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
    Assert.Empty(cut.FindAll("[data-test='flow-script-params']"));
    Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
    cut.Find("input[data-test='flow-input-value']").Input("true");
    Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}

[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
    RegisterServices(); WireInstance();
    _flow.RunScriptAsync(7, "Manual", Arg.Any<JsonElement?>(), null, Arg.Any<CancellationToken>()).Returns(GraphOutcome(true, true));
    var cut = Render<FlowDebugger>();
    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<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}

[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
    RegisterServices(); WireInstance();
    var cut = Render<FlowDebugger>();
    cut.Find("button[data-test='flow-entry-script']").Click();
    cut.Find("select[data-test='flow-site-select']").Change("1");
    cut.Find("select[data-test='flow-instance-select']").Change("7");
    cut.Find("select[data-test='flow-script-select']").Change("Tick");
    Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
    Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}

[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
    RegisterServices(); WireInstance();
    var nav = Services.GetRequiredService<NavigationManager>();
    nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
    var cut = Render<FlowDebugger>();
    cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
    _ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
}

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<FlowScriptOption> _scripts = [], string _selectedScript = "", FlowScriptOption? SelectedScript => _scripts.FirstOrDefault(s => s.CanonicalName == _selectedScript), Dictionary<string, object?> _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 <select> with:
<div class="col-md-3">
    <label class="form-label form-label-sm">Script</label>
    <select class="form-select form-select-sm" data-test="flow-script-select"
            @bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
        <option value="">— choose —</option>
        @foreach (var s in _scripts)
        {
            <option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</option>
        }
    </select>
</div>
  • Inputs card condition: _mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0) (both places, lines 196 and 226).
  • Inputs card script branch:
else if (SelectedScript is { } script)
{
    @if (script.IsWriteKind)
    {
        <div class="mb-2">
            <label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
            <input class="form-control form-control-sm" data-test="flow-input-value"
                   @bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
            <div class="form-text">@script.TriggerType trigger — the write fires the script.</div>
        </div>
    }
    else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
    {
        <div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
    }
    else
    {
        <div data-test="flow-script-params">
            <ParameterValueForm ParameterDefinitions="@script.ParameterDefinitions"
                                Values="_scriptParams"
                                ValuesChanged="@(v => _scriptParams = v)"
                                ValidChanged="@(ok => _scriptParamsValid = ok)" />
        </div>
    }
}
  • 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

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:

[Fact]
public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120()
{
    var group = FlowDebugCommands.Build(Url, Format, Username, Password);
    var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}");
    Assert.Empty(parse.Errors);
    var cmd = group.Subcommands.Single(c => c.Name == "run-script");
    Assert.Equal(45, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--wait-seconds")));
    Assert.Equal(120, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--timeout-seconds")));
}

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 <DelmiaSim01 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

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)

[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:

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

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

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=<token> 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.