From 78fbb13df7624795a26fd3a31ed0927c2d8c55d2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 18 Mar 2026 08:43:13 -0400 Subject: [PATCH] feat: wire Inbound API Route.To().Call() to site instance scripts and add Roslyn compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Inbound API → site script call chain by adding RouteToCallRequest handlers in SiteCommunicationActor and DeploymentManagerActor. Also replaces the placeholder dispatch table in InboundScriptExecutor with Roslyn compilation of API method scripts at startup, enabling user-defined inbound API methods to call instance scripts across the cluster. --- .../Actors/SiteCommunicationActor.cs | 4 ++ src/ScadaLink.Host/Program.cs | 13 ++++ .../InboundScriptExecutor.cs | 70 +++++++++++++++++-- .../ScadaLink.InboundAPI.csproj | 4 ++ .../Actors/DeploymentManagerActor.cs | 40 +++++++++++ 5 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs index 2040268..1bcfd2d 100644 --- a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs @@ -5,6 +5,7 @@ using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Messages.Integration; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.RemoteQuery; @@ -107,6 +108,9 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers // Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager Receive(msg => _deploymentManagerProxy.Forward(msg)); + // Inbound API Route.To().Call() — forward to Deployment Manager for instance routing + Receive(msg => _deploymentManagerProxy.Forward(msg)); + // Pattern 7: Remote Queries Receive(msg => { diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 98ca6ec..cdedc71 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -129,6 +129,19 @@ try app.MapStaticAssets(); app.MapCentralUI(); app.MapInboundAPI(); + + // Compile and register all Inbound API method scripts at startup + using (var scope = app.Services.CreateScope()) + { + var apiRepo = scope.ServiceProvider.GetRequiredService(); + var executor = app.Services.GetRequiredService(); + var methods = await apiRepo.GetAllApiMethodsAsync(); + foreach (var method in methods) + { + executor.CompileAndRegister(method); + } + } + await app.RunAsync(); } else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase)) diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index 881ebc5..fd0cdc8 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; @@ -6,11 +8,7 @@ namespace ScadaLink.InboundAPI; /// /// WP-3: Executes the C# script associated with an inbound API method. -/// The script receives input parameters and a route helper, and returns a result -/// that is serialized as the JSON response. -/// -/// In a full implementation this would use Roslyn scripting. For now, scripts -/// are a simple dispatch table so the rest of the pipeline can be tested end-to-end. +/// Compiles method scripts via Roslyn and caches compiled delegates. /// public class InboundScriptExecutor { @@ -24,13 +22,73 @@ public class InboundScriptExecutor /// /// Registers a compiled script handler for a method name. - /// In production, this would be called after Roslyn compilation of the method's Script property. /// public void RegisterHandler(string methodName, Func> handler) { _scriptHandlers[methodName] = handler; } + /// + /// Compiles and registers a single API method script. + /// + public bool CompileAndRegister(ApiMethod method) + { + if (string.IsNullOrWhiteSpace(method.Script)) + { + _logger.LogWarning("API method {Method} has no script code", method.Name); + return false; + } + + try + { + var scriptOptions = ScriptOptions.Default + .WithReferences( + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(Dictionary<,>).Assembly, + typeof(RouteHelper).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly) + .WithImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks"); + + var compiled = CSharpScript.Create( + method.Script, + scriptOptions, + globalsType: typeof(InboundScriptContext)); + + var diagnostics = compiled.Compile(); + var errors = diagnostics + .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .Select(d => d.GetMessage()) + .ToList(); + + if (errors.Count > 0) + { + _logger.LogWarning( + "API method {Method} script compilation failed: {Errors}", + method.Name, string.Join("; ", errors)); + return false; + } + + _scriptHandlers[method.Name] = async ctx => + { + var state = await compiled.RunAsync(ctx, ctx.CancellationToken); + return state.ReturnValue; + }; + + _logger.LogInformation("API method {Method} script compiled and registered", method.Name); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to compile API method {Method} script", method.Name); + return false; + } + } + /// /// Executes the script for the given method with the provided context. /// diff --git a/src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj b/src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj index 59f3185..c75e249 100644 --- a/src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj +++ b/src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index 52f81e4..9de0b17 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -3,7 +3,9 @@ using Microsoft.Extensions.Logging; using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Messages.Lifecycle; +using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types.Enums; using ScadaLink.HealthMonitoring; using ScadaLink.SiteRuntime.Messages; @@ -77,6 +79,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(RouteDebugViewUnsubscribe); Receive(RouteDebugSnapshot); + // Inbound API Route.To().Call() — route to Instance Actors + Receive(RouteInboundApiCall); + // Internal startup messages Receive(HandleStartupConfigsLoaded); Receive(HandleStartNextBatch); @@ -486,6 +491,41 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } } + // ── Inbound API routing ── + + private void RouteInboundApiCall(RouteToCallRequest request) + { + if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) + { + // Convert to ScriptCallRequest and Ask the Instance Actor + var scriptCall = new ScriptCallRequest( + request.ScriptName, request.Parameters, 0, request.CorrelationId); + var sender = Sender; + instanceActor.Ask(scriptCall, TimeSpan.FromSeconds(30)) + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var result = t.Result; + return new RouteToCallResponse( + request.CorrelationId, result.Success, result.ReturnValue, + result.ErrorMessage, DateTimeOffset.UtcNow); + } + return new RouteToCallResponse( + request.CorrelationId, false, null, + t.Exception?.GetBaseException().Message ?? "Script call timed out", + DateTimeOffset.UtcNow); + }).PipeTo(sender); + } + else + { + Sender.Tell(new RouteToCallResponse( + request.CorrelationId, false, null, + $"Instance '{request.InstanceUniqueName}' not found on this site.", + DateTimeOffset.UtcNow)); + } + } + /// /// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.). /// Persists artifacts to SiteStorageService and recompiles shared scripts.