Files
ScadaBridge/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
T

373 lines
14 KiB
C#

using Akka.Actor;
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;
/// <summary>
/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors.
/// Provides the API surface that user scripts interact with:
/// Instance.GetAttribute("name")
/// Instance.SetAttribute("name", value)
/// Instance.CallScript("scriptName", params)
/// Scripts.CallShared("scriptName", params)
///
/// WP-13 (Phase 7): Integration surface APIs:
/// ExternalSystem.Call("systemName", "methodName", params)
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// Database.Connection("name")
/// Database.CachedWrite("name", "sql", params)
/// Notify.To("listName").Send("subject", "message")
///
/// WP-20: Recursion Limit — call depth tracked and enforced.
/// </summary>
public class ScriptRuntimeContext
{
private readonly IActorRef _instanceActor;
private readonly IActorRef _self;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly int _currentCallDepth;
private readonly int _maxCallDepth;
private readonly TimeSpan _askTimeout;
private readonly ILogger _logger;
private readonly string _instanceName;
/// <summary>
/// WP-13: External system client for ExternalSystem.Call/CachedCall.
/// </summary>
private readonly IExternalSystemClient? _externalSystemClient;
/// <summary>
/// WP-13: Database gateway for Database.Connection/CachedWrite.
/// </summary>
private readonly IDatabaseGateway? _databaseGateway;
/// <summary>
/// WP-13: Notification delivery for Notify.To().Send().
/// </summary>
private readonly INotificationDeliveryService? _notificationService;
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
SharedScriptLibrary sharedScriptLibrary,
int currentCallDepth,
int maxCallDepth,
TimeSpan askTimeout,
string instanceName,
ILogger logger,
IExternalSystemClient? externalSystemClient = null,
IDatabaseGateway? databaseGateway = null,
INotificationDeliveryService? notificationService = null)
{
_instanceActor = instanceActor;
_self = self;
_sharedScriptLibrary = sharedScriptLibrary;
_currentCallDepth = currentCallDepth;
_maxCallDepth = maxCallDepth;
_askTimeout = askTimeout;
_instanceName = instanceName;
_logger = logger;
_externalSystemClient = externalSystemClient;
_databaseGateway = databaseGateway;
_notificationService = notificationService;
}
/// <summary>
/// Gets the current value of an attribute from the Instance Actor.
/// Uses Ask pattern (system boundary between script execution and instance state).
/// </summary>
public async Task<object?> GetAttribute(string attributeName)
{
var correlationId = Guid.NewGuid().ToString();
var request = new GetAttributeRequest(
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
if (!response.Found)
{
_logger.LogWarning(
"GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
attributeName, _instanceName);
}
return response.Value;
}
/// <summary>
/// Sets an attribute value. For data-connected attributes the Instance Actor
/// forwards the write to the DCL, which writes the physical device; the
/// in-memory value is not optimistically updated. For static attributes the
/// Instance Actor updates the in-memory value and persists the override to
/// SQLite. All mutations are serialized through the Instance Actor mailbox.
///
/// The write is awaited so that a device-write failure on a data-connected
/// attribute is surfaced synchronously to the calling script as an
/// <see cref="InvalidOperationException"/>.
/// </summary>
public async Task SetAttribute(string attributeName, string value)
{
var correlationId = Guid.NewGuid().ToString();
var command = new SetStaticAttributeCommand(
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow);
// Ask — mutation serialized through the Instance Actor mailbox; the reply
// carries the device-write outcome for data-connected attributes.
var response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout);
if (!response.Success)
{
throw new InvalidOperationException(
$"SetAttribute('{attributeName}') failed: {response.ErrorMessage}");
}
}
/// <summary>
/// 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, object? parameters = null)
{
var nextDepth = _currentCallDepth + 1;
if (nextDepth > _maxCallDepth)
{
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
_logger.LogError(msg);
throw new InvalidOperationException(msg);
}
var correlationId = Guid.NewGuid().ToString();
var request = new ScriptCallRequest(
scriptName,
ScriptArgs.Normalize(parameters),
nextDepth,
correlationId);
// Ask the Instance Actor, which routes to the appropriate Script Actor
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
if (!result.Success)
{
throw new InvalidOperationException(
$"CallScript('{scriptName}') failed: {result.ErrorMessage}");
}
return result.ReturnValue;
}
/// <summary>
/// Provides access to shared script execution via the Scripts property.
/// </summary>
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
/// <summary>
/// WP-13: Provides access to external system calls.
/// ExternalSystem.Call("systemName", "methodName", params)
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger);
/// <summary>
/// WP-13: Provides access to database operations.
/// Database.Connection("name")
/// Database.CachedWrite("name", "sql", params)
/// </summary>
public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger);
/// <summary>
/// WP-13: Provides access to notification delivery.
/// Notify.To("listName").Send("subject", "message")
/// </summary>
public NotifyHelper Notify => new(_notificationService, _instanceName, _logger);
/// <summary>
/// Helper class for Scripts.CallShared() syntax.
/// </summary>
public class ScriptCallHelper
{
private readonly SharedScriptLibrary _library;
private readonly ScriptRuntimeContext _context;
private readonly int _currentCallDepth;
private readonly int _maxCallDepth;
private readonly ILogger _logger;
internal ScriptCallHelper(
SharedScriptLibrary library,
ScriptRuntimeContext context,
int currentCallDepth,
int maxCallDepth,
ILogger logger)
{
_library = library;
_context = context;
_currentCallDepth = currentCallDepth;
_maxCallDepth = maxCallDepth;
_logger = logger;
}
/// <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,
object? parameters = null,
CancellationToken cancellationToken = default)
{
var nextDepth = _currentCallDepth + 1;
if (nextDepth > _maxCallDepth)
{
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
_logger.LogError(msg);
throw new InvalidOperationException(msg);
}
return await _library.ExecuteAsync(
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
/// <summary>
/// WP-13: Helper for ExternalSystem.Call/CachedCall syntax.
/// </summary>
public class ExternalSystemHelper
{
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
private readonly ILogger _logger;
internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger)
{
_client = client;
_instanceName = instanceName;
_logger = logger;
}
public async Task<ExternalCallResult> Call(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new InvalidOperationException("External system client not available");
return await _client.CallAsync(systemName, methodName, parameters, cancellationToken);
}
public async Task<ExternalCallResult> CachedCall(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new InvalidOperationException("External system client not available");
return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
}
}
/// <summary>
/// WP-13: Helper for Database.Connection/CachedWrite syntax.
/// </summary>
public class DatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
private readonly ILogger _logger;
internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger)
{
_gateway = gateway;
_instanceName = instanceName;
_logger = logger;
}
public async Task<System.Data.Common.DbConnection> Connection(
string name,
CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new InvalidOperationException("Database gateway not available");
return await _gateway.GetConnectionAsync(name, cancellationToken);
}
public async Task CachedWrite(
string name,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new InvalidOperationException("Database gateway not available");
await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
}
}
/// <summary>
/// WP-13: Helper for Notify.To("listName").Send("subject", "message") syntax.
/// </summary>
public class NotifyHelper
{
private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
private readonly ILogger _logger;
internal NotifyHelper(INotificationDeliveryService? service, string instanceName, ILogger logger)
{
_service = service;
_instanceName = instanceName;
_logger = logger;
}
public NotifyTarget To(string listName)
{
return new NotifyTarget(listName, _service, _instanceName, _logger);
}
}
/// <summary>
/// WP-13: Target for Notify.To("listName").Send("subject", "message").
/// </summary>
public class NotifyTarget
{
private readonly string _listName;
private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
private readonly ILogger _logger;
internal NotifyTarget(string listName, INotificationDeliveryService? service, string instanceName, ILogger logger)
{
_listName = listName;
_service = service;
_instanceName = instanceName;
_logger = logger;
}
public async Task<NotificationResult> Send(
string subject,
string message,
CancellationToken cancellationToken = default)
{
if (_service == null)
throw new InvalidOperationException("Notification service not available");
return await _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken);
}
}
}