From 0f28d13da756f811bdf638d31443314ca252dac5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 13:56:59 -0400 Subject: [PATCH] feat(siteruntime): Tracking.Status(id) script API (#23 M3) --- .../Actors/ScriptExecutionActor.cs | 13 +++- .../Scripts/ScriptRuntimeContext.cs | 64 +++++++++++++++- .../Scripts/TrackingApiTests.cs | 76 +++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index a812158..c4244dc 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Interfaces; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types; @@ -105,6 +106,11 @@ public class ScriptExecutionActor : ReceiveActor // composes the SQLite hot-path + drop-oldest ring); null in tests / hosts // that haven't called AddAuditLog, which the helper handles as a no-op. IAuditWriter? auditWriter = null; + // Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store + // backing Tracking.Status(id). Singleton; null in tests / hosts + // that haven't wired the store, which the helper handles by + // throwing on access. + IOperationTrackingStore? operationTrackingStore = null; if (serviceProvider != null) { @@ -115,6 +121,7 @@ public class ScriptExecutionActor : ReceiveActor siteId = serviceScope.ServiceProvider.GetService()?.SiteId ?? string.Empty; auditWriter = serviceScope.ServiceProvider.GetService(); + operationTrackingStore = serviceScope.ServiceProvider.GetService(); } var context = new ScriptRuntimeContext( @@ -138,7 +145,11 @@ public class ScriptExecutionActor : ReceiveActor // ExternalSystem.Call. Writer is best-effort; failures are logged // and swallowed inside the helper so the script's call path is // never aborted by an audit failure. - auditWriter: auditWriter); + auditWriter: auditWriter, + // Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store + // backing Tracking.Status(id). Authoritative source of truth for + // cached-call status — read directly by the script API. + operationTrackingStore: operationTrackingStore); var globals = new ScriptGlobals { diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 2ac8a38..5526554 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Akka.Actor; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Notification; @@ -85,6 +86,14 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + /// + /// Audit Log #23 (M3): site-local tracking store consulted by + /// Tracking.Status(TrackedOperationId). Optional — when null the + /// helper throws on access, mirroring the existing + /// "service-not-wired" behaviour of the other integration helpers. + /// + private readonly IOperationTrackingStore? _operationTrackingStore; + public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, @@ -100,7 +109,8 @@ public class ScriptRuntimeContext ICanTell? siteCommunicationActor = null, string siteId = "", string? sourceScript = null, - IAuditWriter? auditWriter = null) + IAuditWriter? auditWriter = null, + IOperationTrackingStore? operationTrackingStore = null) { _instanceActor = instanceActor; _self = self; @@ -117,6 +127,7 @@ public class ScriptRuntimeContext _siteId = siteId; _sourceScript = sourceScript; _auditWriter = auditWriter; + _operationTrackingStore = operationTrackingStore; } /// @@ -235,6 +246,15 @@ public class ScriptRuntimeContext public NotifyHelper Notify => new( _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger); + /// + /// Audit Log #23 (M3): site-local tracking-status API for cached operations. + /// Tracking.Status(trackedOperationId) reads the site SQLite tracking row + /// directly (authoritative source of truth — no central round-trip) and + /// returns a , or null when the + /// id is unknown / has already been purged. + /// + public TrackingHelper Tracking => new(_operationTrackingStore, _logger); + /// /// Helper class for Scripts.CallShared() syntax. /// @@ -746,4 +766,46 @@ public class ScriptRuntimeContext return notificationId; } } + + /// + /// Audit Log #23 (M3): script-side accessor for cached-operation tracking. + /// Tracking.Status(trackedOperationId) reads the site-local SQLite + /// row directly via — + /// the site is the single source of truth for cached-call status, so no + /// central round-trip is needed and the call is answered authoritatively. + /// + public class TrackingHelper + { + private readonly IOperationTrackingStore? _store; + private readonly ILogger _logger; + + internal TrackingHelper(IOperationTrackingStore? store, ILogger logger) + { + _store = store; + _logger = logger; + } + + /// + /// Returns the latest tracking snapshot for the supplied id, or + /// null when the id is unknown (never recorded, or purged after + /// the retention window). + /// + /// + /// Thrown when the script runtime was constructed without an + /// — mirrors the + /// "service-not-wired" failure mode of the other integration helpers. + /// + public Task Status( + TrackedOperationId trackedOperationId, + CancellationToken cancellationToken = default) + { + if (_store == null) + { + throw new InvalidOperationException( + "Operation tracking store not available"); + } + + return _store.GetStatusAsync(trackedOperationId, cancellationToken); + } + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs new file mode 100644 index 0000000..a0acd1c --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ScadaLink.Commons.Interfaces; +using ScadaLink.Commons.Types; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Scripts; + +/// +/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for +/// Tracking.Status(TrackedOperationId). The helper reads the site-local +/// directly (no central round-trip) and +/// returns the latest , or null when +/// the id is unknown. +/// +public class TrackingApiTests +{ + private static ScriptRuntimeContext.TrackingHelper CreateHelper( + IOperationTrackingStore? store) + { + return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance); + } + + [Fact] + public async Task Status_UnknownId_ReturnsNull() + { + var store = new Mock(); + store + .Setup(s => s.GetStatusAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((TrackingStatusSnapshot?)null); + + var helper = CreateHelper(store.Object); + var result = await helper.Status(TrackedOperationId.New()); + + Assert.Null(result); + } + + [Fact] + public async Task Status_KnownId_ReturnsLatestSnapshot() + { + var id = TrackedOperationId.New(); + var expected = new TrackingStatusSnapshot( + Id: id, + Kind: "ApiCallCached", + TargetSummary: "ERP.GetOrder", + Status: "Delivered", + RetryCount: 2, + LastError: null, + HttpStatus: 200, + CreatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc), + TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc), + SourceInstanceId: "Plant.Pump42", + SourceScript: "ScriptActor:OnTick"); + + var store = new Mock(); + store + .Setup(s => s.GetStatusAsync(id, It.IsAny())) + .ReturnsAsync(expected); + + var helper = CreateHelper(store.Object); + var result = await helper.Status(id); + + Assert.NotNull(result); + Assert.Equal(expected, result); + } + + [Fact] + public async Task Status_NoStoreWired_Throws() + { + var helper = CreateHelper(store: null); + await Assert.ThrowsAsync( + () => helper.Status(TrackedOperationId.New())); + } + +}