Files
lmxopcua/docs/plans/2026-06-09-monaco-script-editor-design.md
T
Joseph Doherty 7a03d01613 docs(scripting): design for Roslyn-backed Monaco script editor
Full IntelliSense parity with scadabridge (completions, hover, signature
help, live diagnostics, formatting, inlay hints, global tag-path
completion), re-seated on OtOpcUa's real script compile context
(ScriptSandbox + VirtualTagContext wrapper + ForbiddenTypeAnalyzer +
DependencyExtractor). Reusable MonacoEditor.razor wired into the
ScriptEdit page and the virtual-tag modal; Monaco vendored locally.
2026-06-09 13:44:20 -04:00

16 KiB

Monaco Script Editor (Roslyn-backed IntelliSense) — Design

Date: 2026-06-09 Status: Approved (brainstorming complete; ready for implementation plan) Author: brainstorming session Related: docs/Uns.md, src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/, sister project scadabridge (/Users/dohertj2/Desktop/scadabridge/src)

Goal

Replace OtOpcUa's proof-of-concept script editor (a CDN Monaco instance mounted over a hidden textarea via an eval + Task.Delay(50) race) with a proper, reusable Monaco code editor offering full Roslyn-backed IntelliSense — the same class of experience as the sister project scadabridge: completions, hover, signature help, live diagnostics, document formatting, inlay hints, and context-aware tag-path completion — but re-seated on OtOpcUa's actual script compile context so what the editor accepts/rejects exactly matches what publish enforces.

Background — current state

Aspect scadabridge (reference) OtOpcUa (today)
Monaco delivery Vendored locally (wwwroot/lib/monaco/vs/, served from _content/), v0.42 CDN (jsDelivr monaco-editor@0.52.2), injected at runtime
Editor component Reusable MonacoEditor.razor (Value/ValueChanged, theme, toolbar, DotNetObjectReference) None — eval-injected loader over a hidden <InputTextArea>, sync-back via dispatched input events
IntelliSense Full: completions/hover/signature-help/diagnostics/format/inlay via /api/script-analysis/*ScriptAnalysisService (Roslyn) None — plaintext csharp highlighting only
Load robustness Deterministic Fragile 50 ms delay; silent fallback to textarea
Air-gap Safe (vendored) Falls back to a plain textarea when CDN unreachable

The current code self-documents the gap: comments say richer editing "lands in Phase D.2."

OtOpcUa scripting runtime (the context IntelliSense must reproduce)

  • Evaluator: ScriptEvaluator (src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs) builds a hand-rolled CSharpCompilation (NOT CSharpScript). It wraps the user's source in:

    using System;
    using System.Linq;
    using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
    using ZB.MOM.WW.OtOpcUa.Core.Scripting;
    namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;
    public static class CompiledScript
    {
        public static TResult Run(ScriptGlobals<TContext> globals)
        {
            var ctx = globals.ctx;
    #line 1
            «user source»   // statement body, MUST end with an explicit `return`
        }
    }
    
  • Globals / context: ScriptGlobals<VirtualTagContext> exposes ctx of type VirtualTagContext : ScriptContext. The in-scope API a script author can call:

    • ctx.GetTag(string path)DataValueSnapshot (.Value, .StatusCode, .SourceTimestampUtc, .ServerTimestampUtc)
    • ctx.SetVirtualTag(string path, object? value)
    • ctx.NowDateTime
    • ctx.LoggerSerilog.ILogger
    • ScriptContext.Deadband(double current, double previous, double tolerance) (static)

    These are real, referenceable types (Scripting.Abstractions), so Roslyn analysis can point at the genuine types — no mirror "host" classes needed (scadabridge had to build mirrors because its runtime globals weren't directly referenceable).

  • Sandbox: ScriptSandbox.Build(typeof(VirtualTagContext)) (src/Core/Scripting/ScriptSandbox.cs) supplies the imports (above) and the restricted metadata-reference set (pinned OtOpcUa assemblies + a BCL subset; no System.IO/System.Net/Process/Reflection).

  • Hard gates publish enforces:

    • ForbiddenTypeAnalyzer — semantic gate against type-forwarding sandbox escapes (e.g. resolving HttpClient).
    • DependencyExtractor.Extract(source) — AST walk requiring ctx.GetTag / ctx.SetVirtualTag paths to be string literals; rejects variables, concatenation, interpolation. Also yields the read/write dependency sets.
  • Binding: VirtualTag.ScriptIdScript.ScriptId (logical FK). A Script is global — one script may be referenced by multiple virtual tags across different equipment, and those tags may have different DataTypes. The script is edited as a Script row (SourceCode + SHA-256 SourceHash), not per tag.

Decisions (from brainstorming)

  1. Scope: Full IntelliSense parity with scadabridge (completions, hover, signature help, diagnostics, formatting, inlay hints) — Roslyn-backed.
  2. Location: Reusable MonacoEditor.razor, wired into both the ScriptEdit page (/scripts/{id}) and the Edit-Virtual-Tag modal (inline script-source panel).
  3. Tag-path completion: Yes, in v1 — global completion of all configured tag + virtual-tag paths inside ctx.GetTag("…") / ctx.SetVirtualTag("…") string literals.
  4. Vendoring: Monaco vendored locally (air-gap safe), replacing the CDN.
  5. Modal save-model: Inline script editing in the modal is its own RowVersion-guarded Save, separate from the virtual-tag Create/Save, with a "Used by N virtual tags — edits affect all of them" warning.

Architecture

┌──────────────────────────── AdminUI (Blazor Server) ────────────────────────────┐
│                                                                                  │
│  Components/Shared/MonacoEditor.razor   ◄── Value/ValueChanged, theme, toolbar    │
│        │  (DotNetObjectReference both ways)                                       │
│        ▼                                                                          │
│  wwwroot/js/monaco-init.js  ──registers──►  completion / hover / signatureHelp /  │
│        │                                    diagnostics / format / inlayHints     │
│        │  fetch POST /api/script-analysis/*                                       │
│        ▼                                                                          │
│  ScriptAnalysis/ScriptAnalysisEndpoints.cs  (minimal-API group, authz-gated)     │
│        ▼                                                                          │
│  ScriptAnalysis/ScriptAnalysisService.cs  ── Roslyn over OtOpcUa's REAL wrapper   │
│        │   ├─ ScriptSandbox.Build(typeof(VirtualTagContext))   (imports + refs)   │
│        │   ├─ CompiledScript.Run(ScriptGlobals<VirtualTagContext>) wrapper        │
│        │   ├─ ForbiddenTypeAnalyzer  (sandbox-escape squiggles)                   │
│        │   ├─ DependencyExtractor    (dynamic-path squiggles)                     │
│        │   └─ IScriptTagCatalog      (tag-path string-literal completions)        │
│        ▼                                                                          │
│  wwwroot/lib/monaco/vs/…  (vendored Monaco, one <script> in App.razor)            │
└──────────────────────────────────────────────────────────────────────────────────┘
                         │ references
                         ▼
  Core.Scripting + Scripting.Abstractions (ScriptSandbox, VirtualTagContext,
  ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor)
  Configuration (OtOpcUaConfigDbContext → tag/virtual-tag paths for the catalog)

Component placement

ScriptAnalysis/ lives inside ZB.MOM.WW.OtOpcUa.AdminUI (mirrors scadabridge's CentralUI layout). New build dependencies on the AdminUI project:

  • Project refs: Core.Scripting, Scripting.Abstractions (and transitively Core.Abstractions). The AdminUI already references Configuration.
  • Packages: Microsoft.CodeAnalysis.CSharp.Scripting and Microsoft.CodeAnalysis.CSharp.Workspaces (pin to scadabridge's 5.0.0 on net10; confirm at plan time).

ScriptAnalysisService — the analysis core

Ported from scadabridge (~90% reusable: position mapping, completion/hover/ signature-help/format/inlay extraction, semantic-model access) but re-seated on OtOpcUa's real compile context:

  1. Wrapped document. Build the analysis source = OtOpcUa's evaluator wrapper with the user's text spliced after #line 1, return type object (a shared script must stay valid no matter which virtual tag's DataType consumes it; per-tag return coercion remains a runtime concern). Use ScriptSandbox.Build(typeof(VirtualTagContext)) for imports + references — single source of truth shared with the evaluator.
  2. Compilation + semantic model. CSharpCompilation.Create over the wrapped SyntaxTree; compilation.GetSemanticModel(tree).
  3. Cursor mapping. Editor (line, column) → offset within the user source → add the wrapper-prefix length → physical offset in the wrapped document. The #line 1 directive makes Roslyn report diagnostic line numbers already in user-source coordinates; completions/hover/signature use the physical offset.
  4. Completions. semanticModel.LookupSymbols(position) for scope; member completion after .; string-literal completion inside ctx.GetTag("…") / ctx.SetVirtualTag("…") delegates to IScriptTagCatalog (replaces scadabridge's Parameters["…"]/Attributes["…"]/Children["…"] cases).
  5. Diagnostics (debounced ~500 ms client-side) = union of:
    • Roslyn compile diagnostics (errors + warnings) on the wrapped compilation.
    • ForbiddenTypeAnalyzer violations → error markers.
    • DependencyExtractor dynamic-path rejections → error markers with the offending span. (These three are precisely what publish enforces.)
  6. Hover / signature help / format / inlay hints. Ported near-verbatim from scadabridge's Roslyn logic; the scadabridge domain-specific string-literal branches are replaced/removed.

Registered scoped (the catalog is scoped over the DB context); metadata references from ScriptSandbox are static and cached. A small memory cache (per scadabridge) backs repeated analysis of identical source.

IScriptTagCatalog

New scoped service. GetPathsAsync(string? filter, CancellationToken) → distinct configured tag + virtual-tag paths from OtOpcUaConfigDbContext, used for string-literal completion. Global (scripts are not equipment-scoped). The endpoint passes the current literal prefix as filter to bound result size.

HTTP endpoints

ScriptAnalysis/ScriptAnalysisEndpoints.csMapScriptAnalysisEndpoints() maps a /api/script-analysis group, gated by the AdminUI's design/write authorization policy:

Route Request Purpose
POST /diagnostics DiagnoseRequest Roslyn + ForbiddenType + dynamic-path markers
POST /completions CompletionsRequest scope/member + tag-path completion
POST /hover HoverRequest type/quick-info markdown
POST /signature-help SignatureHelpRequest parameter help
POST /format FormatRequest NormalizeWhitespace() formatting
POST /inlay-hints InlayHintsRequest inline type hints

DTOs ported from scadabridge's ScriptAnalysisContracts.cs minus the ScriptKind enum (OtOpcUa has a single script kind: virtual-tag).

Front-end

  • Components/Shared/MonacoEditor.razor — reusable: [Parameter] Value, ValueChanged, Height, ReadOnly, ShowToolbar, theme toggle (vs/ vs-dark), Format button. Creates the editor in OnAfterRenderAsync, two-way binding via DotNetObjectReference (OnValueChangedValueChanged), external Value changes pushed via setValue, dispose on teardown, GUID-keyed for multi-instance safety.
  • wwwroot/js/monaco-init.js — ported loader + window.MonacoBlazor with createEditor/setValue/getValue/setMarkers/setEditorOption/format/dispose, and the six Monaco language providers POSTing to /api/script-analysis/* (diagnostics debounced).
  • Vendored Monaco under wwwroot/lib/monaco/vs/…, pulled in by a single <script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"> in App.razor. The CDN monaco-loader.js + the eval/Task.Delay injection in ScriptEdit.razor are removed.

Wire-in points

  • ScriptEdit page (/scripts/{id}): replace <InputTextArea> with <MonacoEditor @bind-Value="_form.SourceCode" Height="…">. Save path unchanged (SHA-256 on save). The old OnAfterRenderAsync JS-injection block is deleted.
  • VirtualTagModal: below the Script dropdown, a collapsible "Script source" panel that loads the selected script's SourceCode into a MonacoEditor, shows a "Used by N virtual tags — edits affect all of them" notice (count from the config DB), and a dedicated Save script button performing a RowVersion-guarded update of the Script row — a separate concurrency unit from the virtual-tag Create/Save. Creating a brand-new script inline is a follow-up, not v1.

Data flow (a keystroke)

  1. User types in Monaco → onDidChangeModelContentValueChanged updates the Blazor model; debounce schedules a diagnostics fetch.
  2. Provider POSTs { source, line, column } to /api/script-analysis/*.
  3. ScriptAnalysisService wraps the source, compiles, computes the result at the mapped offset (consulting IScriptTagCatalog for path literals), returns DTOs.
  4. JS provider maps DTOs to Monaco completions/markers/hover and renders them.

Error handling

  • CDN/JS load failure: with vendoring there is no network dependency; if the vendored asset is somehow missing, the editor surfaces a clear init error rather than silently degrading. (No more silent textarea fallback masking a broken editor.)
  • Analysis exceptions: each endpoint is defensive — a malformed request or an internal Roslyn failure returns an empty result (no completions/markers) rather than 500-ing the editor; logged server-side.
  • Concurrency: modal script Save uses RowVersion; a conflict surfaces the standard "changed by someone else, reload" message (existing pattern).
  • Auth: endpoints require the AdminUI design/write policy; read-only users get the editor read-only.

Testing

No bUnit (established AdminUI pattern — razor/JS proven only by live /run).

  • ScriptAnalysisService (unit, xUnit + Shouldly): member completion after ctx.; scope completion; tag-path completion inside GetTag/SetVirtualTag (via a fake IScriptTagCatalog); hover on ctx.GetTag; signature help; format round-trip; diagnostics overlays — a clean script (no markers), a Roslyn error, a ForbiddenTypeAnalyzer violation (e.g. HttpClient), and a dynamic-path rejection (ctx.GetTag(someVar)); cursor-offset/#line mapping (marker lands on the right user-source line).
  • IScriptTagCatalog (unit, in-memory EF — Configuration.Tests pattern): returns distinct tag + virtual-tag paths; honors the filter prefix.
  • Live docker-dev /run: on /scripts/{id}ctx. shows members; bad code shows a red squiggle; ctx.GetTag(" lists tag paths; Format works; theme toggle. Then the VirtualTagModal — open it, pick a script, edit the inline panel, see the "used by N" notice, save, reopen to confirm persistence.

Done = build clean + dotnet test green + live /run pass.

Out of scope (follow-ups)

  • Creating a brand-new Script inline from the VirtualTagModal.
  • Per-virtual-tag return-type-aware diagnostics (analysis uses object return).
  • Embedding the editor in any surface beyond ScriptEdit + VirtualTagModal.
  • Sharing the analysis backend as a cross-repo package with scadabridge (this is a copy-and-adapt fork; the context models differ).

Hard rules (carried from prior AdminUI work)

  • Stage by path — never git add .; never stage sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no force-push; no --no-verify.
  • The agent does not sign in to the AdminUI — the user signs in for live verification.
  • No Configuration entity/migration change is required by this feature (the catalog reads existing tables; Script/VirtualTag schemas are unchanged).