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:
@@ -126,4 +126,12 @@ public sealed record ResolvedScript
|
|||||||
|
|
||||||
public TimeSpan? MinTimeBetweenRuns { get; init; }
|
public TimeSpan? MinTimeBetweenRuns { get; init; }
|
||||||
public string Source { get; init; } = "Template";
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs
Normal file
17
src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
private TimeSpan? _minTimeBetweenRuns;
|
private TimeSpan? _minTimeBetweenRuns;
|
||||||
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
|
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
|
||||||
private int _executionCounter;
|
private int _executionCounter;
|
||||||
|
private readonly Commons.Types.Scripts.ScriptScope _scope;
|
||||||
|
|
||||||
public ITimerScheduler Timers { get; set; } = null!;
|
public ITimerScheduler Timers { get; set; } = null!;
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||||
|
_scope = scriptConfig.Scope;
|
||||||
|
|
||||||
// Parse trigger configuration
|
// Parse trigger configuration
|
||||||
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
|
||||||
@@ -215,6 +217,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
replyTo,
|
replyTo,
|
||||||
correlationId,
|
correlationId,
|
||||||
_logger,
|
_logger,
|
||||||
|
_scope,
|
||||||
_healthCollector,
|
_healthCollector,
|
||||||
_serviceProvider));
|
_serviceProvider));
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
IActorRef replyTo,
|
IActorRef replyTo,
|
||||||
string correlationId,
|
string correlationId,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
Commons.Types.Scripts.ScriptScope scope,
|
||||||
ISiteHealthCollector? healthCollector = null,
|
ISiteHealthCollector? healthCollector = null,
|
||||||
IServiceProvider? serviceProvider = null)
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
@@ -43,7 +44,7 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
ExecuteScript(
|
ExecuteScript(
|
||||||
scriptName, instanceName, compiledScript, parameters, callDepth,
|
scriptName, instanceName, compiledScript, parameters, callDepth,
|
||||||
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
||||||
self, parent, logger, healthCollector, serviceProvider);
|
self, parent, logger, scope, healthCollector, serviceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ExecuteScript(
|
private static void ExecuteScript(
|
||||||
@@ -60,6 +61,7 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
IActorRef self,
|
IActorRef self,
|
||||||
IActorRef parent,
|
IActorRef parent,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
Commons.Types.Scripts.ScriptScope scope,
|
||||||
ISiteHealthCollector? healthCollector,
|
ISiteHealthCollector? healthCollector,
|
||||||
IServiceProvider? serviceProvider)
|
IServiceProvider? serviceProvider)
|
||||||
{
|
{
|
||||||
@@ -102,7 +104,8 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
Instance = context,
|
Instance = context,
|
||||||
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
|
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
|
||||||
CancellationToken = cts.Token
|
CancellationToken = cts.Token,
|
||||||
|
Scope = scope
|
||||||
};
|
};
|
||||||
|
|
||||||
var state = await compiledScript.RunAsync(globals, cts.Token);
|
var state = await compiledScript.RunAsync(globals, cts.Token);
|
||||||
|
|||||||
105
src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs
Normal file
105
src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -181,6 +181,14 @@ public class ScriptGlobals
|
|||||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||||
public CancellationToken CancellationToken { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
||||||
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
||||||
@@ -204,4 +212,27 @@ public class ScriptGlobals
|
|||||||
/// Usage: Scripts.CallShared("scriptName", params)
|
/// Usage: Scripts.CallShared("scriptName", params)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ public class FlatteningService
|
|||||||
scripts[canonicalName] = script with
|
scripts[canonicalName] = script with
|
||||||
{
|
{
|
||||||
CanonicalName = canonicalName,
|
CanonicalName = canonicalName,
|
||||||
Source = "Composed"
|
Source = "Composed",
|
||||||
|
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: "")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using ScadaLink.Commons.Types.Scripts;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1 of the script-scope rollout: verify path arithmetic for the new
|
||||||
|
/// Attributes / Children / Parent accessors. The actor-mediated reads/writes
|
||||||
|
/// are exercised end-to-end in Phase 2 once flattening carries scope info.
|
||||||
|
/// </summary>
|
||||||
|
public class ScopeAccessorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Root_SelfPath_Empty()
|
||||||
|
{
|
||||||
|
Assert.Equal("", ScriptScope.Root.SelfPath);
|
||||||
|
Assert.Null(ScriptScope.Root.ParentPath);
|
||||||
|
Assert.False(ScriptScope.Root.HasParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompositionScope_HasParent()
|
||||||
|
{
|
||||||
|
var scope = new ScriptScope("TempSensor", "");
|
||||||
|
Assert.True(scope.HasParent);
|
||||||
|
Assert.Equal("", scope.ParentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttributeAccessor_RootScope_ResolvesBareKey()
|
||||||
|
{
|
||||||
|
var acc = new AttributeAccessor(null!, "");
|
||||||
|
Assert.Equal("Temperature", acc.Resolve("Temperature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttributeAccessor_ComposedScope_PrependsPath()
|
||||||
|
{
|
||||||
|
var acc = new AttributeAccessor(null!, "TempSensor");
|
||||||
|
Assert.Equal("TempSensor.Temperature", acc.Resolve("Temperature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttributeAccessor_NestedScope_ChainsPath()
|
||||||
|
{
|
||||||
|
var acc = new AttributeAccessor(null!, "Motor.TempSensor");
|
||||||
|
Assert.Equal("Motor.TempSensor.Temperature", acc.Resolve("Temperature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompositionAccessor_AttributesShareScope()
|
||||||
|
{
|
||||||
|
var comp = new CompositionAccessor(null!, "TempSensor");
|
||||||
|
Assert.Equal("TempSensor", comp.Path);
|
||||||
|
Assert.Equal("TempSensor", comp.Attributes.ScopePrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompositionAccessor_ResolveScript_PrependsPath()
|
||||||
|
{
|
||||||
|
var comp = new CompositionAccessor(null!, "TempSensor");
|
||||||
|
Assert.Equal("TempSensor.Sample", comp.ResolveScript("Sample"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompositionAccessor_EmptyPath_LeavesScriptNameBare()
|
||||||
|
{
|
||||||
|
var comp = new CompositionAccessor(null!, "");
|
||||||
|
Assert.Equal("Sample", comp.ResolveScript("Sample"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChildrenAccessor_FromRoot_GivesUnpathedChild()
|
||||||
|
{
|
||||||
|
var children = new ChildrenAccessor(null!, "");
|
||||||
|
var temp = children["TempSensor"];
|
||||||
|
Assert.Equal("TempSensor", temp.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChildrenAccessor_FromComposition_PrefixesChild()
|
||||||
|
{
|
||||||
|
var children = new ChildrenAccessor(null!, "Motor");
|
||||||
|
var temp = children["TempSensor"];
|
||||||
|
Assert.Equal("Motor.TempSensor", temp.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user