2 Commits

Author SHA1 Message Date
Joseph Doherty efba01d10a 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.
2026-05-12 05:45:24 -04:00
Joseph Doherty 3ed05f0595 docs(scripts): design for template-script scope access
Self / Children / Parent accessors with sync-indexer + async-method
shape. Flattening pipeline emits ScriptScope per resolved script;
ScriptCompilationService seeds the accessors at execution time with
no new actor messages or lookup paths.

Phased: (1) runtime accessors + Scope on ResolvedScript, (2)
flattening + deploy round-trip, (3) editor metadata fetch for child
+ parent shapes, (4) Monaco completion / hover / diagnostics
(SCADA006 unknown attribute, SCADA007 unknown composition).

Out of scope: per-template Roslyn-generated typed accessors,
locking-aware writes (covered by lock-enforcement pass), and
sibling-of-sibling chained navigation.
2026-05-12 05:38:58 -04:00
9 changed files with 442 additions and 3 deletions
@@ -0,0 +1,184 @@
# Script scope access: self / child / parent
## Goal
Template scripts get an ergonomic read/write API for:
- The current template's attributes (`Attributes["X"]`).
- Child composition attributes (`Children["TempSensor"].Attributes["Temperature"]`).
- Child composition scripts (`Children["TempSensor"].CallScript("Sample")`).
- The parent composition (when this template is composed inside another):
`Parent.Attributes["SpeedRPM"]`, `Parent.CallScript("Trip")`.
Editor (Monaco) provides completion, hover, and diagnostics on all the above.
## What already exists
- Each `Template` has `Attributes`, `Compositions` (named sub-template references),
`Scripts`, `Alarms`.
- Flattening produces `ResolvedAttribute.CanonicalName` as the path-qualified name:
direct attrs are bare, composed attrs are `"InstanceName.MemberName"`.
- `InstanceActor` stores `_attributes[canonicalName]` — flat dict keyed by the
fully composed canonical name.
- `ScriptRuntimeContext.GetAttribute(name)` does a flat lookup. So
`GetAttribute("TempSensor.Temperature")` already works if the canonical name
is in the dict. **What's missing is scope-relative access** — a script on
`TempSensor` cannot say "my Temperature" without knowing it's composed under
some parent path.
- `ScriptRuntimeContext.CallScript(name)` Ask-pattern-routes to a Script Actor.
Cross-composition / parent routing is **not** implemented.
- The actor topology is one Instance Actor per top-level instance — composed
sub-templates are **flattened into the parent's actor state**, not separate
actors. This is good news: parent/child access is path arithmetic, not
ActorRef hopping.
## Runtime API (new)
Three accessors layered on `ScriptGlobals` (in addition to the existing
`Instance.*`, `Parameters`, `Scripts.CallShared`, etc.):
```csharp
Attributes["X"] // read; throws if missing
Attributes["X"] = value // write
Attributes.TryGet<T>("X", out v) // typed read with fallback
Children["TempSensor"].Attributes["Temperature"]
Children["TempSensor"].CallScript("Sample", new { count = 3 })
Parent.Attributes["SpeedRPM"] // null check: Parent is null at the root
Parent.CallScript("Trip")
```
Internally each is a thin wrapper holding a `ScopePath` (string) plus a
reference to `ScriptRuntimeContext`. The indexer / `CallScript` prepend the
scope path to the key and delegate to the existing `Instance.GetAttribute` /
`Instance.SetAttribute` / `Instance.CallScript`. No new actor messages, no
new lookup pathway.
`Children["X"]` returns a new accessor with prefix `SelfPath + "." + X`.
`Parent` returns an accessor with the parent prefix (`null` if no parent).
Chained child/parent navigation works naturally because each accessor is the
same type returning the same type.
## Compile-time scope injection
Every compiled script needs to know its own `ScopePath`. That's captured by
the flattening pipeline and passed into `ScriptGlobals` at execution time:
```csharp
public record ScriptScope(
string SelfPath, // "" for root, "TempSensor" for composed
string? ParentPath, // null if SelfPath == ""
IReadOnlyList<string> ChildInstanceNames);
```
`ResolvedScript` gains a `Scope: ScriptScope` field. The flattening service
already walks the composition tree to compute canonical names — extending it
to emit the scope per script is mechanical.
`ScriptCompilationService.Compile` reads the scope and seeds `ScriptGlobals.
Attributes`, `Children`, `Parent` before the script runs. No code-generation;
the accessors close over the scope path at construction time.
## Editor surface
The editor side carries the same metadata that the runtime gets:
- The current template's attribute set (names + types).
- Each composition: instance name → resolved child template's attribute set
AND script list. The form already loads compositions in `TemplateEdit`.
- The parent template's attribute and script lists, ONLY when the open
template is composed inside another. We surface this as `null` otherwise.
New completion contexts:
| In code | Suggests |
|---|---|
| `Attributes["X"]` | declared attribute names of current template |
| `Children["X"]` | composition instance names |
| `Children["X"].Attributes["Y"]` | attribute names of the resolved child template |
| `Children["X"].CallScript("Y"` | script names of the resolved child template |
| `Parent.Attributes["X"]` | parent template's attribute names |
| `Parent.CallScript("X"` | parent template's script names |
New diagnostics:
- **SCADA006**: unknown attribute name on the appropriate scope.
- **SCADA007**: unknown child composition name in `Children["X"]`.
Existing `Instance.GetAttribute("X")` / `Instance.CallScript("X")` keep working
unchanged. Editor support for those can fall out of the same metadata if we
want it.
## Hover + signature help
- Hover `Attributes["X"]``attribute X: <Type> on <TemplateName>`.
- Hover `Children["X"]``composition X: <ChildTemplateName>`.
- Signature help for `Children["X"].CallScript(...)` reuses the existing
shape pipeline once the child template's scripts are reachable as
`ScriptShape[]`.
## What needs to be passed from the form
`TemplateEdit` already loads the open template's attributes and scripts.
Two new pieces:
1. **Resolved child compositions**: for each `Composition` row, fetch the
composed template's `Attributes` and `Scripts`. The repository already
has `GetTemplateByIdAsync` — call it for each composition.
2. **Parent template (if any)**: query the repository for templates that
compose this one. If exactly one, pass its shape. If multiple or none,
pass `null` and emit `Parent.X` accesses as diagnostics-by-the-user (since
the parent context is ambiguous in design time — the runtime knows because
it's running inside one specific deployment, but the editor doesn't).
Edge case: a template composed into multiple parents has no single parent at
edit time. Acceptable behaviour: `Parent` autocompletion is suppressed; using
it still compiles but emits a warning at deploy time. Document this clearly.
## Phased rollout
1. **Runtime first**. Add `Attributes` / `Children` / `Parent` accessors.
Wire scope into `ResolvedScript` and the flattening pipeline. Test with
existing flat templates (Scope.SelfPath = ""). Verify a composed script's
`Attributes["Temperature"]` reads through correctly. Site-runtime tests.
2. **Flattening + deployment**. Verify the deployed artifact carries the new
`Scope` field through `ResolvedScript``FlattenedScript` → site-side.
Run a round-trip deploy + execute.
3. **Editor metadata**. `TemplateEdit` fetches child template shapes for each
composition and optionally the parent. New Monaco context fields.
4. **Editor completion + diagnostics**. New string-literal completion
contexts. SCADA006 / SCADA007 diagnostics on the Diagnose path. Hover.
Each phase is a separate commit and independently shippable.
## Out of scope
- Composing the same template multiple times under different names on the
same parent (already supported by the data model; the editor just lists
each composition).
- Sibling-of-sibling access (`Children["A"].Parent.Children["B"]`). The
accessor API supports it naturally but we don't actively suggest it.
- Locking-aware writes (`Attributes["X"] = v` when X is locked). The
attribute lock is enforced at deployment validation, not at script-write
time; runtime writes that hit a locked attribute should reject. Out of
scope for this design — covered by the existing lock-enforcement pass.
- A formal type for `Children` / `Parent` in the editor's Roslyn analysis
(the strict Roslyn route would auto-generate per-template accessor types).
We use a dictionary-style indexer for both runtime and editor, with editor
awareness coming from the metadata pipeline, not from per-template C#
types.
## Open questions
- Should `Attributes["X"]` throw or return `null` on unknown key? The
existing `GetAttribute` logs a warning and returns `null`. Same here for
consistency.
- ~~Async vs sync indexer?~~ **Decided: both.** Sync `Attributes["X"]` /
`Attributes["X"] = v` indexer for ergonomics, plus
`Attributes.GetAsync("X")` / `Attributes.SetAsync("X", v)` for callers
that want to be explicit about the actor Ask. The sync path internally
blocks on `.GetAwaiter().GetResult()` — acceptable because all script
bodies already run on a dedicated blocking-I/O dispatcher per the project
conventions in CLAUDE.md.
@@ -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;
}
@@ -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 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));
@@ -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);
@@ -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 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);
}
@@ -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: "")
};
}
}
@@ -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);
}
}