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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user