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:
@@ -54,12 +54,59 @@ public class RoslynScriptCompilerTests
|
||||
var resolved = Attributes.Resolve("Temperature");
|
||||
var conn = await Database.Connection("hist");
|
||||
var scope = Scope;
|
||||
var currentAlarms = await Alarms.CurrentAsync();
|
||||
var viaInstance = await Instance.Alarms.CurrentAsync();
|
||||
""";
|
||||
|
||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
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]
|
||||
public void Compile_Empty_ForTriggerExpression()
|
||||
{
|
||||
|
||||
@@ -194,6 +194,36 @@ public class ScriptTrustValidatorTests
|
||||
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 --------------
|
||||
|
||||
// (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the
|
||||
|
||||
Reference in New Issue
Block a user