Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
T
Joseph Doherty d0af884760 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.
2026-08-01 13:12:30 -04:00

571 lines
26 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime
/// <c>ScriptGlobals</c> surface (ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts) member-for-member
/// so the same user code that runs at a site also compiles and runs here.
///
/// Instance-context members — <c>Instance.GetAttribute/SetAttribute/CallScript</c>,
/// <c>Attributes</c>, <c>Children</c>, <c>Parent</c> — need a live deployed
/// instance. With no instance bound they throw <see cref="ScriptSandboxException"/>;
/// with one bound (see <see cref="SandboxInstanceContext"/>) they route to it.
///
/// <c>ExternalSystem</c>, <c>Database</c>, and <c>Scripts.CallShared</c> run
/// against central's real services and fire for real; <c>Notify</c> is a
/// signature-faithful no-op fake. None of them depend on a bound instance.
/// </summary>
public class SandboxScriptHost
{
/// <summary>
/// Script parameters passed to the sandbox.
/// </summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// Cancellation token for the sandbox execution.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Alarm context for the sandbox.
/// </summary>
public AlarmContext? Alarm { get; init; }
/// <summary>
/// Script scope defining the execution context.
/// </summary>
public ScriptScope Scope { get; init; } = ScriptScope.Root;
/// <summary>
/// Instance context providing access to deployed instance data.
/// </summary>
public SandboxInstanceContext Instance { get; init; } = new();
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database => Instance.Database;
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify => Instance.Notify;
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Accessor for attributes scoped to the current instance.
/// </summary>
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for child compositions.
/// </summary>
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for the parent composition, or null if at root.
/// </summary>
public SandboxCompositionAccessor? Parent =>
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>
/// Backs the sandbox <c>Instance</c> when a Test Run is bound to a real
/// deployed instance. Null when unbound. The implementation routes to the
/// instance cross-site over the cluster transport.
/// </summary>
public interface ISandboxInstanceGateway
{
/// <summary>
/// Gets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attribute value, or null if not found.</returns>
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
/// <summary>
/// Sets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="value">The value to set.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
/// <summary>
/// Calls a script with the specified canonical name.
/// </summary>
/// <param name="canonicalScriptName">The canonical name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
/// <summary>
/// Blocks until the named attribute reaches a target value (value-equality,
/// codec-encoded), optionally quality-gated, or the timeout elapses — routed to the
/// bound deployed instance cross-site. Value-equality only: a script-side predicate
/// cannot be routed, so the sandbox predicate overloads stay unsupported.
/// </summary>
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
/// <param name="targetValueEncoded">The <c>AttributeValueCodec</c>-encoded target value, or null for "any change".</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
Task<WaitResult> WaitForAttributeAsync(
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct);
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRuntimeContext</c> —
/// the <c>Instance</c> global. Attribute and sibling-script access needs a real
/// deployed instance: with no gateway wired it throws; with one (a bound
/// instance) it routes cross-site. <c>ExternalSystem</c>/<c>Database</c>/
/// <c>Scripts</c> run against central's real services regardless of binding;
/// <c>Notify</c> is a signature-faithful no-op fake.
/// </summary>
public class SandboxInstanceContext
{
private readonly ISandboxInstanceGateway? _gateway;
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem { get; }
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database { get; }
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify { get; }
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts { get; }
/// <summary>
/// Initializes a new instance of the SandboxInstanceContext.
/// </summary>
/// <param name="gateway">Gateway for accessing deployed instance data, or null if unbound.</param>
/// <param name="external">External system helper, or null to create a default.</param>
/// <param name="database">Database helper, or null to create a default.</param>
/// <param name="notify">Notification helper, or null to create a default.</param>
/// <param name="scripts">Script call helper, or null to create a default.</param>
public SandboxInstanceContext(
ISandboxInstanceGateway? gateway = null,
SandboxExternalHelper? external = null,
SandboxDatabaseHelper? database = null,
SandboxNotifyHelper? notify = null,
SandboxScriptCallHelper? scripts = null)
{
_gateway = gateway;
ExternalSystem = external ?? new SandboxExternalHelper(null, "<sandbox>");
Database = database ?? new SandboxDatabaseHelper(null, "<sandbox>");
Notify = notify ?? new SandboxNotifyHelper();
Scripts = scripts ?? new SandboxScriptCallHelper(null);
}
/// <summary>
/// Gets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAttribute(string attributeName)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"GetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to read live attribute values.");
return _gateway.GetAttributeAsync(attributeName, CancellationToken.None);
}
/// <summary>
/// Sets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <param name="value">The value to set.</param>
public void SetAttribute(string attributeName, string value)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"SetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to write attribute values.");
_gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult();
}
/// <summary>
/// Calls a sibling script.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"CallScript(\"{scriptName}\") needs a deployed instance — " +
"bind one in Test Run to call sibling scripts.");
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
}
/// <summary>
/// Routes a value-equality attribute wait to the bound deployed instance cross-site.
/// Needs a bound instance (the site owns the event-driven waiter); throws when unbound.
/// </summary>
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
/// <param name="targetValueEncoded">The codec-encoded target value, or null for "any change".</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
/// <returns>The wait outcome.</returns>
public Task<WaitResult> WaitForAttribute(
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"WaitForAttribute(\"{canonicalName}\") needs a deployed instance — " +
"bind one in Test Run to exercise the event-driven attribute waiter.");
return _gateway.WaitForAttributeAsync(
canonicalName, targetValueEncoded, timeout, requireGoodQuality, CancellationToken.None);
}
}
/// <summary>
/// Sandbox mirror of <c>ScriptRuntimeContext.ScriptCallHelper</c> —
/// <c>Scripts.CallShared(...)</c>. Compiles and runs the named shared script in
/// the same sandbox via the wired delegate.
/// </summary>
public class SandboxScriptCallHelper
{
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared;
/// <summary>
/// Initializes a new instance of the SandboxScriptCallHelper.
/// </summary>
/// <param name="callShared">Delegate for calling shared scripts, or null if not available.</param>
public SandboxScriptCallHelper(
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
{
_callShared = callShared;
}
/// <summary>
/// Calls a shared script.
/// </summary>
/// <param name="scriptName">The name of the shared script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallShared(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default)
{
if (_callShared == null)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{scriptName}\") — shared-script catalog not configured for Test Run.");
return _callShared(scriptName, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.AttributeAccessor</c> —
/// scope-aware <c>Attributes["X"]</c> access anchored at a canonical-name prefix.
/// </summary>
public class SandboxAttributeAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The scope prefix for attribute resolution.
/// </summary>
public string ScopePrefix { get; }
/// <summary>
/// Initializes a new instance of the SandboxAttributeAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="prefix">The scope prefix for attribute names.</param>
public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
{
_ctx = ctx;
ScopePrefix = prefix;
}
/// <summary>
/// Resolves a key to its fully qualified name within the current scope.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The fully qualified attribute name.</returns>
public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
/// <summary>
/// Gets or sets an attribute value by key.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public object? this[string key]
{
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
}
/// <summary>
/// Gets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
/// <summary>
/// Sets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <param name="value">The value to set, or null.</param>
/// <returns>A task representing the operation.</returns>
public Task SetAsync(string key, object? value)
{
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
return Task.CompletedTask;
}
// Batch-write/wait helpers. These mirror the runtime AttributeAccessor
// (SiteRuntime/Scripts/ScopeAccessors.cs) and the deploy-gate
// ScriptCompileSurface member-for-member so instance scripts using them COMPILE
// in the editor and pass Test Run analysis — previously the
// sandbox omitted them and the editor false-flagged valid scripts with CS1061.
// Execution needs the site's DCL batch path + event-driven attribute waiter,
// for which the central Test Run sandbox has no transport, so each throws a
// clearly-labelled ScriptSandboxException; the same code validates/deploys/runs
// unchanged at a site.
/// <summary>
/// Sandbox stand-in for <c>AttributeAccessor.WriteBatchAndWaitAsync</c>: present
/// for editor/compile parity, throws <see cref="ScriptSandboxException"/> when run
/// in Test Run (no device batch-write transport here).
/// </summary>
/// <param name="values">The attribute values to write as a batch.</param>
/// <param name="flagKey">The flag attribute key to set to signal completion.</param>
/// <param name="flagValue">The value to set on the flag attribute.</param>
/// <param name="responseKey">The attribute key to observe for the device's response.</param>
/// <param name="responseValue">The response value to wait for.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout)
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
/// <summary>
/// Value-equality attribute wait — routed to the bound deployed instance cross-site
/// (the same real event-driven waiter a site uses). Needs a bound instance; throws
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>True when the attribute reached the target within the timeout; false on timeout.</returns>
public async Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> (await _ctx.WaitForAttribute(
Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality)).Matched;
/// <summary>
/// Predicate-form attribute wait. Unsupported in Test Run because a script-side
/// predicate (an in-process lambda) cannot be evaluated on the remote site — only the
/// value-equality overloads route. Throws a clearly-labelled <see cref="ScriptSandboxException"/>.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw PredicateWaitNotRoutable(nameof(WaitAsync));
/// <summary>
/// Value-equality attribute wait returning the full <see cref="WaitResult"/> — routed
/// to the bound deployed instance cross-site. Needs a bound instance; throws
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
=> _ctx.WaitForAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality);
/// <summary>
/// Predicate-form attribute wait returning the full <see cref="WaitResult"/>.
/// Unsupported in Test Run for the same reason as the predicate <see cref="WaitAsync(string, Func{object?, bool}, TimeSpan, bool)"/>
/// overload — an in-process predicate cannot be routed to the remote site.
/// </summary>
/// <param name="key">The attribute key to observe.</param>
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
=> throw PredicateWaitNotRoutable(nameof(WaitForAsync));
private static ScriptSandboxException NotInSandbox(string member) =>
new($"{member}(...) drives live device tags and the site's event-driven " +
"attribute waiter, which aren't available in the central Test Run sandbox — " +
"deploy to a site to exercise batch-write/wait handshakes.");
private static ScriptSandboxException PredicateWaitNotRoutable(string member) =>
new($"{member}(...) with a predicate can't run in Test Run — a script-side " +
"predicate is an in-process lambda that cannot be evaluated on the remote " +
"site. Use the value-equality overload (which routes to the bound instance), " +
"or deploy to a site to exercise predicate waits.");
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.CompositionAccessor</c> —
/// a view of one composition: its attributes plus an invokable <c>CallScript</c>.
/// </summary>
public class SandboxCompositionAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The path to the composition within the instance hierarchy.
/// </summary>
public string Path { get; }
/// <summary>
/// Accessor for attributes within the composition.
/// </summary>
public SandboxAttributeAccessor Attributes { get; }
/// <summary>
/// Initializes a new instance of the SandboxCompositionAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="path">The path to the composition within the instance hierarchy.</param>
public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
{
_ctx = ctx;
Path = path;
Attributes = new SandboxAttributeAccessor(ctx, path);
}
/// <summary>
/// Resolves a script name to its fully qualified name within the composition.
/// </summary>
/// <param name="scriptName">The script name.</param>
/// <returns>The fully qualified script name.</returns>
public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + scriptName;
/// <summary>
/// Calls a script within the composition.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ChildrenAccessor</c> —
/// dictionary-style access to child compositions.
/// </summary>
public class SandboxChildrenAccessor
{
private readonly SandboxInstanceContext _ctx;
private readonly string _selfPath;
/// <summary>
/// Initializes a new instance of the SandboxChildrenAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="selfPath">The path to the parent composition.</param>
public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
{
_ctx = ctx;
_selfPath = selfPath;
}
/// <summary>
/// Gets a child composition by name.
/// </summary>
/// <param name="compositionName">The name of the child composition.</param>
/// <returns>An accessor for the child composition.</returns>
public SandboxCompositionAccessor this[string compositionName]
{
get
{
var path = _selfPath.Length == 0
? compositionName
: _selfPath + "." + compositionName;
return new SandboxCompositionAccessor(_ctx, path);
}
}
}
/// <summary>
/// Distinct exception so the Test Run pipeline can label sandbox-only
/// limitations differently from genuine runtime errors in user code.
/// </summary>
public class ScriptSandboxException : Exception
{
/// <summary>
/// Initializes a new instance of the ScriptSandboxException.
/// </summary>
/// <param name="message">The exception message.</param>
public ScriptSandboxException(string message) : base(message) { }
}