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:
Joseph Doherty
2026-05-16 03:37:56 -04:00
parent d7b05b40e9
commit 295150751f
50 changed files with 2926 additions and 550 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 { }