feat(scripts): add the Alarms.CurrentAsync() read accessor for site scripts

MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition
state: the `Alarm` global exists only inside an on-trigger handler and
describes the one alarm that fired, and native mirrored conditions were
reachable only from the Debug View. That gap blocked the CvdReactor
SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written
without it. `Alarms.CurrentAsync()` closes it.

The data was already local: the script runs inside its own Instance Actor's
context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no
cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather
than reusing DebugSnapshotRequest, which would materialise every attribute
value on every alarm poll; both are served from the same
BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View
can never disagree.

Deliberate shape decisions:
  - NOT scope-prefixed, unlike Attributes. Alarm identity is not a
    scope-relative attribute name (computed alarms are keyed by configured
    name, native conditions by a source-supplied reference), so prefixing
    would hand a composed script a silently truncated list.
  - Read-only. Native alarms are a read-only mirror of the source (no
    ack-back), so no acknowledge/shelve operation is exposed.
  - Placeholder rows are NOT pre-filtered: a caller must be able to tell
    "binding configured and quiet" from "binding unknown". The documented
    filter is `Active && !IsConfiguredPlaceholder`.
  - ScriptAlarm lives in Commons so the runtime accessor and the compile-only
    surface project to the SAME type -- a script binding at the design-time
    gate binds identically at the site. Condition is the authority for
    active/acked/severity, so one filter expression works across computed and
    native alarms.

Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the
reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central
UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the
parity test cannot reach (Central UI does not reference Site Runtime); without
it the design page would false-flag CS1061 on scripts the deploy gate accepts.
It throws a labelled ScriptSandboxException at run time -- there is no central
route to per-instance alarm state, and returning an empty list would read as
"nothing is in alarm", which is worse than an error.

ScriptTrustPolicy needs NO change, and the reason is structural rather than
incidental: the trust boundary is a deny-list over API roots, not an allow-list
of context members. A test pins that no ForbiddenScopes entry prefixes the
Commons script-surface namespace, so a future deny-list entry cannot silently
make ScriptAlarm untouchable.

Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime,
unacked, computed-alarm derivation, placeholder visibility, scope-independence),
2 InstanceActor snapshot cases incl. equality with the Debug View row set, the
full MES script shape compiling against ScriptCompileSurface, 2 trust cases,
and a sandbox diagnose-clean case reading every projected ScriptAlarm field.
This commit is contained in:
Joseph Doherty
2026-08-01 13:12:30 -04:00
parent 01bcca992c
commit d0af884760
14 changed files with 704 additions and 0 deletions
@@ -79,6 +79,36 @@ public class SandboxScriptHost
/// </summary> /// </summary>
public SandboxCompositionAccessor? Parent => public SandboxCompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath); Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath);
/// <summary>
/// Read-only accessor for the instance's current alarm conditions, mirroring the site
/// runtime's <c>Alarms</c> global (MES alarm-status API §5.2). Present so a script using
/// <c>Alarms.CurrentAsync()</c> BINDS in the Test Run editor — without it the editor
/// would show a spurious CS1061 on a script that deploys and runs perfectly.
/// </summary>
public SandboxAlarmsAccessor Alarms { get; } = new();
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.AlarmsAccessor</c>.
/// Exists for editor/compile parity only: alarm state is held per-instance by the site's
/// Instance Actor and there is no central Test Run route to it, so an actual call throws a
/// clearly-labelled <see cref="ScriptSandboxException"/> rather than quietly returning an
/// empty list that would read as "nothing is in alarm".
/// </summary>
public class SandboxAlarmsAccessor
{
/// <summary>
/// Mirrors <c>AlarmsAccessor.CurrentAsync</c> for compile parity; unsupported at run
/// time in the central Test Run sandbox.
/// </summary>
/// <param name="cancellationToken">Token used to cancel the read (unused in the sandbox).</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
=> throw new ScriptSandboxException(
"Alarms.CurrentAsync() reads the site Instance Actor's live alarm conditions, " +
"which aren't available in the central Test Run sandbox — deploy to a site to " +
"exercise alarm-reading scripts.");
} }
/// <summary> /// <summary>
@@ -0,0 +1,40 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
/// <summary>
/// Request for the Instance Actor's CURRENT alarm-condition snapshot, backing the
/// script-facing <c>Alarms.CurrentAsync()</c> accessor (MES alarm-status API §5.2).
///
/// <para>
/// A dedicated message rather than a reuse of <c>DebugSnapshotRequest</c>: the script
/// accessor needs only the alarm rows, and the debug snapshot additionally materialises
/// every attribute value on every call. Same local Ask path as
/// <see cref="GetAttributeRequest"/> — the script runs against its own Instance Actor, so
/// there is no cross-cluster hop.
/// </para>
/// </summary>
/// <param name="CorrelationId">Application-level correlation id echoed on the response.</param>
/// <param name="InstanceUniqueName">Unique name of the instance whose alarms are requested.</param>
/// <param name="Timestamp">When the request was issued (UTC).</param>
public record GetAlarmSnapshotRequest(
string CorrelationId,
string InstanceUniqueName,
DateTimeOffset Timestamp);
/// <summary>
/// The Instance Actor's reply to a <see cref="GetAlarmSnapshotRequest"/> — the same
/// enriched alarm rows the Debug View snapshot carries (computed alarms, mirrored native
/// conditions, and configured-but-quiet native binding placeholders), projected to
/// <c>ScriptAlarm</c> by the accessor rather than by the actor so the message stays a
/// plain mirror of the internal state.
/// </summary>
/// <param name="CorrelationId">Correlation id from the originating request.</param>
/// <param name="InstanceUniqueName">Unique name of the instance the snapshot belongs to.</param>
/// <param name="Alarms">The instance's current alarm rows.</param>
/// <param name="Timestamp">When the snapshot was taken (UTC).</param>
public record GetAlarmSnapshotResponse(
string CorrelationId,
string InstanceUniqueName,
IReadOnlyList<AlarmStateChanged> Alarms,
DateTimeOffset Timestamp);
@@ -0,0 +1,79 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// <summary>
/// One alarm condition as seen by a site script through <c>Alarms.CurrentAsync()</c>
/// (MES alarm-status API §5.2). A flat, script-friendly projection of the instance's
/// retained <c>AlarmStateChanged</c> — computed alarms and mirrored native (OPC UA A&amp;C /
/// MxAccess Gateway) conditions alike — so a script can answer "what is currently in
/// alarm on this instance?" without knowing the actor-internal message shape.
///
/// <para>
/// <b>Read-only snapshot.</b> The values are the instance's state at the moment the
/// accessor's Ask was served; nothing here writes back to the source. Native alarms are a
/// read-only mirror by design (no ack-back).
/// </para>
///
/// <para>
/// <b>Placement in Commons</b> so the SiteRuntime runtime accessor and the ScriptAnalysis
/// compile-only surface project to the SAME type — a script that binds against the
/// design-time surface therefore also binds at the site. Commons is already in
/// <c>ScriptTrustPolicy.DefaultAssemblies</c>, so the type resolves in the script
/// compilation without widening the trust reference set.
/// </para>
/// </summary>
/// <param name="Name">
/// The alarm's name on the instance — the computed alarm's configured name, or the native
/// condition's per-condition source reference.
/// </param>
/// <param name="SourceReference">Native per-condition key (e.g. "Tank01.Level.HiHi"); empty for computed alarms.</param>
/// <param name="NativeSourceCanonicalName">
/// Canonical name of the native alarm SOURCE BINDING this condition belongs to
/// (e.g. "LeftSideAlarms"); empty for computed alarms. Scripts scope by source with this.
/// </param>
/// <param name="Active">Whether the condition is currently active (in alarm).</param>
/// <param name="Acknowledged">Whether the condition has been acknowledged at the source.</param>
/// <param name="Confirmed">Confirmed vs unconfirmed; <c>null</c> when the condition is not confirmable.</param>
/// <param name="Shelved">Whether the source has shelved the condition (any shelve sub-state).</param>
/// <param name="Suppressed">Whether the source has suppressed the condition.</param>
/// <param name="Severity">Severity on the unified 01000 scale.</param>
/// <param name="Kind">"Computed", "NativeOpcUa" or "NativeMxAccess".</param>
/// <param name="Message">Event/per-band operator message; may be empty.</param>
/// <param name="AlarmTypeName">Native alarm type (e.g. "AnalogLimitAlarm.HiHi"); empty for computed alarms.</param>
/// <param name="Category">Native alarm category/taxonomy; empty for computed alarms.</param>
/// <param name="OperatorUser">Operator who acknowledged at the source (display-only); empty otherwise.</param>
/// <param name="OperatorComment">Operator comment captured at the source (display-only); empty otherwise.</param>
/// <param name="OriginalRaiseTime">When the condition originally became active, if the source reports it.</param>
/// <param name="Timestamp">Timestamp of the transition this snapshot row reflects.</param>
/// <param name="AckTime">
/// When the condition was acknowledged, or <c>null</c> while unacknowledged. See
/// <c>AlarmStateChanged.AckTime</c> for provenance — source ack instant where the protocol
/// supplies one (OPC UA A&amp;C), DCL observation time of the ack transition otherwise.
/// </param>
/// <param name="CurrentValue">Current source value (display-only); empty for computed alarms.</param>
/// <param name="LimitValue">Limit/threshold value for native limit alarms (display-only); empty otherwise.</param>
/// <param name="IsConfiguredPlaceholder">
/// True for a placeholder row standing in for a CONFIGURED native source binding that
/// currently holds no conditions. Scripts enumerating real alarms should filter these out.
/// </param>
public sealed record ScriptAlarm(
string Name,
string SourceReference,
string NativeSourceCanonicalName,
bool Active,
bool Acknowledged,
bool? Confirmed,
bool Shelved,
bool Suppressed,
int Severity,
string Kind,
string Message,
string AlarmTypeName,
string Category,
string OperatorUser,
string OperatorComment,
DateTimeOffset? OriginalRaiseTime,
DateTimeOffset Timestamp,
DateTimeOffset? AckTime,
string CurrentValue,
string LimitValue,
bool IsConfiguredPlaceholder);
@@ -64,6 +64,9 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptGlobals.Parent</c>.</summary> /// <summary>Mirrors <c>ScriptGlobals.Parent</c>.</summary>
public CompileCompositionAccessor? Parent => throw new NotSupportedException(CompileOnly); public CompileCompositionAccessor? Parent => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptGlobals.Alarms</c>.</summary>
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary> /// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
public sealed class CompileInstance public sealed class CompileInstance
{ {
@@ -98,6 +101,24 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptRuntimeContext.Tracking</c>.</summary> /// <summary>Mirrors <c>ScriptRuntimeContext.Tracking</c>.</summary>
public CompileTracking Tracking => throw new NotSupportedException(CompileOnly); public CompileTracking Tracking => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptRuntimeContext.Alarms</c>.</summary>
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
}
/// <summary>
/// Compile-only mirror of <c>AlarmsAccessor</c> (MES alarm-status API §5.2).
/// <see cref="CurrentAsync"/> returns the SAME <see cref="ScriptAlarm"/> type the runtime
/// accessor returns — not a compile-only stand-in — so a script that reads
/// <c>a.Severity</c> / <c>a.AckTime</c> binds identically at design time and at the site.
/// </summary>
public sealed class CompileAlarmsAccessor
{
/// <summary>Mirrors <c>AlarmsAccessor.CurrentAsync</c>.</summary>
/// <param name="cancellationToken">Token used to cancel the read.</param>
/// <returns>The instance's current alarm conditions.</returns>
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException(CompileOnly);
} }
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary> /// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary>
@@ -252,6 +252,9 @@ public class InstanceActor : ReceiveActor
// Debug snapshot (one-shot, no subscription) // Debug snapshot (one-shot, no subscription)
Receive<DebugSnapshotRequest>(HandleDebugSnapshot); Receive<DebugSnapshotRequest>(HandleDebugSnapshot);
// Script-facing alarm snapshot backing Alarms.CurrentAsync()
Receive<GetAlarmSnapshotRequest>(HandleGetAlarmSnapshot);
// Handle internal messages // Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded); Receive<LoadOverridesResult>(HandleOverridesLoaded);
} }
@@ -1138,6 +1141,29 @@ public class InstanceActor : ReceiveActor
_alarmTimestamps.Remove(dropped.SourceReference); _alarmTimestamps.Remove(dropped.SourceReference);
} }
/// <summary>
/// Serves the script-facing alarm snapshot behind <c>Alarms.CurrentAsync()</c>
/// (MES alarm-status API §5.2). Reuses <see cref="BuildAlarmStatesSnapshot"/>, so a
/// script sees exactly the alarm set the Debug View shows for the same instant —
/// computed alarms, mirrored native conditions, and the configured-but-quiet native
/// binding placeholders (the accessor's caller decides whether to filter those).
///
/// <para>
/// Deliberately does NOT build the attribute list the debug snapshot carries: a script
/// polling its alarms should not pay to materialise every attribute value. The reply is
/// a plain mirror of the retained <c>AlarmStateChanged</c> rows; the projection to
/// <c>ScriptAlarm</c> happens in the accessor, keeping the actor free of script types.
/// </para>
/// </summary>
private void HandleGetAlarmSnapshot(GetAlarmSnapshotRequest request)
{
Sender.Tell(new GetAlarmSnapshotResponse(
request.CorrelationId,
_instanceUniqueName,
BuildAlarmStatesSnapshot(),
DateTimeOffset.UtcNow));
}
/// <summary> /// <summary>
/// Debug view subscribe — returns snapshot and begins streaming. /// Debug view subscribe — returns snapshot and begins streaming.
/// </summary> /// </summary>
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -292,6 +293,58 @@ public class ChildrenAccessor
} }
} }
/// <summary>
/// Read-only accessor for the instance's CURRENT alarm conditions, exposed to scripts as
/// the <c>Alarms</c> global (MES alarm-status API §5.2). It closes the gap that a site
/// <c>Call</c> script previously had NO way to read alarm state: the on-trigger
/// <c>Alarm</c> context only exists inside an alarm handler, and native mirrored
/// conditions were reachable only from the Debug View.
///
/// <para>
/// <b>Not scope-prefixed</b>, unlike <see cref="AttributeAccessor"/>. Alarm identity is not
/// a scope-relative attribute name: computed alarms are keyed by their configured name and
/// native conditions by a source-supplied reference, and a caller typically wants the whole
/// instance's alarm set to filter itself (e.g. by
/// <see cref="ScriptAlarm.NativeSourceCanonicalName"/>). Every scope therefore sees the
/// same list rather than a silently truncated one.
/// </para>
///
/// <para>
/// <b>Read-only by design.</b> Native alarms are a read-only mirror of the source (no
/// ack-back), so this accessor deliberately offers no acknowledge/shelve operation.
/// </para>
/// </summary>
public class AlarmsAccessor
{
private readonly ScriptRuntimeContext _ctx;
/// <summary>
/// Initializes a new alarms accessor over a script runtime context.
/// </summary>
/// <param name="ctx">The script runtime context whose Instance Actor is queried.</param>
public AlarmsAccessor(ScriptRuntimeContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Returns a snapshot of the instance's current alarm conditions — computed alarms and
/// mirrored native (OPC UA A&amp;C / MxAccess Gateway) conditions alike. Served by a
/// LOCAL Ask to the instance's own actor, the same path attribute reads use.
///
/// <para>
/// The list includes placeholder rows for CONFIGURED native source bindings that hold no
/// conditions (<see cref="ScriptAlarm.IsConfiguredPlaceholder"/>), so a caller can tell
/// "binding is quiet" from "binding is unknown"; callers enumerating real alarms should
/// filter on <c>Active &amp;&amp; !IsConfiguredPlaceholder</c>.
/// </para>
/// </summary>
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
/// <returns>The instance's current alarm conditions; empty when it holds none.</returns>
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
=> _ctx.GetAlarmsAsync(cancellationToken);
}
internal static class ScopeAccessorFactory internal static class ScopeAccessorFactory
{ {
/// <summary> /// <summary>
@@ -269,6 +269,20 @@ public class ScriptGlobals
/// </summary> /// </summary>
public ChildrenAccessor Children => new(Instance, Scope.SelfPath); public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Read-only view of the instance's CURRENT alarm conditions — computed alarms
/// and mirrored native (OPC UA A&amp;C / MxAccess Gateway) conditions alike.
/// Usage: <c>var alarms = await Alarms.CurrentAsync();</c>
///
/// <para>
/// Unlike the <see cref="Alarm"/> global — which exists only inside an on-trigger
/// handler and describes the ONE alarm that fired — this is available to every script
/// and describes the whole instance. Not scope-prefixed: alarm identity is not a
/// scope-relative attribute name, so every scope sees the same list.
/// </para>
/// </summary>
public AlarmsAccessor Alarms => Instance.Alarms;
/// <summary> /// <summary>
/// Parent composition (null when this script is on a root-level template). /// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute; /// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
@@ -9,9 +9,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent; using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.StoreAndForward; using ZB.MOM.WW.ScadaBridge.StoreAndForward;
@@ -696,6 +698,70 @@ public class ScriptRuntimeContext
// threaded so NotifyTarget.Send can stamp it onto NotificationSubmit. // threaded so NotifyTarget.Send can stamp it onto NotificationSubmit.
_sourceNode); _sourceNode);
/// <summary>
/// Read-only access to the instance's CURRENT alarm conditions (MES alarm-status API
/// §5.2). <c>Alarms.CurrentAsync()</c> returns one <see cref="ScriptAlarm"/> per alarm
/// the instance holds — computed alarms and mirrored native (OPC UA A&amp;C / MxAccess
/// Gateway) conditions alike — so a <c>Call</c> script can answer "what is in alarm
/// right now?" without an on-trigger <c>Alarm</c> context.
/// </summary>
public AlarmsAccessor Alarms => new(this);
/// <summary>
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
/// Instance Actor for its retained alarm rows and projects them to
/// <see cref="ScriptAlarm"/>. The script executes inside its own instance's context, so
/// this is a LOCAL Ask on the same node — the same mechanism as
/// <see cref="GetAttribute"/>, never a cross-cluster hop.
/// </summary>
/// <param name="cancellationToken">
/// Cancels the wait for the actor's reply. Defaults to <see cref="CancellationToken.None"/>;
/// the Ask is bounded by the context's ask timeout regardless.
/// </param>
/// <returns>The instance's current alarm conditions; empty when it holds none.</returns>
internal async Task<IReadOnlyList<ScriptAlarm>> GetAlarmsAsync(
CancellationToken cancellationToken = default)
{
var request = new GetAlarmSnapshotRequest(
Guid.NewGuid().ToString(), _instanceName, DateTimeOffset.UtcNow);
var response = await _instanceActor.Ask<GetAlarmSnapshotResponse>(
request, _askTimeout, cancellationToken);
return response.Alarms.Select(ToScriptAlarm).ToList();
}
/// <summary>
/// Projects a retained <see cref="AlarmStateChanged"/> onto the flat, script-facing
/// <see cref="ScriptAlarm"/> shape. <c>Condition</c> is the authority for the
/// active/acked/severity fields (it already folds a computed alarm's State + Priority
/// into the same unified shape), so computed and native alarms read identically from a
/// script. <c>Shelved</c> collapses the shelve sub-states to a single boolean — scripts
/// care whether the operator has parked the alarm, not which shelve flavour was used.
/// </summary>
private static ScriptAlarm ToScriptAlarm(AlarmStateChanged a) => new(
Name: a.AlarmName,
SourceReference: a.SourceReference,
NativeSourceCanonicalName: a.NativeSourceCanonicalName,
Active: a.Condition.Active,
Acknowledged: a.Condition.Acknowledged,
Confirmed: a.Condition.Confirmed,
Shelved: a.Condition.Shelve != AlarmShelveState.Unshelved,
Suppressed: a.Condition.Suppressed,
Severity: a.Condition.Severity,
Kind: a.Kind.ToString(),
Message: a.Message,
AlarmTypeName: a.AlarmTypeName,
Category: a.Category,
OperatorUser: a.OperatorUser,
OperatorComment: a.OperatorComment,
OriginalRaiseTime: a.OriginalRaiseTime,
Timestamp: a.Timestamp,
AckTime: a.AckTime,
CurrentValue: a.CurrentValue,
LimitValue: a.LimitValue,
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder);
/// <summary> /// <summary>
/// Site-local tracking-status API for cached operations. /// Site-local tracking-status API for cached operations.
/// <c>Tracking.Status(trackedOperationId)</c> reads the site SQLite tracking row /// <c>Tracking.Status(trackedOperationId)</c> reads the site SQLite tracking row
@@ -719,4 +719,36 @@ public class ScriptAnalysisServiceTests
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
} }
// ── Alarms read surface (MES alarm-status API §5.2) ───────────────────
[Fact]
public void InstanceScript_AlarmsCurrentAsync_DiagnoseClean()
{
// SandboxScriptHost is the THIRD hand-maintained mirror of the runtime globals
// (Central UI does not reference Site Runtime, so the reflection parity test in
// SiteRuntime.Tests cannot cover it). Without `Alarms` here the editor would
// false-flag CS1061 on the MES alarm-status scripts even though the deploy gate
// accepts them. Every ScriptAlarm field those scripts project is read below, so a
// rename on the record surfaces here rather than in the design page.
var code =
"var alarms = await Alarms.CurrentAsync();\n" +
"var rows = alarms\n" +
" .Where(a => a.Active && !a.IsConfiguredPlaceholder)\n" +
" .Where(a => a.NativeSourceCanonicalName.StartsWith(\"Left\"))\n" +
" .Where(a => a.Severity >= 900 && a.Severity <= 999)\n" +
" .Select(a => new {\n" +
" Name = a.Name,\n" +
" Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,\n" +
" StatusCode = a.Acknowledged ? \"Triggered.Acked\" : \"Triggered\",\n" +
" TriggeredDT = a.OriginalRaiseTime ?? a.Timestamp,\n" +
" AckDT = a.AckTime,\n" +
" AckComment = a.OperatorComment })\n" +
" .ToList();\n" +
"return new { WasSuccessful = true, Alarms = rows };";
var resp = _svc.Diagnose(new DiagnoseRequest(code));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
} }
@@ -54,12 +54,59 @@ public class RoslynScriptCompilerTests
var resolved = Attributes.Resolve("Temperature"); var resolved = Attributes.Resolve("Temperature");
var conn = await Database.Connection("hist"); var conn = await Database.Connection("hist");
var scope = Scope; var scope = Scope;
var currentAlarms = await Alarms.CurrentAsync();
var viaInstance = await Instance.Alarms.CurrentAsync();
"""; """;
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
Assert.Empty(diagnostics); Assert.Empty(diagnostics);
} }
[Fact]
public void Compile_Empty_ForTheMesAlarmStatusScriptShape()
{
// MES alarm-status API §5.3: the CvdReactor.SimpleAlarmStatus body must bind at
// design time. Every ScriptAlarm field the endpoint projects is read here, so a
// rename on the record breaks this test rather than the deployed script.
const string code = """
const int MesBandMin = 900;
const int MesBandMax = 999;
var raw = (Parameters["SAPID"] as string) ?? "";
var code = Parameters["MachineCode"]?.ToString() ?? "";
var core = raw.EndsWith("_LT") ? raw.Substring(0, raw.Length - 3) : raw;
var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null;
System.Func<string, bool> InScope = src =>
string.IsNullOrEmpty(src)
|| side == null
|| src.StartsWith(side, System.StringComparison.OrdinalIgnoreCase)
|| src.StartsWith("Reactor", System.StringComparison.OrdinalIgnoreCase);
var alarms = await Alarms.CurrentAsync();
var infos = alarms
.Where(a => a.Active && !a.IsConfiguredPlaceholder)
.Where(a => InScope(a.NativeSourceCanonicalName))
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
.Select(a => new {
Name = a.Name,
HierarchicalName = code + "." + a.Name,
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax,
Severity = a.Severity,
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
AckDT = a.AckTime,
AckComment = a.OperatorComment,
}).ToList();
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
""";
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
}
[Fact] [Fact]
public void Compile_Empty_ForTriggerExpression() public void Compile_Empty_ForTriggerExpression()
{ {
@@ -194,6 +194,36 @@ public class ScriptTrustValidatorTests
Assert.Empty(ScriptTrustValidator.FindViolations(code)); Assert.Empty(ScriptTrustValidator.FindViolations(code));
} }
[Fact]
public void Allows_AlarmsCurrentAsync_AndTheScriptAlarmType()
{
// MES alarm-status API §5.2/task 4. The trust model is a DENY-list over API roots,
// not an allow-list of context members, so `Alarms` needs no policy entry — but the
// ScriptAlarm projection it returns lives in Commons, which must resolve as a
// permitted namespace. This pins that: a script naming the type explicitly (not just
// via `var`) produces no violation.
const string code = """
var alarms = await Alarms.CurrentAsync();
System.Collections.Generic.IReadOnlyList<
ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm> typed = alarms;
var acked = typed.Where(a => a.AckTime != null).Select(a => a.Name).ToList();
""";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface()
{
// Guards the above from the other direction: if someone ever adds a deny-list root
// that swallows the ZB.MOM.WW namespace, every script accessor's return type would
// become a trust violation. Fail loudly here rather than at deploy time.
Assert.DoesNotContain(
ScriptTrustPolicy.ForbiddenScopes,
scope => typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm)
.Namespace!.StartsWith(scope, StringComparison.Ordinal));
}
// ---- ScriptAnalysis-003: adversarial bypass-vector coverage -------------- // ---- ScriptAnalysis-003: adversarial bypass-vector coverage --------------
// (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the // (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the
@@ -4,6 +4,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -148,6 +149,63 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder); a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder);
} }
// ── MES alarm-status API §5.2: the script-facing alarm snapshot ────────
[Fact]
public void GetAlarmSnapshot_ReturnsTheSameRowsAsTheDebugViewSnapshot()
{
// Alarms.CurrentAsync() must not diverge from what the operator sees in the Debug
// View — both are served from BuildAlarmStatesSnapshot, so a live native condition
// AND the quiet-binding placeholder appear in each.
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
actor.Tell(new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 900, DateTimeOffset.UtcNow)
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "T01.Hi",
NativeSourceCanonicalName = "Pressure",
Condition = new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 900),
AckTime = ackedAt
});
actor.Tell(new GetAlarmSnapshotRequest("c1", "inst", DateTimeOffset.UtcNow));
var reply = ExpectMsg<GetAlarmSnapshotResponse>();
Assert.Equal("c1", reply.CorrelationId);
Assert.Equal("inst", reply.InstanceUniqueName);
var live = Assert.Single(reply.Alarms, a => a.SourceReference == "T01.Hi");
Assert.Equal(AlarmState.Active, live.State);
Assert.Equal(900, live.Condition.Severity);
// §6.4: the ack instant reaches the script accessor, not just the gRPC stream.
Assert.Equal(ackedAt, live.AckTime);
actor.Tell(new SubscribeDebugViewRequest("inst", "c"));
var debug = ExpectMsg<DebugViewSnapshot>();
Assert.Equal(
debug.AlarmStates.Select(a => a.AlarmName).OrderBy(n => n),
reply.Alarms.Select(a => a.AlarmName).OrderBy(n => n));
}
[Fact]
public void GetAlarmSnapshot_WithNoAlarms_RepliesWithTheConfiguredPlaceholdersOnly()
{
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new GetAlarmSnapshotRequest("c2", "inst", DateTimeOffset.UtcNow));
var reply = ExpectMsg<GetAlarmSnapshotResponse>();
var placeholder = Assert.Single(reply.Alarms);
Assert.True(placeholder.IsConfiguredPlaceholder);
Assert.Equal("Pressure", placeholder.NativeSourceCanonicalName);
Assert.Null(placeholder.AckTime);
}
void IDisposable.Dispose() void IDisposable.Dispose()
{ {
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
@@ -0,0 +1,204 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// MES alarm-status API §5.2: the script-facing <c>Alarms.CurrentAsync()</c> accessor.
/// Routes a real <see cref="ScriptRuntimeContext"/> against a TestProbe standing in for the
/// Instance Actor (the same harness shape <c>AttributeAccessorWaitAsyncTests</c> uses), so
/// the Ask contract and the <see cref="AlarmStateChanged"/> → <c>ScriptAlarm</c> projection
/// are both exercised for real.
/// </summary>
public class AlarmsAccessorTests : TestKit, IDisposable
{
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
new(
instanceActor,
instanceActor,
sharedScriptLibrary: null!,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: "CvdReactor01",
logger: NullLogger<ScriptRuntimeContext>.Instance);
void IDisposable.Dispose() => Shutdown();
private static readonly DateTimeOffset RaisedAt = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset AckedAt = new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero);
private static readonly DateTimeOffset ObservedAt = new(2026, 8, 1, 10, 6, 0, TimeSpan.Zero);
/// <summary>An acknowledged native condition mirrored from a source binding.</summary>
private static AlarmStateChanged NativeAcked() =>
new("CvdReactor01", "Z28061.HeartbeatTimeoutAlarm", AlarmState.Active, 950, ObservedAt)
{
Kind = AlarmKind.NativeMxAccess,
Condition = new AlarmConditionState(
Active: true, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 950),
SourceReference = "Z28061.HeartbeatTimeoutAlarm",
NativeSourceCanonicalName = "ReactorAlarms",
AlarmTypeName = "DiscreteAlarm",
Category = "Process",
Message = "Heartbeat lost",
OperatorUser = "op1",
OperatorComment = "investigating",
OriginalRaiseTime = RaisedAt,
AckTime = AckedAt,
CurrentValue = "FAULT",
LimitValue = "0",
};
/// <summary>Replies to the accessor's Ask with the supplied alarm rows.</summary>
private void RespondWith(Akka.TestKit.TestProbe probe, params AlarmStateChanged[] alarms)
{
var request = probe.ExpectMsg<GetAlarmSnapshotRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new GetAlarmSnapshotResponse(
request.CorrelationId, request.InstanceUniqueName, alarms, DateTimeOffset.UtcNow));
}
[Fact]
public async Task CurrentAsync_AsksTheInstanceActor_ForItsOwnInstance()
{
// The script runs inside its own instance's context, so the read is a LOCAL Ask
// stamped with that instance's name — never a cross-instance or cross-cluster hop.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
var request = probe.ExpectMsg<GetAlarmSnapshotRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("CvdReactor01", request.InstanceUniqueName);
Assert.NotEmpty(request.CorrelationId);
probe.Reply(new GetAlarmSnapshotResponse(
request.CorrelationId, request.InstanceUniqueName,
Array.Empty<AlarmStateChanged>(), DateTimeOffset.UtcNow));
Assert.Empty(await pending);
}
[Fact]
public async Task CurrentAsync_ProjectsEveryNativeField_IncludingAckTime()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe, NativeAcked());
var alarm = Assert.Single(await pending);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.Name);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.SourceReference);
Assert.Equal("ReactorAlarms", alarm.NativeSourceCanonicalName);
Assert.True(alarm.Active);
Assert.True(alarm.Acknowledged);
Assert.Null(alarm.Confirmed);
Assert.True(alarm.Shelved); // TimedShelved collapses to a single boolean
Assert.True(alarm.Suppressed);
Assert.Equal(950, alarm.Severity);
Assert.Equal("NativeMxAccess", alarm.Kind);
Assert.Equal("Heartbeat lost", alarm.Message);
Assert.Equal("DiscreteAlarm", alarm.AlarmTypeName);
Assert.Equal("Process", alarm.Category);
Assert.Equal("op1", alarm.OperatorUser);
Assert.Equal("investigating", alarm.OperatorComment);
Assert.Equal(RaisedAt, alarm.OriginalRaiseTime);
Assert.Equal(ObservedAt, alarm.Timestamp);
Assert.Equal(AckedAt, alarm.AckTime); // §6.4 — the whole point of the enrichment
Assert.Equal("FAULT", alarm.CurrentValue);
Assert.Equal("0", alarm.LimitValue);
Assert.False(alarm.IsConfiguredPlaceholder);
}
[Fact]
public async Task CurrentAsync_UnackedNativeAlarm_ReportsNoAckTime()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe, NativeAcked() with
{
Condition = new AlarmConditionState(
true, Acknowledged: false, null, AlarmShelveState.Unshelved, false, 950),
AckTime = null,
});
var alarm = Assert.Single(await pending);
Assert.False(alarm.Acknowledged);
Assert.Null(alarm.AckTime);
Assert.False(alarm.Shelved);
}
[Fact]
public async Task CurrentAsync_ComputedAlarm_ReadsFromTheDerivedCondition()
{
// A computed alarm sets no explicit Condition — it is derived from State + Priority.
// Scripts must still see a coherent Active/Acknowledged/Severity triple so one
// filter expression works across computed and native alarms alike.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe,
new AlarmStateChanged("CvdReactor01", "HighTemp", AlarmState.Active, 700, ObservedAt));
var alarm = Assert.Single(await pending);
Assert.Equal("HighTemp", alarm.Name);
Assert.Equal("Computed", alarm.Kind);
Assert.True(alarm.Active);
Assert.True(alarm.Acknowledged); // computed alarms are auto-acked…
Assert.Null(alarm.AckTime); // …but carry no ack instant
Assert.Equal(700, alarm.Severity);
Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName);
}
[Fact]
public async Task CurrentAsync_SurfacesPlaceholderRows_SoQuietBindingsAreDistinguishable()
{
// The accessor deliberately does NOT pre-filter placeholders: a caller needs to tell
// "this binding is configured and quiet" from "this binding does not exist".
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe,
NativeAcked(),
new AlarmStateChanged("CvdReactor01", "LeftSideAlarms", AlarmState.Normal, 0, ObservedAt)
{
Kind = AlarmKind.NativeMxAccess,
NativeSourceCanonicalName = "LeftSideAlarms",
IsConfiguredPlaceholder = true,
});
var alarms = await pending;
Assert.Equal(2, alarms.Count);
// …and the documented caller-side filter yields only the real alarm.
var real = Assert.Single(alarms, a => a.Active && !a.IsConfiguredPlaceholder);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
}
[Fact]
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
{
// Unlike Attributes, alarm identity is not a scope-relative name. A composed script
// must not silently receive a truncated alarm set.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = new AlarmsAccessor(ctx).CurrentAsync();
RespondWith(probe, NativeAcked());
Assert.Single(await pending);
}
}
@@ -106,6 +106,10 @@ public class CompileSurfaceParityTests
// enforcing a mirror for it automatically — rather than silently // enforcing a mirror for it automatically — rather than silently
// re-opening the WaitAsync-class gap. // re-opening the WaitAsync-class gap.
new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) }, new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) },
// MES alarm-status API §5.2: the Alarms accessor joins the guarded set so a
// future read method (e.g. an ActiveAsync convenience) cannot ship on the
// runtime accessor while the design-time gate stays blind to it.
new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) },
}; };
/// <summary> /// <summary>