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 _siteCallRequests = new(); + private readonly List _outboxRequests = new(); + + // Scripted relay replies — overridable per test before the relay call. + private SiteCallRelayOutcome _siteCallOutcome = SiteCallRelayOutcome.Applied; + private bool _siteReachable = true; + private string? _siteCallError; + public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5)); private FlowDebugService CreateService(object? reply, string role = "Deployer") @@ -52,7 +73,165 @@ public sealed class FlowDebugServiceTests : IDisposable new Claim(JwtTokenService.RoleClaimType, role), }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); - return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, NullLogger.Instance); + + var comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)))); + comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)))); + + return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger.Instance); + } + + /// An External-lane step as FlowStepProjector would project it (positional per ). + private static FlowStep OutboundStep(FlowStepKind kind, FlowStepOutcome outcome, Guid? correlationId, string? siteId) + => new( + Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, + "ERP.GetOrder", null, DateTimeOffset.UtcNow, null, outcome, + null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null, + Lifecycle: null, CorrelationId: correlationId); + + [Fact] + public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite() + { + var c = Guid.NewGuid(); + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, c, "site-a")); + + Assert.True(result.Success, result.Message); + Assert.Contains("relayed", result.Message); + Assert.Contains("site-a", result.Message); + var request = Assert.IsType(Assert.Single(_siteCallRequests)); + Assert.Equal(c, request.TrackedOperationId); + Assert.Equal("site-a", request.SourceSite); + Assert.Equal("tester", request.RequestedBy); + Assert.Empty(_outboxRequests); + } + + [Fact] + public async Task RetryOutbound_Notify_RelaysRetryNotification() + { + var c = Guid.NewGuid(); + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.Notify, FlowStepOutcome.Parked, c, siteId: null)); + + Assert.True(result.Success, result.Message); + Assert.Contains("re-queued", result.Message); + var request = Assert.IsType(Assert.Single(_outboxRequests)); + Assert.Equal(c.ToString("D"), request.NotificationId); + Assert.Equal("tester", request.RequestedBy); + Assert.Empty(_siteCallRequests); + } + + [Fact] + public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall() + { + var c = Guid.NewGuid(); + var svc = CreateService(reply: null); + + var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, c, "site-b")); + + Assert.True(result.Success, result.Message); + Assert.Contains("Discard of", result.Message); + Assert.Contains("site-b", result.Message); + var request = Assert.IsType(Assert.Single(_siteCallRequests)); + Assert.Equal(c, request.TrackedOperationId); + Assert.Equal("site-b", request.SourceSite); + Assert.Equal("tester", request.RequestedBy); + } + + [Fact] + public async Task RetryOutbound_NotParked_RefusesWithoutRelay() + { + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Ok, Guid.NewGuid(), "site-a")); + + Assert.False(result.Success); + Assert.Contains("not parked", result.Message); + Assert.Empty(_siteCallRequests); + Assert.Empty(_outboxRequests); + } + + [Fact] + public async Task RetryOutbound_NoCorrelation_Refuses() + { + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, correlationId: null, "site-a")); + + Assert.False(result.Success); + Assert.Contains("no tracked operation id", result.Message); + Assert.Empty(_siteCallRequests); + Assert.Empty(_outboxRequests); + } + + [Fact] + public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses() + { + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, Guid.NewGuid(), siteId: " ")); + + Assert.False(result.Success); + Assert.Contains("owning site", result.Message); + Assert.Empty(_siteCallRequests); + Assert.Empty(_outboxRequests); + } + + [Fact] + public async Task RetryOutbound_NonOutboundStep_Refuses() + { + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ScriptRun, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); + + Assert.False(result.Success); + Assert.Contains("Only an API call", result.Message); + Assert.Empty(_siteCallRequests); + Assert.Empty(_outboxRequests); + } + + [Fact] + public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage() + { + _siteCallOutcome = SiteCallRelayOutcome.SiteUnreachable; + _siteReachable = false; + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); + + Assert.False(result.Success); + Assert.Contains("unreachable", result.Message); + Assert.Single(_siteCallRequests); + } + + [Fact] + public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage() + { + _siteCallOutcome = SiteCallRelayOutcome.NotParked; + var svc = CreateService(reply: null); + + var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); + + Assert.False(result.Success); + Assert.Contains("no longer parked", result.Message); + } + + [Fact] + public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage() + { + _siteCallOutcome = SiteCallRelayOutcome.OperationFailed; + _siteCallError = "S&F buffer is locked by a sweep."; + var svc = CreateService(reply: null); + + var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); + + Assert.False(result.Success); + Assert.Equal("S&F buffer is locked by a sweep.", result.Message); + Assert.IsType(Assert.Single(_siteCallRequests)); } private static string SuccessPayload(int events, bool completed = true, bool truncated = false) @@ -384,4 +563,42 @@ public sealed class FlowDebugServiceTests : IDisposable }); } } + + /// Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted. + private sealed class ScriptedSiteCallAuditActor : ReceiveActor + { + public ScriptedSiteCallAuditActor(FlowDebugServiceTests test) + { + Receive(r => + { + lock (test._siteCallRequests) test._siteCallRequests.Add(r); + Sender.Tell(new RetrySiteCallResponse(r.CorrelationId, test._siteCallOutcome, + test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError)); + }); + Receive(r => + { + lock (test._siteCallRequests) test._siteCallRequests.Add(r); + Sender.Tell(new DiscardSiteCallResponse(r.CorrelationId, test._siteCallOutcome, + test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError)); + }); + } + } + + /// Stands in for the Notification Outbox singleton proxy: records the request, replies success. + private sealed class ScriptedOutboxActor : ReceiveActor + { + public ScriptedOutboxActor(FlowDebugServiceTests test) + { + Receive(r => + { + lock (test._outboxRequests) test._outboxRequests.Add(r); + Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)); + }); + Receive(r => + { + lock (test._outboxRequests) test._outboxRequests.Add(r); + Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)); + }); + } + } }