feat(scripts): self/child/parent attribute and script accessors

Phases 1+2 of the design at
docs/plans/2026-05-12-script-scope-access-design.md.

Adds ergonomic scope-aware accessors to compiled scripts. A script
on a composed TempSensor reads its own attribute via
Attributes["Temperature"]; reaches up to the parent via
Parent.Attributes["SpeedRPM"]; invokes a child script via
Children["TempSensor"].CallScript("Sample"). All resolve to the
existing flat Instance.GetAttribute / SetAttribute / CallScript
delegates by prepending the script's canonical path prefix.

Runtime types (SiteRuntime.Scripts.ScopeAccessors):
  AttributeAccessor   sync indexer + GetAsync / SetAsync
  CompositionAccessor Attributes + CallScript
  ChildrenAccessor    Children["name"] => CompositionAccessor

ScriptGlobals gains Scope, Attributes, Children, Parent properties.
Sync indexer blocks on the Instance Actor Ask; explicit GetAsync /
SetAsync are also available for callers that want to await.

Plumbing:
  - Commons.Types.Scripts.ScriptScope record (SelfPath / ParentPath).
  - ResolvedScript.Scope (defaults to ScriptScope.Root for back-compat).
  - FlatteningService emits new ScriptScope(prefix, "") for each
    composed script so a script defined on TempSensor composed under
    a parent gets SelfPath = "TempSensor".
  - ScriptActor reads the Scope from its ResolvedScript and forwards
    it through ScriptExecutionActor into ScriptGlobals on each call.

RevisionHashService not touched: the per-script canonical name
already encodes the composition path, so any structural change
already flips the hash.

10 new unit tests on the path arithmetic. Site/Template engine
suites stay green (129 + 199).

Editor surface (Phase 3: metadata fetch, Phase 4: completion +
SCADA006 / SCADA007 diagnostics) follows in the next commits.
This commit is contained in:
Joseph Doherty
2026-05-12 05:45:24 -04:00
parent 3ed05f0595
commit efba01d10a
8 changed files with 258 additions and 3 deletions

View File

@@ -126,4 +126,12 @@ public sealed record ResolvedScript
public TimeSpan? MinTimeBetweenRuns { get; init; }
public string Source { get; init; } = "Template";
/// <summary>
/// Where the script sits in the composition tree. Seeded into ScriptGlobals
/// so the script's <c>Attributes</c> / <c>Children</c> / <c>Parent</c>
/// accessors resolve canonical names with the right path-prefix.
/// </summary>
public ScadaLink.Commons.Types.Scripts.ScriptScope Scope { get; init; }
= ScadaLink.Commons.Types.Scripts.ScriptScope.Root;
}

View File

@@ -0,0 +1,17 @@
namespace ScadaLink.Commons.Types.Scripts;
/// <summary>
/// Where a compiled script sits in the composition tree. Computed at
/// flattening time and seeded into the script's globals at execution time
/// so <c>Attributes["X"]</c> / <c>Parent.X</c> can prepend the right
/// path-prefix when delegating to the existing flat
/// <c>Instance.GetAttribute</c> / <c>Instance.SetAttribute</c> /
/// <c>Instance.CallScript</c> APIs.
/// </summary>
public sealed record ScriptScope(string SelfPath, string? ParentPath)
{
/// <summary>Scope for a script directly on the root template (no compositions).</summary>
public static readonly ScriptScope Root = new("", null);
public bool HasParent => ParentPath != null;
}

View File

@@ -38,6 +38,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private TimeSpan? _minTimeBetweenRuns;
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
private int _executionCounter;
private readonly Commons.Types.Scripts.ScriptScope _scope;
public ITimerScheduler Timers { get; set; } = null!;
@@ -63,6 +64,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_scope = scriptConfig.Scope;
// Parse trigger configuration
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
@@ -215,6 +217,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
replyTo,
correlationId,
_logger,
_scope,
_healthCollector,
_serviceProvider));

View File

@@ -33,6 +33,7 @@ public class ScriptExecutionActor : ReceiveActor
IActorRef replyTo,
string correlationId,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
@@ -43,7 +44,7 @@ public class ScriptExecutionActor : ReceiveActor
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, healthCollector, serviceProvider);
self, parent, logger, scope, healthCollector, serviceProvider);
}
private static void ExecuteScript(
@@ -60,6 +61,7 @@ public class ScriptExecutionActor : ReceiveActor
IActorRef self,
IActorRef parent,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider)
{
@@ -102,7 +104,8 @@ public class ScriptExecutionActor : ReceiveActor
{
Instance = context,
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token
CancellationToken = cts.Token,
Scope = scope
};
var state = await compiledScript.RunAsync(globals, cts.Token);

View File

@@ -0,0 +1,105 @@
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
/// Reads block on the actor Ask; async variants are provided for callers
/// that prefer to await explicitly.
/// </summary>
public class AttributeAccessor
{
private readonly ScriptRuntimeContext _ctx;
/// <summary>Canonical-name prefix, e.g. "" for root or "TempSensor" for a composition.</summary>
public string ScopePrefix { get; }
public AttributeAccessor(ScriptRuntimeContext ctx, string prefix)
{
_ctx = ctx;
ScopePrefix = prefix;
}
public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
public object? this[string key]
{
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
}
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
public Task SetAsync(string key, object? value)
{
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
return Task.CompletedTask;
}
}
/// <summary>
/// A view of one composition at a path. Exposes its attributes via
/// <see cref="AttributeAccessor"/> and an invokable <c>CallScript</c>.
/// </summary>
public class CompositionAccessor
{
private readonly ScriptRuntimeContext _ctx;
/// <summary>Canonical-name path this composition is rooted at.</summary>
public string Path { get; }
public AttributeAccessor Attributes { get; }
public CompositionAccessor(ScriptRuntimeContext ctx, string path)
{
_ctx = ctx;
Path = path;
Attributes = new AttributeAccessor(ctx, path);
}
public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + scriptName;
public Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
}
/// <summary>
/// Dictionary-style accessor for the script's child compositions. Indexing
/// returns a <see cref="CompositionAccessor"/> rooted at the child's path.
/// </summary>
public class ChildrenAccessor
{
private readonly ScriptRuntimeContext _ctx;
private readonly string _selfPath;
public ChildrenAccessor(ScriptRuntimeContext ctx, string selfPath)
{
_ctx = ctx;
_selfPath = selfPath;
}
public CompositionAccessor this[string compositionName]
{
get
{
var path = _selfPath.Length == 0
? compositionName
: _selfPath + "." + compositionName;
return new CompositionAccessor(_ctx, path);
}
}
}
internal static class ScopeAccessorFactory
{
public static AttributeAccessor AttributesFor(ScriptRuntimeContext ctx, string selfPath)
=> new(ctx, selfPath);
public static ChildrenAccessor ChildrenFor(ScriptRuntimeContext ctx, string selfPath)
=> new(ctx, selfPath);
public static CompositionAccessor? ParentFor(ScriptRuntimeContext ctx, string? parentPath)
=> parentPath == null ? null : new CompositionAccessor(ctx, parentPath);
}

View File

@@ -181,6 +181,14 @@ public class ScriptGlobals
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
public CancellationToken CancellationToken { get; set; }
/// <summary>
/// Where this script sits in the composition tree. Defaults to root for
/// scripts on top-level templates; a flattened composed script gets
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
/// </summary>
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
Commons.Types.Scripts.ScriptScope.Root;
/// <summary>
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
@@ -204,4 +212,27 @@ public class ScriptGlobals
/// Usage: Scripts.CallShared("scriptName", params)
/// </summary>
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Read/write the current template's attributes by name. Resolves to the
/// canonical name for the script's scope, so a script on a composed
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
/// </summary>
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Indexed access to child compositions.
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
/// invokes a script on the child.
/// </summary>
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
/// </summary>
public CompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
}

View File

@@ -391,7 +391,8 @@ public class FlatteningService
scripts[canonicalName] = script with
{
CanonicalName = canonicalName,
Source = "Composed"
Source = "Composed",
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: "")
};
}
}