Skip to main content

Morphir Playground

Status: Active.

This proposal brings the capability of morphir-elm's try-morphir page into this repository and extends it. try-morphir compiles Elm to Morphir IR in the browser and renders the result; the frontend and the absence of a backend are both fixed at build time. The Playground makes both choices at run time, driven by the frontends and targets that installed extensions and builtins actually advertise.

The work spans three repositories. ecosystem/morphir-rust gains the installed-frontend metadata the catalog reads. This repository gains three protocol methods, a playground provider, and the morphir playground command. finos/morphir-ui gains a pipeline service, a playground view, and a shared code editor component.

What this design establishes

A user opens the Playground, picks a frontend, types source, compiles it to Morphir IR, inspects the IR through the existing Insight and XRay views, then picks a target and generates artifacts. The frontend and target lists come from one catalog projected from the CLI's extension registry, which carries built-in and installed providers alike. Target choices incompatible with the selected frontend's IR versions are disabled with the reason shown.

Nothing the Playground does writes to the filesystem. Generated artifacts return in the response and the view offers them as downloads.

Background: what already exists

Four pieces are already built, and the design is shaped by them.

The Morphir Extension Protocol carries everything the pipeline needs. CompileRequest already accepts documents: Vec<SourceDocument> with an options.irVersion, and CompileResult returns {ir, irVersion}, which feeds GenerateRequest {ir, target, options} unchanged. The whole pipeline composes in memory. No step needs a file.

Backend capability metadata is already machine readable without starting an extension. A schema-v2 release record carries a BackendRecord with targets, irVersions, and a generate flag, which is how crates/morphir/src/commands/generate/provider.rs validates a live session against an installed record.

Frontends are not symmetric, and closing that gap is part of this work. Over MEP a frontend negotiates a FrontendCapability carrying languages (each with an id and fileExtensions), irVersions, and a compile flag, but an installed release record stores only the Capability::Frontend marker. The languages and IR versions of an installed frontend are therefore unknowable without launching it. morphir-distribution gains a FrontendRecord so the catalog can list installed frontends without starting anything.

The record carries the part of FrontendCapability that survives being written down: the IR versions, the compile flag, and a LanguageRecord per language carrying that language's id and its file extensions. Keeping the extensions attached to their language is what lets the Playground map an editor buffer to a frontend, and lets the code editor choose a syntax mode, without launching anything. incremental and fragments are not persisted — only a negotiated MEP session reports them — so an installed provider's rebuilt capability snapshot leaves them at their defaults, which the catalog must not read as answers.

The record arrives with index schema version 3, following how version 2 introduced backend metadata. A schema-v3 record that declares the frontend capability must carry frontend metadata, and must not carry it otherwise, which are the rules version 2 already applies to backends. Records at version 1 and 2 stay readable. A frontend from one of those contributes a catalog entry whose languages are undeclared, and the Playground lists it as selectable but unlabeled rather than hiding it. An optional field on version 2 would have avoided the version bump, but it would also contradict the requirement version 2 states for backends, leaving two different rules for the same idea.

The loopback UI host in crates/morphir/src/commands/ui/ serves the web app over a session-authenticated localhost binding, with a JSON-RPC websocket, a SessionManifest, and typed ConnectedMethod dispatch.

finos/morphir-ui carries the connected client and the visualization. Its ConnectedRpcClient.call<A, I>(method, params, schema) is generic over method and Effect Schema and already handles reconnection, notifications, and request size limits. InsightView and XRayView render Morphir IR today.

ADR-0004 closes with the observation that this repository publishes no browser-accessible Morphir UI until finos/morphir-ui ships a hosted web build. The Playground is the first feature that gives that hosted build a reason to exist, which is why the design keeps a browser implementation reachable rather than assuming a CLI is present.

Provider catalog

The Playground does not decide which provider serves a language or a target. crates/morphir/src/extensions.rs builds an ExtensionRegistry carrying the built-in providers plus everything list_installed reports, and that registry resolves a language and IR release to one provider — preferring an installed provider over a built-in offering the same thing, and refusing an ambiguous pair. morphir compile and morphir generate resolve through it, and so does the Playground, so a language the CLI can compile is a language the Playground can compile.

morphir.playground.catalog is therefore a projection, not a second source of truth. It walks ExtensionRegistry::providers() and reports, per language and per target, what that provider advertises: the language id and its file extensions, the IR versions, the compile, incremental, and fragments flags for a frontend, the generate flag for a backend, and the provider's id, name, version, origin, and invocation mode. Reading installed frontend metadata without launching anything depends on the FrontendRecord described above.

incremental and fragments are nullable, because that FrontendRecord cannot carry them. The registry says which case it is: ProviderMetadata::capability_metadata_scope() reports Complete for a provider whose snapshot came from the provider itself, and PersistedFrontendBackend for one rebuilt from installed state. The catalog reports a boolean only under the first, and null under the second, so a picker can tell a capability the extension refuses from one the session never learned about. The key is always present, so the client decodes it as a nullable boolean rather than an optional one.

The catalog's entries are ordered installed-before-built-in, matching the precedence the registry applies when it resolves, so the picker never names a provider that a compile would not use.

The Playground offers built-ins. They are registered in the same registry as installed providers and invoked through the same boundary, and a Playground that could not compile the one language the CLI ships support for would be useless on a machine with nothing installed.

Whether a frontend's IR versions intersect a target's is a question the view asks so it can disable an impossible pair and say why. Both version lists are in the catalog, so the view computes it; the host does not need a pairing function of its own, because the registry re-checks the pair when it resolves.

Invocation

Compile and generate go through crate::extensions::invoke_frontend and invoke_backend — the same functions morphir compile and morphir generate call. The registry decides the invocation mode: a built-in runs its typed native handle in process, an installed provider is activated and driven over MEP. The Playground adds no invocation path of its own, which is what keeps "the Playground can run it" equal to "the CLI can run it".

One extension instance per invocation, for now. Reusing a negotiated session across requests needs a session actor in morphir-daemon, because Session::invoke consumes the session and returns it inside InvokeOutcome, so a pool built on a mutex and an Option has an empty-slot failure mode on every error path. That actor is a separate change in ecosystem/morphir-rust and the Playground picks it up when it lands; until then a browser pays extension startup per compile, which is acceptable for a scratch surface and free for the in-process built-ins.

Every invocation carries a wall-clock timeout. WASM extensions have ResourceLimits covering memory, time, and fuel, but a process-backed frontend is an ordinary child process with no bound, and a stuck extension in a Playground means a dead view with no explanation. A lapsed timeout returns a diagnostic on a well-formed result rather than failing the call.

Connected protocol

Three methods join ConnectedMethod in crates/morphir/src/commands/ui/protocol.rs.

MethodParamsResult
morphir.playground.catalog{}{frontends, targets}
morphir.playground.compile{languageId, documents, package, irVersion, options}{success, irVersion, ir, diagnostics, modules}
morphir.playground.generate{ir, irVersion, target, options}{success, artifacts, diagnostics}

The compile parameters are CompileRequest without dependencies, using the same field names, so the provider builds the protocol request by adding an empty dependency list rather than translating between shapes. Generate adds irVersion so the provider can resolve a backend without sniffing the IR.

CONNECTED_PROTOCOL_VERSION goes to 2. The web assets are built from finos/morphir-ui and checked in under ui/assets/, so the host and the app ship together and SessionManifest::validate rejecting a mismatch is the behavior we want. No older client is deployed anywhere to strand.

Session capabilities

A new trait, PlaygroundCapability, sits beside WorkspaceCapability rather than extending it. The two have different lifetimes: a workspace provider binds to a directory when the host binds, and the playground provider holds no filesystem state at all.

BoundUiHost::bind currently takes one workspace provider and always builds a manifest containing it. It takes a record instead:

#[derive(Default)]
pub struct SessionCapabilities {
pub workspace: Option<Arc<dyn WorkspaceCapability>>,
pub playground: Option<Arc<dyn PlaygroundCapability>>,
pub initial_view: Option<InitialView>,
}

Adding a capability later is one field and no call site changes, and each call site names what it provides instead of passing a positional None. Two behaviors follow from the record. Manifest construction folds over the capabilities present rather than assembling a vector by hand, and a request for a capability that is absent returns a structured error saying so. The host assumes a workspace provider always exists today, so there is no way to give that answer at all.

The playground provider gets its own ProviderManifest entry advertising the three methods, alongside the workspace provider's entry.

A request for an absent capability answers with JSON-RPC code -32013, the value the extension protocol uses for the same condition. A request that reached a provider and failed there answers with -32603, so the view can tell a crashed extension from a control it should disable without matching on message text. -32602 stays reserved for a request rejected before any provider ran.

Command surface

morphir playground [--no-open] reuses BoundUiHost unchanged: the same loopback binding, the same launch token exchange, the same embedded assets. It passes no workspace capability and a launch URL that routes to the Playground view.

morphir ui passes both capabilities and lists the Playground in its navigation. When the workspace capability is absent, the manifest says so and the app disables workspace navigation, which is the mechanism the shell already uses for optional capabilities.

Both of those arrive with the client that can use them, not before. The CLI serves a pre-built web bundle checked in under crates/morphir/src/commands/ui/assets/, and that bundle validates the session manifest against a schema requiring every provider it lists to advertise all four core workspace capabilities at version 1. A playground provider advertises morphir/playground/* instead, so a manifest carrying one is rejected whole — the client does not ignore the provider it does not understand, it refuses the session. Attaching the playground provider to morphir ui therefore breaks morphir ui, and a playground-only session, whose sole provider is that one, can never connect at all.

So the server side lands first and stays dormant: the protocol methods, SessionCapabilities, PlaygroundCapability, the provider, the JSON-RPC dispatch and the #/playground launch redirect are all present and tested, but nothing in the CLI hands a playground provider to a session. The morphir playground command, the morphir ui navigation entry and the CONNECTED_PROTOCOL_VERSION bump land together with the vendored bundle that understands them. Server and client ship in one artifact here, so the manifest contract is only ever as wide as the bundle sitting next to it.

Landing on the Playground rather than the workspace is a routing question, and the app has no routing today: its shell route is held in memory and nothing reads the URL. So morphir-ui gains a hash router, and every view becomes addressable. morphir playground then needs no special protocol at all: the loopback host redirects the launch-token exchange to #/playground and the router does the rest. Hash routing rather than paths, because the CLI serves the app's assets directly and a path route would need a single-page fallback there and equivalent rewrite rules on whatever static host later serves the same build. A shareable link to a view falls out of this rather than being designed for.

Pipeline service

packages/morphir-workspace/src/connected.ts gains the three method names alongside the existing CONNECTED_METHODS map, plus Effect Schemas for the catalog and the two results. That module is the TypeScript mirror of the Rust protocol types, and every response decodes through Schema.decodeUnknownSync, so a host and app that disagree fail at the boundary instead of somewhere downstream.

@morphir/ui gains a PipelineService tag with catalog, compile, and generate, each returning an Effect that fails with WorkbenchError. The connected implementation wraps ConnectedRpcClient.call. No new transport code is needed.

AppServices.capabilities gains a pipeline flag so the shell can disable the Playground entry when no pipeline provider is present, matching how it handles the GitHub capability. WorkbenchError already carries an unsupported-capability code, which is what the absent capability path returns.

Playground view

views/playground/PlaygroundView.svelte lays out three regions using the existing RegionPanel and ResizeHandle: a source editor with a frontend picker, results as Insight, XRay, and IR JSON tabs, and a target picker with an artifacts pane. InsightView and XRayView are reused unmodified, fed through decodeMorphirIr and toWorkspaceIr from @morphir/ir, which is the path the connected workspace provider already uses. State lives in playground-state.svelte.ts following the existing runes pattern.

The frontend picker lists each frontend with its providing extension. Selecting one disables targets whose IR versions do not intersect, showing the reason rather than hiding the option.

Compiling is an explicit action. try-morphir recompiled on every keystroke because it compiled in process and returned immediately. Here a compile may start a process backed extension. Debounced automatic compilation becomes reasonable once the session actor is in place and its effect on latency is measured.

The editor holds a list of documents from the start, matching CompileRequest.documents, and the first version shows a single tab. Adding tabs later is view work with no protocol change.

Documents and selections persist through the existing ConfigService, so a reload does not lose work.

Code editor component

packages/morphir-ui/src/components/editor/CodeEditor.svelte is the only module permitted to import an editor library. It takes a bindable value, a languageId, an optional readOnly flag, a list of diagnostics, and a change handler.

The first implementation uses CodeMirror 6. Monaco or another editor can replace it, and three rules keep that a contained change.

languageId is the catalog's language id, not an editor mode name. A language-modes.ts registry maps catalog ids to editor extensions. PlaygroundView passes through what the catalog gave it and never learns how the editor names its modes.

Diagnostics keep the protocol's shape. SourceLocation is a URI and a zero based start and end position, which is the same shape LSP uses, and both CodeMirror and Monaco map from it directly. No editor specific diagnostic type appears in any signature.

An ESLint no-restricted-imports rule bans editor library imports outside components/editor/. Without it the boundary decays the first time someone needs one editor specific feature.

The component ships in packages/morphir-ui rather than under the playground view, because the IR explorer and any future source diff view want it too.

Errors and diagnostics

Compilation problems are data, not failures. The protocol returns CompileResult {success, diagnostics} rather than failing the call, so a compile that finds twelve type errors is a successful request carrying twelve diagnostics. The view renders them as a list and as inline editor markers.

That leaves four failure classes, and the view distinguishes them.

ClassOriginPresentation
Source diagnosticsCompileResult.diagnosticsInline markers and a list, not an error state
Capability absentMissing SessionCapabilities fieldunsupported-capability; the control is disabled with the reason
Extension failedA provider failure or a lapsed timeoutA banner naming the provider; selections are kept
Host disconnectedSocket closeThe existing provider-disconnected path and reconnect

The third class is why nothing is cached across a failure. A crashed extension leaves nothing behind, and the next compile starts a replacement, so the user sees a banner rather than a view that no longer responds.

Testing

Tests come before implementation, following the repository standard.

The provider is tested against registries it is handed, using extension doubles registered through the same NativeExtension path the built-in Gleam provider uses, so a test exercises real resolution rather than a parallel one. Coverage includes an unknown target and an incompatible IR version answered without reaching an extension, a compile that returns diagnostics rather than failing the call, and a timeout that returns a diagnostic.

The protocol gets serde round trip tests per method, mirrored by Effect Schema decode tests over the same JSON fixtures. Sharing the fixtures is what catches drift. Two suites asserting their own separate beliefs would not.

The no-write promise, the one most likely to regress without anyone noticing, is held by censusing three directories across a real invocation: the directory extensions are started in, the process working directory, and the Morphir home. One of those tests drives the built-in Gleam frontend and backend, which write a parse-stage tree unless told not to, so the guard is watching an invocation that would fail it.

The view is tested against a fake PipelineService layer, and CodeEditor is tested against its own contract rather than through CodeMirror, so a future replacement has a specification to satisfy.

End to end, morphir playground --no-open is driven through the launch token exchange to compile a sample and generate a target, asserting the artifacts come back in the response.

Delivery sequence

ecosystem/morphir-rust carries the FrontendRecord in morphir-distribution, which lands there first because the catalog cannot report an installed frontend's languages without it. The session actor is a later, independent change; nothing here waits on it.

Development does not have to wait on a pin. crates/morphir/Cargo.toml declares the morphir-rust crates as path dependencies into the submodule, so a change in morphir-daemon is visible to the CLI build immediately and both sides can be written in one worktree. Sequencing applies to merging: the morphir-rust pull request merges, the pin here advances, then the CLI change merges against the advanced pin.

The protocol methods, SessionCapabilities, and the playground provider follow. The finos/morphir-ui work is gated only by the protocol shape, so the schemas and pipeline service can start as soon as the method definitions are settled.

What this design excludes

try-morphir also evaluates functions through a value editor and steps through type inference. Neither is in scope here. Evaluation needs an evaluator reachable from the view, and @morphir/insight is a static IR to view transform today. Type inference stepping is built on morphir-elm's Morphir.Type.Infer and has no equivalent for other frontends, so it fits poorly beside a frontend picker.

Future work: browser execution

The PipelineService tag is the seam that lets the Playground run without a CLI. A browser implementation of the same three operations, executing WASM extensions in the page, makes the Playground work as a hosted static page and closes the gap ADR-0004 recorded. finos/morphir-ui already reserves the provider id browser-local for this case, and the extension architecture already separates direct in-process execution from actor hosted execution for exactly this reason. The Playground view depends only on the tag, so this becomes a layer swap rather than a rewrite.

Only browser capable extensions can be offered in that mode. Process backed providers stay available in the connected mode.