feat(management): FlowDebug run-method / run-value-change / replay commands (Deployer, active-node gated, inline trace)

Flow Debugger (design 2026-09-05 §7.1): FlowDebugRunMethodCommand / FlowDebugRunValueChangeCommand /
FlowDebugReplayCommand on the per-node ManagementActor (Deployer role; the two run commands
are long-running), all delegating to FlowDebugRunner. RunMethod refuses on a standby with
STANDBY_NODE (ManagementCommandException now carries an optional Code that replaces
COMMAND_FAILED), validates parameters through the real ParameterValidator/SchemaRefResolver
(made public), synthesizes the inbound row (actor = operator, Extra.source = flow-debugger)
and runs the method through InboundScriptExecutor with a trace-sink RouteHelper so the reply
carries the RouteCall row + the site's inline events. RunValueChange does a traced routed
write via IInstanceRouter, then polls IPullTraceEventsClient (interface moved to Commons so
ManagementService can consume it) until a ScriptRun parented to the root settles, else
NO_TRIGGER_WITHIN_WINDOW. Replay reads IAuditLogRepository.GetTraceEventsAsync.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:04:18 -04:00
parent 9300bc1a4f
commit d11dcb77c5
11 changed files with 675 additions and 33 deletions
@@ -4,12 +4,12 @@
{
"id": 0,
"subject": "Task 0: Amend the design doc with the four planning corrections",
"status": "pending"
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: Audit enums \u2014 ScriptTrace channel and four kinds",
"status": "pending",
"subject": "Task 1: Audit enums — ScriptTrace channel and four kinds",
"status": "completed",
"blockedBy": [
0
]
@@ -17,15 +17,15 @@
{
"id": 2,
"subject": "Task 2: Additive Trace / trace-payload fields on the message records",
"status": "pending",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 3,
"subject": "Task 3: ApiMethod.TraceAllCalls \u2014 entity, EF migration, management command, CLI flag",
"status": "pending",
"subject": "Task 3: ApiMethod.TraceAllCalls — entity, EF migration, management command, CLI flag",
"status": "completed",
"blockedBy": [
0
]
@@ -33,7 +33,7 @@
{
"id": 4,
"subject": "Task 4: FlowStep projection + FlowGraph model + FlowGraphBuilder (pure)",
"status": "pending",
"status": "completed",
"blockedBy": [
1,
2
@@ -42,15 +42,15 @@
{
"id": 5,
"subject": "Task 5: FlowTraceCollector (site, per run)",
"status": "pending",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 6,
"subject": "Task 6: ScriptRuntimeContext \u2014 traced flag, trace emission for writes and waits, flag inheritance",
"status": "pending",
"subject": "Task 6: ScriptRuntimeContext — traced flag, trace emission for writes and waits, flag inheritance",
"status": "completed",
"blockedBy": [
4,
5
@@ -58,24 +58,24 @@
},
{
"id": 7,
"subject": "Task 7: ScriptRunLauncher + ScriptActor \u2014 ScriptRun rows, ExecutionId + events, value-change inheritance",
"status": "pending",
"subject": "Task 7: ScriptRunLauncher + ScriptActor — ScriptRun rows, ExecutionId + events, value-change inheritance",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "Task 8: InstanceActor \u2014 trace arm for data-sourced writes; Trace on published changes",
"status": "pending",
"subject": "Task 8: InstanceActor — trace arm for data-sourced writes; Trace on published changes",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 9,
"subject": "Task 9: DeploymentManagerActor routing \u2014 pass Trace, return child id + events",
"status": "pending",
"subject": "Task 9: DeploymentManagerActor routing — pass Trace, return child id + events",
"status": "completed",
"blockedBy": [
7,
8
@@ -83,8 +83,8 @@
},
{
"id": 10,
"subject": "Task 10: Wire \u2014 site_command.proto trace fields, mapper, golden samples",
"status": "pending",
"subject": "Task 10: Wire — site_command.proto trace fields, mapper, golden samples",
"status": "completed",
"blockedBy": [
2,
11
@@ -92,16 +92,16 @@
},
{
"id": 11,
"subject": "Task 11: Wire \u2014 sitestream.proto source_execution_id/trace + PullTraceEvents RPC; site SQLite read by execution ids",
"status": "pending",
"subject": "Task 11: Wire — sitestream.proto source_execution_id/trace + PullTraceEvents RPC; site SQLite read by execution ids",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 12,
"subject": "Task 12: RouteHelper \u2014 always-on RouteCall audit row, WithTrace, inline trace sink",
"status": "pending",
"subject": "Task 12: RouteHelper — always-on RouteCall audit row, WithTrace, inline trace sink",
"status": "completed",
"blockedBy": [
1,
2,
@@ -111,7 +111,7 @@
{
"id": 13,
"subject": "Task 13: InboundScriptExecutor.ExecuteAsync(trace) + endpoint honours TraceAllCalls",
"status": "pending",
"status": "completed",
"blockedBy": [
12
]
@@ -119,14 +119,14 @@
{
"id": 14,
"subject": "Task 14: IAuditLogRepository.GetTraceEventsAsync(rootExecutionId)",
"status": "pending",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 15,
"subject": "Task 15: Management commands \u2014 FlowDebugRunMethod / RunValueChange / Replay",
"subject": "Task 15: Management commands — FlowDebugRunMethod / RunValueChange / Replay",
"status": "pending",
"blockedBy": [
9,
@@ -155,7 +155,7 @@
},
{
"id": 18,
"subject": "Task 18: Central UI service \u2014 IFlowDebugService / FlowDebugService",
"subject": "Task 18: Central UI service — IFlowDebugService / FlowDebugService",
"status": "pending",
"blockedBy": [
15,
@@ -164,7 +164,7 @@
},
{
"id": 19,
"subject": "Task 19: FlowGraphView \u2014 custom SVG swimlane renderer",
"subject": "Task 19: FlowGraphView — custom SVG swimlane renderer",
"status": "pending",
"blockedBy": [
4
@@ -172,7 +172,7 @@
},
{
"id": 20,
"subject": "Task 20: Flow Debugger page \u2014 toolbar, inputs, predicted graph, run, detail panel",
"subject": "Task 20: Flow Debugger page — toolbar, inputs, predicted graph, run, detail panel",
"status": "pending",
"blockedBy": [
18,
@@ -190,7 +190,7 @@
},
{
"id": 22,
"subject": "Task 22: Rig \u2014 retention override, redeploy, verification step H",
"subject": "Task 22: Rig — retention override, redeploy, verification step H",
"status": "pending",
"blockedBy": [
16,
@@ -207,5 +207,5 @@
]
}
],
"lastUpdated": "2026-09-06T03:22:55.422160+00:00"
"lastUpdated": "2026-09-06T03:58:13.917546+00:00"
}
@@ -4,6 +4,7 @@ using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -1,6 +1,6 @@
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.4): central-side client for the site-hosted
@@ -0,0 +1,51 @@
using System.Text.Json;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <summary>
/// Flow Debugger Live run of an inbound API method (design 2026-09-05 §7.1): executes the
/// method script on the ACTIVE central node with <paramref name="Parameters"/> as the request
/// body, tracing every routed site run, and returns the whole flow's audit events inline.
/// Deployer role; refuses on a standby with error code <c>STANDBY_NODE</c>.
/// </summary>
/// <param name="ApiMethodId">The method to run.</param>
/// <param name="Parameters">Flat JSON object of parameters (validated against the method's schema); null = none.</param>
public record FlowDebugRunMethodCommand(int ApiMethodId, JsonElement? Parameters);
/// <summary>
/// Flow Debugger Live run of a value-change trigger (design 2026-09-05 §5.3): writes
/// <paramref name="Value"/> to <paramref name="AttributeName"/> on the instance through the
/// traced routed-write path, then polls the site for the traced run(s) the change spawned.
/// </summary>
/// <param name="InstanceId">The instance to write to.</param>
/// <param name="AttributeName">Canonical attribute name.</param>
/// <param name="Value">Canonical string value to write.</param>
/// <param name="WaitSeconds">How long to wait for a spawned run before reporting <c>NO_TRIGGER_WITHIN_WINDOW</c>.</param>
public record FlowDebugRunValueChangeCommand(int InstanceId, string AttributeName, string Value, int WaitSeconds = 45);
/// <summary>
/// Flow Debugger Replay (design 2026-09-05 §7.1): returns the audit events of the execution
/// tree that <paramref name="ExecutionId"/> belongs to, from the central store.
/// </summary>
/// <param name="ExecutionId">Any execution id in the chain.</param>
public record FlowDebugReplayCommand(Guid ExecutionId);
/// <summary>The result of a Flow Debugger run or replay: the flow's audit events plus outcome metadata.</summary>
/// <param name="RootExecutionId">The root execution id (the inbound request / the value-change write).</param>
/// <param name="Completed">True when the run finished (or the replay found rows); false on timeout / no trigger.</param>
/// <param name="Error">Human-readable failure, null on success.</param>
/// <param name="ErrorCode">Machine-readable failure code, null on success.</param>
/// <param name="Events">The flow's audit events, oldest first — feed <c>FlowGraphBuilder.Build</c>.</param>
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete).</param>
/// <param name="StartedAtUtc">When the run started.</param>
/// <param name="FinishedAtUtc">When the run finished (or the replay was served).</param>
public record FlowDebugRunResult(
Guid RootExecutionId,
bool Completed,
string? Error,
string? ErrorCode,
IReadOnlyList<AuditEvent> Events,
bool Truncated,
DateTimeOffset StartedAtUtc,
DateTimeOffset FinishedAtUtc);
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// today's behavior.
/// </para>
/// </summary>
internal static class SchemaRefResolver
public static class SchemaRefResolver
{
/// <summary>
/// Builds a <c>lib:Name</c> → schema-JSON resolution seam for the supplied
@@ -0,0 +1,317 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
/// <summary>
/// Flow Debugger execution path (design 2026-09-05 §7.1): the single place a Live run or a
/// Replay happens, called by the per-node <c>ManagementActor</c>'s three
/// <c>FlowDebug*</c> handlers so the Central UI (via <c>ManagementActorHolder</c>) and the
/// CLI (via <c>/management</c>) share one path and one audit story. Resolves every
/// collaborator from the scoped <see cref="IServiceProvider"/> the actor hands it, so it is
/// unit-testable with substitutes and needs no actor.
/// </summary>
public sealed class FlowDebugRunner
{
/// <summary>Error code when a Live run is attempted on a standby central node.</summary>
public const string StandbyNodeCode = "STANDBY_NODE";
/// <summary>Error code when a value-change run spawns nothing inside its wait window.</summary>
public const string NoTriggerCode = "NO_TRIGGER_WITHIN_WINDOW";
/// <summary>Error code when the request parameters fail the method's schema.</summary>
public const string ValidationFailedCode = "VALIDATION_FAILED";
/// <summary>Error code when the method / instance / site does not exist.</summary>
public const string NotFoundCode = "NOT_FOUND";
/// <summary>The <c>Extra.source</c> value stamped on rows this runner synthesizes.</summary>
public const string SourceTag = "flow-debugger";
/// <summary>Grace added to a method's timeout before a Live run is reported as timed out (design §10).</summary>
public static readonly TimeSpan RunGrace = TimeSpan.FromSeconds(10);
/// <summary>How often the value-change run polls the site for spawned rows.</summary>
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
/// <summary>Quiet period after the last new row before a value-change run is declared complete.</summary>
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
private readonly ILogger<FlowDebugRunner> _logger;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly Func<DateTimeOffset> _now;
/// <summary>Creates the runner.</summary>
public FlowDebugRunner(ILogger<FlowDebugRunner> logger)
: this(logger, Task.Delay, () => DateTimeOffset.UtcNow)
{
}
/// <summary>Creates the runner with an injectable delay and clock (tests).</summary>
public FlowDebugRunner(
ILogger<FlowDebugRunner> logger,
Func<TimeSpan, CancellationToken, Task> delay,
Func<DateTimeOffset> now)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_delay = delay ?? throw new ArgumentNullException(nameof(delay));
_now = now ?? throw new ArgumentNullException(nameof(now));
}
/// <summary>
/// Live run of an inbound API method (design §7.1): active-node gate, schema validation,
/// synthesized inbound row (actor = operator), traced execution through the real
/// <see cref="InboundScriptExecutor"/> with a trace-sink <see cref="RouteHelper"/>.
/// </summary>
public async Task<FlowDebugRunResult> RunMethodAsync(
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
ArgumentNullException.ThrowIfNull(user);
EnsureActiveNode(sp);
var repo = sp.GetRequiredService<IInboundApiRepository>();
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.", NotFoundCode);
var resolveRef = await SchemaRefResolver.BuildAsync(
sp.GetService<ISharedSchemaRepository>(), [method.ParameterDefinitions], ct).ConfigureAwait(false);
var validation = ParameterValidator.Validate(cmd.Parameters, method.ParameterDefinitions, resolveRef);
if (!validation.IsValid)
{
throw new ManagementCommandException(
validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode);
}
var root = Guid.NewGuid();
var events = new ConcurrentQueue<AuditEvent>();
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(Math.Max(1, method.TimeoutSeconds));
var route = sp.GetRequiredService<RouteHelper>().WithTraceSink(events.Enqueue);
var executor = sp.GetRequiredService<InboundScriptExecutor>();
InboundScriptResult result;
using (var runCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
runCts.CancelAfter(timeout + RunGrace);
try
{
result = await executor.ExecuteAsync(
method, validation.Parameters, route, timeout, runCts.Token,
parentExecutionId: root, trace: true).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
result = new InboundScriptResult(false, null,
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", "TIMEOUT");
}
}
var finishedAt = DateTimeOffset.UtcNow;
var requestJson = cmd.Parameters is { } p ? p.GetRawText() : null;
var inboundRow = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ApiInbound, AuditKind.InboundRequest,
result.Success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: startedAt.UtcDateTime,
actor: user.Username,
target: method.Name,
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
executionId: root,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: result.ErrorMessage,
requestSummary: requestJson,
responseSummary: result.ResultJson,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson());
await WriteCentralAsync(sp, inboundRow, ct).ConfigureAwait(false);
var ordered = new List<AuditEvent>(events.Count + 1) { inboundRow };
ordered.AddRange(events.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
return new FlowDebugRunResult(
root, result.Success, result.ErrorMessage, result.Success ? null : result.ErrorCode,
ordered, Truncated: false, startedAt, finishedAt);
}
/// <summary>
/// Live run of a value-change trigger (design §5.3/§5.4): traced routed write through the
/// site's normal path, then poll the site's trace rows for the spawned run(s).
/// </summary>
public async Task<FlowDebugRunResult> RunValueChangeAsync(
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(cmd.AttributeName))
throw new ManagementCommandException("AttributeName 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);
var root = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
// IInstanceRouter is the same seam RouteHelper uses (central binds it to the comms layer).
var router = sp.GetRequiredService<IInstanceRouter>();
var write = await router.RouteToSetAttributesAsync(
site.SiteIdentifier,
new RouteToSetAttributesRequest(
Guid.NewGuid().ToString(), instance.UniqueName,
new Dictionary<string, string> { [cmd.AttributeName] = cmd.Value ?? string.Empty },
DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true),
ct).ConfigureAwait(false);
var rootRow = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.AttributeWrite,
write.Success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: startedAt.UtcDateTime,
actor: user.Username,
target: cmd.AttributeName,
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
executionId: root,
sourceSiteId: site.SiteIdentifier,
sourceInstanceId: instance.UniqueName,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: write.ErrorMessage,
requestSummary: JsonSerializer.Serialize(new { attribute = cmd.AttributeName, value = cmd.Value }, ScriptTraceExtra.JsonOptions),
extra: new ScriptTraceExtra
{
Source = SourceTag,
Writes = [new AttributeWriteDetail(cmd.AttributeName, cmd.Value, write.Success, write.ErrorMessage)],
}.ToJson());
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
if (!write.Success)
{
return new FlowDebugRunResult(root, false, write.ErrorMessage ?? "Attribute write failed", "WRITE_FAILED",
[rootRow], false, startedAt, DateTimeOffset.UtcNow);
}
// Poll the site for the traced run(s) the write spawned — matched by ParentExecutionId
// == root, then their children — until a ScriptRun parented to root exists AND the
// set has been quiet for SettleWindow, or WaitSeconds elapses.
var puller = sp.GetRequiredService<IPullTraceEventsClient>();
var deadline = _now() + TimeSpan.FromSeconds(Math.Max(1, cmd.WaitSeconds));
var known = new Dictionary<Guid, AuditEvent>();
DateTimeOffset? lastNew = null;
var since = startedAt.UtcDateTime.AddSeconds(-5);
while (!ct.IsCancellationRequested)
{
var ids = new HashSet<Guid> { root };
foreach (var e in known.Values)
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.ExecutionId is { } x) ids.Add(x);
}
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, 500, ct).ConfigureAwait(false);
var added = false;
foreach (var e in pulled)
{
if (known.TryAdd(e.EventId, e)) added = true;
}
if (added) lastNew = _now();
var spawned = known.Values.Any(e =>
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root;
});
var now = _now();
if (spawned && lastNew is { } ln && now - ln >= SettleWindow) break;
if (now >= deadline) break;
await _delay(PollInterval, ct).ConfigureAwait(false);
}
var ordered = new List<AuditEvent> { rootRow };
ordered.AddRange(known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
var completed = known.Values.Any(e =>
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
return d.Kind == nameof(AuditKind.ScriptRun) && d.ParentExecutionId == root;
});
return new FlowDebugRunResult(
root, completed,
completed ? null : $"No traced run was spawned within {cmd.WaitSeconds}s (no value-change trigger on '{cmd.AttributeName}', or no device echo).",
completed ? null : NoTriggerCode,
ordered, false, startedAt, DateTimeOffset.UtcNow);
}
/// <summary>Replay (design §7.1): the stored ScriptTrace chain for any execution id.</summary>
public async Task<FlowDebugRunResult> ReplayAsync(
IServiceProvider sp, FlowDebugReplayCommand cmd, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
var startedAt = DateTimeOffset.UtcNow;
var events = await sp.GetRequiredService<IAuditLogRepository>()
.GetTraceEventsAsync(cmd.ExecutionId, ct).ConfigureAwait(false);
if (events.Count == 0)
{
return new FlowDebugRunResult(cmd.ExecutionId, false,
$"No trace rows found for execution {cmd.ExecutionId:D}.", NotFoundCode,
events, false, startedAt, DateTimeOffset.UtcNow);
}
var rootId = AuditDetailsCodec.Deserialize(events[0].DetailsJson).ExecutionId ?? cmd.ExecutionId;
return new FlowDebugRunResult(rootId, true, null, null, events, false, startedAt, DateTimeOffset.UtcNow);
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService<IActiveNodeGate>();
if (gate is not null && !gate.IsActiveNode)
{
throw new ManagementCommandException(
"Live runs execute on the ACTIVE central node only (Component-InboundAPI: method scripts and " +
"Route.To never run on a standby). Open the Central UI or point the CLI at the active node.",
StandbyNodeCode);
}
}
private static void EnforceSiteScope(AuthenticatedUser user, int siteId)
{
if (user.PermittedSiteIds.Length == 0) return;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
if (!user.PermittedSiteIds.Contains(siteId.ToString()))
{
throw new SiteScopeViolationException(
$"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}] " +
$"and does not include site {siteId}.");
}
}
private async Task WriteCentralAsync(IServiceProvider sp, AuditEvent evt, CancellationToken ct)
{
var writer = sp.GetService<ICentralAuditWriter>();
if (writer is null) return;
try
{
await writer.WriteAsync(evt, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Flow Debugger central audit write failed for {Action}", evt.Action);
}
}
}
@@ -183,7 +183,8 @@ public class ManagementActor : ReceiveActor
var clientMessage = cause is ManagementCommandException
? cause.Message
: $"An internal error occurred (CorrelationId={correlationId}).";
return new ManagementError(correlationId, clientMessage, "COMMAND_FAILED");
var errorCode = cause is ManagementCommandException { Code: { Length: > 0 } code } ? code : "COMMAND_FAILED";
return new ManagementError(correlationId, clientMessage, errorCode);
}
// Static readonly role sets for the authorization gate. Single-role arms
@@ -304,6 +305,10 @@ public class ManagementActor : ReceiveActor
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => DeployerOnly,
// Flow Debugger (design 2026-09-05 §9): Live runs execute scripts and write attributes
// on operator input, Replay reads the audit chain — Deployer, like the Debug View.
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugReplayCommand => DeployerOnly,
// Two-person secured write. Submit is an Operator action;
// approve/reject are Verifier actions. Separation of duties (a write may
// not be verified by its submitter) is enforced inside the handler — the
@@ -401,6 +406,11 @@ public class ManagementActor : ReceiveActor
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),
// Flow Debugger (design 2026-09-05 §7.1) — one execution path for the UI and the CLI.
FlowDebugRunMethodCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunMethodAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunValueChangeAsync(sp, cmd, user, CancellationToken.None),
FlowDebugReplayCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().ReplayAsync(sp, cmd, CancellationToken.None),
// External Systems
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
GetExternalSystemCommand cmd => await HandleGetExternalSystem(sp, cmd),
@@ -4207,4 +4217,16 @@ public class ManagementCommandException : Exception
/// <summary>Initializes a new instance with the given caller-safe error message.</summary>
/// <param name="message">The error message to surface verbatim to the HTTP/CLI caller.</param>
public ManagementCommandException(string message) : base(message) { }
/// <summary>
/// Initializes a new instance with a caller-safe message AND a machine-readable error code
/// that replaces the generic <c>COMMAND_FAILED</c> on the <c>ManagementError</c> reply
/// (Flow Debugger design 2026-09-05 §7.1: <c>STANDBY_NODE</c>, <c>NO_TRIGGER_WITHIN_WINDOW</c>).
/// </summary>
/// <param name="message">The error message to surface verbatim to the HTTP/CLI caller.</param>
/// <param name="code">The error code to surface as <c>ManagementError.ErrorCode</c>.</param>
public ManagementCommandException(string message, string code) : base(message) { Code = code; }
/// <summary>Optional machine-readable error code; null = <c>COMMAND_FAILED</c>.</summary>
public string? Code { get; }
}
@@ -55,6 +55,9 @@ public static class ManagementEndpoints
// it is N instance deploys, so it must never inherit the ordinary Ask
// timeout or the caller would 504 while the batch is still applying.
typeof(MgmtDeploySiteCommand),
// Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout
// (+ grace) or the value-change wait window — never the ordinary Ask timeout.
typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand),
];
/// <summary>
@@ -14,6 +14,8 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddManagementService(this IServiceCollection services)
{
services.AddSingleton<ManagementActorHolder>();
// Flow Debugger (design 2026-09-05 §7.1): the runner behind the three FlowDebug* commands.
services.AddSingleton<FlowDebug.FlowDebugRunner>();
services.AddOptions<ManagementServiceOptions>()
.BindConfiguration("ScadaBridge:ManagementService")
.ValidateOnStart();
@@ -0,0 +1,242 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Flow Debugger design 2026-09-05 §7.1: the runner behind the three FlowDebug* commands —
/// standby gate, schema validation before any routing, synthesized inbound row + sink events
/// on a Live run, the value-change poll loop, and Replay from the repository.
/// </summary>
public class FlowDebugRunnerTests
{
private static readonly AuthenticatedUser Deployer = new("ops", "Ops", new[] { "Deployer" }, Array.Empty<string>());
private readonly ServiceCollection _services = new();
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
private readonly IInstanceLocator _locator = Substitute.For<IInstanceLocator>();
private readonly IInstanceRouter _router = Substitute.For<IInstanceRouter>();
private readonly List<AuditEvent> _central = new();
private readonly IActiveNodeGate _gate = Substitute.For<IActiveNodeGate>();
private readonly InboundScriptExecutor _executor = new(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
private DateTimeOffset _clock = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
public FlowDebugRunnerTests()
{
_gate.IsActiveNode.Returns(true);
var writer = Substitute.For<ICentralAuditWriter>();
writer.WriteAsync(Arg.Do<AuditEvent>(_central.Add), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
_services.AddSingleton(_apiRepo);
_services.AddSingleton(_gate);
_services.AddSingleton(writer);
_services.AddSingleton(_locator);
_services.AddSingleton(_router);
_services.AddSingleton(_executor);
_services.AddScoped(sp => new RouteHelper(_locator, _router, sp.GetService<ICentralAuditWriter>(), null));
}
private FlowDebugRunner Runner() => new(
NullLogger<FlowDebugRunner>.Instance,
(d, _) => { _clock += d; return Task.CompletedTask; },
() => _clock);
private static JsonElement Json(string s) => JsonDocument.Parse(s).RootElement.Clone();
[Fact]
public async Task RunMethod_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheMethod()
{
_gate.IsActiveNode.Returns(false);
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() =>
Runner().RunMethodAsync(_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(1, null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.StandbyNodeCode, ex.Code);
Assert.Contains("ACTIVE central node", ex.Message);
await _apiRepo.DidNotReceiveWithAnyArgs().GetApiMethodByIdAsync(default, default);
}
[Fact]
public async Task RunMethod_InvalidParameters_FailsBeforeAnyRouteCall()
{
var method = new ApiMethod("M", "return 1;")
{
Id = 3, TimeoutSeconds = 5,
ParameterDefinitions = "{\"type\":\"object\",\"properties\":{\"n\":{\"type\":\"integer\"}},\"required\":[\"n\"]}",
};
_apiRepo.GetApiMethodByIdAsync(3, Arg.Any<CancellationToken>()).Returns(method);
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() =>
Runner().RunMethodAsync(_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(3, Json("{}")), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.ValidationFailedCode, ex.Code);
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
Assert.Empty(_central);
}
[Fact]
public async Task RunMethod_HappyPath_ReturnsInboundRow_ThenSinkEvents_Ordered_AndWritesTheInboundRow()
{
var method = new ApiMethod("M", "x") { Id = 4, TimeoutSeconds = 5 };
_apiRepo.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>()).Returns(method);
_locator.GetSiteIdForInstanceAsync("inst", Arg.Any<CancellationToken>()).Returns("site-a");
var child = Guid.NewGuid();
var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow.AddSeconds(1), executionId: child, target: "work");
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, 7, null, DateTimeOffset.UtcNow, child, [siteRun]));
_executor.RegisterHandler("M", ctx => ctx.Route.To("inst").Call("work"));
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
new FlowDebugRunMethodCommand(4, Json("{\"a\":1}")), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.Null(result.ErrorCode);
Assert.True(captured!.Trace);
Assert.Equal(result.RootExecutionId, captured.ParentExecutionId);
// inbound (synthesized) → RouteCall (sink) → site ScriptRun (sink, from the reply)
Assert.Equal(3, result.Events.Count);
var inbound = result.Events[0].AsRow();
Assert.Equal(AuditKind.InboundRequest, inbound.Kind);
Assert.Equal("ops", result.Events[0].Actor);
Assert.Equal("M", result.Events[0].Target);
Assert.Equal(result.RootExecutionId, inbound.ExecutionId);
Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(inbound.Extra)!.Source);
Assert.Contains("\"a\":1", inbound.RequestSummary);
Assert.Equal(AuditKind.RouteCall, result.Events[1].AsRow().Kind);
Assert.Same(siteRun, result.Events[2]);
// Central writer saw the inbound row AND the always-on RouteCall row.
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.InboundRequest);
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.RouteCall);
}
[Fact]
public async Task RunMethod_UnknownMethod_IsNotFound()
{
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() =>
Runner().RunMethodAsync(_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(99, null), Deployer, CancellationToken.None));
Assert.Equal(FlowDebugRunner.NotFoundCode, ex.Code);
}
private (ITemplateEngineRepository Templates, ISiteRepository Sites, IPullTraceEventsClient Puller) WireValueChange()
{
var templates = Substitute.For<ITemplateEngineRepository>();
var sites = Substitute.For<ISiteRepository>();
var puller = Substitute.For<IPullTraceEventsClient>();
templates.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()).Returns(new Instance("Pump1") { Id = 5, SiteId = 2 });
sites.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(new Site("Site A", "site-a") { Id = 2 });
_router.RouteToSetAttributesAsync("site-a", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
_services.AddSingleton(templates);
_services.AddSingleton(sites);
_services.AddSingleton(puller);
return (templates, sites, puller);
}
[Fact]
public async Task RunValueChange_TracedWrite_ThenPollsUntilSpawnedRunSettles()
{
var (_, _, puller) = WireValueChange();
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));
var calls = 0;
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
calls++;
var ids = (IReadOnlyCollection<Guid>)ci[1];
var root = ids.First();
// Second poll onwards: a ScriptRun parented to the root exists.
return calls >= 2
? (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
eventId: Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), executionId: Guid.NewGuid(), parentExecutionId: root, target: "OnStatus")]
: Array.Empty<AuditEvent>();
});
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 30), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.True(write!.Trace);
Assert.Equal(result.RootExecutionId, write.ParentExecutionId);
Assert.Equal("Pump1", write.InstanceUniqueName);
Assert.Equal("Running", write.AttributeValues["Status"]);
Assert.Equal(2, result.Events.Count);
Assert.Equal(AuditKind.AttributeWrite, result.Events[0].AsRow().Kind);
Assert.Equal("flow-debugger", ScriptTraceExtra.TryParse(result.Events[0].AsRow().Extra)!.Source);
Assert.Equal(AuditKind.ScriptRun, result.Events[1].AsRow().Kind);
// Settled: at least one quiet poll (2 s window) after the last new row, then stopped.
Assert.True(calls >= 3, $"expected settle polls, got {calls}");
}
[Fact]
public async Task RunValueChange_NoSpawnWithinWindow_ReportsNoTriggerCode()
{
var (_, _, puller) = WireValueChange();
puller.PullAsync(default!, default!, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 3), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal(FlowDebugRunner.NoTriggerCode, result.ErrorCode);
Assert.Single(result.Events); // just the synthesized write row
}
[Fact]
public async Task RunValueChange_OutOfScopeSite_IsRefused()
{
WireValueChange();
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "9" });
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunValueChangeAsync(
_services.BuildServiceProvider(), new FlowDebugRunValueChangeCommand(5, "Status", "x"), scoped, CancellationToken.None));
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task Replay_ReturnsRepositoryRows_WithRootFromTheFirstRow()
{
var repo = Substitute.For<IAuditLogRepository>();
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var rows = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, executionId: root),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: child, parentExecutionId: root),
};
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>()).Returns(rows);
_services.AddSingleton(repo);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None);
Assert.True(result.Completed);
Assert.Equal(root, result.RootExecutionId);
Assert.Equal(rows, result.Events);
var empty = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(Guid.NewGuid()), CancellationToken.None);
Assert.False(empty.Completed);
Assert.Equal(FlowDebugRunner.NotFoundCode, empty.ErrorCode);
}
[Fact]
public void RoleGate_AllThreeCommands_AreDeployerOnly()
{
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b"), new FlowDebugReplayCommand(Guid.NewGuid()) })
Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd));
}
}
@@ -138,6 +138,10 @@ public class RequiredRoleMatrixTests
["RetryParkedMessage"] = [Roles.Deployer],
["DiscardParkedMessage"] = [Roles.Deployer],
["DebugSnapshot"] = [Roles.Deployer],
// Flow Debugger (design 2026-09-05 §9): Live runs + Replay are Deployer, like Debug View.
["FlowDebugRunMethod"] = [Roles.Deployer],
["FlowDebugRunValueChange"] = [Roles.Deployer],
["FlowDebugReplay"] = [Roles.Deployer],
// ---- Two-person secured write ----------------------------------------------
["SubmitSecuredWrite"] = [Roles.Operator],