commit 3753c6bc37f8bb0c64186bf4ee4ce2c5adb7bb1c Author: flemming-it Date: Mon May 25 14:31:08 2026 +0200 feat: v0.1.0 — Studio Plugin SDK First release of the SDK that Studio plugins depend on. What v0.1 ships: - WIT contract under wit/ (studio-plugin.wit + deps/platform/world.wit, both verbatim from fai/platform/wit/). - wit-bindgen 0.36 generate! call with pub_export_macro enabled so the emitted __export_studio_plugin_impl! macro is reachable from downstream plugin crates. - export_plugin!(MyType) convenience macro that wraps the bindgen-generated impl macro. - README documenting usage + v0.1 limits (single-world -> all-three-hooks-required, no proc-macro yet, edition 2021 pinned by wit-bindgen output). What v0.1 doesn't yet do: - Plugins still declare all three Guest impls (theme, translate, output-view). v0.2 splits the WIT into one world per hook. - No #[plugin(...)] proc-macro. v0.2 wraps generate! + Guest impl generation into a single attribute. - Host-side: the hub doesn't yet load studio-plugin-world components or expose InvokePlugin RPC — that's the next push. Validated against the in-tree studio-theme-solarized plugin: 22 KiB stripped .wasm artefact, valid Component Model, passes wasm-tools component wit inspection. Signed-off-by: flemming-it diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcbc4ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +**/*.rs.bk +.DS_Store +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ae2a806 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,57 @@ +# fai-studio-plugin-sdk +# +# Ergonomic wrapper around the WIT bindings declared in +# `wit/studio-plugin.wit`. Studio plugins depend on this crate +# rather than dealing with raw wit-bindgen output. v0.1 ships +# WITHOUT the `#[plugin(...)]` proc-macro: plugins write the +# `export!()` invocation themselves with the structs the SDK +# provides. The macro lands in v0.2 once the contract is +# stable enough. +# +# Build a plugin: +# +# cargo build --release --target wasm32-wasip2 +# +# Use: +# +# use fai_studio_plugin_sdk::{export_plugin, theme, ColorScheme, +# PluginError}; +# +# struct MyTheme; +# impl theme::Guest for MyTheme { +# fn theme_for(brightness: String) +# -> Result { +# ... +# } +# } +# +# export_plugin!(MyTheme with_types_in fai_studio_plugin_sdk::bindings); + +[package] +name = "fai-studio-plugin-sdk" +version = "0.1.0" +# edition 2021 for now — wit-bindgen 0.36 emits the older +# `#[link_section]` / `unsafe`-attribute syntax that the +# edition-2024 compiler rejects. Bump to 2024 when moving +# to a wit-bindgen version with edition-2024-compatible +# codegen. +edition = "2021" +authors = ["Dr. Stefan Flemming "] +license = "Apache-2.0" +publish = false +description = "F∆I Studio Plugin SDK — Rust wrapper for the studio-plugin WIT" +repository = "https://git.flemming.ai/fai/studio-plugin-sdk" +rust-version = "1.85" + +[lib] +crate-type = ["rlib"] + +[dependencies] +wit-bindgen = "0.36" + +[package.metadata.component] +package = "fai:studio-plugin-sdk" + +[package.metadata.component.target] +path = "wit" +world = "studio-plugin" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bceff8 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# fai-studio-plugin-sdk + +Rust SDK for writing F∆I Studio plugins. + +## Status + +**v0.1 builds.** Studio plugins targeting the `studio-plugin` +world declared in `wit/studio-plugin.wit` can be compiled to +`wasm32-wasip2` against this crate today. The proof-of- +contract plugin is `studio-theme-solarized` in the +fai-modules namespace. + +What's NOT yet implemented: + +- **Hub side**: a Component-Model loader path for + `studio-plugin`-world components + the `InvokePlugin` + gRPC RPC that Studio talks to. Tracked in + `fai/platform/docs/advanced/studio-plugin-sdk.md`. +- **Studio side**: `plugin_host.dart` that discovers + installed `studio.*` capabilities and dispatches. +- **Per-hook worlds**: today's v0.1 ships a single world + (`studio-plugin`) that exports all three hooks + (`theme`, `translate`, `output-view`). A plugin only + caring about one hook still has to declare empty / + declined impls for the other two. v0.2 splits into one + world per hook. +- **`#[plugin(...)]` proc-macro**: v0.1 ships a plain + `export_plugin!` macro that wraps the wit-bindgen-emitted + `__export_studio_plugin_impl!`. The proc-macro that + generates the WIT bindings + the `Guest` impls in one + pass arrives in v0.2. + +## Usage + +```toml +# Cargo.toml of your plugin +[package] +name = "my_studio_plugin" +version = "0.1.0" +edition = "2021" # match SDK edition + +[lib] +crate-type = ["cdylib"] + +[dependencies] +fai-studio-plugin-sdk = { git = "https://git.flemming.ai/fai/studio-plugin-sdk", branch = "main" } +``` + +```rust +// src/lib.rs of your plugin +use fai_studio_plugin_sdk::{bindings, export_plugin}; + +type ColorScheme = bindings::fai::studio_plugin::plugin_types::ColorScheme; +type PluginError = bindings::fai::studio_plugin::plugin_types::PluginError; +type RenderedOutput = bindings::fai::studio_plugin::plugin_types::RenderedOutput; + +struct MyPlugin; + +impl bindings::exports::fai::studio_plugin::theme::Guest for MyPlugin { + fn theme_for(brightness: String) -> Result { + // Return a ColorScheme or PluginError::Declined. + } +} + +// v0.1: every plugin must declare all three hooks. Plugins +// that don't actually serve a hook return Declined. +impl bindings::exports::fai::studio_plugin::translate::Guest for MyPlugin { + fn translate(_: String, _: String, _: String) -> Result { + Err(PluginError::Declined("not a translate plugin".to_string())) + } +} +impl bindings::exports::fai::studio_plugin::output_view::Guest for MyPlugin { + fn render(_: String, _: Vec) -> Result { + Err(PluginError::Declined("not a view plugin".to_string())) + } +} + +export_plugin!(MyPlugin); +``` + +Then: + +```bash +cargo build --release --target wasm32-wasip2 +fai pack . -o my-plugin-0.1.0.fai +``` + +## WIT layout + +``` +wit/ +├── studio-plugin.wit # main world, fai:studio-plugin@0.1.0 +└── deps/ + └── platform/ + └── world.wit # fai:platform@1.0.0 (host imports) +``` + +Both files are verbatim copies of the authoritative versions +in `fai/platform/wit/`. When the contract changes upstream, +re-copy here in lock-step. + +## Why edition 2021 + +wit-bindgen 0.36 emits `#[link_section]` / unsafe-attribute +syntax that the edition-2024 compiler rejects. Bump when +moving to a wit-bindgen version with edition-2024-compatible +codegen. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..32add0c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86" +targets = ["wasm32-wasip2"] +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bc890c5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,104 @@ +//! `fai-studio-plugin-sdk` — ergonomic wrapper around the +//! Studio-plugin WIT contract. +//! +//! Studio plugins are WASM components targeting the +//! `studio-plugin` world declared in `wit/studio-plugin.wit` +//! (a verbatim copy of `fai/platform`'s authoritative file). +//! They export between one and three hooks — `translate`, +//! `output-view`, `theme` — and import the same `host` +//! interface every flow module uses for structured logging. +//! +//! # SKELETON +//! +//! v0.1 of this crate gives plugins: +//! +//! * `wit_bindgen!`-generated bindings re-exported under +//! [`bindings`] so plugins reference one stable name. +//! * Ergonomic re-exports for the most-used types +//! ([`ColorScheme`], [`PluginError`], [`RenderedOutput`]). +//! * An `export_plugin!` macro that hides the `export!` +//! incantation. v0.1 takes a single struct that +//! implements one or more `Guest` traits; v0.2 will +//! turn this into a proper proc-macro +//! (`#[plugin(theme, translate)]`). +//! +//! What v0.1 deliberately does NOT yet do: +//! +//! * Generate any glue for the *host* side (the hub still +//! needs a dedicated component loader for +//! `studio-plugin`-world components — that lands as part +//! of the `InvokePlugin` RPC implementation). +//! * Provide testing helpers. Plugins author their own +//! unit tests against the trait impls directly. + +#![allow(missing_docs)] + +/// Raw bindings generated from the WIT contract. Plugins +/// access types through the convenient re-exports below in +/// most cases; this module is here for when they need the +/// underlying definitions (e.g. to implement multiple Guest +/// traits in one struct). +pub mod bindings { + wit_bindgen::generate!({ + world: "studio-plugin", + path: "wit", + // Generate bindings for the entire WIT package + // (incl. fai:platform/host that studio-plugin imports + // for log emission), so plugins don't need a separate + // `with: …` mapping. + generate_all, + // Export the `export!` macro publicly so downstream + // plugin crates can invoke it via the SDK's + // `export_plugin!` wrapper without each plugin + // re-running `wit_bindgen::generate!()` against a + // copy of the WIT files. + pub_export_macro: true, + }); +} + +// v0.1: plugin authors reference paths inside `bindings::` +// directly. The typical ones are: +// +// bindings::exports::fai::studio_plugin::theme::Guest +// bindings::exports::fai::studio_plugin::translate::Guest +// bindings::exports::fai::studio_plugin::output_view::Guest +// bindings::fai::studio_plugin::plugin_types::ColorScheme +// bindings::fai::studio_plugin::plugin_types::PluginError +// bindings::fai::studio_plugin::plugin_types::RenderedOutput +// +// Stable re-exports under terser names land in v0.2 — they +// have to wait for the proc-macro that materialises the +// `Guest` impls anyway. + +/// Convenience constructor: declare a plugin component by +/// pointing this macro at the struct that implements the hook +/// `Guest` traits. +/// +/// ```ignore +/// use fai_studio_plugin_sdk::{export_plugin, bindings}; +/// +/// struct MyTheme; +/// impl bindings::exports::fai::studio_plugin::theme::Guest for MyTheme { +/// fn theme_for(b: String) +/// -> Result +/// { +/// // ... +/// } +/// } +/// +/// export_plugin!(MyTheme); +/// ``` +/// +/// One struct can implement multiple `Guest` traits — the SDK +/// does not require a separate struct per hook. +/// +/// Under the hood this resolves to the `__export_studio_plugin_impl!` +/// macro that `wit_bindgen::generate!(pub_export_macro: true)` +/// emitted at the crate root. +#[macro_export] +macro_rules! export_plugin { + ($struct:ident) => { + $crate::__export_studio_plugin_impl!($struct with_types_in $crate::bindings); + }; +} diff --git a/wit/deps/platform/world.wit b/wit/deps/platform/world.wit new file mode 100644 index 0000000..9a40fbf --- /dev/null +++ b/wit/deps/platform/world.wit @@ -0,0 +1,99 @@ +// ============================================================= +// FROZEN. Wire contract between hub and modules. +// ============================================================= +// +// This file is the stable wire contract between the F∆I Hub +// and any module compiled against it. It is **frozen at v1.0**. +// +// Frozen means: +// +// - No removal, rename, or signature change of any existing +// interface, function, type, field, or variant case. +// - Additive changes (new types, new variant cases at the end, +// new fields with stable defaults) require a minor bump +// (1.0 -> 1.1) and a coordinated review. +// - Any breaking change requires a major bump (1.0 -> 2.0) +// and a parallel-world transition plan. +// +// The fai_runtime crate ships a snapshot test that fails if +// this file changes without an intentional, reviewed update. +// See `crates/fai_runtime/tests/wit_freeze.rs`. +// +// Module authors do NOT depend on this file directly. They +// use `fai-module-sdk`, which wraps these bindings behind a +// stable, ergonomic surface. That is what protects modules +// from any future evolution of this contract. + +package fai:platform@1.0.0; + +/// Types that flow between the host (hub) and modules. +interface types { + /// A typed value passed between the host and a module. + variant payload { + /// Plain UTF-8 text. + text(string), + /// Arbitrary JSON encoded as a string. + json(string), + /// Raw bytes with a MIME type. + bytes(bytes-value), + /// A reference to a file, by URI. + file-ref(file-ref), + } + + /// Inline byte content. + record bytes-value { + mime-type: string, + data: list, + } + + /// File reference by URI. + record file-ref { + uri: string, + mime-type: string, + size-bytes: u64, + sha256: string, + } + + /// Context passed with every invocation. + record invocation-context { + invocation-id: string, + capability-namespace: string, + capability-name: string, + deadline-ms: u64, + } + + /// Structured error type returned by modules. + variant invocation-error { + invalid-input(string), + permission-denied(string), + resource-exhausted(string), + deadline-exceeded, + internal(string), + } +} + +/// The host-provided interface that modules can import to talk back. +interface host { + /// Log a structured message. Level is "trace", "debug", "info", "warn", "error". + log: func(level: string, message: string); +} + +/// The core interface every module exports. +interface invoke { + use types.{payload, invocation-context, invocation-error}; + + /// Invoke the module with inputs, receive outputs or an error. + invoke: func( + ctx: invocation-context, + inputs: list>, + ) -> result>, invocation-error>; +} + +/// The world a module component targets. +/// +/// Modules `export` the invoke interface to be callable by the hub. +/// Modules `import` the host interface to emit logs. +world module { + import host; + export invoke; +} diff --git a/wit/studio-plugin.wit b/wit/studio-plugin.wit new file mode 100644 index 0000000..28d8f38 --- /dev/null +++ b/wit/studio-plugin.wit @@ -0,0 +1,187 @@ +// ============================================================= +// DRAFT. Studio plugin contract — NOT yet frozen. +// ============================================================= +// +// This file is the v0-draft WIT contract for the Studio plugin +// subsystem described in `docs/advanced/studio-plugins.md`. +// +// Status: draft. Subject to break-change until the first +// in-tree plugin (studio.theme.solarized) round-trips through +// the Studio plugin-host implementation. Once it does, this +// file moves to v0.1 and follows the same freeze-rules as +// `world.wit`. +// +// Why a separate WIT file: +// +// - `world.wit` defines the host-to-module wire for *flow +// modules* (text.extract, llm.chat etc.). Those modules +// speak in payloads + flow capabilities — concepts the +// plugin layer doesn't need. +// - Studio plugins are also WASM modules sandboxed by the +// hub, but their host is the Studio GUI, not the flow +// engine. They want a different surface: theme tokens, +// translation calls, output-view widget specs. +// +// Plugins target the `studio-plugin` world below. The host +// (Studio) instantiates the component, calls the relevant +// hook function per declared `studio_extension.hook` in +// module.yaml, and renders / wires the result. + +package fai:studio-plugin@0.1.0; + +// ============================================================= +// Shared types +// ============================================================= + +interface plugin-types { + /// What kind of hook the plugin satisfies. The hub reads + /// this from `module.yaml#studio_extension.hook`. A single + /// plugin module can declare multiple hooks (e.g. a theme + /// plugin that also provides a translation back-end) by + /// exporting more than one of the hook interfaces; the hub + /// matches each exported hook against the declared hook + /// list. + enum hook-kind { + translate, + output-view, + theme, + } + + /// Studio renders in a Material 3 color-scheme. Plugins + /// produce one for each brightness; both are required so + /// the operator's light/dark toggle keeps working. + record color-scheme { + /// "light" or "dark". The plugin returns one scheme per + /// brightness via separate `theme-for` calls. + brightness: string, + /// 32-bit ARGB packed colors. Order matches Material 3: + /// primary, on-primary, secondary, on-secondary, + /// tertiary, on-tertiary, error, on-error, surface, + /// on-surface, surface-variant, on-surface-variant, + /// outline, outline-variant. + /// Studio fills any missing slot from its built-in + /// fallback before applying. + tokens: list, + } + + /// Describes how to render a single flow output. Returned + /// from `output-view.render`. Studio reads `kind` and + /// builds the matching widget tree. + variant rendered-output { + /// Plain text — Studio shows a SelectableText. + text(string), + /// Markdown — Studio shows a Markdown widget with the + /// shared FaiTheme.markdownStyle. + markdown(string), + /// Already-pretty-printed JSON — Studio shows it in a + /// code-block container. + pretty-json(string), + /// PNG / JPEG image data, ready to feed Image.memory. + /// MIME stays out because Flutter sniffs it; the plugin + /// is responsible for delivering a real image format. + image(list), + /// Bag-of-fields the plugin couldn't render but wants + /// passed through. Studio falls back to its built-in + /// renderer. `reason` is operator-visible — explain why + /// the plugin declined. + passthrough(string), + } + + /// Generic error a plugin can return without panicking. + variant plugin-error { + /// The plugin understands the request but refused (e.g. + /// translate called with from==to). Studio falls back + /// to the built-in renderer. + declined(string), + /// The plugin is mis-configured (e.g. translate plugin + /// pointed at an unreachable Ollama endpoint). Operator + /// sees the message in the friendly-error mapper. + misconfigured(string), + /// Generic catch-all. Avoid when one of the above fits. + internal(string), + } +} + +// ============================================================= +// Hook interfaces — each is optional. A plugin exports the +// ones it satisfies; the hub only calls those. +// ============================================================= + +/// `translate` hook. Used by Studio to translate server-supplied +/// English text (MCP tool descriptions, n8n endpoint names, LLM +/// output) into the active locale. The honest `[EN]` badge stays +/// the fallback when no translate plugin is installed or every +/// installed plugin declines. +interface translate { + use plugin-types.{plugin-error}; + + /// Translate `text` from `from-locale` to `to-locale`. + /// BCP-47 codes (e.g. "en", "de", "en-US"). Empty + /// `from-locale` lets the plugin auto-detect; an empty + /// `to-locale` is an error — the host always knows the + /// target locale. + translate: func( + text: string, + from-locale: string, + to-locale: string, + ) -> result; +} + +/// `output-view` hook. Used by Studio's FaiFlowOutput widget +/// to ask a plugin "do you want to render this payload?". The +/// plugin returns either a rendered-output variant or +/// `passthrough` so Studio falls back. Plugins are queried in +/// operator-configured priority order; the first non- +/// `passthrough` answer wins. +interface output-view { + use plugin-types.{rendered-output, plugin-error}; + + /// `mime-type` is the value from the bytes/file payload + /// (e.g. "application/pdf", "image/png"). Empty when the + /// payload kind doesn't carry one (text / json). + /// `data` is the payload's raw content. Text/json payloads + /// arrive UTF-8 encoded; bytes/file payloads stay raw. + /// The plugin decides whether to handle the type. + render: func( + mime-type: string, + data: list, + ) -> result; +} + +/// `theme` hook. Used by Studio's theme selector at startup. +/// The plugin returns one color-scheme per brightness; Studio +/// merges with its built-in fallback for unspecified tokens. +interface theme { + use plugin-types.{color-scheme, plugin-error}; + + /// `brightness` is "light" or "dark". The host calls the + /// function twice at theme-apply time (once per brightness) + /// so plugins can implement both in one place. + theme-for: func(brightness: string) -> result; +} + +// ============================================================= +// World — what a plugin component looks like to the host. +// ============================================================= + +/// A Studio plugin component. Plugins target this world via +/// `cargo build --target wasm32-wasip2` after declaring it in +/// their Cargo.toml's `[package.metadata.component]`. +/// +/// Plugins export only the hooks they implement. The hub +/// probes which exports exist via component-introspection +/// before issuing the matching call — so a plugin that only +/// declares `hook: theme` in module.yaml needs to export only +/// the `theme` interface, not the others. +world studio-plugin { + /// Plugins import the same `host` interface as flow modules, + /// so they can emit structured logs without a separate + /// bridge. Logs flow into the hub's event log. + import fai:platform/host@1.0.0; + + /// Hook exports. Each is optional; the hub introspects the + /// component to learn which the plugin supports. + export translate; + export output-view; + export theme; +}