From efba01d10a956e1b330ac3126e67760426465880 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 05:45:24 -0400 Subject: [PATCH] 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. --- .../Flattening/FlattenedConfiguration.cs | 8 ++ .../Types/Scripts/ScriptScope.cs | 17 +++ .../Actors/ScriptActor.cs | 3 + .../Actors/ScriptExecutionActor.cs | 7 +- .../Scripts/ScopeAccessors.cs | 105 ++++++++++++++++++ .../Scripts/ScriptCompilationService.cs | 31 ++++++ .../Flattening/FlatteningService.cs | 3 +- .../Scripts/ScopeAccessorTests.cs | 87 +++++++++++++++ 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs create mode 100644 src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs create mode 100644 tests/ScadaLink.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs diff --git a/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs index 8548af8..500d6d2 100644 --- a/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs +++ b/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs @@ -126,4 +126,12 @@ public sealed record ResolvedScript public TimeSpan? MinTimeBetweenRuns { get; init; } public string Source { get; init; } = "Template"; + + /// + /// Where the script sits in the composition tree. Seeded into ScriptGlobals + /// so the script's Attributes / Children / Parent + /// accessors resolve canonical names with the right path-prefix. + /// + public ScadaLink.Commons.Types.Scripts.ScriptScope Scope { get; init; } + = ScadaLink.Commons.Types.Scripts.ScriptScope.Root; } diff --git a/src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs b/src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs new file mode 100644 index 0000000..fd2a930 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Scripts/ScriptScope.cs @@ -0,0 +1,17 @@ +namespace ScadaLink.Commons.Types.Scripts; + +/// +/// Where a compiled script sits in the composition tree. Computed at +/// flattening time and seeded into the script's globals at execution time +/// so Attributes["X"] / Parent.X can prepend the right +/// path-prefix when delegating to the existing flat +/// Instance.GetAttribute / Instance.SetAttribute / +/// Instance.CallScript APIs. +/// +public sealed record ScriptScope(string SelfPath, string? ParentPath) +{ + /// Scope for a script directly on the root template (no compositions). + public static readonly ScriptScope Root = new("", null); + + public bool HasParent => ParentPath != null; +} diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs index 52e4339..0af75c2 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -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)); diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index c5e4c99..f969afc 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -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()), - CancellationToken = cts.Token + CancellationToken = cts.Token, + Scope = scope }; var state = await compiledScript.RunAsync(globals, cts.Token); diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs new file mode 100644 index 0000000..8b60f4b --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs @@ -0,0 +1,105 @@ +namespace ScadaLink.SiteRuntime.Scripts; + +/// +/// Scope-aware view onto the instance's attributes, anchored at a path prefix. +/// Attributes["X"] 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. +/// +public class AttributeAccessor +{ + private readonly ScriptRuntimeContext _ctx; + + /// Canonical-name prefix, e.g. "" for root or "TempSensor" for a composition. + 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 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; + } +} + +/// +/// A view of one composition at a path. Exposes its attributes via +/// and an invokable CallScript. +/// +public class CompositionAccessor +{ + private readonly ScriptRuntimeContext _ctx; + + /// Canonical-name path this composition is rooted at. + 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 CallScript(string scriptName, IReadOnlyDictionary? parameters = null) + => _ctx.CallScript(ResolveScript(scriptName), parameters); +} + +/// +/// Dictionary-style accessor for the script's child compositions. Indexing +/// returns a rooted at the child's path. +/// +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); +} diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index a920dcd..6d6d11e 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -181,6 +181,14 @@ public class ScriptGlobals public ScriptParameters Parameters { get; set; } = new ScriptParameters(); public CancellationToken CancellationToken { get; set; } + /// + /// 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. + /// + public Commons.Types.Scripts.ScriptScope Scope { get; set; } = + Commons.Types.Scripts.ScriptScope.Root; + /// /// 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) /// public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts; + + /// + /// 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 Attributes["Temperature"]. + /// + public AttributeAccessor Attributes => new(Instance, Scope.SelfPath); + + /// + /// Indexed access to child compositions. + /// Children["TempSensor"].Attributes["Temperature"] reads the + /// composed child's attribute. Children["TempSensor"].CallScript("Sample") + /// invokes a script on the child. + /// + public ChildrenAccessor Children => new(Instance, Scope.SelfPath); + + /// + /// Parent composition (null when this script is on a root-level template). + /// Parent.Attributes["SpeedRPM"] reaches the parent's attribute; + /// Parent.CallScript("Trip") invokes a parent script. + /// + public CompositionAccessor? Parent => + Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath); } diff --git a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs index 56355cd..e018283 100644 --- a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs @@ -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: "") }; } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs new file mode 100644 index 0000000..0618815 --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs @@ -0,0 +1,87 @@ +using ScadaLink.Commons.Types.Scripts; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Scripts; + +/// +/// 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. +/// +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); + } +}