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
@@ -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);
}
}
}