using System.Text.Json; using Akka.Actor; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; /// /// Pure, thread-safe deploy-time gate: given a flattened-configuration JSON, it /// compiles every script, every alarm on-trigger script, and every Expression /// trigger just as does at /// runtime — but WITHOUT touching any actor state. It returns the collected /// compile errors so the Deployment Manager can reject a deployment whose scripts /// do not compile on the site (design spec: site-side compile failure rejects the /// deployment; no partial state is applied). /// /// Because it holds no actor state and only calls /// (whose only state is a thread-safe compile cache), it is pure and thread-safe; the /// Deployment Manager nevertheless invokes it synchronously on the singleton's actor /// thread as a pure prefix step, preserving mailbox-FIFO deploy ordering (see /// ). Compiles of unchanged /// script bodies are deduped by that cache, which is shared across the whole site because /// the compilation service is a DI singleton. /// public sealed class DeployCompileValidator { private readonly ScriptCompilationService _compilationService; /// Initializes a new instance of the . /// The same compilation service the Instance Actor uses at runtime. public DeployCompileValidator(ScriptCompilationService compilationService) => _compilationService = compilationService; /// /// Prefix carried by every runtime-name error, so a caller can tell a name rejection /// () from a compile failure when wording its reply. /// private const string ActorNameErrorPrefix = "Invalid runtime name: "; /// True when came from the runtime-name check. /// One error returned by . /// for a runtime-name error. public static bool IsActorNameError(string error) => error.StartsWith(ActorNameErrorPrefix, StringComparison.Ordinal); /// /// Validates every compilable artifact in the flattened configuration. /// Returns an empty list when the whole configuration compiles; otherwise /// one human-readable error per failing artifact. Malformed JSON is itself a /// single error (this also fronts the poison-config case for startup load). /// /// The flattened-configuration JSON to validate. /// /// The instance unique name the Deployment Manager will create the Instance Actor under. /// When supplied it is checked as an actor name along with every script/alarm/native-source /// child name (review 2026-10-21 01/SR-C1); checks the child names only. /// /// A list of compile-error messages; empty if the configuration is clean. public IReadOnlyList Validate(string configJson, string? instanceName = null) { if (!TryParse(configJson, out var config, out var parseError)) return [parseError!]; return Validate(config!, instanceName); } /// /// Deserializes a flattened-configuration JSON exactly as /// does, so a caller that needs the parsed object as well (the Deployment Manager, which hands /// it on to the Instance Actor — review 2026-10-21 01/SR-P5) parses once instead of once per /// consumer. The error text is the one reports. /// /// The flattened-configuration JSON. /// The parsed configuration, or on failure. /// The deserialization error, or on success. /// when the JSON produced a configuration. public static bool TryParse(string configJson, out FlattenedConfiguration? config, out string? error) { try { config = JsonSerializer.Deserialize(configJson); } catch (JsonException ex) { config = null; error = $"Configuration JSON failed to deserialize: {ex.Message}"; return false; } if (config == null) { error = "Configuration JSON failed to deserialize: result was null."; return false; } error = null; return true; } /// /// Validates an already-parsed flattened configuration — the same checks as /// without a second deserialization (01/SR-P5). /// /// The parsed flattened configuration. /// See . /// A list of error messages; empty if the configuration is clean. public IReadOnlyList Validate(FlattenedConfiguration config, string? instanceName = null) { var errors = new List(); // Runtime names first: they are cheap, and a name Akka refuses would otherwise surface // as an InvalidActorNameException at Context.ActorOf instead of a readable rejection. AddActorNameErrors(errors, config, instanceName); // Scripts: full execution-path compile against ScriptGlobals. foreach (var script in config.Scripts) { var result = _compilationService.Compile(script.CanonicalName, script.Code); if (!result.IsSuccess) errors.Add($"Script '{script.CanonicalName}': {string.Join("; ", result.Errors)}"); // Expression-triggered scripts also compile their trigger expression. AddTriggerExpressionError(errors, script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}"); } // Alarms: on-trigger script reference must resolve + compile, and any // Expression trigger must compile — mirrors InstanceActor.CreateChildActors. foreach (var alarm in config.Alarms) { if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName)) { var triggerScriptDef = config.Scripts .FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName); if (triggerScriptDef == null) { errors.Add($"Alarm '{alarm.CanonicalName}': on-trigger script " + $"'{alarm.OnTriggerScriptCanonicalName}' is not defined."); } else { var result = _compilationService.Compile( $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code); if (!result.IsSuccess) errors.Add($"Alarm '{alarm.CanonicalName}' on-trigger script " + $"'{alarm.OnTriggerScriptCanonicalName}': {string.Join("; ", result.Errors)}"); } } AddTriggerExpressionError(errors, alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}"); } return errors; } /// /// Populates the shared compile cache for a whole startup batch (review 2026-10-21 01/SR-P3): /// every DISTINCT script body and trigger expression across is /// compiled once, up to at a time. Same compile units /// as — scripts and alarm on-trigger /// scripts against ScriptGlobals, Expression triggers against /// TriggerExpressionGlobals — but deduplicated first, because the cache is keyed on /// code: N instances of one template would otherwise race N identical cold compiles in /// parallel. Best-effort: verdicts are discarded and a throwing unit is skipped, since every /// Instance Actor's own compile remains the correctness backstop. /// /// The batch's parsed configurations. /// Upper bound on concurrent Roslyn compiles. public void WarmCompileCache(IReadOnlyList configs, int maxDegreeOfParallelism) { var units = new Dictionary<(bool IsTrigger, string Code), string>(); foreach (var config in configs) { foreach (var script in config.Scripts) { units.TryAdd((false, script.Code), script.CanonicalName); AddTriggerUnit(units, script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}"); } // Alarm on-trigger scripts compile the referenced SCRIPT's body against ScriptGlobals, // i.e. the same cache key as that script — already a unit above. foreach (var alarm in config.Alarms) AddTriggerUnit(units, alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}"); } Parallel.ForEach( units, new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism) }, unit => { try { if (unit.Key.IsTrigger) _compilationService.CompileTriggerExpression(unit.Value, unit.Key.Code); else _compilationService.Compile(unit.Value, unit.Key.Code); } catch { // Best-effort warm — the Instance Actor's PreStart compiles it the hard way. } }); } private static void AddTriggerUnit( Dictionary<(bool IsTrigger, string Code), string> units, string? triggerType, string? triggerConfigJson, string compileName) { if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase)) return; var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson); if (expression != null) units.TryAdd((true, expression), compileName); } /// /// Review 2026-10-21 01/SR-C1: every name that becomes an Akka actor name on the site — the /// Instance Actor itself (DeploymentManagerActor.CreateInstanceActor) and its /// script-/alarm-/native-alarm- children /// (InstanceActor.CreateChildActors) — must be a legal path element. An instance named /// "Furnace #3" used to make Context.ActorOf throw on the Deployment Manager /// singleton's own thread, restarting it and every Instance Actor on the site. /// /// /// Decided with Akka's own , not the Commons mirror /// ActorNameRules: the mirror is currently STRICTER (it refuses ", ( and /// ), which Akka accepts), so gating on it would reject deployments that work today. /// /// private static void AddActorNameErrors( List errors, FlattenedConfiguration config, string? instanceName) { if (instanceName != null) AddActorNameError(errors, "Instance", instanceName, instanceName); foreach (var script in config.Scripts) AddActorNameError(errors, "Script", script.CanonicalName, $"script-{script.CanonicalName}"); foreach (var alarm in config.Alarms) AddActorNameError(errors, "Alarm", alarm.CanonicalName, $"alarm-{alarm.CanonicalName}"); foreach (var source in config.NativeAlarmSources) AddActorNameError(errors, "Native alarm source", source.CanonicalName, $"native-alarm-{source.CanonicalName}"); } private static void AddActorNameError( List errors, string kind, string? name, string actorName) { if (ActorPath.IsValidPathElement(actorName)) return; errors.Add($"{ActorNameErrorPrefix}{kind} name '{name}' cannot be used as a runtime actor " + "name — use ASCII letters, digits and -_.*$+:@&=,!~';() only (no spaces, '#', " + "'/', '[', ']', '%' or non-ASCII letters), and do not start it with '$'."); } /// /// Compiles the boolean trigger expression for an Expression-triggered script /// or alarm and records any failure. No-op for non-Expression triggers or a /// blank/malformed expression config — mirroring /// InstanceActor.CompileTriggerExpression, where those cases yield an /// inert trigger rather than a deployment error. /// private void AddTriggerExpressionError( List errors, string? triggerType, string? triggerConfigJson, string compileName) { if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase)) return; var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson); if (expression == null) return; var result = _compilationService.CompileTriggerExpression(compileName, expression); if (!result.IsSuccess) errors.Add($"Trigger expression '{compileName}': {string.Join("; ", result.Errors)}"); } }