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.
8.4 KiB
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
TemplatehasAttributes,Compositions(named sub-template references),Scripts,Alarms. - Flattening produces
ResolvedAttribute.CanonicalNameas the path-qualified name: direct attrs are bare, composed attrs are"InstanceName.MemberName". InstanceActorstores_attributes[canonicalName]— flat dict keyed by the fully composed canonical name.ScriptRuntimeContext.GetAttribute(name)does a flat lookup. SoGetAttribute("TempSensor.Temperature")already works if the canonical name is in the dict. What's missing is scope-relative access — a script onTempSensorcannot 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.):
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:
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
nullotherwise.
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 asScriptShape[].
What needs to be passed from the form
TemplateEdit already loads the open template's attributes and scripts.
Two new pieces:
- Resolved child compositions: for each
Compositionrow, fetch the composed template'sAttributesandScripts. The repository already hasGetTemplateByIdAsync— call it for each composition. - Parent template (if any): query the repository for templates that
compose this one. If exactly one, pass its shape. If multiple or none,
pass
nulland emitParent.Xaccesses 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
-
Runtime first. Add
Attributes/Children/Parentaccessors. Wire scope intoResolvedScriptand the flattening pipeline. Test with existing flat templates (Scope.SelfPath = ""). Verify a composed script'sAttributes["Temperature"]reads through correctly. Site-runtime tests. -
Flattening + deployment. Verify the deployed artifact carries the new
Scopefield throughResolvedScript→FlattenedScript→ site-side. Run a round-trip deploy + execute. -
Editor metadata.
TemplateEditfetches child template shapes for each composition and optionally the parent. New Monaco context fields. -
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"] = vwhen 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/Parentin 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 returnnullon unknown key? The existingGetAttributelogs a warning and returnsnull. Same here for consistency. Async vs sync indexer?Decided: both. SyncAttributes["X"]/Attributes["X"] = vindexer for ergonomics, plusAttributes.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.