feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding
The Test Run sandbox and Monaco analysis modelled a script API that had drifted from the site runtime's ScriptGlobals, so real scripts failed to compile in Test Run. Realign both to the runtime surface (Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the duplicate ScriptHost stub so the two cannot diverge again. - Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call) accept an anonymous object instead of a hand-built dictionary, via a shared ScriptArgs normalizer; existing dictionary calls still compile. - Test Run can optionally bind to a deployed instance, so Instance/ Attributes/CallScript route to it cross-site; adds site-side RouteToGetAttributes/RouteToSetAttributes handlers. - Adds Test Run panels to the API method and template script editors. - Fixes the TestDatabaseQuery seed script, which queried a table that never existed. Also commits unrelated in-progress work already in the tree: the health monitoring report loop, site streaming changes, and the Admin/Design data-connection and SMTP page reorganization.
This commit is contained in:
@@ -4,6 +4,7 @@ using ScadaLink.Commons.Messages.Artifacts;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Deployment;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.Lifecycle;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
@@ -81,6 +82,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Inbound API Route.To().Call() — route to Instance Actors
|
||||
Receive<RouteToCallRequest>(RouteInboundApiCall);
|
||||
Receive<RouteToGetAttributesRequest>(RouteInboundApiGetAttributes);
|
||||
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
|
||||
|
||||
// Internal startup messages
|
||||
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
|
||||
@@ -567,6 +570,75 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s)
|
||||
/// call (or a central Test Run bound to the instance). Asks the Instance Actor
|
||||
/// per attribute and combines the results.
|
||||
/// </summary>
|
||||
private void RouteInboundApiGetAttributes(RouteToGetAttributesRequest request)
|
||||
{
|
||||
if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
|
||||
{
|
||||
Sender.Tell(new RouteToGetAttributesResponse(
|
||||
request.CorrelationId, new Dictionary<string, object?>(), false,
|
||||
$"Instance '{request.InstanceUniqueName}' not found on this site.",
|
||||
DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = Sender;
|
||||
var names = request.AttributeNames;
|
||||
var asks = names
|
||||
.Select(name => instanceActor.Ask<GetAttributeResponse>(
|
||||
new GetAttributeRequest(
|
||||
request.CorrelationId, request.InstanceUniqueName, name, DateTimeOffset.UtcNow),
|
||||
TimeSpan.FromSeconds(30)))
|
||||
.ToArray();
|
||||
|
||||
Task.WhenAll(asks).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
var values = new Dictionary<string, object?>();
|
||||
for (var i = 0; i < names.Count; i++)
|
||||
values[names[i]] = t.Result[i].Found ? t.Result[i].Value : null;
|
||||
return new RouteToGetAttributesResponse(
|
||||
request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
return new RouteToGetAttributesResponse(
|
||||
request.CorrelationId, new Dictionary<string, object?>(), false,
|
||||
t.Exception?.GetBaseException().Message ?? "Attribute read timed out",
|
||||
DateTimeOffset.UtcNow);
|
||||
}).PipeTo(sender);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes attribute values on a deployed instance for a Route.To().SetAttribute(s)
|
||||
/// call (or a central Test Run bound to the instance). Writes are Tell'd to the
|
||||
/// Instance Actor — serialized through its mailbox — and acknowledged optimistically,
|
||||
/// matching the fire-and-forget semantics of Instance.SetAttribute.
|
||||
/// </summary>
|
||||
private void RouteInboundApiSetAttributes(RouteToSetAttributesRequest request)
|
||||
{
|
||||
if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
|
||||
{
|
||||
Sender.Tell(new RouteToSetAttributesResponse(
|
||||
request.CorrelationId, false,
|
||||
$"Instance '{request.InstanceUniqueName}' not found on this site.",
|
||||
DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (name, value) in request.AttributeValues)
|
||||
{
|
||||
instanceActor.Tell(new SetStaticAttributeCommand(
|
||||
request.CorrelationId, request.InstanceUniqueName, name, value, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
Sender.Tell(new RouteToSetAttributesResponse(
|
||||
request.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
|
||||
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
|
||||
|
||||
@@ -216,26 +216,19 @@ public class InstanceActor : ReceiveActor
|
||||
PublishAndNotifyChildren(changed);
|
||||
|
||||
// Persist asynchronously -- fire and forget since the actor is the source of truth
|
||||
var self = Self;
|
||||
var sender = Sender;
|
||||
// and SetAttribute is called from scripts via Tell (no response consumer).
|
||||
var instanceName = _instanceUniqueName;
|
||||
var attributeName = command.AttributeName;
|
||||
var logger = _logger;
|
||||
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
var success = t.IsCompletedSuccessfully;
|
||||
var error = t.Exception?.GetBaseException().Message;
|
||||
if (!success)
|
||||
{
|
||||
// Value is already in memory; log the persistence failure
|
||||
// In-memory state is authoritative
|
||||
}
|
||||
return new SetStaticAttributeResponse(
|
||||
command.CorrelationId,
|
||||
_instanceUniqueName,
|
||||
command.AttributeName,
|
||||
success,
|
||||
error,
|
||||
DateTimeOffset.UtcNow);
|
||||
}).PipeTo(sender);
|
||||
logger.LogWarning(
|
||||
t.Exception?.GetBaseException(),
|
||||
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
|
||||
instanceName,
|
||||
attributeName);
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,7 +61,7 @@ public class CompositionAccessor
|
||||
public string ResolveScript(string scriptName) =>
|
||||
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
||||
|
||||
public Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
@@ -116,8 +117,10 @@ public class ScriptRuntimeContext
|
||||
/// Calls a sibling script on the same instance by name (Ask pattern).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// WP-22: Uses Ask pattern for CallScript.
|
||||
/// <paramref name="parameters"/> may be a dictionary or an anonymous object
|
||||
/// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
||||
/// </summary>
|
||||
public async Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
||||
public async Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
if (nextDepth > _maxCallDepth)
|
||||
@@ -131,7 +134,7 @@ public class ScriptRuntimeContext
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var request = new ScriptCallRequest(
|
||||
scriptName,
|
||||
parameters,
|
||||
ScriptArgs.Normalize(parameters),
|
||||
nextDepth,
|
||||
correlationId);
|
||||
|
||||
@@ -200,10 +203,12 @@ public class ScriptRuntimeContext
|
||||
/// <summary>
|
||||
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// <paramref name="parameters"/> may be a dictionary or an anonymous
|
||||
/// object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
||||
/// </summary>
|
||||
public async Task<object?> CallShared(
|
||||
string scriptName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
@@ -215,7 +220,8 @@ public class ScriptRuntimeContext
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
|
||||
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
|
||||
return await _library.ExecuteAsync(
|
||||
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace ScadaLink.SiteRuntime.Streaming;
|
||||
/// <summary>
|
||||
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
|
||||
/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell.
|
||||
/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow.
|
||||
/// A BroadcastHub fans events out to per-subscriber graphs, each filtered by
|
||||
/// instance name and bounded by a drop-oldest buffer.
|
||||
///
|
||||
/// Filterable by instance name for debug view (WP-25).
|
||||
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
|
||||
@@ -20,11 +21,13 @@ namespace ScadaLink.SiteRuntime.Streaming;
|
||||
public class SiteStreamManager : ISiteStreamSubscriber
|
||||
{
|
||||
private ActorSystem? _system;
|
||||
private IMaterializer? _materializer;
|
||||
private readonly int _bufferSize;
|
||||
private readonly ILogger<SiteStreamManager> _logger;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private IActorRef? _sourceActor;
|
||||
private Source<ISiteStreamEvent, NotUsed>? _hubSource;
|
||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||
|
||||
public SiteStreamManager(
|
||||
@@ -36,64 +39,73 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the stream source. Must be called after ActorSystem is ready.
|
||||
/// Initializes the broadcast stream. Must be called after ActorSystem is ready.
|
||||
/// The ActorSystem is passed here rather than via the constructor so that
|
||||
/// SiteStreamManager can be created by DI before the actor system exists.
|
||||
/// </summary>
|
||||
public void Initialize(ActorSystem system)
|
||||
{
|
||||
_system = system;
|
||||
var materializer = _system.Materializer();
|
||||
_materializer = _system.Materializer();
|
||||
|
||||
var source = Source.ActorRef<ISiteStreamEvent>(
|
||||
_bufferSize,
|
||||
OverflowStrategy.DropHead);
|
||||
var (sourceActor, hubSource) = Source.ActorRef<ISiteStreamEvent>(
|
||||
_bufferSize,
|
||||
OverflowStrategy.DropHead)
|
||||
.ToMaterialized(
|
||||
BroadcastHub.Sink<ISiteStreamEvent>(bufferSize: 256),
|
||||
Keep.Both)
|
||||
.Run(_materializer);
|
||||
|
||||
var (actorRef, _) = source
|
||||
.PreMaterialize(materializer);
|
||||
|
||||
_sourceActor = actorRef;
|
||||
_sourceActor = sourceActor;
|
||||
_hubSource = hubSource;
|
||||
|
||||
_logger.LogInformation(
|
||||
"SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize);
|
||||
"SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an attribute value change to the stream.
|
||||
/// Publishes an attribute value change to the broadcast hub.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
public void PublishAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
|
||||
// Also forward to filtered subscribers
|
||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an alarm state change to the stream.
|
||||
/// Publishes an alarm state change to the broadcast hub.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
public void PublishAlarmStateChanged(AlarmStateChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
|
||||
// Also forward to filtered subscribers
|
||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Subscribe to events for a specific instance (debug view).
|
||||
/// Returns a subscription ID for unsubscribing.
|
||||
/// Materializes a per-subscriber filtered stream off the BroadcastHub
|
||||
/// with a drop-oldest buffer; returns a subscription ID for unsubscribing.
|
||||
/// </summary>
|
||||
public string Subscribe(string instanceName, IActorRef subscriber)
|
||||
{
|
||||
if (_hubSource is null || _materializer is null)
|
||||
throw new InvalidOperationException("SiteStreamManager.Initialize must be called before Subscribe");
|
||||
|
||||
var subscriptionId = Guid.NewGuid().ToString();
|
||||
var capturedInstance = instanceName;
|
||||
var capturedSubscriber = subscriber;
|
||||
|
||||
var killSwitch = _hubSource
|
||||
.Where(ev => ev.InstanceUniqueName == capturedInstance)
|
||||
.Buffer(_bufferSize, OverflowStrategy.DropHead)
|
||||
.ViaMaterialized(KillSwitches.Single<ISiteStreamEvent>(), Keep.Right)
|
||||
.To(Sink.ForEach<ISiteStreamEvent>(ev => capturedSubscriber.Tell(ev)))
|
||||
.Run(_materializer);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_subscriptions[subscriptionId] = new SubscriptionInfo(
|
||||
instanceName, subscriber, DateTimeOffset.UtcNow);
|
||||
instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -104,44 +116,47 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Unsubscribe from instance events.
|
||||
/// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber
|
||||
/// stream graph via its KillSwitch.
|
||||
/// </summary>
|
||||
public bool Unsubscribe(string subscriptionId)
|
||||
{
|
||||
SubscriptionInfo? info;
|
||||
lock (_lock)
|
||||
{
|
||||
var removed = _subscriptions.Remove(subscriptionId);
|
||||
if (removed)
|
||||
{
|
||||
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
||||
}
|
||||
return removed;
|
||||
if (!_subscriptions.Remove(subscriptionId, out info))
|
||||
return false;
|
||||
}
|
||||
|
||||
info.KillSwitch.Shutdown();
|
||||
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Remove all subscriptions for a specific subscriber actor.
|
||||
/// Called when connection is interrupted.
|
||||
/// Called when a connection is interrupted.
|
||||
/// </summary>
|
||||
public void RemoveSubscriber(IActorRef subscriber)
|
||||
{
|
||||
List<SubscriptionInfo> toShutdown;
|
||||
lock (_lock)
|
||||
{
|
||||
var toRemove = _subscriptions
|
||||
var matched = _subscriptions
|
||||
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
foreach (var kvp in matched)
|
||||
_subscriptions.Remove(kvp.Key);
|
||||
toShutdown = matched.Select(kvp => kvp.Value).ToList();
|
||||
}
|
||||
|
||||
foreach (var id in toRemove)
|
||||
{
|
||||
_subscriptions.Remove(id);
|
||||
}
|
||||
foreach (var info in toShutdown)
|
||||
info.KillSwitch.Shutdown();
|
||||
|
||||
if (toRemove.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} subscriptions for disconnected subscriber", toRemove.Count);
|
||||
}
|
||||
if (toShutdown.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} subscriptions for disconnected subscriber", toShutdown.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,28 +168,9 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
||||
get { lock (_lock) { return _subscriptions.Count; } }
|
||||
}
|
||||
|
||||
private void ForwardToSubscribers(string instanceName, object message)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
{
|
||||
if (sub.InstanceName == instanceName)
|
||||
{
|
||||
// Fire-and-forget to subscriber
|
||||
sub.Subscriber.Tell(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SubscriptionInfo(
|
||||
string InstanceName,
|
||||
IActorRef Subscriber,
|
||||
IKillSwitch KillSwitch,
|
||||
DateTimeOffset SubscribedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for events published to the site stream.
|
||||
/// </summary>
|
||||
public interface ISiteStreamEvent { }
|
||||
|
||||
Reference in New Issue
Block a user