diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs
deleted file mode 100644
index 56b195a4..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
-
-///
-/// Persistence seam for ScriptedAlarmActor's in-memory state across actor restarts.
-/// Captures only the slice the actor's 3-state machine needs (Inactive / Active /
-/// Acknowledged + last transition + last-ack user). The fuller GxP audit trail
-/// ('s Comments/Confirmed/Shelving)
-/// stays in the production engine binding — this seam is the small surface the actor
-/// consumes directly.
-///
-public interface IAlarmActorStateStore
-{
- /// Loads the persisted state snapshot for an alarm actor.
- /// The alarm identifier.
- /// Cancellation token.
- /// The alarm state snapshot if found; null if the alarm has no persisted state.
- Task LoadAsync(string alarmId, CancellationToken ct);
- /// Saves the alarm actor state snapshot.
- /// The state snapshot to persist.
- /// Cancellation token.
- Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
-}
-
-/// Persisted slice of ScriptedAlarmActor's state. Active is NOT persisted —
-/// it re-derives from the evaluator on startup per Phase 7 decision #14. State here
-/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up
-/// Active and operator interaction had already happened.
-/// Matches ScriptedAlarm.ScriptedAlarmId.
-/// Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string.
-/// When the actor last transitioned.
-/// Who acknowledged most recently. Null when never acked.
-public sealed record AlarmActorStateSnapshot(
- string AlarmId,
- string State,
- DateTime LastTransitionUtc,
- string? LastAckUser);
-
-/// No-op default. Bound when no production store is configured (tests, smoke runs).
-/// Load returns null → actor boots Inactive; Save is a no-op so state doesn't leak.
-public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
-{
- public static readonly NullAlarmActorStateStore Instance = new();
- private NullAlarmActorStateStore() { }
- /// Always returns null, indicating no persisted state.
- /// The alarm identifier (unused).
- /// Cancellation token (unused).
- public Task LoadAsync(string alarmId, CancellationToken ct) =>
- Task.FromResult(null);
- /// Completes immediately without persisting anything.
- /// The state snapshot (ignored).
- /// Cancellation token (unused).
- public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
- Task.CompletedTask;
-}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs
deleted file mode 100644
index 6ad1464e..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
-
-///
-/// Abstraction over the scripted-alarm predicate engine. Production binds this to a
-/// wrapper around ScriptedAlarmEngine from Core.ScriptedAlarms; default
-/// binding is which keeps the alarm in its
-/// current state (so an unconfigured node never spuriously alarms).
-///
-public interface IScriptedAlarmEvaluator
-{
- /// Evaluates an alarm predicate against the provided dependencies.
- /// The unique identifier of the alarm being evaluated.
- /// The predicate expression to evaluate.
- /// Read-only dictionary of variable names to values for predicate evaluation.
- /// Result containing success flag, alarm active state, and optional failure reason.
- ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies);
-}
-
-/// Result of one alarm-predicate evaluation. Active is only meaningful when
-/// Success is true; on failure the caller should keep the prior state and log Reason.
-public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
-{
- /// Creates a successful alarm evaluation result with the given active state.
- /// Whether the alarm condition is active.
- /// A successful evaluation result.
- public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
-
- /// Creates a failed alarm evaluation result with the given reason.
- /// Description of the evaluation failure cause.
- /// A failed evaluation result.
- public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
-}
-
-/// Default that always returns Active = false, Success = true. Safe no-op:
-/// no alarm fires when no real engine is bound.
-public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
-{
- public static readonly NullScriptedAlarmEvaluator Instance = new();
- private NullScriptedAlarmEvaluator() { }
- /// Returns an inactive alarm result for every evaluation (safe no-op behavior).
- /// The alarm identifier (ignored).
- /// The predicate expression (ignored).
- /// The variable dependencies (ignored).
- /// Always returns an inactive alarm result.
- public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies)
- => ScriptedAlarmEvalResult.Ok(active: false);
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs
deleted file mode 100644
index 8a247381..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System.Collections.Concurrent;
-using Microsoft.Extensions.Logging;
-using ZB.MOM.WW.OtOpcUa.Commons.Engines;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
-using ZB.MOM.WW.OtOpcUa.Core.Scripting;
-using SerilogLogger = Serilog.ILogger;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
-
-///
-/// F9b — production binding. Compiles each unique
-/// predicate once via against
-/// and caches the resulting evaluator. Predicates are
-/// pure functions returning bool:
-/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
-///
-/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
-/// as ;
-/// preserves the prior state on failure (does not flip Active/Inactive).
-///
-public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
-{
- private readonly ConcurrentDictionary> _cache
- = new(StringComparer.Ordinal);
- private readonly ILogger _logger;
- private readonly SerilogLogger _scriptRoot;
- private readonly TimeSpan _runTimeout;
- private bool _disposed;
-
- /// Initializes a new instance of the Roslyn scripted alarm evaluator.
- /// Logger for diagnostic messages (host diagnostics).
- /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page.
- /// Optional timeout for script evaluation; defaults to 2 seconds.
- public RoslynScriptedAlarmEvaluator(
- ILogger logger,
- ScriptRootLogger scriptRoot,
- TimeSpan? runTimeout = null)
- {
- _logger = logger;
- _scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
- _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
- }
-
- /// Evaluates a scripted alarm predicate against provided dependencies.
- /// The alarm identifier for logging purposes.
- /// The predicate expression to evaluate.
- /// Variables available to the predicate expression.
- /// Evaluation result with success flag and active state or failure reason.
- public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies)
- {
- if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
- if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
-
- ScriptEvaluator evaluator;
- try
- {
- evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator.Compile);
- }
- catch (CompilationErrorException ex)
- {
- _logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
- return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
- }
- catch (ScriptSandboxViolationException ex)
- {
- _logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
- return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
- return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
- }
-
- var readCache = BuildReadCache(dependencies);
- // Per-evaluation script logger: bind both ScriptId and AlarmId from the alarm id so the
- // Script-log page can attribute each line to the owning scripted alarm.
- var scriptLog = _scriptRoot
- .ForContext(ScriptLoggerFactory.ScriptIdProperty, alarmId)
- .ForContext(ScriptLoggerFactory.AlarmIdProperty, alarmId);
- var context = new AlarmPredicateContext(readCache, scriptLog);
-
- try
- {
- using var cts = new CancellationTokenSource(_runTimeout);
- var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
- return ScriptedAlarmEvalResult.Ok(active);
- }
- catch (OperationCanceledException)
- {
- return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
- return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
- }
- }
-
- private static IReadOnlyDictionary BuildReadCache(
- IReadOnlyDictionary deps)
- {
- var nowUtc = DateTime.UtcNow;
- var cache = new Dictionary(StringComparer.Ordinal);
- foreach (var kv in deps)
- {
- cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
- }
- return cache;
- }
-
- /// Disposes the evaluator and all cached script evaluators.
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- foreach (var ev in _cache.Values)
- {
- try { ev.Dispose(); } catch { /* best-effort */ }
- }
- _cache.Clear();
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 1c0cd98f..f2a5109e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -114,14 +114,6 @@ if (hasDriver)
sp.GetRequiredService()));
builder.Services.AddSingleton(sp => sp.GetRequiredService());
- // F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
- // any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
- builder.Services.AddSingleton(sp =>
- new RoslynScriptedAlarmEvaluator(
- sp.GetRequiredService().CreateLogger(),
- sp.GetRequiredService()));
- builder.Services.AddSingleton(sp => sp.GetRequiredService());
-
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
// races Akka startup. ScriptRootLogger wraps the composed pipeline (rolling scripts-*.log +
// error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs
deleted file mode 100644
index 40d8d34b..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Logging;
-using ZB.MOM.WW.OtOpcUa.Commons.Engines;
-using ZB.MOM.WW.OtOpcUa.Configuration;
-using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
-
-///
-/// Production-side backed by the
-/// table in the central config DB. The actor's
-/// 3-state enum projects into the table's two persisted dimensions: Acked + an
-/// internal "_lastActiveState" recorded via a synthetic mapping (Inactive ⇒ Acked,
-/// Active ⇒ Unacked, Acknowledged ⇒ Acked). ActiveState itself is deliberately NOT
-/// persisted — re-derives from the evaluator on startup (Phase 7 decision #14).
-///
-public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
-{
- private readonly IDbContextFactory _dbFactory;
- private readonly ILogger _logger;
-
- /// Initializes a new instance of the EfAlarmActorStateStore.
- /// The factory for creating database contexts.
- /// The logger instance.
- public EfAlarmActorStateStore(
- IDbContextFactory dbFactory,
- ILogger logger)
- {
- _dbFactory = dbFactory;
- _logger = logger;
- }
-
- /// Loads the alarm state snapshot from the database.
- /// The identifier of the alarm.
- /// The cancellation token.
- /// The alarm state snapshot, or null if not found.
- public async Task LoadAsync(string alarmId, CancellationToken ct)
- {
- using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
- var row = await db.ScriptedAlarmStates.AsNoTracking()
- .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
- .ConfigureAwait(false);
- if (row is null) return null;
-
- var state = MapAckedToActorState(row.AckedState);
- return new AlarmActorStateSnapshot(
- AlarmId: alarmId,
- State: state,
- LastTransitionUtc: row.UpdatedAtUtc,
- LastAckUser: row.LastAckUser);
- }
-
- /// Saves the alarm state snapshot to the database.
- /// The alarm state snapshot to save.
- /// The cancellation token.
- public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
- {
- using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
- var row = await db.ScriptedAlarmStates
- .FirstOrDefaultAsync(r => r.ScriptedAlarmId == snapshot.AlarmId, ct)
- .ConfigureAwait(false);
-
- var ackedState = MapActorStateToAcked(snapshot.State);
- if (row is null)
- {
- db.ScriptedAlarmStates.Add(new ScriptedAlarmState
- {
- ScriptedAlarmId = snapshot.AlarmId,
- EnabledState = "Enabled",
- AckedState = ackedState,
- ConfirmedState = "Confirmed",
- ShelvingState = "Unshelved",
- LastAckUser = snapshot.LastAckUser,
- LastAckUtc = string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal)
- ? snapshot.LastTransitionUtc
- : null,
- UpdatedAtUtc = snapshot.LastTransitionUtc,
- CommentsJson = "[]",
- });
- }
- else
- {
- row.AckedState = ackedState;
- row.LastAckUser = snapshot.LastAckUser ?? row.LastAckUser;
- if (string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal))
- row.LastAckUtc = snapshot.LastTransitionUtc;
- row.UpdatedAtUtc = snapshot.LastTransitionUtc;
- }
-
- try
- {
- await db.SaveChangesAsync(ct).ConfigureAwait(false);
- }
- catch (DbUpdateConcurrencyException ex)
- {
- // Two actors racing to save the same alarm is benign — the last writer wins on
- // UpdatedAtUtc, and the next transition on either side will write again. Log
- // + drop so a race doesn't crash the dispatcher.
- _logger.LogDebug(ex, "EfAlarmActorStateStore: concurrency conflict for {AlarmId}; dropping save",
- snapshot.AlarmId);
- }
- }
-
- private static string MapActorStateToAcked(string actorState) => actorState switch
- {
- "Active" => "Unacknowledged",
- "Acknowledged" => "Acknowledged",
- // Inactive maps to Acknowledged — when an alarm clears, nothing is left to ack.
- _ => "Acknowledged",
- };
-
- private static string MapAckedToActorState(string ackedState)
- {
- // Only Active distinguishes from Acked — Inactive comes from a re-eval, not from
- // the table. Persisted "Unacknowledged" implies the actor was last Active +
- // un-acked; we restore it to Active so a restart doesn't drop pending operator work.
- return string.Equals(ackedState, "Unacknowledged", StringComparison.Ordinal)
- ? "Active"
- : "Acknowledged";
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
index f4d56349..b0bdbe53 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
@@ -10,11 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
///
/// Production-side backed by the
-/// table in the central config DB. Unlike the narrower
-/// (which only persists AckedState for the actor's
-/// 3-state enum), this store maps the full Part 9 —
-/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator
-/// comments.
+/// table in the central config DB. This store maps the
+/// full Part 9 — Enabled / Acked / Confirmed / Shelving
+/// + the ack/confirm audit trail + operator comments.
///
///
///
@@ -34,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
///
/// serializes to/from
/// via System.Text.Json. An empty list
-/// round-trips as "[]" (matching the entity default + ).
+/// round-trips as "[]" (matching the entity default).
///
///
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
@@ -78,9 +76,9 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
///
///
/// Concurrency assumption: saves for a given alarmId are serialized by the
- /// owning host actor (one actor owns the engine per equipment), mirroring
- /// EfAlarmActorStateStore. The check-then-insert pattern is therefore safe under
- /// that guarantee — two concurrent inserts for the same alarm cannot occur in the live
+ /// owning host actor (one actor owns the engine per equipment). The check-then-insert
+ /// pattern is therefore safe under that guarantee — two concurrent inserts for the same
+ /// alarm cannot occur in the live
/// runtime. The catch handles the edge case of a
/// racing concurrent restart during crash recovery.
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs
deleted file mode 100644
index 50fcc2c0..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs
+++ /dev/null
@@ -1,254 +0,0 @@
-using Akka.Actor;
-using Akka.Cluster.Tools.PublishSubscribe;
-using Akka.Event;
-using ZB.MOM.WW.OtOpcUa.Commons.Engines;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
-using ZB.MOM.WW.OtOpcUa.Commons.Observability;
-using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
-
-public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }
-
-///
-/// One scripted alarm. Receives dependency value updates, runs the predicate via an
-/// injected , and on transitions publishes both
-/// an on the cluster alerts DPS topic and a
-/// on script-logs. Manual
-/// + still flow through the same state machine so the
-/// legacy callers keep working.
-///
-public sealed class ScriptedAlarmActor : ReceiveActor
-{
- public const string AlertsTopic = "alerts";
- public const string ScriptLogsTopic = "script-logs";
-
- public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
- public sealed record ConditionMet(string Reason);
- public sealed record AcknowledgeAlarm(string Actor);
- public sealed record ConditionCleared;
- public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc);
-
- public sealed record AlarmConfig(
- string AlarmId,
- string AlarmName,
- string EquipmentPath,
- int Severity,
- string? Predicate);
-
- private readonly AlarmConfig _config;
- private readonly IScriptedAlarmEvaluator _evaluator;
- private readonly IAlarmActorStateStore _stateStore;
- private readonly Func? _publisherFactory;
- private readonly ILoggingAdapter _log = Context.GetLogger();
- private readonly Dictionary _dependencies = new(StringComparer.Ordinal);
-
- private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
- private string? _lastAckUser;
-
- public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
-
- /// Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies.
- /// The alarm configuration.
- /// The alarm evaluator; defaults to null evaluator if not provided.
- /// Optional factory for creating DPS publishers.
- /// Optional state store for persistence; defaults to null store if not provided.
- /// Akka Props for creating the actor.
- public static Props Props(
- AlarmConfig config,
- IScriptedAlarmEvaluator? evaluator = null,
- Func? publisherFactory = null,
- IAlarmActorStateStore? stateStore = null) =>
- Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
- config,
- evaluator ?? NullScriptedAlarmEvaluator.Instance,
- publisherFactory,
- stateStore ?? NullAlarmActorStateStore.Instance));
-
- /// Legacy single-arg ctor kept for callers that only care about the state machine
- /// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to Props(new AlarmConfig(...)).
- /// The alarm identifier, used as both alarm ID and name.
- /// Akka Props for creating the actor with minimal configuration.
- public static Props Props(string alarmId) =>
- Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
-
- /// Initializes a new ScriptedAlarmActor with the given configuration and dependencies.
- /// The alarm configuration.
- /// The alarm predicate evaluator.
- /// Optional factory for creating DPS publishers.
- /// The state store for loading and saving alarm state.
- public ScriptedAlarmActor(
- AlarmConfig config,
- IScriptedAlarmEvaluator evaluator,
- Func? publisherFactory,
- IAlarmActorStateStore stateStore)
- {
- _config = config;
- _evaluator = evaluator;
- _publisherFactory = publisherFactory;
- _stateStore = stateStore;
-
- Receive(OnDependencyChanged);
- Receive(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
- Receive(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
- Receive(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
- Receive(OnStateRestored);
- }
-
- ///
- protected override void PreStart()
- {
- // Load persisted state — when the store has a row, restore in-memory state before the
- // first dependency-change arrives. Async I/O is piped back as StateRestored so we don't
- // block the message-loop thread; until it arrives the actor stays at the default Inactive.
- var self = Self;
- _ = Task.Run(async () =>
- {
- try
- {
- var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None)
- .ConfigureAwait(false);
- if (snapshot is null) return;
- if (!Enum.TryParse(snapshot.State, ignoreCase: true, out var parsed))
- return;
- self.Tell(new StateRestored(parsed, snapshot.LastAckUser));
- }
- catch (Exception ex)
- {
- _log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive",
- _config.AlarmId);
- }
- });
- }
-
- private void OnStateRestored(StateRestored msg)
- {
- // Active is re-derived from the evaluator at the next DependencyValueChanged — we still
- // restore Active here so operators don't lose the in-flight transition if a restart races
- // ahead of the next eval. The first evaluator tick will correct it if the condition cleared.
- _state = msg.State;
- _lastAckUser = msg.LastAckUser;
- _log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})",
- _config.AlarmId, _state, _lastAckUser ?? "(none)");
- }
-
- private void OnDependencyChanged(DependencyValueChanged msg)
- {
- _dependencies[msg.TagId] = msg.Value;
-
- if (string.IsNullOrEmpty(_config.Predicate)) return;
-
- ScriptedAlarmEvalResult result;
- try
- {
- result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies);
- }
- catch (Exception ex)
- {
- _log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId);
- PublishLog("Error", $"evaluator threw: {ex.Message}");
- return;
- }
-
- if (!result.Success)
- {
- PublishLog("Warning", result.Reason ?? "evaluator failure");
- return;
- }
-
- // Active condition wins regardless of ack state — re-firing is suppressed because
- // _state already == Active. Cleared moves Active OR Acknowledged → Inactive.
- if (result.Active && _state == ScriptedAlarmActorState.Inactive)
- {
- Transition(ScriptedAlarmActorState.Active, user: "system");
- }
- else if (!result.Active && _state != ScriptedAlarmActorState.Inactive)
- {
- Transition(ScriptedAlarmActorState.Inactive, user: "system");
- }
- }
-
- private void Transition(ScriptedAlarmActorState next, string user)
- {
- var prev = _state;
- _state = next;
- if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user;
- _log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);
-
- var nowUtc = DateTime.UtcNow;
- Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
- PersistStateAsync(nowUtc);
-
- var kind = next switch
- {
- ScriptedAlarmActorState.Active => "Activated",
- ScriptedAlarmActorState.Acknowledged => "Acknowledged",
- ScriptedAlarmActorState.Inactive => "Cleared",
- _ => next.ToString(),
- };
-
- OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
- new KeyValuePair("state", kind.ToLowerInvariant()));
-
- var evt = new AlarmTransitionEvent(
- AlarmId: _config.AlarmId,
- EquipmentPath: _config.EquipmentPath,
- AlarmName: _config.AlarmName,
- TransitionKind: kind,
- Severity: _config.Severity,
- Message: $"{_config.AlarmName} {kind}",
- User: user,
- TimestampUtc: nowUtc);
-
- PublishOrFallback(AlertsTopic, evt);
- PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})");
- }
-
- private void PublishLog(string level, string message)
- {
- var entry = new ScriptLogEntry(
- ScriptId: _config.AlarmId,
- Level: level,
- Message: message,
- TimestampUtc: DateTime.UtcNow,
- VirtualTagId: null,
- AlarmId: _config.AlarmId,
- EquipmentId: null);
- PublishOrFallback(ScriptLogsTopic, entry);
- }
-
- private void PublishOrFallback(string topic, object payload)
- {
- if (_publisherFactory is not null)
- {
- _publisherFactory().Publish(topic, payload);
- return;
- }
- DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload));
- }
-
- private void PersistStateAsync(DateTime nowUtc)
- {
- var snapshot = new AlarmActorStateSnapshot(
- AlarmId: _config.AlarmId,
- State: _state.ToString(),
- LastTransitionUtc: nowUtc,
- LastAckUser: _lastAckUser);
-
- // Fire-and-forget. Save failures get logged but don't block the message loop —
- // the worst case is a restart loses one transition, which then re-derives from
- // the evaluator's next tick anyway.
- _ = Task.Run(async () =>
- {
- try
- {
- await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId);
- }
- });
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs
deleted file mode 100644
index cce8a271..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-using Microsoft.Extensions.Logging.Abstractions;
-using Serilog;
-using Serilog.Events;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
-using ZB.MOM.WW.OtOpcUa.Core.Scripting;
-using ZB.MOM.WW.OtOpcUa.Host.Engines;
-
-namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
-
-///
-/// F9b — verifies compiles alarm predicates,
-/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
-/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
-/// AlarmPredicateContext throws on writes — predicates must stay pure).
-///
-public sealed class RoslynScriptedAlarmEvaluatorTests
-{
- /// Captures published records for assertion.
- private sealed class FakePublisher : IScriptLogPublisher
- {
- /// Gets the entries published so far.
- public List Published { get; } = [];
-
- ///
- public void Publish(ScriptLogEntry entry) => Published.Add(entry);
- }
-
- /// Builds a no-op for tests that don't assert on logging.
- private static ScriptRootLogger NoOpScriptRoot() =>
- new(new LoggerConfiguration().CreateLogger());
-
- /// Verifies evaluation of predicate returning true reports Active.
- [Fact]
- public void Evaluate_predicate_returning_true_reports_Active()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
-
- var result = sut.Evaluate(
- alarmId: "alarm-hi",
- predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
- dependencies: new Dictionary { ["temp"] = 150 });
-
- result.Success.ShouldBeTrue(result.Reason);
- result.Active.ShouldBeTrue();
- }
-
- /// Verifies evaluation of predicate returning false reports Inactive.
- [Fact]
- public void Evaluate_predicate_returning_false_reports_Inactive()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
-
- var result = sut.Evaluate(
- alarmId: "alarm-hi",
- predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
- dependencies: new Dictionary { ["temp"] = 50 });
-
- result.Success.ShouldBeTrue(result.Reason);
- result.Active.ShouldBeFalse();
- }
-
- /// Verifies compiled predicates are cached across calls.
- [Fact]
- public void Evaluate_caches_compiled_predicate_across_calls()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
- const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
-
- var first = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = true });
- var second = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = false });
-
- first.Active.ShouldBeTrue();
- second.Active.ShouldBeFalse();
- }
-
- /// Verifies compile errors return Failure.
- [Fact]
- public void Evaluate_compile_error_returns_Failure()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
-
- var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary());
-
- result.Success.ShouldBeFalse();
- result.Reason!.ShouldContain("compile");
- }
-
- /// Verifies predicate writing virtual tag returns Failure.
- [Fact]
- public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
-
- // AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
- var result = sut.Evaluate(
- alarmId: "alarm-bad-write",
- predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
- dependencies: new Dictionary());
-
- result.Success.ShouldBeFalse();
- result.Reason!.ShouldContain("threw");
- }
-
- /// Verifies empty predicate returns Failure.
- [Fact]
- public void Evaluate_empty_predicate_returns_Failure()
- {
- using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
-
- sut.Evaluate("alarm-empty", "", new Dictionary()).Success.ShouldBeFalse();
- }
-
- /// Verifies evaluation after dispose returns Failure.
- [Fact]
- public void Evaluate_after_dispose_returns_Failure()
- {
- var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
- sut.Dispose();
-
- var result = sut.Evaluate("alarm", "return true;", new Dictionary());
-
- result.Success.ShouldBeFalse();
- result.Reason!.ShouldContain("disposed");
- }
-
- ///
- /// A predicate's ctx.Logger.Warning(...) call flows through the injected root script
- /// logger and out the , producing one
- /// carrying the message, the bound AlarmId, and the
- /// Warning level.
- ///
- [Fact]
- public void Script_logger_call_publishes_entry_with_bound_alarm_identity()
- {
- var publisher = new FakePublisher();
- var root = new LoggerConfiguration()
- .MinimumLevel.Verbose()
- .WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
- .CreateLogger();
- using var sut = new RoslynScriptedAlarmEvaluator(
- NullLogger.Instance, new ScriptRootLogger(root));
-
- var result = sut.Evaluate(
- alarmId: "alarm-log",
- predicate: "ctx.Logger.Warning(\"alarm log\"); return true;",
- dependencies: new Dictionary());
-
- result.Success.ShouldBeTrue(result.Reason);
- result.Active.ShouldBeTrue();
- publisher.Published.Count.ShouldBe(1);
- var entry = publisher.Published[0];
- entry.Message.ShouldBe("alarm log");
- entry.AlarmId.ShouldBe("alarm-log");
- entry.ScriptId.ShouldBe("alarm-log");
- entry.Level.ShouldBe("Warning");
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs
deleted file mode 100644
index a7393ed5..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs
+++ /dev/null
@@ -1,176 +0,0 @@
-using System.Collections.Concurrent;
-using Akka.Actor;
-using Akka.TestKit;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Commons.Engines;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
-using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
-using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
-using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
-
-public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
-{
- /// Verifies that full state cycle publishes StateChanged messages to parent at each transition.
- [Fact]
- public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
- {
- var parent = CreateTestProbe();
- var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
-
- actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
- var t1 = parent.ExpectMsg();
- t1.State.ShouldBe(ScriptedAlarmActorState.Active);
-
- actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe"));
- var t2 = parent.ExpectMsg();
- t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
-
- actor.Tell(new ScriptedAlarmActor.ConditionCleared());
- var t3 = parent.ExpectMsg();
- t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
- }
-
- /// Verifies that duplicate ConditionMet messages in Active state are ignored.
- [Fact]
- public void Duplicate_ConditionMet_in_Active_is_ignored()
- {
- var parent = CreateTestProbe();
- var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
-
- actor.Tell(new ScriptedAlarmActor.ConditionMet("first"));
- parent.ExpectMsg();
-
- actor.Tell(new ScriptedAlarmActor.ConditionMet("second"));
- parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
- }
-
- /// Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.
- [Fact]
- public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic()
- {
- var capture = new CapturingPublisher();
- var parent = CreateTestProbe();
- var config = new ScriptedAlarmActor.AlarmConfig(
- AlarmId: "alarm-7",
- AlarmName: "High Temp",
- EquipmentPath: "/site-1/line-A/oven",
- Severity: 800,
- Predicate: "temp > 80");
- var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
- config,
- evaluator: new ThresholdEvaluator(80),
- publisherFactory: () => new DPSPublisher(capture.Publish)));
-
- actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
- parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Active);
-
- AwaitAssert(() =>
- {
- var transitionEvt = capture.Payloads.OfType().SingleOrDefault();
- transitionEvt.ShouldNotBeNull();
- transitionEvt.AlarmId.ShouldBe("alarm-7");
- transitionEvt.AlarmName.ShouldBe("High Temp");
- transitionEvt.EquipmentPath.ShouldBe("/site-1/line-A/oven");
- transitionEvt.Severity.ShouldBe(800);
- transitionEvt.TransitionKind.ShouldBe("Activated");
- transitionEvt.User.ShouldBe("system");
-
- var log = capture.Payloads.OfType().SingleOrDefault();
- log.ShouldNotBeNull();
- log.AlarmId.ShouldBe("alarm-7");
- }, duration: TimeSpan.FromSeconds(1));
- }
-
- /// Verifies that clear transition publishes Cleared event.
- [Fact]
- public void Engine_clear_transition_publishes_Cleared_event()
- {
- var capture = new CapturingPublisher();
- var parent = CreateTestProbe();
- var config = new ScriptedAlarmActor.AlarmConfig("alarm-7", "High Temp", "/p", 500, "temp > 80");
- var evaluator = new ThresholdEvaluator(80);
- var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
- config, evaluator,
- publisherFactory: () => new DPSPublisher(capture.Publish)));
-
- actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
- parent.ExpectMsg();
-
- actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
- parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Inactive);
-
- AwaitAssert(() =>
- {
- var kinds = capture.Payloads.OfType().Select(e => e.TransitionKind).ToList();
- kinds.ShouldContain("Activated");
- kinds.ShouldContain("Cleared");
- }, duration: TimeSpan.FromSeconds(1));
- }
-
- /// Verifies that manual acknowledge emits Acknowledged transition with the user.
- [Fact]
- public void Manual_acknowledge_emits_Acknowledged_transition_with_user()
- {
- var capture = new CapturingPublisher();
- var parent = CreateTestProbe();
- var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump Fail", "/eq", 700, Predicate: null);
- var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
- config, evaluator: null,
- publisherFactory: () => new DPSPublisher(capture.Publish)));
-
- actor.Tell(new ScriptedAlarmActor.ConditionMet("driver-fault"));
- parent.ExpectMsg();
-
- actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
- parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
-
- AwaitAssert(() =>
- {
- var ackEvt = capture.Payloads.OfType()
- .SingleOrDefault(e => e.TransitionKind == "Acknowledged");
- ackEvt.ShouldNotBeNull();
- ackEvt.User.ShouldBe("operator-jane");
- }, duration: TimeSpan.FromSeconds(1));
- }
-
- /// A threshold-based alarm evaluator for testing.
- private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
- {
- private readonly double _threshold;
-
- /// Initializes a new instance of the ThresholdEvaluator class.
- /// The threshold value to compare against.
- public ThresholdEvaluator(double threshold) { _threshold = threshold; }
-
- ///
- public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary deps)
- {
- if (!deps.TryGetValue("temp", out var raw) || raw is null)
- return ScriptedAlarmEvalResult.Failure("missing temp");
- return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold);
- }
- }
-
- /// A test publisher that captures published messages.
- private sealed class CapturingPublisher
- {
- /// Gets the topics that messages were published to.
- public ConcurrentBag Topics { get; } = new();
-
- /// Gets the payloads that were published.
- public ConcurrentBag