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
@@ -4,6 +4,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
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.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -148,6 +149,63 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
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()
{
// 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
// re-opening the WaitAsync-class gap.
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>