feat(siteruntime): Tracking.Status(id) script API (#23 M3)
This commit is contained in:
@@ -2,6 +2,7 @@ using Akka.Actor;
|
|||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Interfaces;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
@@ -105,6 +106,11 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
|
// 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.
|
// that haven't called AddAuditLog, which the helper handles as a no-op.
|
||||||
IAuditWriter? auditWriter = null;
|
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)
|
if (serviceProvider != null)
|
||||||
{
|
{
|
||||||
@@ -115,6 +121,7 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
|
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
|
||||||
?? string.Empty;
|
?? string.Empty;
|
||||||
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
|
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
|
||||||
|
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var context = new ScriptRuntimeContext(
|
var context = new ScriptRuntimeContext(
|
||||||
@@ -138,7 +145,11 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
// ExternalSystem.Call. Writer is best-effort; failures are logged
|
// ExternalSystem.Call. Writer is best-effort; failures are logged
|
||||||
// and swallowed inside the helper so the script's call path is
|
// and swallowed inside the helper so the script's call path is
|
||||||
// never aborted by an audit failure.
|
// 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
|
var globals = new ScriptGlobals
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text.RegularExpressions;
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Instance;
|
using ScadaLink.Commons.Messages.Instance;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
@@ -85,6 +86,14 @@ public class ScriptRuntimeContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IAuditWriter? _auditWriter;
|
private readonly IAuditWriter? _auditWriter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3): site-local tracking store consulted by
|
||||||
|
/// <c>Tracking.Status(TrackedOperationId)</c>. Optional — when null the
|
||||||
|
/// helper throws on access, mirroring the existing
|
||||||
|
/// "service-not-wired" behaviour of the other integration helpers.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IOperationTrackingStore? _operationTrackingStore;
|
||||||
|
|
||||||
public ScriptRuntimeContext(
|
public ScriptRuntimeContext(
|
||||||
IActorRef instanceActor,
|
IActorRef instanceActor,
|
||||||
IActorRef self,
|
IActorRef self,
|
||||||
@@ -100,7 +109,8 @@ public class ScriptRuntimeContext
|
|||||||
ICanTell? siteCommunicationActor = null,
|
ICanTell? siteCommunicationActor = null,
|
||||||
string siteId = "",
|
string siteId = "",
|
||||||
string? sourceScript = null,
|
string? sourceScript = null,
|
||||||
IAuditWriter? auditWriter = null)
|
IAuditWriter? auditWriter = null,
|
||||||
|
IOperationTrackingStore? operationTrackingStore = null)
|
||||||
{
|
{
|
||||||
_instanceActor = instanceActor;
|
_instanceActor = instanceActor;
|
||||||
_self = self;
|
_self = self;
|
||||||
@@ -117,6 +127,7 @@ public class ScriptRuntimeContext
|
|||||||
_siteId = siteId;
|
_siteId = siteId;
|
||||||
_sourceScript = sourceScript;
|
_sourceScript = sourceScript;
|
||||||
_auditWriter = auditWriter;
|
_auditWriter = auditWriter;
|
||||||
|
_operationTrackingStore = operationTrackingStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -235,6 +246,15 @@ public class ScriptRuntimeContext
|
|||||||
public NotifyHelper Notify => new(
|
public NotifyHelper Notify => new(
|
||||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
|
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
|
||||||
|
/// <c>Tracking.Status(trackedOperationId)</c> reads the site SQLite tracking row
|
||||||
|
/// directly (authoritative source of truth — no central round-trip) and
|
||||||
|
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
|
||||||
|
/// id is unknown / has already been purged.
|
||||||
|
/// </summary>
|
||||||
|
public TrackingHelper Tracking => new(_operationTrackingStore, _logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper class for Scripts.CallShared() syntax.
|
/// Helper class for Scripts.CallShared() syntax.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -746,4 +766,46 @@ public class ScriptRuntimeContext
|
|||||||
return notificationId;
|
return notificationId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3): script-side accessor for cached-operation tracking.
|
||||||
|
/// <c>Tracking.Status(trackedOperationId)</c> reads the site-local SQLite
|
||||||
|
/// row directly via <see cref="IOperationTrackingStore.GetStatusAsync"/> —
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class TrackingHelper
|
||||||
|
{
|
||||||
|
private readonly IOperationTrackingStore? _store;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
internal TrackingHelper(IOperationTrackingStore? store, ILogger logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the latest tracking snapshot for the supplied id, or
|
||||||
|
/// <c>null</c> when the id is unknown (never recorded, or purged after
|
||||||
|
/// the retention window).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when the script runtime was constructed without an
|
||||||
|
/// <see cref="IOperationTrackingStore"/> — mirrors the
|
||||||
|
/// "service-not-wired" failure mode of the other integration helpers.
|
||||||
|
/// </exception>
|
||||||
|
public Task<TrackingStatusSnapshot?> Status(
|
||||||
|
TrackedOperationId trackedOperationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_store == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Operation tracking store not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _store.GetStatusAsync(trackedOperationId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for
|
||||||
|
/// <c>Tracking.Status(TrackedOperationId)</c>. The helper reads the site-local
|
||||||
|
/// <see cref="IOperationTrackingStore"/> directly (no central round-trip) and
|
||||||
|
/// returns the latest <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when
|
||||||
|
/// the id is unknown.
|
||||||
|
/// </summary>
|
||||||
|
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<IOperationTrackingStore>();
|
||||||
|
store
|
||||||
|
.Setup(s => s.GetStatusAsync(It.IsAny<TrackedOperationId>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IOperationTrackingStore>();
|
||||||
|
store
|
||||||
|
.Setup(s => s.GetStatusAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.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<InvalidOperationException>(
|
||||||
|
() => helper.Status(TrackedOperationId.New()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user