feat(central-ui): FlowDebugService relays Retry/Discard for a parked outbound step

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 11:04:03 -04:00
parent 9c31f70105
commit 2025ec346f
3 changed files with 363 additions and 3 deletions
@@ -3,10 +3,14 @@ using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -22,6 +26,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// so the server's Deployer gate runs against the real identity. The Live-run Ask waits up
/// to <see cref="RunAskTimeout"/> (the server bounds a run by the method timeout + grace and
/// a value-change by its wait window; both fit inside two minutes).
/// Retry / Discard of a parked outbound node (follow-ups plan 2026-09-06 D4) goes through
/// <see cref="CommunicationService"/> to the SAME central-local proxies the Site Calls and
/// Notification report pages use, so the page and the report never disagree on what a relay does.
/// </summary>
public sealed class FlowDebugService : IFlowDebugService
{
@@ -44,6 +51,7 @@ public sealed class FlowDebugService : IFlowDebugService
private readonly AuthenticationStateProvider _auth;
private readonly IInboundApiRepository _inboundApi;
private readonly ITemplateEngineRepository _templates;
private readonly CommunicationService _comms;
private readonly ILogger<FlowDebugService> _logger;
/// <summary>Initializes the service.</summary>
@@ -52,12 +60,14 @@ public sealed class FlowDebugService : IFlowDebugService
AuthenticationStateProvider auth,
IInboundApiRepository inboundApi,
ITemplateEngineRepository templates,
CommunicationService comms,
ILogger<FlowDebugService> logger)
{
_holder = holder ?? throw new ArgumentNullException(nameof(holder));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
_comms = comms ?? throw new ArgumentNullException(nameof(comms));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -177,6 +187,109 @@ public sealed class FlowDebugService : IFlowDebugService
public Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default)
=> SendAndBuildAsync(new FlowDebugReplayCommand(executionId), ReplayAskTimeout, cancellationToken);
/// <inheritdoc/>
public Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default)
=> RelayAsync(step, retry: true, cancellationToken);
/// <inheritdoc/>
public Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default)
=> RelayAsync(step, retry: false, cancellationToken);
/// <summary>
/// The Retry / Discard relay behind <see cref="RetryOutboundAsync"/> and
/// <see cref="DiscardOutboundAsync"/>: guards first (nothing leaves the node for a step
/// that cannot be acted on), then the outbox or the site-call relay by step kind, with the
/// outcome mapped to the report pages' wording. A transport fault becomes a failed result,
/// never an exception on the page.
/// </summary>
private async Task<FlowOutboundActionResult> RelayAsync(FlowStep step, bool retry, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(step);
var verb = retry ? "Retry" : "Discard";
if (!FlowStepProjector.IsOutbound(step.Kind))
return new(false, "Only an API call, database call or notification node can be retried or discarded.");
if (step.Outcome != FlowStepOutcome.Parked)
return new(false, "This node is not parked — nothing to retry or discard.");
if (step.CorrelationId is not Guid c)
return new(false, "This call has no tracked operation id (a sync call is not store-and-forwarded), so there is nothing to retry or discard.");
var isNotify = step.Kind == FlowStepKind.Notify;
if (!isNotify && string.IsNullOrWhiteSpace(step.SiteId))
return new(false, "The owning site of this call is unknown.");
var shortId = c.ToString("N")[..12];
try
{
var requestedBy = await _auth.GetCurrentUsernameAsync();
var corr = Guid.NewGuid().ToString("N");
if (isNotify)
{
var notificationId = c.ToString("D");
if (retry)
{
var r = await _comms.RetryNotificationAsync(new RetryNotificationRequest(corr, notificationId, requestedBy), ct);
return new(r.Success, r.Success
? $"Notification {shortId} re-queued for delivery."
: r.ErrorMessage ?? "Retry failed.");
}
var d = await _comms.DiscardNotificationAsync(new DiscardNotificationRequest(corr, notificationId, requestedBy), ct);
return new(d.Success, d.Success
? $"Notification {shortId} discarded."
: d.ErrorMessage ?? "Discard failed.");
}
var site = step.SiteId!;
SiteCallRelayOutcome outcome;
bool siteReachable;
string? errorMessage;
if (retry)
{
var r = await _comms.RetrySiteCallAsync(new RetrySiteCallRequest(corr, c, site, requestedBy), ct);
(outcome, siteReachable, errorMessage) = (r.Outcome, r.SiteReachable, r.ErrorMessage);
}
else
{
var d = await _comms.DiscardSiteCallAsync(new DiscardSiteCallRequest(corr, c, site, requestedBy), ct);
(outcome, siteReachable, errorMessage) = (d.Outcome, d.SiteReachable, d.ErrorMessage);
}
return MapRelayOutcome(outcome, siteReachable, errorMessage, $"{verb} of {shortId} relayed to {site}.");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Flow Debugger {Verb} relay failed for {Kind} {CorrelationId}", verb, step.Kind, c);
return new(false, $"{verb} failed: {ex.Message}");
}
}
/// <summary>
/// Maps a site-call relay outcome exactly as the Site Calls report page's
/// <c>ShowRelayOutcome</c> does: <see cref="SiteCallRelayOutcome.Applied"/> succeeds;
/// <see cref="SiteCallRelayOutcome.NotParked"/> is a definitive "nothing to do";
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> — or an
/// <see cref="SiteCallRelayOutcome.OperationFailed"/> that also reports the site unreachable —
/// gets the unreachable wording; anything else the site's own message or a generic failure.
/// </summary>
private static FlowOutboundActionResult MapRelayOutcome(
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
{
const string Unreachable = "Site unreachable — the relay did not reach the owning site. Try again once the site is back online.";
return outcome switch
{
SiteCallRelayOutcome.Applied => new(true, appliedMessage),
SiteCallRelayOutcome.NotParked => new(false, errorMessage ?? "The site reported nothing to do — the cached call is no longer parked."),
SiteCallRelayOutcome.SiteUnreachable => new(false, errorMessage ?? Unreachable),
SiteCallRelayOutcome.OperationFailed when !siteReachable => new(false, errorMessage ?? Unreachable),
_ => new(false, errorMessage ?? "The site could not apply the action."),
};
}
/// <summary>
/// Resolves <c>instanceCode.scriptName</c> to a script body: instance by unique name →
/// its template chain (nearest definition wins) → the named script's code.
@@ -23,6 +23,16 @@ public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw,
public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode);
}
/// <summary>
/// Outcome of a Retry / Discard relayed from a Flow Debugger node (follow-ups plan 2026-09-06 D4).
/// <see cref="Message"/> is operator-facing and uses the same wording as the Site Calls /
/// Notification report pages, whether the relay was refused before it left the page or the
/// owning site / outbox answered.
/// </summary>
/// <param name="Success">True when the relay was applied (site <c>Applied</c>, or outbox success).</param>
/// <param name="Message">What happened, in the report pages' words.</param>
public sealed record FlowOutboundActionResult(bool Success, string Message);
/// <summary>
/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape
/// prediction from script syntax, and the three management commands (Live run of a method,
@@ -66,4 +76,24 @@ public interface IFlowDebugService
/// <param name="executionId">Any execution id in the chain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
/// <summary>
/// Relays a Retry of a parked outbound node to its owner — the Site Call Audit relay for an
/// <see cref="FlowStepKind.ApiCall"/> / <see cref="FlowStepKind.DbCall"/> (keyed by the step's
/// <see cref="FlowStep.CorrelationId"/> = <c>TrackedOperationId</c> and <see cref="FlowStep.SiteId"/>),
/// the Notification Outbox for a <see cref="FlowStepKind.Notify"/> (<c>CorrelationId</c> =
/// <c>NotificationId</c>). A step that is not outbound, not <see cref="FlowStepOutcome.Parked"/>,
/// has no correlation id, or (site kinds) no site id is refused with a message and never relayed.
/// </summary>
/// <param name="step">The outbound step to retry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
/// <summary>
/// Relays a Discard of a parked outbound node to its owner. Same routing and refusals as
/// <see cref="RetryOutboundAsync"/>.
/// </summary>
/// <param name="step">The outbound step to discard.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
}