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.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.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
///
/// 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 ManagementActor's three
/// FlowDebug* handlers so the Central UI (via ManagementActorHolder) and the
/// CLI (via /management) share one path and one audit story. Resolves every
/// collaborator from the scoped the actor hands it, so it is
/// unit-testable with substitutes and needs no actor.
///
public sealed class FlowDebugRunner
{
/// Error code when a Live run is attempted on a standby central node.
public const string StandbyNodeCode = "STANDBY_NODE";
/// Error code when a value-change run spawns nothing inside its wait window.
public const string NoTriggerCode = "NO_TRIGGER_WITHIN_WINDOW";
/// Error code when the request parameters fail the method's schema.
public const string ValidationFailedCode = "VALIDATION_FAILED";
/// Error code when the method / instance / site does not exist.
public const string NotFoundCode = "NOT_FOUND";
/// Error code when a direct script call (design 2026-09-08 §4.4) fails at the site.
public const string CallFailedCode = "CALL_FAILED";
/// Error code when a Live run exceeds the method's / script's timeout plus .
public const string TimeoutCode = "TIMEOUT";
///
/// Timeout for a direct script call whose script declares none — mirrors
/// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds's default (the runner has no site options).
///
public const int DefaultScriptTimeoutSeconds = 30;
///
/// Ceiling on a write-kind run's spawned-run wait, whatever the command asks for. It must stay
/// UNDER ManagementServiceOptions.LongRunningCommandTimeout (5 minutes by default), which
/// is the Ask the management endpoint wraps this command in: a longer wait would leave the poll
/// loop running on the node after the caller's Ask has already expired, holding its scoped
/// provider and pulling from the site for a result nobody can receive.
///
public const int MaxWaitSeconds = 295;
/// Time reserved for serializing and returning a management reply.
public static readonly TimeSpan ResponseGrace = TimeSpan.FromSeconds(5);
///
/// Arch-review F2: 's code for a routed call refused
/// because the target site is outside the operator's site scope. InboundAPI cannot
/// reference this assembly, so it raises its own RouteSiteScopeException and the
/// runner converts the code back to .
///
public const string SiteScopeDeniedCode = "SITE_SCOPE_DENIED";
/// The Extra.source value stamped on rows this runner synthesizes.
public const string SourceTag = "flow-debugger";
/// Grace added to a method's timeout before a Live run is reported as timed out (design §10).
public static readonly TimeSpan RunGrace = TimeSpan.FromSeconds(10);
/// How often the value-change run polls the site for spawned rows.
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
/// Quiet period after the last new row before a value-change run is declared complete.
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
///
/// Site page ceiling. Full pages are drained with an exclusive timestamp/event-id cursor;
/// execution IDs are split into independent batches of at most 64.
///
public const int PullTraceEventsMax = 2000;
private readonly ILogger _logger;
private readonly Func _delay;
private readonly Func _now;
/// Creates the runner.
public FlowDebugRunner(ILogger logger)
: this(logger, Task.Delay, () => DateTimeOffset.UtcNow)
{
}
/// Creates the runner with an injectable delay and clock (tests).
public FlowDebugRunner(
ILogger logger,
Func delay,
Func now)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_delay = delay ?? throw new ArgumentNullException(nameof(delay));
_now = now ?? throw new ArgumentNullException(nameof(now));
}
///
/// 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
/// with a trace-sink .
///
///
/// Arch-review F1: the sink is a — the same collector the
/// site launcher fills — so the returned copy is REDACTED and capped by count and bytes,
/// and a Live result shows exactly the filtered rows a later Replay reads back. The
/// redactor is AddAuditLog's singleton; a host that
/// registered none (a test composition root) collects raw but is still capped.
///
///
public Task RunMethodAsync(
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunMethodCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task RunMethodCoreAsync(
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
ArgumentNullException.ThrowIfNull(user);
EnsureActiveNode(sp);
var repo = sp.GetRequiredService();
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(), [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();
// One redactor for the whole run: the collector filters the emitted rows, and the runner
// filters the rows it synthesizes ITSELF (review follow-up) — the central writer redacts a
// private copy, so without this the caller's copy of the inbound row would be the raw one.
var redactor = sp.GetService();
var collector = new FlowTraceCollector(redactor: redactor);
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(Math.Max(1, method.TimeoutSeconds));
// Arch-review F2: a method Live run has no single target site to check up front the
// way RunValueChangeAsync does — the operator picks a METHOD, and where it routes is
// only known once the script runs. So the operator's scope is pushed into the
// RouteHelper and enforced at route time, before any site traffic.
var scope = await ResolvePermittedSiteIdentifiersAsync(sp, user, ct).ConfigureAwait(false);
var route = sp.GetRequiredService().WithTraceSink(evt => collector.Add(evt, lowPriority: FlowStepProjector.IsLogLine(evt))).WithSiteScope(scope);
// Step-logs D7: a ScriptLog row rides the collector's low-priority lane so the step rows
// emitted at run end are never crowded out of the inline copy (Replay stays complete).
var executor = sp.GetRequiredService();
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, TimeoutMessage(timeout), TimeoutCode);
}
}
var finishedAt = DateTimeOffset.UtcNow;
// Close the sink here, not at the point the list is built: an un-awaited call finishing
// after the run returned must not mutate the snapshot the reply is assembled from.
var events = collector.Complete();
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()?.NodeName,
executionId: root,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: result.ErrorMessage,
errorDetail: result.ErrorDetail,
requestSummary: requestJson,
responseSummary: result.ResultJson,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson());
await WriteCentralAsync(sp, inboundRow, ct).ConfigureAwait(false);
// Arch-review F2: convert the executor's code into the same authorization failure
// RunValueChangeAsync raises, so both Live entry points surface one exception type
// (→ ManagementUnauthorized). Deliberately AFTER the inbound row is written: a
// denied attempt must still be audited against the operator's name.
if (string.Equals(result.ErrorCode, SiteScopeDeniedCode, StringComparison.Ordinal))
{
throw new SiteScopeViolationException(
result.ErrorMessage ?? "Access denied: routed call outside your site scope.");
}
// Arch-review F5: the collector's emission order is authoritative — central rows carry central's
// clock and site rows the site's, so re-sorting by OccurredAtUtc lets a skewed site clock float
// its steps ahead of the RouteCall that started them. (The value-change path below keeps its
// OrderBy: every row it merges was stamped by the same site.)
var ordered = new List(events.Count + 1) { redactor?.Apply(inboundRow) ?? inboundRow };
ordered.AddRange(events);
return new FlowDebugRunResult(
root, result.Success, result.ErrorMessage, result.Success ? null : result.ErrorCode,
ordered, Truncated: collector.Truncated, startedAt, finishedAt, LogsTruncated: collector.LogsTruncated);
}
///
/// 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).
///
public Task RunValueChangeAsync(
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunValueChangeCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task RunValueChangeCoreAsync(
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, 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()
.GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode);
EnforceSiteScope(user, instance.SiteId);
var site = await sp.GetRequiredService()
.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
return await WriteAndPollAsync(
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, commandDeadline, ct)
.ConfigureAwait(false);
}
///
/// Live run of a site script by name (design 2026-09-08 §4): resolves the script's effective
/// definition from the flattened instance configuration and branches on its trigger kind —
/// write kinds go through , every other kind is a traced
/// with one operator-attributed
/// root row. Calling directly
/// rather than through is deliberate: the helper emits its own
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
///
public Task RunScriptAsync(
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunScriptCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task RunScriptCoreAsync(
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(cmd.ScriptName))
throw new ManagementCommandException("ScriptName is required.", ValidationFailedCode);
EnsureActiveNode(sp);
var instance = await sp.GetRequiredService()
.GetInstanceByIdAsync(cmd.InstanceId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.", NotFoundCode);
EnforceSiteScope(user, instance.SiteId);
var site = await sp.GetRequiredService()
.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
// Design §2: the flattened configuration is the one place canonical names, effective triggers and
// effective parameter definitions all live (composition + inheritance + instance overrides applied).
var flattened = await sp.GetRequiredService()
.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false).ConfigureAwait(false);
if (flattened.IsFailure)
throw new ManagementCommandException($"Could not resolve instance '{instance.UniqueName}': {flattened.Error}", NotFoundCode);
var script = flattened.Value.Configuration.Scripts
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.ScriptName, StringComparison.OrdinalIgnoreCase))
?? throw new ManagementCommandException($"Script '{cmd.ScriptName}' is not defined on instance '{instance.UniqueName}'.", NotFoundCode);
if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType))
{
if (!ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute))
{
throw new ManagementCommandException(
$"Script '{script.CanonicalName}' has a {script.TriggerType} trigger with no readable attribute; fix its trigger configuration.",
ValidationFailedCode);
}
if (cmd.Value is null)
throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
return await WriteAndPollAsync(
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, commandDeadline, ct).ConfigureAwait(false);
}
var resolveRef = await SchemaRefResolver.BuildAsync(
sp.GetService(), [script.ParameterDefinitions], ct).ConfigureAwait(false);
var validation = ParameterValidator.Validate(cmd.Parameters, script.ParameterDefinitions, resolveRef);
if (!validation.IsValid)
throw new ManagementCommandException(validation.ErrorMessage ?? "Parameter validation failed", ValidationFailedCode);
var root = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
// Review follow-up: the root row is synthesized here, so the copy handed back must be
// redacted by this method — WriteCentralAsync's writer only redacts its own private copy.
var redactor = sp.GetService();
var collector = new FlowTraceCollector(redactor: redactor);
var timeout = TimeSpan.FromSeconds(Math.Max(1, script.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds));
var router = sp.GetRequiredService();
var request = new RouteToCallRequest(
Guid.NewGuid().ToString(), instance.UniqueName, script.CanonicalName,
validation.Parameters.Count == 0 ? null : validation.Parameters,
DateTimeOffset.UtcNow, ParentExecutionId: root, Trace: true);
RouteToCallResponse response;
// Null while the site ANSWERED (a success or a Success=false reply); otherwise the code to
// report for a routed Ask that threw instead of answering.
string? faultCode = null;
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)
{
faultCode = TimeoutCode;
response = new RouteToCallResponse(
request.CorrelationId, false, null, TimeoutMessage(timeout), DateTimeOffset.UtcNow);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// RouteToCallAsync is an Akka Ask bounded by CommunicationOptions.IntegrationTimeout,
// so it throws AskTimeoutException (NOT an OperationCanceledException) when the site
// never answers — and any transport fault throws too. "No answer" is NOT "did not
// happen": the site may already have run the script. Letting the exception escape
// would lose the root row entirely and show the operator MapFault's generic internal
// error for a call that ran, so it is converted into a failed response the same way
// RouteHelper.CallAsync types its Ask faults and WriteAndPollAsync reports a failed
// write. A cancellation of the CALLER's token still propagates (filtered out above).
faultCode = ex is Akka.Actor.AskTimeoutException ? TimeoutCode : CallFailedCode;
_logger.LogWarning(ex,
"Flow Debugger routed call to site {Site} for {Instance}.{Script} faulted",
site.SiteIdentifier, instance.UniqueName, script.CanonicalName);
response = new RouteToCallResponse(
request.CorrelationId, false, null, ex.Message, DateTimeOffset.UtcNow);
}
}
// Everything from here to WriteCentralAsync runs AFTER the site already executed the
// script — a real side effect. Nothing in this stretch may throw, or the root row is never
// written and the operator sees ManagementActor.MapFault's generic "internal error" for a
// call that actually succeeded. So the trace drain skips a faulting event and the return
// value falls back to a placeholder, the same way InboundScriptExecutor turns a script's
// own faults into a captured error rather than an escaping exception.
foreach (var e in response.TraceEvents ?? [])
{
try
{
collector.Add(e, lowPriority: FlowStepProjector.IsLogLine(e));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Flow Debugger dropped an inline trace event from the run of {Script} on {Instance}",
script.CanonicalName, instance.UniqueName);
}
}
var events = collector.Complete();
var rootRow = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.RouteCall,
response.Success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: startedAt.UtcDateTime,
actor: user.Username,
target: $"{instance.UniqueName}.{script.CanonicalName}",
sourceNode: sp.GetService()?.NodeName,
executionId: root,
sourceSiteId: site.SiteIdentifier,
sourceInstanceId: instance.UniqueName,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: response.ErrorMessage,
requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null,
responseSummary: SerializeReturnValue(response.ReturnValue),
extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson());
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
// Arch-review F5: emission order is authoritative — the root row carries central's clock and
// the site rows the site's, so re-sorting by OccurredAtUtc lets a skewed site clock float its
// steps ahead of the RouteCall that started them.
var ordered = new List(events.Count + 1) { redactor?.Apply(rootRow) ?? rootRow };
ordered.AddRange(events);
return new FlowDebugRunResult(
root, response.Success, response.ErrorMessage,
response.Success ? null : faultCode ?? CallFailedCode,
ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow,
LogsTruncated: collector.LogsTruncated);
}
///
/// The traced attribute write + spawned-run poll shared by and
/// the write-kind branch of the script entry's 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. is null for the attribute-level command and the script's
/// canonical name for the script entry — it only changes the NO_TRIGGER message.
///
private async Task WriteAndPollAsync(
IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
string attributeName, string? value, int waitSeconds, string? triggerScript, DateTimeOffset commandDeadline, CancellationToken ct)
{
var root = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
// Review follow-up: the root row is synthesized here, so the copy handed back must be
// redacted by this method — WriteCentralAsync's writer only redacts its own private copy.
var redactor = sp.GetService();
// IInstanceRouter is the same seam RouteHelper uses (central binds it to the comms layer).
var router = sp.GetRequiredService();
var write = await router.RouteToSetAttributesAsync(
site.SiteIdentifier,
new RouteToSetAttributesRequest(
Guid.NewGuid().ToString(), instance.UniqueName,
new Dictionary { [attributeName] = 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: attributeName,
sourceNode: sp.GetService()?.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 = attributeName, value = value }, ScriptTraceExtra.JsonOptions),
extra: new ScriptTraceExtra
{
Source = SourceTag,
Writes = [new AttributeWriteDetail(attributeName, value, write.Success, write.ErrorMessage)],
}.ToJson());
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
var redactedRoot = redactor?.Apply(rootRow) ?? rootRow;
if (!write.Success)
{
return new FlowDebugRunResult(root, false, write.ErrorMessage ?? "Attribute write failed", "WRITE_FAILED",
[redactedRoot], false, startedAt, DateTimeOffset.UtcNow);
}
// The run that COUNTS as completion. For the script entry (design 2026-09-08 §4.3) that is
// the script the operator chose, matched on Target — the site stamps ScriptRunLauncher's
// `target: scriptName` on the row. Other scripts the same write happens to fire are still
// collected below (the graph draws the siblings) but must never report the CHOSEN script as
// having run. triggerScript == null is the attribute-level command, which expects no
// particular script, so any spawned run satisfies it.
bool IsAwaitedRun(AuditEvent e)
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.Kind != nameof(AuditKind.ScriptRun) || d.ParentExecutionId != root) return false;
return triggerScript is null
|| string.Equals(e.Target, triggerScript, StringComparison.OrdinalIgnoreCase);
}
// Poll the site for the traced run(s) the write spawned — matched by ParentExecutionId
// == root, then their children — until the awaited ScriptRun exists AND the set has been
// quiet for SettleWindow, or the (clamped) wait elapses.
var puller = sp.GetRequiredService();
var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds);
var deadline = _now() + TimeSpan.FromSeconds(wait);
if (deadline > commandDeadline) deadline = commandDeadline;
var known = new Dictionary();
DateTimeOffset? lastNew = null;
// Root is a fresh GUID for this command, so matching it and its discovered descendants
// requires no cross-node timestamp lower bound. Site clocks may be behind central.
var since = DateTime.MinValue;
// Stable ID batches avoid silently dropping descendants beyond the per-read ID cap.
var assigned = new HashSet();
var batches = new List();
while (!ct.IsCancellationRequested)
{
var discovered = new HashSet { root };
foreach (var e in known.Values)
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.ExecutionId is { } x) discovered.Add(x);
}
foreach (var ids in discovered.Where(id => assigned.Add(id)).Chunk(64))
batches.Add(new TraceReadBatch(ids, since));
var added = false;
foreach (var batch in batches)
{
// ScriptRun/operation closing rows are inserted at completion but stamped with
// their start time, potentially behind a cursor that already saw live log rows.
// Drain every page within this scan, then intentionally rescan on the next poll.
// This trades repeated transfer for correct late/backdated-row discovery.
batch.SinceUtc = since;
batch.AfterEventId = null;
while (!ct.IsCancellationRequested && _now() < deadline)
{
var pulled = await puller.PullAsync(site.SiteIdentifier, batch.ExecutionIds,
batch.SinceUtc, PullTraceEventsMax, ct, batch.AfterEventId).ConfigureAwait(false);
foreach (var e in pulled)
if (known.TryAdd(e.EventId, e)) added = true;
if (pulled.Count == 0) break;
var last = pulled[^1];
var nextTime = last.OccurredAtUtc.UtcDateTime;
var nextId = last.EventId.ToString("D");
// An old peer ignoring the additive cursor must not cause a tight repeat loop.
var advanced = nextTime > batch.SinceUtc || nextTime == batch.SinceUtc
&& (batch.AfterEventId is null || string.CompareOrdinal(nextId, batch.AfterEventId) > 0);
if (!advanced) break;
batch.SinceUtc = nextTime;
batch.AfterEventId = nextId;
if (pulled.Count < PullTraceEventsMax) break;
}
}
if (added) lastNew = _now();
var spawned = known.Values.Any(IsAwaitedRun);
var now = _now();
if (spawned && lastNew is { } ln && now - ln >= SettleWindow) break;
if (now >= deadline) break;
await _delay(PollInterval, ct).ConfigureAwait(false);
}
var collector = new FlowTraceCollector();
collector.Add(redactedRoot);
foreach (var evt in known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId))
collector.Add(evt, lowPriority: FlowStepProjector.IsLogLine(evt));
var ordered = collector.Complete();
var completed = known.Values.Any(IsAwaitedRun);
return new FlowDebugRunResult(
root, completed,
completed ? null : triggerScript is null
? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)."
: $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).",
completed ? null : NoTriggerCode,
ordered, collector.Truncated, startedAt, DateTimeOffset.UtcNow, LogsTruncated: collector.LogsTruncated);
}
/// Replay (design §7.1): the stored ScriptTrace chain for any execution id.
public async Task ReplayAsync(
IServiceProvider sp, FlowDebugReplayCommand cmd, CancellationToken ct, AuthenticatedUser user)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
var startedAt = DateTimeOffset.UtcNow;
var events = await sp.GetRequiredService()
.GetTraceEventsAsync(cmd.ExecutionId, ct, user.Roles.Contains("Administrator", StringComparer.OrdinalIgnoreCase) || user.PermittedSiteIds.Length == 0 ? null : user.PermittedSiteIds).ConfigureAwait(false);
if (events.Count == 0 && events is not ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch { Truncated: true })
{
return new FlowDebugRunResult(cmd.ExecutionId, false,
$"No trace rows found for execution {cmd.ExecutionId:D}.", NotFoundCode,
events, false, startedAt, DateTimeOffset.UtcNow);
}
var batch = events as ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch;
var rootId = batch?.RootExecutionId ?? (events.Count == 0 ? cmd.ExecutionId : AuditDetailsCodec.Deserialize(events[0].DetailsJson).ExecutionId ?? cmd.ExecutionId);
var collector = new FlowTraceCollector();
foreach (var evt in events) collector.Add(evt);
return new FlowDebugRunResult(rootId, true, null, null, collector.Complete(), batch?.Truncated == true || collector.Truncated, startedAt, DateTimeOffset.UtcNow);
}
///
/// The timed-out message shared by both Live entry points, so the two paths never drift.
///
private static string TimeoutMessage(TimeSpan timeout) =>
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace";
///
/// Serializes a routed call's return value for the root row's ResponseSummary, NEVER
/// throwing: rejects named floating-point literals,
/// so a script returning double.NaN / an infinity — or any other value the serializer
/// cannot represent — would otherwise take down the audit write of a call that already ran.
/// The placeholder keeps the row (and the operator's Live result) intact.
///
///
/// The catch is deliberately broad (as 's already is): the value
/// crossing this seam came from a user-authored script, and the serializer's failure mode varies
/// by value — ArgumentException for a non-finite number, for a
/// cycle, for an unmappable type. A best-effort audit summary
/// must not be the thing that fails a call the site already ran.
///
///
private static string? SerializeReturnValue(object? returnValue)
{
if (returnValue is null) return null;
try
{
return JsonSerializer.Serialize(returnValue, ScriptTraceExtra.JsonOptions);
}
catch (Exception ex)
{
return $"";
}
}
private sealed class TraceReadBatch(Guid[] executionIds, DateTime sinceUtc)
{
public Guid[] ExecutionIds { get; } = executionIds;
public DateTime SinceUtc { get; set; } = sinceUtc;
public string? AfterEventId { get; set; }
}
private async Task WithinCommandBudgetAsync(
IServiceProvider sp, Func> run, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
var timeout = sp.GetService>()?.Value.LongRunningCommandTimeout
?? TimeSpan.FromMinutes(5);
var budget = timeout - ResponseGrace;
if (budget <= TimeSpan.Zero)
throw new ManagementCommandException("Long-running command timeout must exceed the response grace.", ValidationFailedCode);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(budget);
try
{
return await run(_now() + budget, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
throw new ManagementCommandException("Flow Debugger command exceeded its total execution budget.", TimeoutCode);
}
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService();
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}.");
}
}
///
/// Arch-review F2: the operator's PermittedSiteIds — NUMERIC site ids as strings —
/// resolved to the site IDENTIFIERS speaks, for
/// RouteHelper.WithSiteScope. Returns null (unscoped) for an unscoped user or an
/// Administrator, exactly as treats them.
///
///
/// A returned set may be EMPTY — a scoped user whose sites no longer exist routes nowhere.
/// That is the intended fail-closed outcome, and is why null and empty are not the same.
///
///
private static async Task?> ResolvePermittedSiteIdentifiersAsync(
IServiceProvider sp, AuthenticatedUser user, CancellationToken ct)
{
if (user.PermittedSiteIds.Length == 0) return null;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
var sites = sp.GetRequiredService();
var set = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var raw in user.PermittedSiteIds)
{
if (!int.TryParse(raw, out var id)) continue;
var site = await sites.GetSiteByIdAsync(id, ct).ConfigureAwait(false);
if (site is not null) set.Add(site.SiteIdentifier);
}
return set;
}
private async Task WriteCentralAsync(IServiceProvider sp, AuditEvent evt, CancellationToken ct)
{
var writer = sp.GetService();
if (writer is null) return;
try
{
await writer.WriteAsync(evt, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Flow Debugger central audit write failed for {Action}", evt.Action);
}
}
}