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

286 lines
16 KiB
Markdown

# 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:
```csharp
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.Now` → `DateTime`
- `ctx.Logger` → `Serilog.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.ScriptId` → `Script.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 `DataType`s. 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.cs` — `MapScriptAnalysisEndpoints()`
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` (`OnValueChanged` → `ValueChanged`),
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 → `onDidChangeModelContent` → `ValueChanged` 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).