diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
index bb170697..c143bb3c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
@@ -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 (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
+/// 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.
///
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 _logger;
/// Initializes the service.
@@ -52,12 +60,14 @@ public sealed class FlowDebugService : IFlowDebugService
AuthenticationStateProvider auth,
IInboundApiRepository inboundApi,
ITemplateEngineRepository templates,
+ CommunicationService comms,
ILogger 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 ReplayAsync(Guid executionId, CancellationToken cancellationToken = default)
=> SendAndBuildAsync(new FlowDebugReplayCommand(executionId), ReplayAskTimeout, cancellationToken);
+ ///
+ public Task RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default)
+ => RelayAsync(step, retry: true, cancellationToken);
+
+ ///
+ public Task DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default)
+ => RelayAsync(step, retry: false, cancellationToken);
+
+ ///
+ /// The Retry / Discard relay behind and
+ /// : 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.
+ ///
+ private async Task 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}");
+ }
+ }
+
+ ///
+ /// Maps a site-call relay outcome exactly as the Site Calls report page's
+ /// ShowRelayOutcome does: succeeds;
+ /// is a definitive "nothing to do";
+ /// — or an
+ /// that also reports the site unreachable —
+ /// gets the unreachable wording; anything else the site's own message or a generic failure.
+ ///
+ 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."),
+ };
+ }
+
///
/// Resolves instanceCode.scriptName to a script body: instance by unique name →
/// its template chain (nearest definition wins) → the named script's code.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
index 7376094a..b055d0b2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
@@ -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);
}
+///
+/// Outcome of a Retry / Discard relayed from a Flow Debugger node (follow-ups plan 2026-09-06 D4).
+/// 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.
+///
+/// True when the relay was applied (site Applied, or outbox success).
+/// What happened, in the report pages' words.
+public sealed record FlowOutboundActionResult(bool Success, string Message);
+
///
/// 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
/// Any execution id in the chain.
/// Cancellation token.
Task ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Relays a Retry of a parked outbound node to its owner — the Site Call Audit relay for an
+ /// / (keyed by the step's
+ /// = TrackedOperationId and ),
+ /// the Notification Outbox for a (CorrelationId =
+ /// NotificationId). A step that is not outbound, not ,
+ /// has no correlation id, or (site kinds) no site id is refused with a message and never relayed.
+ ///
+ /// The outbound step to retry.
+ /// Cancellation token.
+ Task RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
+
+ ///
+ /// Relays a Discard of a parked outbound node to its owner. Same routing and refusals as
+ /// .
+ ///
+ /// The outbound step to discard.
+ /// Cancellation token.
+ Task DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
index cae654f8..8b1f8df8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
@@ -3,6 +3,7 @@ using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
@@ -10,10 +11,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
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.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+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,9 +26,17 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
///
/// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1):
-/// the management-command bridge (holder null, error mapping, success → graph) and the
-/// prediction resolver wiring over substituted repositories.
+/// the management-command bridge (holder null, error mapping, success → graph), the
+/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
+/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
///
+///
+/// is a concrete class with non-virtual methods, so the
+/// relay tests wire its two central-local proxies (SetSiteCallAudit /
+/// SetNotificationOutbox) to scripted s on the test's
+/// — the same seam SiteCallsReportPageTests and
+/// NotificationReportPageTests use.
+///
public sealed class FlowDebugServiceTests : IDisposable
{
private static readonly JsonSerializerOptions ServerJson = new()
@@ -38,6 +50,15 @@ public sealed class FlowDebugServiceTests : IDisposable
private readonly ITemplateEngineRepository _templates = Substitute.For();
private readonly List _received = new();
+ // Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
+ private readonly List