Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
69 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 withgit reset --hard <current feature-branch sha>and only then confirms HEAD (see memoryagent-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:
opusfor high-risk (runner, actor/roles, page),sonnetfor small/trivial; the merged diff gets anopuscode-reviewerpass 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; theScripts/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(afterFlowDebugRunValueChangeCommand, 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(existingRunValueChange_*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 afterRunValueChangeAsync) - 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: addFlowDebugRunScriptCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunScriptAsync(sp, cmd, user, CancellationToken.None),ManagementEndpoints.cs:60: addtypeof(FlowDebugRunScriptCommand)with a comment// script entry (design 2026-09-08): a direct call lasts up to the script's timeout (+ grace).AuthorizationPolicies.csremark: list all three run commands.- If there is a management-command registry (grep
FlowDebugRunValueChangeCommandacrosssrc/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 withgrep -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(aftertemplates); field_pipeline. Add a per-service cacheDictionary<int, IReadOnlyList<FlowScriptOption>> _scriptCache. GetInstanceScriptsAsync: instance null →[]; flatten withvalidateScripts: false; failure → log Debug +[]; project eachResolvedScript→FlowScriptOption(CanonicalName, TriggerType, ScriptTriggerAttribute.TryRead(...) ? attr : null, ParameterDefinitions, ExecutionTimeoutSeconds), ordered byCanonicalName(StringComparer.OrdinalIgnoreCase); cache.- Keep a private
PredictValueChangeAsync(instance, attributeName, scripts)body (today's code) but sourced from the flattened scripts rather thanCollectEffectiveScriptsAsync— i.e.IsValueChangeOnbecomesScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) && a.Equals(attributeName, OrdinalIgnoreCase)overResolvedScripts.CollectEffectiveScriptsAsyncstays forPredictMethodAsync's route resolution (unchanged). PredictScriptAsync: resolve the option; null →PredictedFlow.Empty. Write kind → the value-change prediction withoption.TriggerAttribute(if the attribute is unreadable, return a singleScriptRunnode with notetrigger 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/RunValueChangeAsyncand the oldIsValueChangeOn. - 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(grepnew FlowDebugService(intests/) 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(FlowStepDetailids) - 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";StorageInputsPrefixstays for methods; addStorageScriptInputsPrefix = "flowDebugger.scriptInputs."keyed{instanceId}.{script}. - Query:
[SupplyParameterFromQuery] public string? Script { get; set; }replacesAttribute. - Fields: remove
_attributes,_selectedAttribute; addList<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);whereBuildScriptParameters()returnsnullwhen_scriptParams.Count == 0elseJsonSerializer.SerializeToElement(_scriptParams)(values are the form's objects;JsonElementvalues 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), newBuildRunScriptafterBuildRunValueChange(:85-116),run-value-changedescription text - Modify:
src/ZB.MOM.WW.ScadaBridge.CLI/README.md:2273-2345(flow-debugsection: intro sentence, newrun-scriptsubsection beforerun-value-change,run-value-changenote) - 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 by2026-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(addFlowDebugRunScriptCommandto the paragraph) - Modify:
CLAUDE.md:199(Flow Debugger bullet: CLI listrun-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 tracedRouteToCallwith aParameterValueFormfrom the script'sParameterDefinitions, one operator-attributedRouteCallroot row;run-value-changestays as the attribute-level CLI escape hatch.") - Modify:
../scadaproj/CLAUDE.mdScadaBridge 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).
- Orchestrator: merged-diff
code-reviewer(opus) overgit diff main...HEAD; fix Mediums+ before proceeding. dotnet build ZB.MOM.WW.ScadaBridge.slnx→ 0 warnings.dotnet test ZB.MOM.WW.ScadaBridge.slnx→ only the 11 known Playwright role-negative failures (seescadabridge-cluster-opsskill).bash docker/deploy.sh(main checkout, not a worktree).- 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. dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~FlowDebuggerTests"→ all pass.- 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. - Record outcomes in the plan ledger; hand back to the user for merge/push (finishing-a-development-branch).