From 4f8a2eef06cd09a35494658e6e752cb23426ee69 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 23 Jun 2026 11:15:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20browser=20cloud=20nodes=20+=20emit?= =?UTF-8?q?/effects=20contracts=20(ADR-0007=E2=80=930009)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interlocking architecture changes that had accumulated uncommitted on main; they share files (context.rs, runtime/mod.rs, CONTEXT.md) so they land together. ADR-0007 — node wire-interface (emit) contract: - Catalog impls[] carry emits[] beside ports[]; Rust gains Component::emits() with compile-checked const handles (no raw emit("…") literals); the implicit "value" is centralized as ComponentBase::VALUE_HANDLE. - Codegen emits COMPONENT_EMITS/EmitOf into _base.types.ts; handle.tsx SourceProps.id is EmitOf. - Live guard tests/catalog_parity.rs asserts Rust ports()/emits() ≡ node-components.json both directions (replaces the dead build.rs port check). Caught 9 dead output handles, resolved. ADR-0008 — effects apply policy: - Canonical order (outbound_bytes → cancellations → wakeups → cloud_requests → component_events) lives once in core as Effects::apply + EffectsSink; desktop Actor delegates, browser FlowReactor mirrors (effects-sink.ts), both conformance-tested. ADR-0009 — cloud as a sans-IO Effect; cloud nodes run in both hosts: - Mqtt/Llm/Figma nodes + POD configs relocated desktop → microflow-core (runtime/cloud/* gated `cloud`; config/* ungated); registered in register_all like any built-in. Deleted the host-injection machinery (register_factory/register_node/register_cloud + desktop register_cloud_nodes + the desktop runtime/cloud/ module). - A node's dispatch records a CloudRequest; the host's EffectsSink::perform_cloud performs it: desktop CloudPerformer (rumqttc/reqwest), browser FlowReactor. - Browser cloud: wasm enables `cloud` + exposes injectEvent / subscriberWirings / deliverMessage. LLM via fetch (mirrors HttpLlmProvider, latest-wins abort); MQTT/Figma via mqtt.js over WSS with a per-broker connection manager + a pure subscription reconcile (mirrors the desktop flow_update dedup/diff) + the Figma uid handshake. Provider/broker resolve from their Zustand stores; display topics feed the figma store via a platform-agnostic ingestMqttMessage. - D4 signed off (user-entered/keyless, direct-by-default). Phase 4 (CORS proxy) declined — browser cloud is direct-only; users allowlist microflow on their own endpoints. Verified: cargo clippy --workspace --all-targets -- -D warnings -W clippy::pedantic clean; cargo test --workspace 440 passed; bun test 151; tsc 0; vite production build OK (mqtt bundles, no polyfills). Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 95 +++- apps/web/node-components.json | 119 ++++- apps/web/package.json | 1 + apps/web/scripts/codegen-node-registry.ts | 43 ++ apps/web/src-tauri/Cargo.toml | 2 +- apps/web/src-tauri/build.rs | 86 +-- apps/web/src-tauri/src/runtime/cloud/llm.rs | 421 --------------- apps/web/src-tauri/src/runtime/cloud/mod.rs | 89 ---- apps/web/src-tauri/src/runtime/host.rs | 496 +++++++++++------- apps/web/src-tauri/src/runtime/mod.rs | 15 +- apps/web/src-tauri/tests/catalog_parity.rs | 95 ++++ apps/web/src/components/flow/handle.tsx | 23 +- .../flow/nodes/_base/_base.types.ts | 57 ++ .../src/components/flow/nodes/figma/figma.tsx | 2 - .../web/src/components/flow/nodes/led/led.tsx | 2 +- .../components/flow/nodes/matrix/matrix.tsx | 2 +- .../web/src/components/flow/nodes/rgb/rgb.tsx | 2 +- .../src/components/flow/nodes/servo/servo.tsx | 2 +- .../components/flow/nodes/stepper/stepper.tsx | 14 - .../components/flow/nodes/trigger/trigger.tsx | 2 +- .../firmata/__tests__/effects-sink.test.ts | 79 +++ .../lib/firmata/__tests__/llm-client.test.ts | 83 +++ .../__tests__/mqtt-subscriptions.test.ts | 90 ++++ apps/web/src/lib/firmata/board-controller.ts | 29 +- apps/web/src/lib/firmata/cloud/llm-client.ts | 72 +++ apps/web/src/lib/firmata/cloud/mqtt-client.ts | 78 +++ .../lib/firmata/cloud/mqtt-subscriptions.ts | Bin 0 -> 3810 bytes apps/web/src/lib/firmata/effects-sink.ts | 46 ++ apps/web/src/lib/firmata/flow-reactor.ts | 257 ++++++++- apps/web/src/lib/runtime/wasm.ts | 15 + apps/web/src/stores/figma.ts | 35 +- bun.lock | 11 +- crates/microflow-core/src/config/figma.rs | 43 ++ crates/microflow-core/src/config/llm.rs | 43 ++ crates/microflow-core/src/config/mod.rs | 6 + crates/microflow-core/src/config/mqtt.rs | 38 ++ .../src/runtime/cloud/figma.rs | 227 +++----- .../microflow-core/src/runtime/cloud/llm.rs | 221 ++++++++ .../microflow-core/src/runtime/cloud/mod.rs | 55 ++ .../microflow-core}/src/runtime/cloud/mqtt.rs | 191 +++---- .../microflow-core/src/runtime/component.rs | 26 +- crates/microflow-core/src/runtime/context.rs | 232 +++++++- .../src/runtime/control/counter.rs | 4 + .../src/runtime/control/delay.rs | 10 +- .../src/runtime/control/trigger.rs | 8 +- .../src/runtime/generator/constant.rs | 6 +- .../src/runtime/generator/interval.rs | 10 +- .../src/runtime/generator/oscillator.rs | 6 +- .../src/runtime/input/button.rs | 25 +- .../src/runtime/input/hotkey.rs | 19 +- .../src/runtime/input/i2c_device.rs | 6 +- .../src/runtime/input/motion.rs | 12 +- .../src/runtime/input/proximity.rs | 4 + .../src/runtime/input/sensor.rs | 4 + .../src/runtime/input/switch.rs | 14 +- crates/microflow-core/src/runtime/mod.rs | 39 +- .../microflow-core/src/runtime/output/led.rs | 5 + .../src/runtime/output/matrix.rs | 2 + .../src/runtime/output/monitor.rs | 4 + .../src/runtime/output/piezo.rs | 2 + .../src/runtime/output/pixel.rs | 14 +- .../src/runtime/output/relay.rs | 4 + .../microflow-core/src/runtime/output/rgb.rs | 4 + .../src/runtime/output/servo.rs | 4 + .../src/runtime/output/stepper.rs | 2 + crates/microflow-core/src/runtime/registry.rs | 48 +- .../src/runtime/transformation/calculate.rs | 4 + .../src/runtime/transformation/compare.rs | 9 +- .../src/runtime/transformation/function.rs | 4 + .../src/runtime/transformation/gate.rs | 9 +- .../src/runtime/transformation/range_map.rs | 8 +- .../src/runtime/transformation/smooth.rs | 4 + crates/microflow-runtime-wasm/Cargo.toml | 8 +- crates/microflow-runtime-wasm/src/lib.rs | 139 ++++- .../0007-node-wire-interface-emit-contract.md | 194 +++++++ docs/adr/0008-effects-apply-policy.md | 124 +++++ docs/adr/0009-cloud-sans-io-capability.md | 237 +++++++++ 77 files changed, 3207 insertions(+), 1234 deletions(-) delete mode 100644 apps/web/src-tauri/src/runtime/cloud/llm.rs delete mode 100644 apps/web/src-tauri/src/runtime/cloud/mod.rs create mode 100644 apps/web/src-tauri/tests/catalog_parity.rs create mode 100644 apps/web/src/lib/firmata/__tests__/effects-sink.test.ts create mode 100644 apps/web/src/lib/firmata/__tests__/llm-client.test.ts create mode 100644 apps/web/src/lib/firmata/__tests__/mqtt-subscriptions.test.ts create mode 100644 apps/web/src/lib/firmata/cloud/llm-client.ts create mode 100644 apps/web/src/lib/firmata/cloud/mqtt-client.ts create mode 100644 apps/web/src/lib/firmata/cloud/mqtt-subscriptions.ts create mode 100644 apps/web/src/lib/firmata/effects-sink.ts create mode 100644 crates/microflow-core/src/config/figma.rs create mode 100644 crates/microflow-core/src/config/llm.rs create mode 100644 crates/microflow-core/src/config/mqtt.rs rename {apps/web/src-tauri => crates/microflow-core}/src/runtime/cloud/figma.rs (65%) create mode 100644 crates/microflow-core/src/runtime/cloud/llm.rs create mode 100644 crates/microflow-core/src/runtime/cloud/mod.rs rename {apps/web/src-tauri => crates/microflow-core}/src/runtime/cloud/mqtt.rs (54%) create mode 100644 docs/adr/0007-node-wire-interface-emit-contract.md create mode 100644 docs/adr/0008-effects-apply-policy.md create mode 100644 docs/adr/0009-cloud-sans-io-capability.md diff --git a/CONTEXT.md b/CONTEXT.md index c145266c..c0339d9a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,11 +51,19 @@ The shared underscore-prefix routing in `runtime/mod.rs::process_event` dispatch ## Port -A named edge-input slot on a **Component (Rust trait)**, delivered as `target_handle` from a flow edge into `Component::dispatch`. Each impl declares its closed Port set via `fn ports() -> &'static [&'static str]`. The set is mirrored to `node-components.json impls[].ports[]`, which drives the frontend codegen below. ⚠ The Rust-side assertion that once panicked on a `ports()`-vs-catalog drift was **dropped** when the runtime moved to `microflow-core` (core hand-registers nodes; the desktop `build.rs` codegen is now dead build output) — restoring a Rust↔catalog port guard is tracked in [ADR-0006](docs/adr/0006-rehost-runtime-on-core.md). The frontend codegen (`apps/web/scripts/codegen-node-registry.ts`) emits `COMPONENT_PORTS` (a typed const object) and `PortOf` (a literal-union helper) into `_base/_base.types.ts`, so ReactFlow target handles are type-checked against the same source of truth. Empty array for components with no edge inputs (e.g. `Constant`). +A named edge-input slot on a **Component (Rust trait)**, delivered as `target_handle` from a flow edge into `Component::dispatch`. Each impl declares its closed Port set via `fn ports() -> &'static [&'static str]`. The set is mirrored to `node-components.json impls[].ports[]`, which drives the frontend codegen below. The Rust↔catalog drift assertion `build.rs` once generated was **dropped** in the re-host ([ADR-0006](docs/adr/0006-rehost-runtime-on-core.md)); it is restored — for ports **and** Emits, both directions — as the live **Catalog Parity Guard** ([ADR-0007](docs/adr/0007-node-wire-interface-emit-contract.md)), and the dead `build.rs` port codegen is slated for deletion. The frontend codegen (`apps/web/scripts/codegen-node-registry.ts`) emits `COMPONENT_PORTS` (a typed const object) and `PortOf` (a literal-union helper) into `_base/_base.types.ts`, so ReactFlow target handles are type-checked against the same source of truth. Empty array for components with no edge inputs (e.g. `Constant`). Examples: `Led` accepts `"true"/"false"/"toggle"/"value"`; `Stepper` accepts `"value"/"to"/"stop"/"zero"/"enable"`; `Button` accepts `"read"` (and receives the digital-pin Hardware Callback separately via `on_pin_change`). -Distinct from **Internal Event** names (never on edges, self-routed only) and **Hardware Callback** names (never on edges, runtime-delivered only). +Distinct from **Emit** names (edge *outputs*), **Internal Event** names (never on edges, self-routed only), and **Hardware Callback** names (never on edges, runtime-delivered only). + +## Emit + +A named edge-**output** slot on a **Component (Rust trait)** — the `source_handle` a component emits on, delivered to the **FlowRouter** for fanout. The symmetric counterpart of **Port**. Each impl declares its closed Emit set via `fn emits() -> &'static [&'static str]`, mirrored to `node-components.json impls[].emits[]` and to the frontend as `COMPONENT_EMITS` / `EmitOf` (so React source `` ids are type-checked against the same source of truth). Decision in [ADR-0007](docs/adr/0007-node-wire-interface-emit-contract.md). + +Emit handles are compile-checked on the Rust side: each impl declares its handles as associated `const`s (e.g. `Button::E_EVENT`) referenced at every emit site **and** in `emits()`; a mistyped emit does not compile. The implicit `"value"` emit — fired by `ComponentBase::set_value` whenever the value changes — is centralized as `ComponentBase::VALUE_HANDLE` and listed in `emits()` by every value-emitting node. Examples: `Button` emits `"event"/"true"/"false"/"hold"/"value"`; `Compare`/`Gate` emit `"true"/"false"/"value"`; `Llm` emits `"thinking"/"value"/"done"/"error"`; value-only sinks emit just `"value"`. + +Excludes `_`-prefixed **Internal Event** / wakeup names (e.g. `_hold`, `_tick`) — those are self-routed and never appear on an edge. The **Catalog Parity Guard** (`apps/web/src-tauri/tests/catalog_parity.rs`) asserts `emits()` ≡ catalog `emits[]` for every impl. ## Internal Event @@ -93,7 +101,11 @@ The per-dispatch capability context. Lives in `crates/microflow-core/src/runtime ## Effects -The side-effect record the host executes after one runtime turn. Lives in `crates/microflow-core/src/runtime/context.rs`. `Effects { outbound_bytes: Vec, component_events: Vec, wakeups: Vec, cancellations: Vec }`. Every `FlowRuntime` entry point (`update_flow`, `feed_bytes`, `wake`, `dispatch`, `deliver_message`, `inject_event`, `dispatch_key_event`) returns one `Effects`. The **Runtime Host** writes `outbound_bytes` to the port, dispatches `component_events` to its UI stores, arms each [`Wakeup`](#wakeup) as a timer, and clears `cancellations`. Nothing crosses the wire until the host applies the effects — which is what makes the runtime testable without hardware or a host (feed input, assert on the returned `Effects`). +The side-effect record the host executes after one runtime turn. Lives in `crates/microflow-core/src/runtime/context.rs`. `Effects { outbound_bytes: Vec, component_events: Vec, wakeups: Vec, cancellations: Vec }`. Every `FlowRuntime` entry point (`update_flow`, `feed_bytes`, `wake`, `dispatch`, `deliver_message`, `inject_event`, `dispatch_key_event`) returns one `Effects`. The **Runtime Host** writes `outbound_bytes` to the port, dispatches `component_events` to its UI stores, arms each [`Wakeup`](#wakeup) as a timer, and clears `cancellations`. Nothing crosses the wire until the host applies the effects — which is what makes the runtime testable without hardware or a host (feed input, assert on the returned `Effects`). *How* a host applies the four fields — their order — is not the host's choice: it is fixed by [`Effects::apply`](#effectssink) ([ADR-0008](docs/adr/0008-effects-apply-policy.md)). + +## EffectsSink + +The typed per-field hook surface a [Runtime Host](#runtime-host) implements to apply one turn's [`Effects`](#effects); the **apply policy** that drives it lives in core, once. `Effects::apply(&self, sink: &mut impl EffectsSink)` (`crates/microflow-core/src/runtime/context.rs`) iterates the fields in the **canonical order** — `outbound_bytes → cancellations → wakeups → cloud_requests → component_events` — calling one hook each: `write_bytes`, `cancel_wakeup`, `arm_wakeup`, `perform_cloud`, `dispatch_event`. Bytes first (wire latency); cancel-before-arm (so a cancel + re-arm of the same logical timer in one turn is safe); cloud calls launched before UI events leave; UI events last (they leave the runtime and do not feed back this turn). Decided in [ADR-0008](docs/adr/0008-effects-apply-policy.md) after the two hosts' inline apply loops had already drifted in order. The platform *primitives* behind each hook stay per-host (desktop: serial flush + Tauri `emit` + Tokio timer; browser: `connection.write` + store ingest + `setTimeout`). The desktop `Actor` calls `Effects::apply` directly; the browser reactor cannot reach into Rust, so it mirrors the same shape in `apps/web/src/lib/firmata/effects-sink.ts` (`applyEffects` + an `EffectsSink` interface `FlowReactor` implements), held in lockstep by a conformance test on both sides (`context::apply_tests` / `__tests__/effects-sink.test.ts`). Adding an `Effects` field adds a hook here — a compile error in every sink until handled (exactly how ADR-0009's `cloud_requests` field forced `perform_cloud`, for [`CloudRequest`](#cloudrequest)s, into the order). ## BoardWriter @@ -125,25 +137,64 @@ Distinct from the **Component Catalog**: catalog is metadata for _registration_ ## Runtime Services -Typed bundle of every external service the runtime can hand to a component, threaded through component construction. Lives in `runtime/services/mod.rs` as `RuntimeServices`. One field per **Capability Trait** / **Service Registry**: - -- `llm_registry: Arc` -- `mqtt_publisher: Arc` -- _(future kinds — HTTP, OSC, WebSocket — accrete as new fields with no churn in unrelated components)_ - -Built once at application startup (`AppState::run`) and reused across every `flow_update`. `Clone` so it can be carried alongside a pending `FlowUpdate` on `AppState::pending_flow` and replayed on board-connect without losing live registries. - -Replaces the former `RuntimeContext` struct: same role, more honest name. The corresponding ADR is [ADR-0002](docs/adr/0002-per-capability-service-traits.md). - -## Component Deps - -The associated `type Deps: FromServices` on the `ComponentBuilder` trait — each impl's typed record of the slice of **Runtime Services** it needs to construct. - -- Components that need nothing declare `type Deps = ();` (the default-friendliest shape — `FromServices for ()` returns unit). 29 of the 32 Catalog impls do this. -- `Llm` declares `type Deps = Arc`. -- `Mqtt` and `Figma` declare `type Deps = Arc`. - -The component registry's `Factory` closure (`runtime/registry.rs::make_factory`) projects `::from_services(services)` at build time and hands the slice to `B::build`. Adding a new external kind = add a field to `RuntimeServices` + a `FromServices` impl for the new `Arc<..>` — zero touches in the 29 unaffected builders. +> ⚠ **Reconciled (2026-06 · post-re-host).** ADR-0002 Phase 4 designed a +> `RuntimeServices` bundle + `ComponentBuilder::Deps: FromServices` threaded +> through construction. The re-host ([ADR-0006](docs/adr/0006-rehost-runtime-on-core.md)) +> **superseded that mechanism** to keep `tokio`/`reqwest`/`rumqttc` out of +> `microflow-core`. It does not exist in the current code; the description below +> is the actual state. + +The **Capability Trait**s and **Service Registry**s (`LlmRegistry`, +`MqttPublisher`, `LlmProvider`, …) live in the **desktop** crate +(`apps/web/src-tauri/src/runtime/services/`), not in core — core stays +dependency-light. They are **not** threaded through a `RuntimeServices` bundle or +a `Deps` associated type. Core's `ComponentBuilder` is `{ type Config; fn +build(id, config) }` (`runtime/component.rs:226`) — no services. As of +[ADR-0009](docs/adr/0009-cloud-sans-io-capability.md) the cloud nodes no longer +capture services either: they are sans-IO and emit [`CloudRequest`](#cloudrequest)s +the host performs (see **Cloud Node Registration** below). The live +`MqttPublisher` / `LlmRegistry` now live on the desktop host's **CloudPerformer** +(behind the [`EffectsSink`](#effectssink) `perform_cloud` hook), not on the nodes. + +## CloudRequest + +An outbound cloud call a node asks the host to perform, recorded as the +[`Effects`](#effects) field `cloud_requests` (the sans-IO replacement for the old +in-component `tokio::spawn`). Lives in `crates/microflow-core/src/runtime/context.rs`. +`CloudRequest { source: Arc, kind: CloudRequestKind }`; `CloudRequestKind` is +`MqttPublish { broker_id, topic, payload, retain }` (fire-and-forget) or +`LlmGenerate { provider_id, model, system, prompt }` (result re-enters). A cloud +node's `dispatch` calls `ctx.request_cloud(kind)` instead of touching the network; +the host's [`EffectsSink`](#effectssink) `perform_cloud` hook performs it, and any +result re-enters via `FlowRuntime::inject_event` keyed on `source`. The node thus +holds no Tokio/`reqwest`/`rumqttc` and is unit-tested by asserting the recorded +request (`cloud::test_support::recorded_cloud_requests`). Decided in +[ADR-0009](docs/adr/0009-cloud-sans-io-capability.md); on the desktop the I/O is +performed by the **CloudPerformer** (a deep module on the actor holding the live +services + the latest-wins LLM task table). Phase 3 added the browser performer: +`FlowReactor.performCloud` does `LlmGenerate` directly via `fetch` (mirroring the +desktop `HttpLlmProvider`, with latest-wins `AbortController` cancellation) and +re-enters the result through the wasm `injectEvent` binding. `MqttPublish` +(MQTT + Figma) publishes over WSS via `mqtt.js`; inbound subscribe routing comes +back through the wasm `deliverMessage` binding, reconciled from +`subscriberWirings()` on each `applyFlow` (mirroring the desktop `flow_update`). + +## Cloud Node Registration + +Cloud nodes are no longer special-cased. Because they are sans-IO (ADR-0009), +the `Mqtt`/`Llm`/`Figma` nodes live in `microflow-core` +(`runtime/cloud/{mqtt,llm,figma}.rs` behind the `cloud` feature; their POD configs +in `config/{mqtt,llm,figma}.rs`, ungated) and register in +`registry.rs::register_all` via the same typed `register::(name)` every +built-in uses — landing in `declared()`, so the **Catalog Parity Guard** reads +them uniformly. Both the desktop bin and the browser wasm build enable `cloud`, +so each gets the cloud nodes from this one place; the desktop's hand-written +factory closures and the `register_factory` / `register_node` / `register_cloud` +helpers are **deleted** (the host-injection machinery collapsed once cloud went +sans-IO). All that stays per-host is the [`EffectsSink`](#effectssink) +`perform_cloud`: the desktop **CloudPerformer** (`rumqttc`/`reqwest`) and the +browser `FlowReactor` (LLM via `fetch`; MQTT/Figma over WSS via `mqtt.js`, with a +per-broker connection manager in `lib/firmata/cloud/`). ## Capability Trait diff --git a/apps/web/node-components.json b/apps/web/node-components.json index 9920146a..004be774 100644 --- a/apps/web/node-components.json +++ b/apps/web/node-components.json @@ -156,6 +156,13 @@ "requiresHardware": true, "ports": [ "read" + ], + "emits": [ + "event", + "true", + "false", + "hold", + "value" ] }, { @@ -164,6 +171,9 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "value" ] }, { @@ -172,13 +182,21 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "true", + "false", + "value" ] }, { "name": "Constant", "category": "generator", "requiresHardware": false, - "ports": [] + "ports": [], + "emits": [ + "value" + ] }, { "name": "Counter", @@ -189,6 +207,9 @@ "decrement", "reset", "set" + ], + "emits": [ + "value" ] }, { @@ -197,6 +218,9 @@ "requiresHardware": false, "ports": [ "trigger" + ], + "emits": [ + "event" ] }, { @@ -216,6 +240,10 @@ "green", "blue", "opacity" + ], + "emits": [ + "change", + "value" ] }, { @@ -224,6 +252,9 @@ "requiresHardware": false, "ports": [ "trigger" + ], + "emits": [ + "value" ] }, { @@ -232,6 +263,11 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "true", + "false", + "value" ] }, { @@ -241,6 +277,12 @@ "usesHostAdapter": true, "ports": [ "key_event" + ], + "emits": [ + "event", + "true", + "false", + "value" ] }, { @@ -250,6 +292,9 @@ "ports": [ "write", "trigger" + ], + "emits": [ + "value" ] }, { @@ -259,6 +304,9 @@ "ports": [ "start", "stop" + ], + "emits": [ + "event" ] }, { @@ -270,6 +318,9 @@ "false", "toggle", "value" + ], + "emits": [ + "value" ] }, { @@ -279,6 +330,12 @@ "usesRuntimeContext": true, "ports": [ "trigger" + ], + "emits": [ + "thinking", + "value", + "done", + "error" ] }, { @@ -289,6 +346,9 @@ "value", "reset", "reinitialize" + ], + "emits": [ + "value" ] }, { @@ -297,6 +357,9 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "value" ] }, { @@ -305,6 +368,12 @@ "requiresHardware": true, "ports": [ "read" + ], + "emits": [ + "event", + "true", + "false", + "value" ] }, { @@ -314,6 +383,9 @@ "usesHostAdapter": true, "ports": [ "trigger" + ], + "emits": [ + "value" ] }, { @@ -324,6 +396,9 @@ "start", "stop", "reset" + ], + "emits": [ + "value" ] }, { @@ -333,6 +408,9 @@ "ports": [ "trigger", "stop" + ], + "emits": [ + "value" ] }, { @@ -344,6 +422,10 @@ "color", "set", "reset" + ], + "emits": [ + "event", + "value" ] }, { @@ -352,6 +434,9 @@ "requiresHardware": true, "ports": [ "read" + ], + "emits": [ + "value" ] }, { @@ -360,6 +445,10 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "to", + "value" ] }, { @@ -370,6 +459,9 @@ "true", "false", "toggle" + ], + "emits": [ + "value" ] }, { @@ -382,6 +474,9 @@ "blue", "alpha", "off" + ], + "emits": [ + "value" ] }, { @@ -390,6 +485,9 @@ "requiresHardware": true, "ports": [ "read" + ], + "emits": [ + "value" ] }, { @@ -402,6 +500,9 @@ "value", "rotate", "stop" + ], + "emits": [ + "value" ] }, { @@ -410,6 +511,9 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "value" ] }, { @@ -422,6 +526,9 @@ "stop", "zero", "enable" + ], + "emits": [ + "value" ] }, { @@ -430,6 +537,12 @@ "requiresHardware": true, "ports": [ "read" + ], + "emits": [ + "event", + "true", + "false", + "value" ] }, { @@ -438,6 +551,10 @@ "requiresHardware": false, "ports": [ "value" + ], + "emits": [ + "bang", + "value" ] } ] diff --git a/apps/web/package.json b/apps/web/package.json index 41dcd8d5..ebcc2cec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -62,6 +62,7 @@ "lucide-react": "^0.577.0", "monaco-editor": "^0.55.1", "motion": "^12.36.0", + "mqtt": "^5.15.1", "next-themes": "^0.4.6", "react": "19.2.4", "react-colorful": "^5.6.1", diff --git a/apps/web/scripts/codegen-node-registry.ts b/apps/web/scripts/codegen-node-registry.ts index e66aa976..421f663d 100644 --- a/apps/web/scripts/codegen-node-registry.ts +++ b/apps/web/scripts/codegen-node-registry.ts @@ -33,6 +33,21 @@ const entryPorts = (e: { impl: string; name: string }): readonly string[] => { return ports; }; +// Map impl name -> declared Emit set (edge outputs / source handles). Variants +// inherit their parent impl's emits. Mirrors impls[].emits[] and the Rust impl's +// Component::emits(). See CONTEXT.md § Emit. +const implEmits = new Map( + impls.map((i) => [ + i.name, + Object.freeze(((i as Record).emits as string[] | undefined) ?? []), + ]), +); +const entryEmits = (e: { impl: string; name: string }): readonly string[] => { + const emits = implEmits.get(e.impl); + if (!emits) throw new Error(`Entry ${e.name} references unknown impl ${e.impl}`); + return emits; +}; + // _base/_base.types.ts const typeNames = entries.map((e) => ` "${e.name}"`).join(",\n"); const portsObjectLines = entries @@ -42,6 +57,13 @@ const portsObjectLines = entries return ` ${e.name}: ${literal} as const,`; }) .join("\n"); +const emitsObjectLines = entries + .map((e) => { + const emits = entryEmits(e); + const literal = emits.length === 0 ? "[]" : `[${emits.map((p) => `"${p}"`).join(", ")}]`; + return ` ${e.name}: ${literal} as const,`; + }) + .join("\n"); const baseTypesContent = `// GENERATED — edit node-components.json, then run \`bun run codegen\` export const COMPONENT_TYPES = [ @@ -74,6 +96,27 @@ ${portsObjectLines} export type PortOf = T extends ComponentType ? (typeof COMPONENT_PORTS)[T][number] : never; + +/** + * Declared **Emit** set per Component (catalog-driven). Mirrors + * \`impls[].emits[]\` in \`node-components.json\` and the Rust impl's + * \`Component::emits()\`. The Catalog Parity Guard + * (\`src-tauri/tests/catalog_parity.rs\`) asserts equality; this is the single + * source of truth for what source handles a ReactFlow edge may originate from. + * See CONTEXT.md § Emit. + */ +export const COMPONENT_EMITS = { +${emitsObjectLines} +} as const satisfies Record; + +/** + * Valid \`source_handle\` literal-union for a given Component instance type. + * Distributive conditional ensures the result is the union of emit literals + * across all members of \`T\` when \`T\` is itself a union of ComponentTypes. + */ +export type EmitOf = T extends ComponentType + ? (typeof COMPONENT_EMITS)[T][number] + : never; `; writeFileSync(join(nodesDir, "_base/_base.types.ts"), baseTypesContent); diff --git a/apps/web/src-tauri/Cargo.toml b/apps/web/src-tauri/Cargo.toml index 0b4c556b..51043517 100644 --- a/apps/web/src-tauri/Cargo.toml +++ b/apps/web/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ tauri-build = { version = "2.5.3", features = [] } serde_json = "1" [dependencies] -microflow-core = { path = "../../../crates/microflow-core", features = ["runtime", "js"] } +microflow-core = { path = "../../../crates/microflow-core", features = ["runtime", "js", "cloud"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" diff --git a/apps/web/src-tauri/build.rs b/apps/web/src-tauri/build.rs index fa5d4d3c..4846ed6f 100644 --- a/apps/web/src-tauri/build.rs +++ b/apps/web/src-tauri/build.rs @@ -1,83 +1,9 @@ -use std::collections::HashMap; -use std::fmt::Write; -use std::fs; -use std::path::Path; - fn main() { + // The runtime lives in `microflow-core`, which hand-registers nodes in + // `ComponentRegistry::register_all`. The old codegen that parsed + // `node-components.json` into a `register_all_body.rs` (carrying the + // Rust↔catalog port-drift assertion) was dropped in the re-host (ADR-0006) + // and nothing included it. The Rust↔catalog port/emit guard now lives as a + // live test — `tests/catalog_parity.rs` (ADR-0007). tauri_build::build(); - - let manifest_path = "../node-components.json"; - println!("cargo:rerun-if-changed={manifest_path}"); - - let content = fs::read_to_string(manifest_path) - .expect("Failed to read node-components.json"); - let json: serde_json::Value = serde_json::from_str(&content) - .expect("Failed to parse node-components.json"); - - let entries = json["entries"].as_array() - .expect("entries must be an array"); - let impls = json["impls"].as_array() - .expect("impls must be an array"); - - // Build impl lookup: name -> (category, requires_hardware, ports_literal). - // `requiresHardware` is load-bearing: it picks `register_hardware::` - // (which adds a `HardwareComponent` bound) over `register::`. A catalog - // entry marked `requiresHardware: true` whose impl forgot `HardwareComponent` - // fails to compile. - // `ports` is the catalog declaration of the impl's **Port** set; emitted - // here as a Rust slice literal and asserted equal to `B::ports()` inside - // the registry. See `CONTEXT.md` § Port and ADR-0001. - let mut impl_meta: HashMap<&str, (&str, bool, String)> = HashMap::new(); - for i in impls { - let name = i["name"].as_str().expect("impl.name"); - let category = i["category"].as_str().expect("impl.category"); - let requires_hardware = i["requiresHardware"].as_bool().expect("impl.requiresHardware"); - let ports = i["ports"].as_array() - .unwrap_or_else(|| panic!("impl {name:?} missing 'ports' array")); - let mut ports_lit = String::from("&["); - for (idx, p) in ports.iter().enumerate() { - let s = p.as_str() - .unwrap_or_else(|| panic!("impl {name:?}: every port must be a string")); - if idx > 0 { ports_lit.push_str(", "); } - write!(ports_lit, "\"{s}\"").unwrap(); - } - ports_lit.push(']'); - impl_meta.insert(name, (category, requires_hardware, ports_lit)); - } - - // Validate: every entry's impl exists in impls - for e in entries { - let entry_name = e["name"].as_str().expect("entry.name"); - let impl_name = e["impl"].as_str().expect("entry.impl"); - assert!( - impl_meta.contains_key(impl_name), - "entry {entry_name:?} references unknown impl {impl_name:?}" - ); - } - - // Body is included into ComponentRegistry::register_all, so it must be - // valid as a sequence of statements. No `use` items — emit fully - // qualified `super::::` paths inline. - // Wrap in `{ ... }` so the result is a single block expression suitable - // for include! at expression position inside ComponentRegistry::register_all. - let mut body = String::new(); - body.push_str("// GENERATED by build.rs from node-components.json — do not edit\n"); - body.push_str("{\n"); - - for e in entries { - let entry_name = e["name"].as_str().unwrap(); - let impl_name = e["impl"].as_str().unwrap(); - let (category, requires_hardware, ports_lit) = &impl_meta[impl_name]; - let helper = if *requires_hardware { "register_hardware" } else { "register" }; - writeln!( - body, - " self.{helper}::(\"{entry_name}\", {ports_lit});" - ).unwrap(); - } - - body.push_str("}\n"); - - let out_dir = std::env::var("OUT_DIR").unwrap(); - fs::write(Path::new(&out_dir).join("register_all_body.rs"), body) - .expect("Failed to write register_all_body.rs"); } diff --git a/apps/web/src-tauri/src/runtime/cloud/llm.rs b/apps/web/src-tauri/src/runtime/cloud/llm.rs deleted file mode 100644 index 39a7da7e..00000000 --- a/apps/web/src-tauri/src/runtime/cloud/llm.rs +++ /dev/null @@ -1,421 +0,0 @@ -//! LLM cloud node on core's [`Component`] trait. -//! -//! Resolves an [`LlmProvider`](crate::runtime::services::LlmProvider) by id -//! against the shared [`LlmRegistry`] and emits the text response downstream. -//! -//! # Handles -//! - `trigger` (input): any incoming value starts generation -//! - `{{var}}` (input): dynamic prompt template variables -//! - `thinking` / `done` / `value` / `error` (outputs) -//! -//! # Threading -//! `dispatch` runs on the runtime's owner thread and emits "thinking"=true -//! synchronously into the sink (drained this turn). Generation runs on a spawned -//! Tokio task; its results cannot touch the `!Send` sink, so they cross back via -//! the injected [`CloudEmitter`] — the host folds each one into the runtime with -//! `inject_event`. Provider lookup happens *per dispatch* so credential rotation -//! takes effect on the next `trigger` without rebuilding the component. -//! -//! [`Component`]: microflow_core::runtime::Component - -use crate::runtime::cloud::CloudEmitter; -use crate::runtime::services::{LlmError, LlmRegistry, LlmRequest}; -use microflow_core::runtime::{ - Component, ComponentBase, ComponentEvent, ComponentValue, RuntimeContext, RuntimeError, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; - -/// Static config — the structural fields that describe *what* this node -/// generates. Credentials live on the registry's provider impls, not here. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmConfig { - /// Human-facing provider kind label (`ollama`, `openrouter`, …). Surfaced in - /// logs; not load-bearing for the runtime. - #[serde(default = "default_provider")] - pub provider: String, - /// Frontend provider record id; resolved against [`LlmRegistry`] at dispatch time. - #[serde(default)] - pub provider_id: String, - #[serde(default)] - pub model: String, - #[serde(default)] - pub prompt: String, - #[serde(default)] - pub system: String, -} - -fn default_provider() -> String { - "ollama".to_string() -} - -impl Default for LlmConfig { - fn default() -> Self { - Self { - provider: default_provider(), - provider_id: String::new(), - model: String::new(), - prompt: String::new(), - system: String::new(), - } - } -} - -pub struct Llm { - base: ComponentBase, - config: LlmConfig, - /// Stored values for `{{var}}` template slots in the prompt. - template_vars: HashMap, - /// Tokio handle injected by the host so the sync `dispatch` can spawn the - /// async generation task. - rt_handle: Option, - /// Abort handle for the currently running generation task. - running_task: Option, - /// Shared LLM provider registry. Cloned into each spawned task so the lookup - /// happens at dispatch time, not at construction time. - llm_registry: Arc, - /// Send seam for async results to re-enter the single-threaded core. - emitter: Option>, -} - -impl Llm { - #[must_use] - pub fn new( - id: String, - config: LlmConfig, - llm_registry: Arc, - rt_handle: Option, - emitter: Option>, - ) -> Self { - Self { - base: ComponentBase::new(id, ComponentValue::String(String::new())), - config, - template_vars: HashMap::new(), - rt_handle, - running_task: None, - llm_registry, - emitter, - } - } - - fn build_prompt(&self) -> String { - let mut prompt = self.config.prompt.clone(); - for (key, value) in &self.template_vars { - prompt = prompt.replace(&format!("{{{{{key}}}}}"), value); - } - prompt - } - - /// Emit synchronously into the sink (drained this turn), bypassing the - /// per-handle dedup so "thinking" always fires on `trigger`. - fn emit_now(&self, handle: &'static str, value: ComponentValue) { - if let Some(sink) = &self.base.sink { - sink.borrow_mut().push_back(ComponentEvent { - source: Arc::clone(&self.base.id), - source_handle: Arc::from(handle), - value, - edge_id: None, - sequence: 0, - }); - } - } - - fn spawn_generate(&mut self, prompt: String) { - // Cancel any in-flight request. - if let Some(abort) = self.running_task.take() { - log::info!("[Llm] {} cancelling previous task", self.base.id); - abort.abort(); - } - - let component_id = Arc::clone(&self.base.id); - let emitter = self.emitter.clone(); - let registry = Arc::clone(&self.llm_registry); - let provider_id = self.config.provider_id.clone(); - let request = LlmRequest { - model: self.config.model.clone(), - system: if self.config.system.is_empty() { - None - } else { - Some(self.config.system.clone()) - }, - prompt, - }; - - let Some(handle) = &self.rt_handle else { - log::error!("[Llm] {component_id} no Tokio runtime available, cannot spawn task"); - return; - }; - - let join_handle = handle.spawn(async move { - let send = |handle: &'static str, value: ComponentValue| { - if let Some(em) = &emitter { - em.emit(Arc::clone(&component_id), handle, value); - } - }; - - let Some(provider) = registry.get(&provider_id).await else { - log::error!("[Llm] {component_id} provider '{provider_id}' not in registry"); - send("thinking", ComponentValue::Bool(false)); - send( - "error", - ComponentValue::String(format!("LLM provider '{provider_id}' not configured")), - ); - return; - }; - - log::info!( - "[Llm] {component_id} → provider={provider_id} model={}", - request.model - ); - - match provider.generate(request).await { - Ok(response) => { - log::info!("[Llm] {component_id} response: {} chars", response.text.len()); - send("thinking", ComponentValue::Bool(false)); - send("value", ComponentValue::String(response.text)); - send("done", ComponentValue::Bool(true)); - } - Err(LlmError::Cancelled) => { - log::info!("[Llm] {component_id} cancelled"); - // No error event; the abort path disowned this task. - } - Err(e) => { - log::error!("[Llm] {component_id} generate failed: {e}"); - send("thinking", ComponentValue::Bool(false)); - send("error", ComponentValue::String(e.to_string())); - } - } - }); - - self.running_task = Some(join_handle.abort_handle()); - } -} - -impl Component for Llm { - fn ports() -> &'static [&'static str] { - &["trigger"] - } - - fn base(&self) -> &ComponentBase { - &self.base - } - fn base_mut(&mut self) -> &mut ComponentBase { - &mut self.base - } - fn component_type(&self) -> &'static str { - "Llm" - } - - fn dispatch( - &mut self, - method: &str, - args: ComponentValue, - _ctx: &mut RuntimeContext, - ) -> Result<(), RuntimeError> { - match method { - "trigger" => { - self.emit_now("thinking", ComponentValue::Bool(true)); - let prompt = self.build_prompt(); - self.spawn_generate(prompt); - } - var => { - let val_str = match &args { - ComponentValue::String(s) => s.clone(), - ComponentValue::Number(n) => n.to_string(), - ComponentValue::Bool(b) => b.to_string(), - _ => String::new(), - }; - self.template_vars.insert(var.to_string(), val_str); - } - } - Ok(()) - } - - fn destroy(&mut self) { - if let Some(abort) = self.running_task.take() { - abort.abort(); - } - log::info!("[Llm] {} destroyed", self.base.id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::runtime::cloud::test_support::{with_test_ctx, RecordingCloudEmitter}; - use crate::runtime::services::{LlmProvider, RecordingLlmProvider}; - use std::time::Duration; - - /// Poll the recorder until a result on `handle` arrives or the deadline passes. - async fn wait_for( - emitter: &RecordingCloudEmitter, - handle: &str, - timeout: Duration, - ) -> Vec<(Arc, String, ComponentValue)> { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let snap = emitter.recorded(); - if snap.iter().any(|(_, h, _)| h == handle) - || tokio::time::Instant::now() >= deadline - { - return snap; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - - #[tokio::test] - async fn dispatches_to_registry_provider_and_emits_value() { - let registry = Arc::new(LlmRegistry::new()); - let recorder = Arc::new(RecordingLlmProvider::new()); - recorder.script_ok("hi back"); - registry - .insert("test-provider".into(), recorder.clone() as Arc) - .await; - - let emitter = Arc::new(RecordingCloudEmitter::new()); - let config = LlmConfig { - provider_id: "test-provider".into(), - model: "test-model".into(), - prompt: "hello".into(), - ..LlmConfig::default() - }; - - let mut llm = Llm::new( - "node-1".into(), - config, - Arc::clone(®istry), - Some(tokio::runtime::Handle::current()), - Some(emitter.clone() as Arc), - ); - - with_test_ctx("node-1", |ctx| { - llm.dispatch("trigger", ComponentValue::Bool(true), ctx) - .expect("trigger ok"); - }); - - let events = wait_for(&emitter, "done", Duration::from_secs(2)).await; - let value = events.iter().find_map(|(_, h, v)| { - if h == "value" { - if let ComponentValue::String(s) = v { - return Some(s.clone()); - } - } - None - }); - assert_eq!(value.as_deref(), Some("hi back")); - assert!(events.iter().any(|(_, h, _)| h == "done")); - - let calls = recorder.recorded(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].model, "test-model"); - assert_eq!(calls[0].prompt, "hello"); - assert!(calls[0].system.is_none()); - } - - #[tokio::test] - async fn emits_error_when_provider_not_in_registry() { - let registry = Arc::new(LlmRegistry::new()); // empty - let emitter = Arc::new(RecordingCloudEmitter::new()); - let config = LlmConfig { - provider_id: "missing".into(), - ..LlmConfig::default() - }; - let mut llm = Llm::new( - "node-1".into(), - config, - Arc::clone(®istry), - Some(tokio::runtime::Handle::current()), - Some(emitter.clone() as Arc), - ); - - with_test_ctx("node-1", |ctx| { - llm.dispatch("trigger", ComponentValue::Bool(true), ctx) - .unwrap(); - }); - - let events = wait_for(&emitter, "error", Duration::from_secs(2)).await; - let err = events.iter().find(|(_, h, _)| h == "error"); - assert!(err.is_some(), "expected error event, got {events:?}"); - if let Some((_, _, ComponentValue::String(msg))) = err { - assert!(msg.contains("missing")); - } else { - panic!("error event carried non-string value"); - } - } - - #[tokio::test] - async fn forwards_system_prompt_when_set() { - let registry = Arc::new(LlmRegistry::new()); - let recorder = Arc::new(RecordingLlmProvider::new()); - recorder.script_ok("ok"); - registry - .insert("p".into(), recorder.clone() as Arc) - .await; - - let emitter = Arc::new(RecordingCloudEmitter::new()); - let config = LlmConfig { - provider_id: "p".into(), - system: "you are terse".into(), - prompt: "hi".into(), - ..LlmConfig::default() - }; - let mut llm = Llm::new( - "node-1".into(), - config, - Arc::clone(®istry), - Some(tokio::runtime::Handle::current()), - Some(emitter.clone() as Arc), - ); - - with_test_ctx("node-1", |ctx| { - llm.dispatch("trigger", ComponentValue::Bool(true), ctx) - .unwrap(); - }); - wait_for(&emitter, "done", Duration::from_secs(2)).await; - - let calls = recorder.recorded(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].system.as_deref(), Some("you are terse")); - } - - #[tokio::test] - async fn substitutes_template_vars_into_prompt() { - let registry = Arc::new(LlmRegistry::new()); - let recorder = Arc::new(RecordingLlmProvider::new()); - recorder.script_ok("ok"); - registry - .insert("p".into(), recorder.clone() as Arc) - .await; - - let emitter = Arc::new(RecordingCloudEmitter::new()); - let config = LlmConfig { - provider_id: "p".into(), - prompt: "hello {{name}}".into(), - ..LlmConfig::default() - }; - let mut llm = Llm::new( - "node-1".into(), - config, - Arc::clone(®istry), - Some(tokio::runtime::Handle::current()), - Some(emitter.clone() as Arc), - ); - - with_test_ctx("node-1", |ctx| { - // Set the template var via the {{var}} input port. - llm.dispatch("name", ComponentValue::String("world".into()), ctx) - .unwrap(); - // Trigger the generation. - llm.dispatch("trigger", ComponentValue::Bool(true), ctx) - .unwrap(); - }); - wait_for(&emitter, "done", Duration::from_secs(2)).await; - - let calls = recorder.recorded(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].prompt, "hello world"); - } -} diff --git a/apps/web/src-tauri/src/runtime/cloud/mod.rs b/apps/web/src-tauri/src/runtime/cloud/mod.rs deleted file mode 100644 index 8364e0c9..00000000 --- a/apps/web/src-tauri/src/runtime/cloud/mod.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Cloud nodes (`Mqtt`, `Llm`, `Figma`) re-homed onto the `microflow-core` -//! `Component` trait. -//! -//! These nodes do async network I/O (MQTT publish, LLM generation) that the -//! sans-IO core deliberately has no concept of. The "desktop-only cloud" -//! resolution: the nodes stay in the desktop crate (so `microflow-core` pulls -//! no tokio/reqwest/mqtt dependencies), implement core's [`Component`], and keep -//! their service handles plus a captured [`tokio::runtime::Handle`] to spawn -//! work. -//! -//! - **Publishes are fire-and-forget** — `dispatch` spawns the async publish and -//! returns immediately; nothing needs to flow back. -//! - **Results that must re-enter the runtime** (an LLM response) cross back over -//! a [`CloudEmitter`]. The core's emit queue is `!Send` and cannot be touched -//! from a spawned task on another thread, so the spawned task hands -//! `(source, handle, value)` to the host, which calls -//! `FlowRuntime::inject_event` on the owner thread and applies the `Effects`. -//! -//! The host registers these via `FlowRuntime::register_node`, with factory -//! closures capturing the live `MqttManager` / `LlmRegistry` / runtime handle. -//! -//! [`Component`]: microflow_core::runtime::Component - -use microflow_core::runtime::ComponentValue; -use std::sync::Arc; - -pub mod figma; -pub mod llm; -pub mod mqtt; - -/// `Send` seam for asynchronous cloud-node results to re-enter the -/// single-threaded core. A spawned task (e.g. an LLM generation) calls -/// [`emit`](CloudEmitter::emit) with the emitting node's id, the output handle, -/// and the value; the host forwards it to the runtime's owner thread, which -/// calls `FlowRuntime::inject_event` and applies the resulting `Effects`. -pub trait CloudEmitter: Send + Sync { - fn emit(&self, source: Arc, handle: &'static str, value: ComponentValue); -} - -#[cfg(test)] -pub(crate) mod test_support { - use super::CloudEmitter; - use microflow_core::firmata::FirmataClient; - use microflow_core::runtime::{ - BufferBoardWriter, ComponentValue, RuntimeContext, ScheduleRequests, - }; - use std::sync::{Arc, Mutex}; - - /// Build a throwaway [`RuntimeContext`] and run `f` with it. Cloud nodes - /// ignore the board / clock / scheduler, so the discarded buffer + client - /// are just there to satisfy the borrow. - pub fn with_test_ctx(node_id: &str, f: impl FnOnce(&mut RuntimeContext) -> R) -> R { - let mut client = FirmataClient::new(); - let mut out = Vec::new(); - let mut writer = BufferBoardWriter::new(&mut client, &mut out); - let mut reqs = ScheduleRequests::default(); - let mut ctx = RuntimeContext::new(&mut writer, 0.0, node_id, &mut reqs); - f(&mut ctx) - } - - /// Records cloud-node async emits for assertions. - #[derive(Default)] - pub struct RecordingCloudEmitter { - events: Mutex, String, ComponentValue)>>, - } - - impl RecordingCloudEmitter { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn recorded(&self) -> Vec<(Arc, String, ComponentValue)> { - self.events - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - } - } - - impl CloudEmitter for RecordingCloudEmitter { - fn emit(&self, source: Arc, handle: &'static str, value: ComponentValue) { - self.events - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push((source, handle.to_string(), value)); - } - } -} diff --git a/apps/web/src-tauri/src/runtime/host.rs b/apps/web/src-tauri/src/runtime/host.rs index 5a63c871..47b25c44 100644 --- a/apps/web/src-tauri/src/runtime/host.rs +++ b/apps/web/src-tauri/src/runtime/host.rs @@ -18,14 +18,13 @@ //! //! [`Effects`]: microflow_core::runtime::Effects -use crate::runtime::cloud::{self, CloudEmitter}; -use crate::runtime::services::{LlmRegistry, MqttPublisher}; +use crate::runtime::services::{LlmError, LlmRegistry, LlmRequest, MqttPublisher}; +use microflow_core::runtime::cloud; use microflow_core::flow::FlowUpdate; use microflow_core::runtime::{ - Component, ComponentValue, Effects, FlowRuntime, RuntimeError, SubscriberWiring, Wakeup, - WakeupId, + CloudRequest, CloudRequestKind, ComponentBase, ComponentEvent, ComponentValue, Effects, + EffectsSink, FlowRuntime, SubscriberWiring, Wakeup, WakeupId, }; -use serde::Deserialize; use std::collections::HashMap; use std::io::{ErrorKind, Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -91,110 +90,6 @@ pub enum ActorMsg { Shutdown, } -/// [`CloudEmitter`] that forwards a cloud node's async result to the actor as an -/// [`ActorMsg::Inject`], so it re-enters the `!Send` runtime on the owner thread. -pub struct ChannelEmitter { - tx: UnboundedSender, -} - -impl ChannelEmitter { - #[must_use] - pub fn new(tx: UnboundedSender) -> Self { - Self { tx } - } -} - -impl CloudEmitter for ChannelEmitter { - fn emit(&self, source: Arc, handle: &'static str, value: ComponentValue) { - let _ = self.tx.send(ActorMsg::Inject { - source, - handle: handle.to_string(), - value, - }); - } -} - -/// Inject the desktop's cloud nodes into `runtime`'s registry. The factory -/// closures capture the live `MqttManager` publish handle / `LlmRegistry`, the -/// Tokio runtime handle (to spawn publish/generation), and the [`CloudEmitter`] -/// (LLM async results). Keeps the async/network impls in the desktop crate so -/// `microflow-core` pulls no tokio/reqwest/mqtt dependencies. -pub fn register_cloud_nodes( - runtime: &mut FlowRuntime, - mqtt_publisher: Arc, - llm_registry: Arc, - rt_handle: Option, - emitter: Arc, -) { - { - let publisher = Arc::clone(&mqtt_publisher); - let rt = rt_handle.clone(); - runtime.register_node( - "Mqtt", - Box::new(move |id, data| { - let config = cloud::mqtt::MqttConfig::deserialize(data).map_err(|e| { - RuntimeError::ConfigDeserialize { - component: "Mqtt".to_string(), - source: e, - } - })?; - Ok(Box::new(cloud::mqtt::Mqtt::new( - id, - config, - Arc::clone(&publisher), - rt.clone(), - )) as Box) - }), - ); - } - - { - let publisher = Arc::clone(&mqtt_publisher); - let rt = rt_handle.clone(); - runtime.register_node( - "Figma", - Box::new(move |id, data| { - let config = cloud::figma::FigmaConfig::deserialize(data).map_err(|e| { - RuntimeError::ConfigDeserialize { - component: "Figma".to_string(), - source: e, - } - })?; - Ok(Box::new(cloud::figma::Figma::new( - id, - config, - Arc::clone(&publisher), - rt.clone(), - )) as Box) - }), - ); - } - - { - let registry = Arc::clone(&llm_registry); - let rt = rt_handle.clone(); - let emitter = Arc::clone(&emitter); - runtime.register_node( - "Llm", - Box::new(move |id, data| { - let config = cloud::llm::LlmConfig::deserialize(data).map_err(|e| { - RuntimeError::ConfigDeserialize { - component: "Llm".to_string(), - source: e, - } - })?; - Ok(Box::new(cloud::llm::Llm::new( - id, - config, - Arc::clone(®istry), - rt.clone(), - Some(Arc::clone(&emitter)), - )) as Box) - }), - ); - } -} - /// Spawn the actor thread. The caller owns the channel (so the `Send + Sync` /// sender can live on `AppState` + the hardware `BoardLink`) and the `connected` /// flag the hardware monitor reads (the actor clears it on a serial read error, @@ -263,14 +158,15 @@ struct Actor { app: AppHandle, rt_handle: Handle, self_tx: UnboundedSender, - mqtt_publisher: Arc, - llm_registry: Arc, connected: Arc, /// Monotonic clock origin; `now_ms` is elapsed-since-start. start: Instant, /// Live host timers keyed by wakeup id, so cancellations + fired timers /// can be dropped. timers: HashMap, + /// Performs cloud `Effects` (ADR-0009): holds the MQTT/LLM services and the + /// in-flight LLM task table. `EffectsSink::perform_cloud` delegates here. + cloud: CloudPerformer, } impl Actor { @@ -282,34 +178,27 @@ impl Actor { self_tx: UnboundedSender, connected: Arc, ) -> Self { - let mut actor = Self { + let cloud = CloudPerformer::new( + mqtt_publisher, + llm_registry, + rt_handle.clone(), + self_tx.clone(), + ); + // Cloud nodes are sans-IO and auto-registered by core (the `cloud` + // feature); the actor only supplies the `CloudPerformer` that performs + // their recorded requests. + Self { rt: FlowRuntime::new(), port: None, last_flow: None, app, rt_handle, self_tx, - mqtt_publisher, - llm_registry, connected, start: Instant::now(), timers: HashMap::new(), - }; - actor.rt = actor.build_runtime(); - actor - } - - /// A fresh runtime with the cloud nodes registered (capturing live services). - fn build_runtime(&self) -> FlowRuntime { - let mut rt = FlowRuntime::new(); - register_cloud_nodes( - &mut rt, - Arc::clone(&self.mqtt_publisher), - Arc::clone(&self.llm_registry), - Some(self.rt_handle.clone()), - Arc::new(ChannelEmitter::new(self.self_tx.clone())), - ); - rt + cloud, + } } /// Advance the runtime clock to now. @@ -360,7 +249,7 @@ impl Actor { handle.abort(); } // Fresh runtime → clean pin-mode/reporting init on the new board. - self.rt = self.build_runtime(); + self.rt = FlowRuntime::new(); if let Err(e) = self.rt.seed_pins(&pins_json) { log::warn!("[actor] seed_pins failed: {e}"); } @@ -448,54 +337,172 @@ impl Actor { } } - /// Apply one turn's [`Effects`]: bytes to the wire, events to the UI, timers - /// armed/cancelled. + /// Apply one turn's [`Effects`] in the canonical order (ADR-0008). The order + /// lives in core (`Effects::apply`); this actor is the `EffectsSink` that + /// supplies the four desktop platform primitives. fn apply(&mut self, effects: Effects) { - if !effects.outbound_bytes.is_empty() { - if let Some(port) = self.port.as_mut() { - if let Err(e) = port - .write_all(&effects.outbound_bytes) - .and_then(|()| port.flush()) - { - log::warn!("[actor] serial write error: {e}; dropping board"); - self.port = None; - self.connected.store(false, Ordering::Release); - } - } - } - - for event in &effects.component_events { - let _ = self.app.emit("component-event", event); - } + effects.apply(self); + } +} - for wakeup in effects.wakeups { - self.arm_timer(wakeup); +/// The desktop platform primitives behind the ADR-0008 [`EffectsSink`] hooks: +/// serial write + flush, Tauri `emit`, and Tokio timer arm/abort. The canonical +/// *order* these fire in is owned by `Effects::apply`, not here. +impl EffectsSink for Actor { + fn write_bytes(&mut self, bytes: &[u8]) { + if let Some(port) = self.port.as_mut() { + if let Err(e) = port.write_all(bytes).and_then(|()| port.flush()) { + log::warn!("[actor] serial write error: {e}; dropping board"); + self.port = None; + self.connected.store(false, Ordering::Release); + } } + } - for id in effects.cancellations { - if let Some(handle) = self.timers.remove(&id) { - handle.abort(); - } + fn cancel_wakeup(&mut self, id: WakeupId) { + if let Some(handle) = self.timers.remove(&id) { + handle.abort(); } } /// Arm a host timer that fires `ActorMsg::Wake` after the wakeup's delay. - fn arm_timer(&mut self, wakeup: Wakeup) { + fn arm_wakeup(&mut self, wakeup: &Wakeup) { let tx = self.self_tx.clone(); - let Wakeup { id, node_id, method, delay_ms } = wakeup; + let id = wakeup.id; + let node_id = wakeup.node_id.clone(); + let method = wakeup.method.clone(); + let delay_ms = wakeup.delay_ms; let join = self.rt_handle.spawn(async move { tokio::time::sleep(Duration::from_millis(delay_ms)).await; let _ = tx.send(ActorMsg::Wake { id, node_id, method }); }); self.timers.insert(id, join.abort_handle()); } + + /// Perform a cloud node's outbound call (ADR-0009) by delegating to the + /// [`CloudPerformer`], which owns the MQTT/LLM services + the in-flight LLM + /// task table. The ordering (cloud before UI events) is fixed by + /// `Effects::apply`; this just supplies the primitive. + fn perform_cloud(&mut self, request: &CloudRequest) { + self.cloud.perform(request); + } + + fn dispatch_event(&mut self, event: &ComponentEvent) { + let _ = self.app.emit("component-event", event); + } +} + +/// Performs cloud `Effects` for the desktop host (ADR-0009): the network I/O that +/// used to be spawned *inside* the cloud components, relocated behind one small +/// `perform(&CloudRequest)` interface. Holds the live MQTT/LLM services, the +/// Tokio handle to spawn on, the channel LLM results re-enter through +/// (`ActorMsg::Inject` → `FlowRuntime::inject_event`), and the per-node in-flight +/// LLM task table (latest-wins cancellation). Unlike the `Actor` it lives on, it +/// needs no Tauri `AppHandle`, so it is unit-testable directly. +struct CloudPerformer { + mqtt_publisher: Arc, + llm_registry: Arc, + rt_handle: Handle, + /// Where LLM results re-enter the runtime (`ActorMsg::Inject`). + tx: UnboundedSender, + /// In-flight LLM generation tasks keyed by issuing node id, so a fresh + /// `LlmGenerate` for the same node cancels its predecessor. A late result + /// from an aborted/removed node is harmless — its edges are gone, so + /// `inject_event` routes nowhere. + llm_tasks: HashMap, tokio::task::AbortHandle>, +} + +impl CloudPerformer { + fn new( + mqtt_publisher: Arc, + llm_registry: Arc, + rt_handle: Handle, + tx: UnboundedSender, + ) -> Self { + Self { mqtt_publisher, llm_registry, rt_handle, tx, llm_tasks: HashMap::new() } + } + + /// Perform one cloud request. The `reqwest`/`rumqttc` bodies are the same as + /// the old in-component spawns; only their home changed. + fn perform(&mut self, request: &CloudRequest) { + match &request.kind { + CloudRequestKind::MqttPublish { broker_id, topic, payload, retain } => { + let publisher = Arc::clone(&self.mqtt_publisher); + let source = Arc::clone(&request.source); + let broker_id = broker_id.clone(); + let topic = topic.clone(); + let payload = payload.clone(); + let retain = *retain; + self.rt_handle.spawn(async move { + if let Err(e) = publisher.publish(&broker_id, &topic, &payload, retain).await { + log::error!( + "[cloud] {source} mqtt publish failed (broker={broker_id} topic={topic}): {e}" + ); + } + }); + } + CloudRequestKind::LlmGenerate { provider_id, model, system, prompt } => { + // Latest-wins: a new generation for this node cancels the prior + // in-flight one (the abort the node used to hold itself). + if let Some(prev) = self.llm_tasks.remove(&request.source) { + prev.abort(); + } + let registry = Arc::clone(&self.llm_registry); + let tx = self.tx.clone(); + let source = Arc::clone(&request.source); + let provider_id = provider_id.clone(); + let req = LlmRequest { + model: model.clone(), + system: system.clone(), + prompt: prompt.clone(), + }; + let join = self.rt_handle.spawn(async move { + // Result handles are sourced from `Llm`'s own consts so the + // host injects on exactly the handles the node declares in + // `emits()`. `thinking=true` is emitted synchronously by the + // node's dispatch; only the resolution re-enters here. + let send = |handle: &str, value: ComponentValue| { + let _ = tx.send(ActorMsg::Inject { + source: Arc::clone(&source), + handle: handle.to_string(), + value, + }); + }; + let Some(provider) = registry.get(&provider_id).await else { + send(cloud::llm::Llm::E_THINKING, ComponentValue::Bool(false)); + send( + cloud::llm::Llm::E_ERROR, + ComponentValue::String(format!( + "LLM provider '{provider_id}' not configured" + )), + ); + return; + }; + match provider.generate(req).await { + Ok(response) => { + send(cloud::llm::Llm::E_THINKING, ComponentValue::Bool(false)); + send(ComponentBase::VALUE_HANDLE, ComponentValue::String(response.text)); + send(cloud::llm::Llm::E_DONE, ComponentValue::Bool(true)); + } + Err(LlmError::Cancelled) => {} + Err(e) => { + send(cloud::llm::Llm::E_THINKING, ComponentValue::Bool(false)); + send(cloud::llm::Llm::E_ERROR, ComponentValue::String(e.to_string())); + } + } + }); + self.llm_tasks.insert(Arc::clone(&request.source), join.abort_handle()); + } + } + } } #[cfg(test)] mod tests { use super::*; - use crate::runtime::cloud::test_support::RecordingCloudEmitter; - use crate::runtime::services::{RecordedPublish, RecordingMqttPublisher}; + use crate::runtime::services::{ + LlmProvider, RecordedPublish, RecordingLlmProvider, RecordingMqttPublisher, + }; use microflow_core::flow::{FlowEdge, FlowNode, Position}; use std::time::Duration; @@ -508,12 +515,8 @@ mod tests { } } - fn deps() -> (Arc, Arc, Arc) { - ( - Arc::new(RecordingMqttPublisher::new()), - Arc::new(LlmRegistry::new()), - Arc::new(RecordingCloudEmitter::new()), - ) + fn req(source: &str, kind: CloudRequestKind) -> CloudRequest { + CloudRequest { source: Arc::from(source), kind } } async fn wait_for_publishes( @@ -531,36 +534,129 @@ mod tests { } } - #[test] - fn channel_emitter_forwards_inject() { - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let emitter = ChannelEmitter::new(tx); - emitter.emit(Arc::from("node-1"), "value", ComponentValue::Number(7.0)); - - match rx.try_recv().expect("a message") { - ActorMsg::Inject { source, handle, value } => { - assert_eq!(source.as_ref(), "node-1"); - assert_eq!(handle, "value"); - assert_eq!(value, ComponentValue::Number(7.0)); + /// Poll the actor channel for the first `Inject` on `handle` (the path an LLM + /// result re-enters by), or `None` on timeout. + async fn wait_for_inject( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + handle: &str, + timeout: Duration, + ) -> Option { + let deadline = tokio::time::Instant::now() + timeout; + loop { + match rx.try_recv() { + Ok(ActorMsg::Inject { handle: h, value, .. }) if h == handle => return Some(value), + Ok(_) => {} + Err(_) => { + if tokio::time::Instant::now() >= deadline { + return None; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } } - _ => panic!("expected Inject"), } } #[tokio::test] - async fn registered_figma_node_builds_and_publishes_on_dispatch() { - // End-to-end proof of the registration seam: register → update_flow - // builds the node from JSON → dispatch drives the captured publisher. - let (publisher, llm, emitter) = deps(); - let mut rt = FlowRuntime::new(); - register_cloud_nodes( - &mut rt, + async fn perform_cloud_mqtt_publish_reaches_publisher() { + // The relocated IO regression net: a `MqttPublish` request drives the + // live publisher (the body that used to live in the Mqtt component). + let publisher = Arc::new(RecordingMqttPublisher::new()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut performer = CloudPerformer::new( publisher.clone() as Arc, - llm, - Some(tokio::runtime::Handle::current()), - emitter as Arc, + Arc::new(LlmRegistry::new()), + tokio::runtime::Handle::current(), + tx, + ); + + performer.perform(&req( + "m", + CloudRequestKind::MqttPublish { + broker_id: "broker-1".into(), + topic: "sensors/light".into(), + payload: b"42".to_vec(), + retain: true, + }, + )); + + let sent = wait_for_publishes(&publisher, 1, Duration::from_secs(1)).await; + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].broker_id, "broker-1"); + assert_eq!(sent[0].topic, "sensors/light"); + assert_eq!(sent[0].payload, b"42"); + assert!(sent[0].retain); + } + + #[tokio::test] + async fn perform_cloud_llm_generate_injects_result() { + // The relocated LLM IO: a `LlmGenerate` request resolves the provider and + // feeds the response back via `ActorMsg::Inject` on the `value` handle. + let registry = Arc::new(LlmRegistry::new()); + let recorder = Arc::new(RecordingLlmProvider::new()); + recorder.script_ok("hi back"); + registry + .insert("p".into(), recorder.clone() as Arc) + .await; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut performer = CloudPerformer::new( + Arc::new(RecordingMqttPublisher::new()) as Arc, + Arc::clone(®istry), + tokio::runtime::Handle::current(), + tx, + ); + + performer.perform(&req( + "llm", + CloudRequestKind::LlmGenerate { + provider_id: "p".into(), + model: "test-model".into(), + system: None, + prompt: "hello".into(), + }, + )); + + let value = wait_for_inject(&mut rx, "value", Duration::from_secs(2)).await; + assert_eq!(value, Some(ComponentValue::String("hi back".into()))); + + let calls = recorder.recorded(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].model, "test-model"); + assert_eq!(calls[0].prompt, "hello"); + } + + #[tokio::test] + async fn perform_cloud_llm_missing_provider_injects_error() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut performer = CloudPerformer::new( + Arc::new(RecordingMqttPublisher::new()) as Arc, + Arc::new(LlmRegistry::new()), // empty registry + tokio::runtime::Handle::current(), + tx, ); + performer.perform(&req( + "llm", + CloudRequestKind::LlmGenerate { + provider_id: "missing".into(), + model: "m".into(), + system: None, + prompt: "hi".into(), + }, + )); + + match wait_for_inject(&mut rx, "error", Duration::from_secs(2)).await { + Some(ComponentValue::String(msg)) => assert!(msg.contains("missing")), + other => panic!("expected error string, got {other:?}"), + } + } + + #[test] + fn registered_figma_node_emits_cloud_request_on_dispatch() { + // End-to-end of the registration + sans-IO seam: core auto-registers the + // cloud nodes → update_flow builds the node from JSON → dispatch returns + // the `CloudRequest` in the turn's `Effects` (no broker, no Tokio). + let mut rt = FlowRuntime::new(); rt.update_flow(FlowUpdate { nodes: vec![node( "fig", @@ -575,27 +671,21 @@ mod tests { edges: vec![], }); - rt.dispatch("fig", "true", ComponentValue::Bool(true)); - - let delivered = wait_for_publishes(&publisher, 1, Duration::from_secs(1)).await; - assert_eq!(delivered.len(), 1); - assert_eq!(delivered[0].topic, "microflow/uid-1/app/variable/1-2/set"); - assert_eq!(delivered[0].payload, b"true"); + let effects = rt.dispatch("fig", "true", ComponentValue::Bool(true)); + assert_eq!(effects.cloud_requests.len(), 1); + match effects.cloud_requests.into_iter().next().unwrap().kind { + CloudRequestKind::MqttPublish { topic, payload, .. } => { + assert_eq!(topic, "microflow/uid-1/app/variable/1-2/set"); + assert_eq!(payload, b"true"); + } + other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + } } #[test] fn registers_mqtt_and_llm_without_error() { - // Smoke: both build from JSON via the registry (no ComponentNotFound). - let (publisher, llm, emitter) = deps(); + // Smoke: both build from JSON via core's auto-registration (no ComponentNotFound). let mut rt = FlowRuntime::new(); - register_cloud_nodes( - &mut rt, - publisher as Arc, - llm, - None, - emitter as Arc, - ); - rt.update_flow(FlowUpdate { nodes: vec![ node( diff --git a/apps/web/src-tauri/src/runtime/mod.rs b/apps/web/src-tauri/src/runtime/mod.rs index 1f82c488..3902d9d0 100644 --- a/apps/web/src-tauri/src/runtime/mod.rs +++ b/apps/web/src-tauri/src/runtime/mod.rs @@ -8,19 +8,20 @@ //! - [`commands`] — the Tauri commands (`flow_update`, `component_call`, …) that //! post `ActorMsg`s to the actor and await its replies. //! - [`host`] — the actor thread: owns the core `FlowRuntime` + the serial port, -//! applies each turn's `Effects` (serial writes, `component-event` emits, -//! wakeup timers), and bridges cloud-node results back in via `CloudEmitter`. -//! - [`cloud`] — the desktop-only `Mqtt`/`Llm`/`Figma` nodes, implemented on -//! core's `Component` trait and injected with `FlowRuntime::register_node`. -//! - [`services`] — the `MqttPublisher` / `LlmRegistry` handles the cloud -//! factories capture (and that `flow_update` syncs credentials into). +//! applies each turn's `Effects` via its `EffectsSink` (serial writes, +//! `component-event` emits, wakeup timers, and cloud calls through the +//! `CloudPerformer`); cloud results re-enter as `ActorMsg::Inject`. The sans-IO +//! `Mqtt`/`Llm`/`Figma` nodes now live in `microflow-core` (ADR-0009, `cloud` +//! feature) and are auto-registered there; the desktop's only cloud-specific +//! code is the `CloudPerformer` that performs their recorded `CloudRequest`s. +//! - [`services`] — the `MqttPublisher` / `LlmRegistry` handles the +//! `CloudPerformer` holds (and that `flow_update` syncs credentials into). //! //! The previous desktop-local runtime — its own executor, router, registry, //! wiring and a full duplicate set of node impls — was removed once the core //! re-host went live. `microflow-core` is now the single source of truth for //! flow execution on both desktop and web. -pub mod cloud; pub mod commands; pub mod host; pub mod services; diff --git a/apps/web/src-tauri/tests/catalog_parity.rs b/apps/web/src-tauri/tests/catalog_parity.rs new file mode 100644 index 00000000..a70f6bd0 --- /dev/null +++ b/apps/web/src-tauri/tests/catalog_parity.rs @@ -0,0 +1,95 @@ +//! Catalog Parity Guard (ADR-0007). +//! +//! Asserts every node's Rust wire interface equals its declaration in +//! `apps/web/node-components.json`, in **both** directions: +//! - `impls[].ports` ≡ `::ports()` +//! - `impls[].emits` ≡ `::emits()` +//! - the set of registered names ≡ `entries[].name` (every entry buildable, +//! no orphan registrations). +//! +//! This is the live replacement for the `build.rs` port-drift assertion that +//! silently stopped running in the re-host (ADR-0006). It lives in the desktop +//! crate because that is the one place that enables every core feature at once +//! (`js`, so `Function` is present, and `cloud`, so `Mqtt`/`Llm`/`Figma` are). +//! +//! A build script cannot introspect Rust trait impls, so this runs as a normal +//! test: every node — core, aliases like `Vibration`/`Force`, and the sans-IO +//! cloud nodes — comes from `ComponentRegistry::declared()`, recorded at +//! registration in core's `register_all`. + +use std::collections::{BTreeSet, HashMap}; + +use microflow_core::runtime::ComponentRegistry; +use serde_json::Value; + +fn catalog() -> Value { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../node-components.json"); + let raw = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}")); + serde_json::from_str(&raw).expect("node-components.json is valid JSON") +} + +fn set(items: &[&str]) -> BTreeSet { + items.iter().map(|s| (*s).to_string()).collect() +} + +fn json_set(v: &Value) -> BTreeSet { + v.as_array() + .expect("ports/emits must be an array") + .iter() + .map(|x| x.as_str().expect("each handle must be a string").to_string()) + .collect() +} + +#[test] +fn catalog_matches_rust_ports_and_emits() { + let cat = catalog(); + + // Rust-declared (ports, emits) by registration name. Core's `register_all` + // records every node — including the sans-IO cloud nodes (`Mqtt`/`Llm`/ + // `Figma`) under the `cloud` feature this crate enables — so the guard reads + // them all uniformly from `declared()`. + let registry = ComponentRegistry::new(); + let mut declared: HashMap, BTreeSet)> = HashMap::new(); + for (name, handles) in registry.declared() { + declared.insert(name.clone(), (set(handles.0), set(handles.1))); + } + + // (1) Forward parity: every catalog impl matches its Rust declaration. + for im in cat["impls"].as_array().expect("impls must be an array") { + let name = im["name"].as_str().expect("impl.name").to_string(); + let (rust_ports, rust_emits) = declared + .get(&name) + .unwrap_or_else(|| panic!("catalog impl `{name}` is not registered in Rust")); + + assert_eq!( + &json_set(&im["ports"]), + rust_ports, + "PORT drift for `{name}` (catalog impls[].ports != Rust ports())" + ); + + let emits = im + .get("emits") + .unwrap_or_else(|| panic!("catalog impl `{name}` is missing the `emits` array")); + assert_eq!( + &json_set(emits), + rust_emits, + "EMIT drift for `{name}` (catalog impls[].emits != Rust emits())" + ); + } + + // (2) No-orphan / buildability: registered names ≡ catalog entry names. + let entry_names: BTreeSet = cat["entries"] + .as_array() + .expect("entries must be an array") + .iter() + .map(|e| e["name"].as_str().expect("entry.name").to_string()) + .collect(); + let registered: BTreeSet = declared.keys().cloned().collect(); + assert_eq!( + registered, + entry_names, + "registered names != catalog entries[].name\n only in Rust: {:?}\n only in catalog: {:?}", + registered.difference(&entry_names).collect::>(), + entry_names.difference(®istered).collect::>(), + ); +} diff --git a/apps/web/src/components/flow/handle.tsx b/apps/web/src/components/flow/handle.tsx index cdf2d027..54ab5886 100644 --- a/apps/web/src/components/flow/handle.tsx +++ b/apps/web/src/components/flow/handle.tsx @@ -11,7 +11,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cva } from "class-variance-authority"; -import type { ComponentType, PortOf } from "./nodes/_base/_base.types"; +import type { ComponentType, EmitOf, PortOf } from "./nodes/_base/_base.types"; const HANDLE_SIZE = 18; const HANDLE_TRANSLATE_OFFSET = HANDLE_SIZE * 0.9; @@ -175,16 +175,25 @@ type TargetProps = CommonProps & { }; /** - * Source (output) handle — emits flow edges. `id` is the emit-handle name - * the component calls `ComponentBase::emit(handle)` with. This namespace - * is not yet catalogued; for now `id` is free-form `string`. + * Source (output) handle — emits flow edges. `id` must be a declared **Emit** + * of the parent Component (the handle it passes to `ComponentBase::emit`). When + * `` is used without binding the generic, `T` defaults to the union of + * every catalogued Component, so `id` must at minimum match _some_ Emit in the + * catalog — typos against the aggregate emit set fail at compile time. Bind the + * generic explicitly (` ...>`) for per-Component tightening + * that catches cross-Component emit confusion too. + * + * Mirrors `Component::emits()` in the Rust runtime. See `CONTEXT.md` § Emit and + * ADR-0007. */ -type SourceProps = CommonProps & { +type SourceProps = CommonProps & { type: "source"; - id?: string; + id: EmitOf; }; -export type HandleProps_ = TargetProps | SourceProps; +export type HandleProps_ = + | TargetProps + | SourceProps; type Props = HandleProps_; const handle = cva("text-xs flex z-50 shadow-none after:content-[''] after:absolute after:leading-3 after:top-0 after:left-0 after:w-full after:h-full after:bg-transparent", { diff --git a/apps/web/src/components/flow/nodes/_base/_base.types.ts b/apps/web/src/components/flow/nodes/_base/_base.types.ts index 378d9285..e19faf9f 100644 --- a/apps/web/src/components/flow/nodes/_base/_base.types.ts +++ b/apps/web/src/components/flow/nodes/_base/_base.types.ts @@ -102,3 +102,60 @@ export const COMPONENT_PORTS = { export type PortOf = T extends ComponentType ? (typeof COMPONENT_PORTS)[T][number] : never; + +/** + * Declared **Emit** set per Component (catalog-driven). Mirrors + * `impls[].emits[]` in `node-components.json` and the Rust impl's + * `Component::emits()`. The Catalog Parity Guard + * (`src-tauri/tests/catalog_parity.rs`) asserts equality; this is the single + * source of truth for what source handles a ReactFlow edge may originate from. + * See CONTEXT.md § Emit. + */ +export const COMPONENT_EMITS = { + Button: ["event", "true", "false", "hold", "value"] as const, + Calculate: ["value"] as const, + Compare: ["true", "false", "value"] as const, + Constant: ["value"] as const, + Counter: ["value"] as const, + Delay: ["event"] as const, + Figma: ["change", "value"] as const, + Force: ["value"] as const, + Function: ["value"] as const, + Gate: ["true", "false", "value"] as const, + HallEffect: ["value"] as const, + Hotkey: ["event", "true", "false", "value"] as const, + I2cDevice: ["value"] as const, + Interval: ["event"] as const, + Ldr: ["value"] as const, + Led: ["value"] as const, + Llm: ["thinking", "value", "done", "error"] as const, + Matrix: ["value"] as const, + Monitor: ["value"] as const, + Motion: ["event", "true", "false", "value"] as const, + Mqtt: ["value"] as const, + Oscillator: ["value"] as const, + Piezo: ["value"] as const, + Pixel: ["event", "value"] as const, + Potentiometer: ["value"] as const, + Proximity: ["value"] as const, + RangeMap: ["to", "value"] as const, + Relay: ["value"] as const, + Rgb: ["value"] as const, + Sensor: ["value"] as const, + Servo: ["value"] as const, + Smooth: ["value"] as const, + Stepper: ["value"] as const, + Switch: ["event", "true", "false", "value"] as const, + Tilt: ["value"] as const, + Trigger: ["bang", "value"] as const, + Vibration: ["value"] as const, +} as const satisfies Record; + +/** + * Valid `source_handle` literal-union for a given Component instance type. + * Distributive conditional ensures the result is the union of emit literals + * across all members of `T` when `T` is itself a union of ComponentTypes. + */ +export type EmitOf = T extends ComponentType + ? (typeof COMPONENT_EMITS)[T][number] + : never; diff --git a/apps/web/src/components/flow/nodes/figma/figma.tsx b/apps/web/src/components/flow/nodes/figma/figma.tsx index da8f6fc6..113967c9 100644 --- a/apps/web/src/components/flow/nodes/figma/figma.tsx +++ b/apps/web/src/components/flow/nodes/figma/figma.tsx @@ -59,8 +59,6 @@ function FigmaHandles(props: { variableId?: string; id: string }) { - - )} {variable?.resolvedType === "COLOR" && ( diff --git a/apps/web/src/components/flow/nodes/led/led.tsx b/apps/web/src/components/flow/nodes/led/led.tsx index 09a72681..a834f7ce 100644 --- a/apps/web/src/components/flow/nodes/led/led.tsx +++ b/apps/web/src/components/flow/nodes/led/led.tsx @@ -30,7 +30,7 @@ export function Led(props: Props) { isConnectable={!!isPmw} /> - + ); } diff --git a/apps/web/src/components/flow/nodes/matrix/matrix.tsx b/apps/web/src/components/flow/nodes/matrix/matrix.tsx index 405f5a48..6f94f60f 100644 --- a/apps/web/src/components/flow/nodes/matrix/matrix.tsx +++ b/apps/web/src/components/flow/nodes/matrix/matrix.tsx @@ -50,7 +50,7 @@ export function Matrix(props: Props) { offset={-0.5} /> - + ); } diff --git a/apps/web/src/components/flow/nodes/rgb/rgb.tsx b/apps/web/src/components/flow/nodes/rgb/rgb.tsx index 2ab42675..bccd5940 100644 --- a/apps/web/src/components/flow/nodes/rgb/rgb.tsx +++ b/apps/web/src/components/flow/nodes/rgb/rgb.tsx @@ -18,7 +18,7 @@ export function Rgb(props: Props) { - + ); } diff --git a/apps/web/src/components/flow/nodes/servo/servo.tsx b/apps/web/src/components/flow/nodes/servo/servo.tsx index 14619e8d..47ba807e 100644 --- a/apps/web/src/components/flow/nodes/servo/servo.tsx +++ b/apps/web/src/components/flow/nodes/servo/servo.tsx @@ -27,7 +27,7 @@ export function Servo(props: Props) { )} - + ); } diff --git a/apps/web/src/components/flow/nodes/stepper/stepper.tsx b/apps/web/src/components/flow/nodes/stepper/stepper.tsx index dd624fd2..9c7e43e7 100644 --- a/apps/web/src/components/flow/nodes/stepper/stepper.tsx +++ b/apps/web/src/components/flow/nodes/stepper/stepper.tsx @@ -58,20 +58,6 @@ export function Stepper(props: Props) { hint="reset position" offset={1.5} /> - - ); } diff --git a/apps/web/src/components/flow/nodes/trigger/trigger.tsx b/apps/web/src/components/flow/nodes/trigger/trigger.tsx index 772ee000..feb0d0b4 100644 --- a/apps/web/src/components/flow/nodes/trigger/trigger.tsx +++ b/apps/web/src/components/flow/nodes/trigger/trigger.tsx @@ -15,7 +15,7 @@ export function Trigger(props: Props) { - + ); } diff --git a/apps/web/src/lib/firmata/__tests__/effects-sink.test.ts b/apps/web/src/lib/firmata/__tests__/effects-sink.test.ts new file mode 100644 index 00000000..b5b16eac --- /dev/null +++ b/apps/web/src/lib/firmata/__tests__/effects-sink.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { + applyEffects, + type CloudRequest, + type ComponentEvent, + type EffectsSink, + type Wakeup, +} from "../effects-sink"; +import type { Effects } from "@/lib/runtime/wasm"; + +// The browser half of the ADR-0008 conformance scenario. Its Rust twin is +// `microflow-core`'s `context::apply_tests` — both assert the same canonical +// order (bytes → cancel → arm → event) and that nothing double-fires, so the +// two hosts cannot silently drift the way `apply` already did once. + +/** Records each hook call as a tag so order is a plain array assertion. */ +class Recorder implements EffectsSink { + readonly calls: string[] = []; + writeBytes(bytes: number[]): void { + this.calls.push(`write:${bytes.length}`); + } + cancelWakeup(id: number): void { + this.calls.push(`cancel:${id}`); + } + armWakeup(wakeup: Wakeup): void { + this.calls.push(`arm:${wakeup.id}`); + } + performCloud(request: CloudRequest): void { + this.calls.push(`cloud:${request.source}`); + } + dispatchEvent(event: ComponentEvent): void { + this.calls.push(`event:${event.sourceHandle}`); + } +} + +function event(sourceHandle: string): ComponentEvent { + return { source: "n", sourceHandle, value: true, edgeId: null, sequence: 0 }; +} + +describe("applyEffects (ADR-0008 canonical order)", () => { + test("drives hooks in order bytes → cancel → arm → cloud → event, no double-fire", () => { + const fx: Effects = { + outboundBytes: [0x90, 0x01, 0x00], + componentEvents: [event("value")], + wakeups: [{ id: 9, nodeId: "t", method: "_tick", delayMs: 100 }], + cancellations: [7], + cloudRequests: [ + { + source: "llm", + kind: "llmGenerate", + providerId: "p", + model: "m", + system: null, + prompt: "hi", + }, + ], + }; + + const rec = new Recorder(); + applyEffects(fx, rec); + + expect(rec.calls).toEqual(["write:3", "cancel:7", "arm:9", "cloud:llm", "event:value"]); + }); + + test("skips writeBytes when there are no outbound bytes", () => { + const fx: Effects = { + outboundBytes: [], + componentEvents: [event("value")], + wakeups: [], + cancellations: [], + cloudRequests: [], + }; + + const rec = new Recorder(); + applyEffects(fx, rec); + + expect(rec.calls).toEqual(["event:value"]); + }); +}); diff --git a/apps/web/src/lib/firmata/__tests__/llm-client.test.ts b/apps/web/src/lib/firmata/__tests__/llm-client.test.ts new file mode 100644 index 00000000..4f689cdb --- /dev/null +++ b/apps/web/src/lib/firmata/__tests__/llm-client.test.ts @@ -0,0 +1,83 @@ +// Browser LLM transport conformance (ADR-0009 Phase 3). Asserts `performLlmGenerate` +// mirrors the desktop `HttpLlmProvider`: OpenAI-compatible URL + body, optional +// system message, Bearer only when keyed, and `choices[0].message.content` out. + +import { afterEach, describe, expect, test } from "bun:test"; +import { performLlmGenerate } from "../cloud/llm-client"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubFetch(impl: typeof fetch): void { + globalThis.fetch = impl; +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { status: 200, ...init }); +} + +describe("performLlmGenerate", () => { + test("posts OpenAI-compatible body to /v1/chat/completions, no auth when keyless", async () => { + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + stubFetch(async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(_input); + capturedInit = init; + return jsonResponse({ choices: [{ message: { content: "hi back" } }] }); + }); + + const text = await performLlmGenerate( + { baseUrl: "http://localhost:11434/", apiKey: "" }, + { model: "llama3", system: null, prompt: "hello" }, + ); + + expect(text).toBe("hi back"); + // Trailing slash trimmed, suffix appended (matches desktop trim_end_matches). + expect(capturedUrl).toBe("http://localhost:11434/v1/chat/completions"); + const headers = (capturedInit?.headers ?? {}) as Record; + expect(headers.authorization).toBeUndefined(); + const body = JSON.parse(String(capturedInit?.body)) as unknown; + expect(body).toEqual({ + model: "llama3", + messages: [{ role: "user", content: "hello" }], + stream: false, + }); + }); + + test("prepends a system message and sends Bearer auth when keyed", async () => { + let body: { messages: Array<{ role: string; content: string }> } | undefined; + let auth: string | undefined; + stubFetch(async (_input: RequestInfo | URL, init?: RequestInit) => { + body = JSON.parse(String(init?.body)) as typeof body; + auth = ((init?.headers ?? {}) as Record).authorization; + return jsonResponse({ choices: [{ message: { content: "ok" } }] }); + }); + + await performLlmGenerate( + { baseUrl: "https://api.openrouter.ai", apiKey: "sk-x" }, + { model: "m", system: "be terse", prompt: "p" }, + ); + + expect(auth).toBe("Bearer sk-x"); + expect(body?.messages).toEqual([ + { role: "system", content: "be terse" }, + { role: "user", content: "p" }, + ]); + }); + + test("throws on a non-2xx response", async () => { + stubFetch(async () => new Response("nope", { status: 500, statusText: "Internal Server Error" })); + await expect( + performLlmGenerate({ baseUrl: "http://x", apiKey: "" }, { model: "m", system: null, prompt: "p" }), + ).rejects.toThrow(/500/); + }); + + test("throws when the response is missing message content", async () => { + stubFetch(async () => jsonResponse({ choices: [] })); + await expect( + performLlmGenerate({ baseUrl: "http://x", apiKey: "" }, { model: "m", system: null, prompt: "p" }), + ).rejects.toThrow(/content/); + }); +}); diff --git a/apps/web/src/lib/firmata/__tests__/mqtt-subscriptions.test.ts b/apps/web/src/lib/firmata/__tests__/mqtt-subscriptions.test.ts new file mode 100644 index 00000000..077f9654 --- /dev/null +++ b/apps/web/src/lib/firmata/__tests__/mqtt-subscriptions.test.ts @@ -0,0 +1,90 @@ +// Reconcile-logic conformance for the browser MQTT host (ADR-0009 Phase 3), +// mirroring the desktop `flow_update` dedup/diff (commands.rs). + +import { describe, expect, test } from "bun:test"; +import { + beats, + diffSubscriptions, + reconcileDesired, + subKey, + uidBrokers, + type ActiveSub, + type SubKind, + type SubscriberWiring, +} from "../cloud/mqtt-subscriptions"; + +const wiring = (nodeId: string, kind: SubKind, brokerId: string, topic: string): SubscriberWiring => ({ + nodeId, + kind, + brokerId, + topic, +}); +const active = (nodeId: string, kind: SubKind, brokerId: string, topic: string): ActiveSub => ({ + nodeId, + kind, + brokerId, + topic, +}); + +describe("reconcileDesired", () => { + test("a routing kind wins over displayEcho on the same (broker, topic)", () => { + const desired = reconcileDesired([ + wiring("zEcho", "displayEcho", "b", "t"), + wiring("aRoute", "topicAware", "b", "t"), + ]); + expect(desired.size).toBe(1); + expect(desired.get(subKey("b", "t"))?.nodeId).toBe("aRoute"); + expect(desired.get(subKey("b", "t"))?.kind).toBe("topicAware"); + }); + + test("ties break on the lower node id", () => { + const desired = reconcileDesired([wiring("n2", "plain", "b", "t"), wiring("n1", "plain", "b", "t")]); + expect(desired.get(subKey("b", "t"))?.nodeId).toBe("n1"); + }); + + test("distinct topics are each kept", () => { + const desired = reconcileDesired([wiring("n1", "plain", "b", "t1"), wiring("n1", "plain", "b", "t2")]); + expect(desired.size).toBe(2); + }); +}); + +describe("diffSubscriptions", () => { + test("new subscribes, gone unsubscribes, identical untouched", () => { + const live = new Map([ + [subKey("b", "keep"), active("n", "plain", "b", "keep")], + [subKey("b", "gone"), active("n", "plain", "b", "gone")], + ]); + const desired = new Map([ + [subKey("b", "keep"), active("n", "plain", "b", "keep")], + [subKey("b", "new"), active("n", "plain", "b", "new")], + ]); + const { subscribe, unsubscribe } = diffSubscriptions(desired, live); + expect(subscribe.map((s) => s.topic)).toEqual(["new"]); + expect(unsubscribe.map((s) => s.topic)).toEqual(["gone"]); + }); + + test("an owner change re-subscribes the topic", () => { + const live = new Map([[subKey("b", "t"), active("old", "plain", "b", "t")]]); + const desired = new Map([[subKey("b", "t"), active("new", "plain", "b", "t")]]); + expect(diffSubscriptions(desired, live).subscribe.map((s) => s.nodeId)).toEqual(["new"]); + }); +}); + +describe("uidBrokers", () => { + test("maps uid → broker over microflow topics, ignoring others", () => { + const map = uidBrokers([ + active("n", "topicAware", "b1", "microflow/u1/figma/variable/1-2"), + active("n", "displayEcho", "b1", "microflow/u1/figma/status"), + active("m", "plain", "b2", "sensors/x"), + ]); + expect(map.get("u1")).toBe("b1"); + expect(map.size).toBe(1); + }); +}); + +test("beats: routing beats echo, else lower id wins", () => { + const route = active("z", "plain", "b", "t"); + const echo = active("a", "displayEcho", "b", "t"); + expect(beats(route, echo)).toBe(true); + expect(beats(echo, route)).toBe(false); +}); diff --git a/apps/web/src/lib/firmata/board-controller.ts b/apps/web/src/lib/firmata/board-controller.ts index b2ff8d9e..5cfb1f87 100644 --- a/apps/web/src/lib/firmata/board-controller.ts +++ b/apps/web/src/lib/firmata/board-controller.ts @@ -17,6 +17,9 @@ import { toast } from "sonner"; import type { BoardState } from "@/lib/bindings/BoardState"; import { useBoardStore } from "@/stores/board"; +import { useFigmaStore } from "@/stores/figma"; +import { useLlmProviderStore } from "@/stores/llm-provider"; +import { useMqttBrokerStore } from "@/stores/mqtt-broker"; import { bringUpBoard, detectBoard, @@ -27,7 +30,29 @@ import { type BoardConnection, type WebSerialPort, } from "./web-serial"; -import { FlowReactor } from "./flow-reactor"; +import { FlowReactor, type CloudDeps } from "./flow-reactor"; + +/** Cloud lookups the reactor needs to perform cloud requests (ADR-0009). Read + * live from the provider store via `getState()` (this module is not a React + * component) so credential edits apply to the next request without re-attaching. + * Direct-by-default per D4: the user's own key in the user's own browser. */ +const cloudDeps: CloudDeps = { + resolveLlmProvider: (id) => { + const provider = useLlmProviderStore.getState().getProvider(id); + return provider ? { baseUrl: provider.baseUrl, apiKey: provider.apiKey } : undefined; + }, + resolveBroker: (id) => { + const broker = useMqttBrokerStore.getState().getBroker(id); + return broker + ? { id: broker.id, url: broker.url, username: broker.username, password: broker.password } + : undefined; + }, + // Feed inbound Figma display topics (variables list / plugin status) into the + // figma store — the browser counterpart of the desktop "mqtt-message" event. + onMqttMessage: (topic, payload) => { + useFigmaStore.getState().ingestMqttMessage(topic, new TextDecoder().decode(payload)); + }, +}; /** The single active browser board connection (the desktop owns its own). */ let active: BoardConnection | null = null; @@ -127,7 +152,7 @@ async function bringUp( // the board is still up; the flow just won't run. reactor?.dispose(); try { - reactor = await FlowReactor.attach(active); + reactor = await FlowReactor.attach(active, cloudDeps); if (latestFlowJson) reactor.applyFlow(latestFlowJson); } catch (reactorError) { console.error("[board-controller] flow reactor attach failed:", reactorError); diff --git a/apps/web/src/lib/firmata/cloud/llm-client.ts b/apps/web/src/lib/firmata/cloud/llm-client.ts new file mode 100644 index 00000000..0173d27f --- /dev/null +++ b/apps/web/src/lib/firmata/cloud/llm-client.ts @@ -0,0 +1,72 @@ +// Browser LLM transport for ADR-0009 Phase 3 (cloud-as-Effect, browser host). +// +// The wasm `Llm` node is sans-IO: it emits an `llmGenerate` `CloudRequest`; THIS +// is the browser half of what the desktop's `HttpLlmProvider` does natively — +// one POST to an OpenAI-compatible `/v1/chat/completions` endpoint. Kept a pure +// transport (provider connection in, text out / throws) so it unit-tests against +// a stubbed `fetch` with no runtime or store in scope. Provider resolution +// (providerId → baseUrl/apiKey) and result re-entry live in the reactor. +// +// Per ADR-0009 D4 the call is **direct**: the user's own key, in the user's own +// browser. CORS is the only practical blocker (a proxy fallback is Phase 4). + +/** The connection half of an `LlmProviderConfig` — what a request needs. */ +export type LlmProviderConn = { + baseUrl: string; + apiKey: string; +}; + +/** The request half carried by an `llmGenerate` cloud request. */ +export type LlmGenerateInput = { + model: string; + system: string | null; + prompt: string; +}; + +/** + * POST one generation to an OpenAI-compatible endpoint and return the assistant + * text. Mirrors the desktop `HttpLlmProvider::generate` byte-for-byte: trim a + * trailing slash, append `/v1/chat/completions`, send `{model, messages, stream: + * false}` with an optional `system` message, `Authorization: Bearer` only when a + * key is set, and read `choices[0].message.content`. + * + * Throws on transport failure, non-2xx, or a response missing the content field. + * Pass an `AbortSignal` for latest-wins cancellation (a re-trigger supersedes). + */ +export async function performLlmGenerate( + provider: LlmProviderConn, + input: LlmGenerateInput, + signal?: AbortSignal, +): Promise { + const base = provider.baseUrl.replace(/\/+$/, ""); + const url = `${base}/v1/chat/completions`; + + const messages: Array<{ role: "system" | "user"; content: string }> = []; + if (input.system && input.system.length > 0) { + messages.push({ role: "system", content: input.system }); + } + messages.push({ role: "user", content: input.prompt }); + + const headers: Record = { "content-type": "application/json" }; + if (provider.apiKey.length > 0) headers.authorization = `Bearer ${provider.apiKey}`; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ model: input.model, messages, stream: false }), + signal, + }); + + if (!response.ok) { + throw new Error(`LLM request failed: ${response.status} ${response.statusText}`); + } + + const json = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = json.choices?.[0]?.message?.content; + if (typeof text !== "string") { + throw new Error("LLM response missing choices[0].message.content"); + } + return text; +} diff --git a/apps/web/src/lib/firmata/cloud/mqtt-client.ts b/apps/web/src/lib/firmata/cloud/mqtt-client.ts new file mode 100644 index 00000000..5e834e28 --- /dev/null +++ b/apps/web/src/lib/firmata/cloud/mqtt-client.ts @@ -0,0 +1,78 @@ +// Browser MQTT-over-WebSocket connections for the cloud host (ADR-0009 Phase 3). +// +// The desktop's `MqttManager` (rumqttc, native sockets) has no browser analog: +// a browser can only speak MQTT over WS/WSS. This is that analog — one lazily +// created `mqtt.js` client per broker id, reused across publishes/subscribes, +// fanning inbound messages to per-topic handlers (one per topic, matching the +// desktop's single per-topic callback). `url` MUST be a `ws://`/`wss://` endpoint. + +import mqtt, { type MqttClient } from "mqtt"; + +/** The connection half of an `MqttBrokerConfig` — what a client needs. */ +export type BrokerConn = { + id: string; + url: string; + username?: string; + password?: string; +}; + +/** Invoked for each inbound message on a subscribed topic (exact-match topic). */ +export type MqttMessageHandler = (topic: string, payload: Uint8Array) => void; + +const randomClientId = (): string => `microflow-web-${Math.random().toString(16).slice(2, 10)}`; + +export class BrokerConnections { + private readonly clients = new Map(); + /** brokerId → topic → handler (one handler per topic, like the desktop). */ + private readonly handlers = new Map>(); + + /** Lazily connect (or reuse) the client for a broker. */ + private client(conn: BrokerConn): MqttClient { + const existing = this.clients.get(conn.id); + if (existing) return existing; + + const topicHandlers = new Map(); + this.handlers.set(conn.id, topicHandlers); + + const client = mqtt.connect(conn.url, { + clientId: randomClientId(), + username: conn.username !== undefined && conn.username.length > 0 ? conn.username : undefined, + password: conn.password !== undefined && conn.password.length > 0 ? conn.password : undefined, + reconnectPeriod: 4000, + }); + client.on("message", (topic: string, payload: Uint8Array) => { + topicHandlers.get(topic)?.(topic, new Uint8Array(payload)); + }); + client.on("error", (error: unknown) => { + console.warn(`[mqtt] broker ${conn.id} error:`, error); + }); + this.clients.set(conn.id, client); + return client; + } + + /** Publish a payload. Our cloud payloads are UTF-8 text bytes, sent as text. */ + publish(conn: BrokerConn, topic: string, payload: Uint8Array, retain: boolean): void { + this.client(conn).publish(topic, new TextDecoder().decode(payload), { retain }); + } + + /** Subscribe `topic` on the broker, replacing any prior handler for it. */ + subscribe(conn: BrokerConn, topic: string, handler: MqttMessageHandler): void { + const client = this.client(conn); + this.handlers.get(conn.id)?.set(topic, handler); + client.subscribe(topic, (error) => { + if (error) console.warn(`[mqtt] subscribe ${topic} on ${conn.id} failed:`, error); + }); + } + + unsubscribe(brokerId: string, topic: string): void { + this.handlers.get(brokerId)?.delete(topic); + this.clients.get(brokerId)?.unsubscribe(topic); + } + + /** End every connection (host teardown). */ + disposeAll(): void { + for (const client of this.clients.values()) client.end(true); + this.clients.clear(); + this.handlers.clear(); + } +} diff --git a/apps/web/src/lib/firmata/cloud/mqtt-subscriptions.ts b/apps/web/src/lib/firmata/cloud/mqtt-subscriptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e35e62167c2c7591c207f1b42fbc500b3a6e3548 GIT binary patch literal 3810 zcma)9+iu%N5bd+SVk`tHWyqw9K2?rVxOEB`LE5xQQ51#|uE>?OHE-Enno(>6ed-4k z{lb1p&+INKDz=LR1|o^OGiPSboEatwy=j*w)2gwmu1L$Qt}<09;l8eFO?EA5sq4;2 zO|O1_`uJ1~B>1D(t(H_Ot#P+rx$C(!SGH~_T@`ir zu5EH*Wjgqh6p5xWNhgrgt(ZDaG3rD^* zWPB`3K<$)P)uka@Q(Z{{1f^xp0K*|4aD$@}!n=}shO>>zru6Th|B$X*i~Dq?;FFe8 zSjv@Y3b8q?t71bkTi3Xtq+ydwEyWf5I5>zAMyooPq;h8rAS+y7tw>x7RUv&0yHMAX zjI?BG5*4*owieZ;$H<8SoHkoaLm#ECxdgzt1Kz~+V@>6IYvVu63?BPT%wbH~Lo1CH z%RFN zgJe&B;LBt;jbt9ZM6zf>6QoI16S}2|%jiibv~*uX3SS_3lVgwSClqHW8|qp%C|6}s z%n-4MJR@O%)wa6wXwu@`Vc54_Yf1%-RzMo&RISuW0@q!_=W2{?_=N}y!YKh zE}HGFyAZ<3mdbe_i)R-zNq!KS1!I_nBetF8J>TwuKFKVofqVwEp8@4FLSnRNZjkTg zi{Ib9dGVSn^+jKXH;yS(iiWP_hPd&?y~b#S(qptMaMtMrYVlHTLLY5&79#93hQ9R) zcQx?Er1Z&6AK%@Rn*a@;T(0lZB z-4Z{Ydih94fY4g#`TL5(7AwYq9uqkmu+n`4ZX9@g0bdl}QJcg4&3kFU&2a%Z5K<61PN8d(u z9}3{qCZIgA#EYSQ5Zf>P-4H||Os9wqLk>k;n zgXTVs{H2bkZE!qw#t7`;e$OyB70=vJ_E}m+8rQ{r6AnGw#jeskS2&+@>OwkaK`c90 z6N0V~{&`od9SbH0%fZ-au;xJbhqG2|NIfoufa6PPBbW%Mm=gQ_q1#*6MkX4fnNfH) zoel;tPT2q+^)WkJcUyi0S3M`VKTdfv_c`w;G&kFvwo@hr6HW(+X@NrWp}q+9t~ac1X$_1?0v;_mLqM7o%g~!6t8|2Xg#N*u|TVr}+PkX3ac{_nF44a^dkg|}I#~6!f z-4gTobCCU}mx|?>e&-j*2y;l&h?wUWbT?{+M~*BA7U15q?omtqu~D0xJ9^|7+$|x# z-EJwsM=t(DWGo0YEP91ueaibU?g3N6$yOdDY=9p(tyxF;z{Y!s3Wtat_i!Bj1H>3h zw(E@PIBni&F&GUJI7<0!e4JYq(-ca`#ADkowiMQ_%sjJ^I)3YT zgz&iYom;6*#6_CZ85RRZ_G}mc&p**>ui0av#!CX&@~KFNbN3V)ewcOauCH<6FYqt$ z&J;5LtI8RZ*WZyAa+u7yw#wy7@gDs2DeVJ;V+Wo>2>Ba}AimAm)1d30k^~1G8-{tk z!~;3tEh$uP}_fViat jiT$Oo{H5UlN8CS&3t3%ab$L!l^zHB~@yk!h4+r=kRVWYe literal 0 HcmV?d00001 diff --git a/apps/web/src/lib/firmata/effects-sink.ts b/apps/web/src/lib/firmata/effects-sink.ts new file mode 100644 index 00000000..46320445 --- /dev/null +++ b/apps/web/src/lib/firmata/effects-sink.ts @@ -0,0 +1,46 @@ +// The browser mirror of the core `EffectsSink` + `Effects::apply` (ADR-0008). +// +// The Rust↔TS boundary means the browser host cannot call into core's +// `Effects::apply`; instead it mirrors the same four-hook shape in the same +// canonical order. The shared *order* — not shared code — is the contract, and +// `__tests__/effects-sink.test.ts` is the browser half of the conformance +// scenario that holds this in lockstep with the Rust `apply_tests`. + +import type { CloudRequest, Effects } from "@/lib/runtime/wasm"; + +export type { CloudRequest }; +/** One scheduled wakeup, as carried in the `Effects` serde shape. */ +export type Wakeup = Effects["wakeups"][number]; +/** One emitted component event, as carried in the `Effects` serde shape. */ +export type ComponentEvent = Effects["componentEvents"][number]; + +/** + * The four platform primitives an effects application drives — the TypeScript + * shape of the Rust `EffectsSink`. The {@link FlowReactor} implements these + * (serial write, `clearTimeout`, `setTimeout`, store ingest); {@link applyEffects} + * sequences them. A new `Effects` field adds a method here, mirroring the + * compile-time new-field guard the Rust trait gives the desktop sink. + */ +export interface EffectsSink { + writeBytes(bytes: number[]): void; + cancelWakeup(id: number): void; + armWakeup(wakeup: Wakeup): void; + performCloud(request: CloudRequest): void; + dispatchEvent(event: ComponentEvent): void; +} + +/** + * Apply one turn's effects in the **canonical order** (ADR-0008, extended by + * ADR-0009), mirroring the Rust `Effects::apply`: `outboundBytes → cancellations + * → wakeups → cloudRequests → componentEvents`. Bytes first (wire latency), + * cancel-before-arm (so a cancel + re-arm of the same logical timer in one turn + * is safe), cloud calls launched before UI events leave, UI events last (they + * leave the runtime and do not feed back this turn). + */ +export function applyEffects(fx: Effects, sink: EffectsSink): void { + if (fx.outboundBytes.length > 0) sink.writeBytes(fx.outboundBytes); + for (const id of fx.cancellations) sink.cancelWakeup(id); + for (const wakeup of fx.wakeups) sink.armWakeup(wakeup); + for (const request of fx.cloudRequests) sink.performCloud(request); + for (const event of fx.componentEvents) sink.dispatchEvent(event); +} diff --git a/apps/web/src/lib/firmata/flow-reactor.ts b/apps/web/src/lib/firmata/flow-reactor.ts index 9c72d4a3..7c9f252f 100644 --- a/apps/web/src/lib/firmata/flow-reactor.ts +++ b/apps/web/src/lib/firmata/flow-reactor.ts @@ -11,6 +11,22 @@ import { applyComponentEvent } from "@/lib/event-ingest"; import { createFlowRuntime, type Effects, type FlowRuntime } from "@/lib/runtime/wasm"; +import { performLlmGenerate, type LlmProviderConn } from "./cloud/llm-client"; +import { BrokerConnections, type BrokerConn } from "./cloud/mqtt-client"; +import { + diffSubscriptions, + reconcileDesired, + uidBrokers, + type ActiveSub, + type SubscriberWiring, +} from "./cloud/mqtt-subscriptions"; +import { + applyEffects, + type CloudRequest, + type ComponentEvent, + type EffectsSink, + type Wakeup, +} from "./effects-sink"; import type { BoardConnection } from "./web-serial"; /** Edges as carried in the core `FlowUpdate` JSON (Rust camelCase). */ @@ -22,6 +38,33 @@ type CoreEdge = { targetHandle: string; }; +/** + * Host-supplied resolvers the reactor needs to perform cloud requests (ADR-0009). + * Keeps the reactor decoupled from the Zustand stores: the board controller + * passes thin lookups so the reactor never imports app state directly. + */ +export type CloudDeps = { + /** Resolve a `providerId` (from an `llmGenerate` request) to its connection. */ + resolveLlmProvider: (providerId: string) => LlmProviderConn | undefined; + /** Resolve a `brokerId` (from an `mqttPublish` request or subscriber wiring) to + * its WSS connection. */ + resolveBroker: (brokerId: string) => BrokerConn | undefined; + /** Optional UI feed for inbound broker messages (e.g. the Figma store), + * mirroring the desktop "mqtt-message" event. `nodeId` is set for routed + * (plain/topicAware) messages, omitted for display-echo. */ + onMqttMessage?: (topic: string, payload: Uint8Array, nodeId?: string) => void; +}; + +// The `Llm` node's output handles. ADR-0007 contract: these MUST equal the +// catalog `emits` for `Llm` (`thinking`/`value`/`done`/`error`), which the +// Catalog Parity Guard pins to the Rust `Llm::emits()` / `Llm::E_*` consts. The +// browser host injects results on exactly these handles, mirroring the desktop +// `CloudPerformer`. +const LLM_THINKING = "thinking"; +const LLM_VALUE = "value"; +const LLM_DONE = "done"; +const LLM_ERROR = "error"; + const now = (): number => typeof performance !== "undefined" ? performance.now() : Date.now(); @@ -31,18 +74,30 @@ const now = (): number => * {@link applyFlow} and raw bytes via {@link feedBytes}; {@link dispose} on * teardown. */ -export class FlowReactor { +export class FlowReactor implements EffectsSink { private runtime: FlowRuntime | null = null; private readonly timers = new Map>(); + /** In-flight LLM generations keyed by issuing node id (latest-wins: a fresh + * trigger aborts its predecessor, mirroring the desktop `CloudPerformer`). */ + private readonly llmAborts = new Map(); + /** Per-broker MQTT-over-WSS connections + the live subscription set, reconciled + * on each `applyFlow` against the runtime's subscriber wirings. */ + private readonly brokers = new BrokerConnections(); + private liveSubs = new Map(); private edges: CoreEdge[] = []; private disposed = false; - private constructor(private readonly connection: BoardConnection) {} + private constructor( + private readonly connection: BoardConnection, + private readonly cloud: CloudDeps | null, + ) {} /** Instantiate the wasm runtime and seed its pin table from the detection - * session's discovered capabilities (so inbound decode + analog math work). */ - static async attach(connection: BoardConnection): Promise { - const reactor = new FlowReactor(connection); + * session's discovered capabilities (so inbound decode + analog math work). + * `cloud` supplies the provider/broker lookups cloud nodes need; omit it and + * cloud requests are logged and skipped. */ + static async attach(connection: BoardConnection, cloud?: CloudDeps): Promise { + const reactor = new FlowReactor(connection, cloud ?? null); const runtime = await createFlowRuntime(); try { runtime.setPins(connection.session.pinsJson()); @@ -62,6 +117,7 @@ export class FlowReactor { this.edges = []; } this.apply(this.runtime.updateFlow(flowJson, now())); + this.reconcileSubscriptions(); } /** Feed raw inbound serial bytes (from the Web Serial read loop). */ @@ -70,15 +126,22 @@ export class FlowReactor { this.apply(this.runtime.feedBytes(bytes, now())); } - /** Tear down: cancel every pending timer and drop the runtime. */ + /** Tear down: cancel every pending timer, abort in-flight cloud calls, and + * drop the runtime. */ dispose(): void { this.disposed = true; for (const handle of this.timers.values()) clearTimeout(handle); this.timers.clear(); + for (const controller of this.llmAborts.values()) controller.abort(); + this.llmAborts.clear(); + this.brokers.disposeAll(); + this.liveSubs.clear(); this.runtime = null; } - /** Apply one turn's effects: write bytes, reconcile timers, render events. */ + /** Apply one turn's effects in the canonical order (ADR-0008). The order + * lives in {@link applyEffects} (mirroring the Rust `Effects::apply`); this + * reactor is the `EffectsSink` supplying the four browser primitives below. */ private apply(effectsJson: string): void { if (this.disposed) return; let fx: Effects; @@ -88,32 +151,174 @@ export class FlowReactor { console.error("[flow-reactor] bad effects json:", error); return; } + applyEffects(fx, this); + } - if (fx.outboundBytes.length > 0) { - void this.connection.write(Uint8Array.from(fx.outboundBytes)).catch((error: unknown) => { - console.warn("[flow-reactor] write failed:", error); - }); + // --- EffectsSink: the browser platform primitives (ADR-0008) --------------- + + writeBytes(bytes: number[]): void { + void this.connection.write(Uint8Array.from(bytes)).catch((error: unknown) => { + console.warn("[flow-reactor] write failed:", error); + }); + } + + cancelWakeup(id: number): void { + const handle = this.timers.get(id); + if (handle !== undefined) { + clearTimeout(handle); + this.timers.delete(id); } + } - for (const id of fx.cancellations) { - const handle = this.timers.get(id); - if (handle !== undefined) { - clearTimeout(handle); - this.timers.delete(id); - } + armWakeup(wakeup: Wakeup): void { + const handle = setTimeout(() => { + this.timers.delete(wakeup.id); + if (!this.runtime || this.disposed) return; + this.apply(this.runtime.wake(wakeup.nodeId, wakeup.method, now())); + }, wakeup.delayMs); + this.timers.set(wakeup.id, handle); + } + + performCloud(request: CloudRequest): void { + if (request.kind === "llmGenerate") { + void this.runLlm(request); + return; } + // mqttPublish covers both the Mqtt publish node and Figma set-value. + this.publishMqtt(request.brokerId, request.topic, request.payload, request.retain); + } - for (const wakeup of fx.wakeups) { - const handle = setTimeout(() => { - this.timers.delete(wakeup.id); - if (!this.runtime || this.disposed) return; - this.apply(this.runtime.wake(wakeup.nodeId, wakeup.method, now())); - }, wakeup.delayMs); - this.timers.set(wakeup.id, handle); + dispatchEvent(event: ComponentEvent): void { + applyComponentEvent(event, this.edges); + } + + // --- Cloud: LLM generation (ADR-0009 Phase 3) ------------------------------- + + /** Perform an `llmGenerate` request and re-enter the result on the `Llm` + * node's handles, mirroring the desktop `CloudPerformer`: `thinking = true` + * was already emitted synchronously by the node's dispatch; here we resolve + * the provider, POST, and inject `thinking = false` + `value`/`done` (or + * `error`). Latest-wins: a re-trigger for the same node aborts this one. */ + private async runLlm(request: Extract): Promise { + const { source } = request; + const provider = this.cloud?.resolveLlmProvider(request.providerId); + if (!provider) { + this.injectLlmError(source, `LLM provider '${request.providerId}' not configured`); + return; } - for (const event of fx.componentEvents) { - applyComponentEvent(event, this.edges); + this.llmAborts.get(source)?.abort(); + const controller = new AbortController(); + this.llmAborts.set(source, controller); + + try { + const text = await performLlmGenerate( + provider, + { model: request.model, system: request.system, prompt: request.prompt }, + controller.signal, + ); + if (controller.signal.aborted || this.disposed) return; + this.inject(source, LLM_THINKING, false); + this.inject(source, LLM_VALUE, text); + this.inject(source, LLM_DONE, true); + } catch (error) { + // A superseded (aborted) or torn-down generation drops silently — its + // result would route nowhere (mirrors the desktop `LlmError::Cancelled`). + if (controller.signal.aborted || this.disposed) return; + this.injectLlmError(source, error instanceof Error ? error.message : String(error)); + } finally { + if (this.llmAborts.get(source) === controller) this.llmAborts.delete(source); } } + + private injectLlmError(source: string, message: string): void { + this.inject(source, LLM_THINKING, false); + this.inject(source, LLM_ERROR, message); + } + + /** Inject one cloud result value on `handle` and apply the cascade it drives. + * `value` is a `ComponentValue` — a bare boolean/string serializes to the + * untagged JSON the runtime parses. */ + private inject(source: string, handle: string, value: boolean | string): void { + if (!this.runtime || this.disposed) return; + this.apply(this.runtime.injectEvent(source, handle, JSON.stringify(value), now())); + } + + // --- Cloud: MQTT + Figma over WSS (ADR-0009 Phase 3) ------------------------ + + /** Reconcile the runtime's subscriber wirings into WSS subscriptions (mirrors + * the desktop `flow_update` reconcile): subscribe new/changed topics, + * unsubscribe gone ones, and run the Figma uid connect/disconnect lifecycle. + * Called after every `applyFlow`. */ + private reconcileSubscriptions(): void { + if (!this.runtime || this.disposed) return; + let wirings: SubscriberWiring[]; + try { + wirings = JSON.parse(this.runtime.subscriberWirings()) as SubscriberWiring[]; + } catch (error) { + console.error("[flow-reactor] bad subscriberWirings json:", error); + return; + } + + const desired = reconcileDesired(wirings); + const { subscribe, unsubscribe } = diffSubscriptions(desired, this.liveSubs); + + this.figmaLifecycle(uidBrokers(this.liveSubs.values()), uidBrokers(desired.values())); + + for (const sub of unsubscribe) this.brokers.unsubscribe(sub.brokerId, sub.topic); + for (const sub of subscribe) this.subscribeOne(sub); + this.liveSubs = desired; + } + + private subscribeOne(sub: ActiveSub): void { + const broker = this.cloud?.resolveBroker(sub.brokerId); + if (!broker) { + console.warn(`[flow-reactor] no broker '${sub.brokerId}' configured for ${sub.topic}`); + return; + } + this.brokers.subscribe(broker, sub.topic, (topic, payload) => this.onInbound(sub, topic, payload)); + } + + /** Inbound broker message: routing kinds drive the flow via `deliverMessage`; + * every message is also offered to the optional UI feed (the desktop emits the + * same "mqtt-message" for both — the Figma store filters by topic). */ + private onInbound(sub: ActiveSub, topic: string, payload: Uint8Array): void { + if (!this.runtime || this.disposed) return; + if (sub.kind === "plain" || sub.kind === "topicAware") { + this.apply(this.runtime.deliverMessage(sub.nodeId, topic, payload, now())); + this.cloud?.onMqttMessage?.(topic, payload, sub.nodeId); + } else { + this.cloud?.onMqttMessage?.(topic, payload); + } + } + + /** Figma plugin handshake over MQTT: a uid that just appeared announces + * `connected` (retained) + requests its current variable values; a vanished + * uid publishes `disconnected`. Mirrors the desktop `flow_update` tail. */ + private figmaLifecycle(oldUids: Map, newUids: Map): void { + for (const [uid, brokerId] of oldUids) { + if (!newUids.has(uid)) { + this.publishText(brokerId, `microflow/${uid}/app/status`, "disconnected", true); + } + } + for (const [uid, brokerId] of newUids) { + if (oldUids.has(uid)) continue; + this.publishText(brokerId, `microflow/${uid}/app/status`, "connected", true); + this.publishText(brokerId, `microflow/${uid}/app/variables/request`, "", false); + } + } + + private publishMqtt(brokerId: string, topic: string, payload: number[], retain: boolean): void { + const broker = this.cloud?.resolveBroker(brokerId); + if (!broker) { + console.warn(`[flow-reactor] no broker '${brokerId}' configured for publish to ${topic}`); + return; + } + this.brokers.publish(broker, topic, Uint8Array.from(payload), retain); + } + + private publishText(brokerId: string, topic: string, text: string, retain: boolean): void { + const broker = this.cloud?.resolveBroker(brokerId); + if (broker) this.brokers.publish(broker, topic, new TextEncoder().encode(text), retain); + } } diff --git a/apps/web/src/lib/runtime/wasm.ts b/apps/web/src/lib/runtime/wasm.ts index a35ac70f..50f34555 100644 --- a/apps/web/src/lib/runtime/wasm.ts +++ b/apps/web/src/lib/runtime/wasm.ts @@ -47,10 +47,25 @@ export type Wakeup = { delayMs: number; }; +/** An outbound cloud call the host must perform (matches the Rust `CloudRequest` + * serde shape: `kind`-tagged, `source` is the issuing node id). The result, if + * any, re-enters the runtime via its inject path. */ +export type CloudRequest = { source: string } & ( + | { kind: "mqttPublish"; brokerId: string; topic: string; payload: number[]; retain: boolean } + | { + kind: "llmGenerate"; + providerId: string; + model: string; + system: string | null; + prompt: string; + } +); + /** The side effects of one runtime turn (matches the Rust `Effects` serde shape). */ export type Effects = { outboundBytes: number[]; componentEvents: ComponentEvent[]; wakeups: Wakeup[]; cancellations: number[]; + cloudRequests: CloudRequest[]; }; diff --git a/apps/web/src/stores/figma.ts b/apps/web/src/stores/figma.ts index 34130198..6e1b4a62 100644 --- a/apps/web/src/stores/figma.ts +++ b/apps/web/src/stores/figma.ts @@ -18,39 +18,44 @@ type FigmaStore = { setVariables: (vars: Record) => void; setPluginConnected: (connected: boolean) => void; setUniqueId: (id: string) => void; + /** Apply one inbound Figma display message (variables list / plugin status), + * filtering by the current uid. Platform-agnostic: the desktop calls it from + * the Tauri "mqtt-message" event; the browser calls it from the flow + * reactor's WSS message feed. */ + ingestMqttMessage: (topic: string, payload: string) => void; }; -export const useFigmaStore = create((set) => ({ +export const useFigmaStore = create((set, get) => ({ variables: {}, pluginConnected: false, uniqueId: "anonymous", setVariables: (variables) => set({ variables }), setPluginConnected: (pluginConnected) => set({ pluginConnected }), setUniqueId: (uniqueId) => set({ uniqueId }), -})); - -// ---- Passive listener for MQTT messages emitted by the Rust runtime ---- -// The runtime handles all subscriptions/unsubscriptions. The frontend just -// listens for the Tauri "mqtt-message" event and updates the store when -// it sees Figma display topics (variables list, plugin status). -if (isDesktop()) { - listen("mqtt-message", (event) => { - const { topic, payload } = event.payload; - const { uniqueId, setVariables, setPluginConnected } = useFigmaStore.getState(); - + ingestMqttMessage: (topic, payload) => { + const { uniqueId } = get(); if ( topic === `microflow/${uniqueId}/figma/variables` || topic === `microflow/${uniqueId}/app/variables/response` ) { try { - setVariables(JSON.parse(payload) as Record); + set({ variables: JSON.parse(payload) as Record }); } catch { /* ignore malformed JSON */ } return; } - if (topic === `microflow/${uniqueId}/figma/status`) { - setPluginConnected(payload === "connected"); + set({ pluginConnected: payload === "connected" }); } + }, +})); + +// ---- Passive listener for MQTT messages emitted by the Rust runtime ---- +// The desktop runtime handles all subscriptions and emits the Tauri +// "mqtt-message" event; the browser feeds `ingestMqttMessage` from the flow +// reactor instead (see board-controller). Both land in the same store. +if (isDesktop()) { + listen("mqtt-message", (event) => { + useFigmaStore.getState().ingestMqttMessage(event.payload.topic, event.payload.payload); }); } diff --git a/bun.lock b/bun.lock index c971f69c..2862efba 100644 --- a/bun.lock +++ b/bun.lock @@ -152,6 +152,7 @@ "lucide-react": "^0.577.0", "monaco-editor": "^0.55.1", "motion": "^12.36.0", + "mqtt": "^5.15.1", "next-themes": "^0.4.6", "react": "19.2.4", "react-colorful": "^5.6.1", @@ -1100,7 +1101,7 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], - "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], @@ -3210,12 +3211,16 @@ "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@tauri-apps/plugin-fs/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "@tauri-apps/plugin-dialog/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + + "@tauri-apps/plugin-opener/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], "@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], + "@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "@tscircuit/circuit-json-flex/@tscircuit/miniflex": ["@tscircuit/miniflex@0.0.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oRC0up2psp8dJD1CzXyUiFuhQZUWLdZNl9EAqOf/hHqXDhPKMU6wM79S+XQuaB0gdWNRnwcURHPPaKLw/ka3DQ=="], @@ -3404,6 +3409,8 @@ "web/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "web/mqtt": ["mqtt@5.15.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA=="], + "worker-factory/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], "worker-timers/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], diff --git a/crates/microflow-core/src/config/figma.rs b/crates/microflow-core/src/config/figma.rs new file mode 100644 index 00000000..292f3509 --- /dev/null +++ b/crates/microflow-core/src/config/figma.rs @@ -0,0 +1,43 @@ +//! Figma Node config — shared by the live runtime and (future) codegen emitter. +//! +//! Pure data describing which Figma variable (via the plugin's MQTT bridge) this +//! node mirrors: the broker id, the plugin's `unique_id`, the `variable_id`, the +//! resolved Figma type, and the debounce window. The broker credentials live on +//! the host, never here. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FigmaConfig { + #[serde(default)] + pub broker_id: String, + #[serde(default)] + pub unique_id: String, + #[serde(default)] + pub variable_id: String, + #[serde(default = "default_resolved_type")] + pub resolved_type: String, + #[serde(default = "default_debounce_time")] + pub debounce_time: u64, +} + +fn default_resolved_type() -> String { + "STRING".to_string() +} + +fn default_debounce_time() -> u64 { + 100 +} + +impl Default for FigmaConfig { + fn default() -> Self { + Self { + broker_id: String::new(), + unique_id: String::new(), + variable_id: String::new(), + resolved_type: "STRING".to_string(), + debounce_time: 100, + } + } +} diff --git a/crates/microflow-core/src/config/llm.rs b/crates/microflow-core/src/config/llm.rs new file mode 100644 index 00000000..990b3bec --- /dev/null +++ b/crates/microflow-core/src/config/llm.rs @@ -0,0 +1,43 @@ +//! LLM Node config — shared by the live runtime and (future) codegen emitter. +//! +//! The structural fields that describe *what* this node generates (provider id, +//! model, prompt, system). Credentials/base-URL live on the host's provider +//! registry (desktop) or the browser's provider store, resolved when the request +//! is performed — never here. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LlmConfig { + /// Human-facing provider kind label (`ollama`, `openrouter`, …). Surfaced in + /// logs; not load-bearing for the runtime. + #[serde(default = "default_provider")] + pub provider: String, + /// Frontend provider record id; resolved against the host's registry when + /// the request is performed. + #[serde(default)] + pub provider_id: String, + #[serde(default)] + pub model: String, + #[serde(default)] + pub prompt: String, + #[serde(default)] + pub system: String, +} + +fn default_provider() -> String { + "ollama".to_string() +} + +impl Default for LlmConfig { + fn default() -> Self { + Self { + provider: default_provider(), + provider_id: String::new(), + model: String::new(), + prompt: String::new(), + system: String::new(), + } + } +} diff --git a/crates/microflow-core/src/config/mod.rs b/crates/microflow-core/src/config/mod.rs index 922f8657..80bda61b 100644 --- a/crates/microflow-core/src/config/mod.rs +++ b/crates/microflow-core/src/config/mod.rs @@ -49,3 +49,9 @@ pub mod compare; pub mod gate; pub mod range_map; pub mod smooth; + +// cloud (sans-IO nodes; POD config kept ungated like the rest so codegen can +// reach it too — the network I/O is the host's, not the config's) +pub mod figma; +pub mod llm; +pub mod mqtt; diff --git a/crates/microflow-core/src/config/mqtt.rs b/crates/microflow-core/src/config/mqtt.rs new file mode 100644 index 00000000..c19c6bcc --- /dev/null +++ b/crates/microflow-core/src/config/mqtt.rs @@ -0,0 +1,38 @@ +//! MQTT Node config — shared by the live runtime and (future) codegen emitter. +//! +//! Pure data: the broker id + topic + direction + `QoS` the `Mqtt` node publishes +//! to or subscribes from. Credentials live on the host (desktop service registry +//! / browser store), never here — this struct is the sans-IO node's whole config. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttConfig { + #[serde(default)] + pub broker_id: String, + #[serde(default)] + pub direction: String, + #[serde(default)] + pub topic: String, + #[serde(default = "default_qos")] + pub qos: String, + #[serde(default)] + pub retain: bool, +} + +fn default_qos() -> String { + "1".to_string() +} + +impl Default for MqttConfig { + fn default() -> Self { + Self { + broker_id: String::new(), + direction: "subscribe".to_string(), + topic: String::new(), + qos: "1".to_string(), + retain: false, + } + } +} diff --git a/apps/web/src-tauri/src/runtime/cloud/figma.rs b/crates/microflow-core/src/runtime/cloud/figma.rs similarity index 65% rename from apps/web/src-tauri/src/runtime/cloud/figma.rs rename to crates/microflow-core/src/runtime/cloud/figma.rs index f83cf430..6748d462 100644 --- a/apps/web/src-tauri/src/runtime/cloud/figma.rs +++ b/crates/microflow-core/src/runtime/cloud/figma.rs @@ -3,77 +3,33 @@ //! Bridges Figma design variables into the flow via MQTT: subscribes to the //! plugin's variable topics (delivered back via //! [`receive_raw_message`](Component::receive_raw_message)) and, on `dispatch`, -//! computes a payload, emits "value"/"change" downstream, and spawns a -//! fire-and-forget publish back to Figma. +//! computes a payload, emits "value"/"change" downstream, and records a +//! [`CloudRequestKind::MqttPublish`] back to Figma for the host to perform +//! (sans-IO — the node never touches the broker, ADR-0009). //! -//! [`Component`]: microflow_core::runtime::Component +//! [`Component`]: crate::runtime::Component -use crate::runtime::services::MqttPublisher; -use microflow_core::runtime::{ - Component, ComponentBase, ComponentValue, RuntimeContext, RuntimeError, SubscriberWiring, +use crate::runtime::{ + CloudRequestKind, Component, ComponentBase, ComponentBuilder, ComponentValue, RuntimeContext, + RuntimeError, SubscriberWiring, }; -use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::sync::Arc; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FigmaConfig { - #[serde(default)] - pub broker_id: String, - #[serde(default)] - pub unique_id: String, - #[serde(default)] - pub variable_id: String, - #[serde(default = "default_resolved_type")] - pub resolved_type: String, - #[serde(default = "default_debounce_time")] - pub debounce_time: u64, -} - -fn default_resolved_type() -> String { - "STRING".to_string() -} - -fn default_debounce_time() -> u64 { - 100 -} -impl Default for FigmaConfig { - fn default() -> Self { - Self { - broker_id: String::new(), - unique_id: String::new(), - variable_id: String::new(), - resolved_type: "STRING".to_string(), - debounce_time: 100, - } - } -} +pub use crate::config::figma::FigmaConfig; pub struct Figma { base: ComponentBase, config: FigmaConfig, - /// Shared MQTT publish handle. Cloned into each spawned publish task. - publisher: Arc, - /// Tokio handle injected by the host so `dispatch` (sync) can spawn the - /// async publish call. - rt_handle: Option, } impl Figma { + const E_CHANGE: &'static str = "change"; + #[must_use] - pub fn new( - id: String, - config: FigmaConfig, - publisher: Arc, - rt_handle: Option, - ) -> Self { + pub fn new(id: String, config: FigmaConfig) -> Self { Self { base: ComponentBase::new(id, ComponentValue::String(String::new())), config, - publisher, - rt_handle, } } @@ -188,37 +144,30 @@ impl Figma { } } - /// Push a JSON payload back into Figma over the configured broker, spawning - /// the async publish onto the captured Tokio handle. - fn publish(&self, payload: String) { - let publisher = Arc::clone(&self.publisher); - let broker_id = self.config.broker_id.clone(); - let topic = self.set_topic(); - let component_id = Arc::clone(&self.base.id); - - let Some(handle) = &self.rt_handle else { - log::error!("[Figma] {component_id} no Tokio runtime available, cannot spawn publish"); - return; - }; - - handle.spawn(async move { - log::debug!("[Figma] {component_id} publish → broker={broker_id} topic={topic}"); - if let Err(e) = publisher - .publish(&broker_id, &topic, payload.as_bytes(), false) - .await - { - log::error!( - "[Figma] {component_id} publish failed (broker={broker_id} topic={topic}): {e}" - ); - } + /// Record a publish of `payload` back into Figma over the configured broker + /// (sans-IO — the host's `perform_cloud` performs it). + fn publish(&self, payload: String, ctx: &mut RuntimeContext) { + ctx.request_cloud(CloudRequestKind::MqttPublish { + broker_id: self.config.broker_id.clone(), + topic: self.set_topic(), + payload: payload.into_bytes(), + retain: false, }); } /// Emit a value on both "value" (node display) and "change" (downstream). fn emit_value(&mut self, value: ComponentValue) { self.base.value = value.clone(); - self.base.emit_with_value("value", Cow::Owned(value.clone())); - self.base.emit_with_value("change", Cow::Owned(value)); + self.base.emit_with_value(ComponentBase::VALUE_HANDLE, Cow::Owned(value.clone())); + self.base.emit_with_value(Self::E_CHANGE, Cow::Owned(value)); + } +} + +impl ComponentBuilder for Figma { + type Config = FigmaConfig; + + fn build(id: String, config: FigmaConfig) -> Result { + Ok(Self::new(id, config)) } } @@ -230,6 +179,10 @@ impl Component for Figma { ] } + fn emits() -> &'static [&'static str] { + &[Self::E_CHANGE, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -279,7 +232,7 @@ impl Component for Figma { &mut self, method: &str, args: ComponentValue, - _ctx: &mut RuntimeContext, + ctx: &mut RuntimeContext, ) -> Result<(), RuntimeError> { let payload = match method { // ---- BOOLEAN ---- @@ -335,7 +288,7 @@ impl Component for Figma { let new_value = self.parse_payload(payload.as_bytes()); self.emit_value(new_value); - self.publish(payload); + self.publish(payload, ctx); Ok(()) } @@ -352,24 +305,7 @@ impl Component for Figma { #[cfg(test)] mod tests { use super::*; - use crate::runtime::cloud::test_support::with_test_ctx; - use crate::runtime::services::{RecordedPublish, RecordingMqttPublisher}; - use std::time::Duration; - - async fn wait_for_publishes( - recorder: &RecordingMqttPublisher, - min: usize, - timeout: Duration, - ) -> Vec { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let snap = recorder.recorded(); - if snap.len() >= min || tokio::time::Instant::now() >= deadline { - return snap; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - } + use crate::runtime::cloud::test_support::recorded_cloud_requests; fn config() -> FigmaConfig { FigmaConfig { @@ -381,75 +317,70 @@ mod tests { } } - #[tokio::test] - async fn dispatch_true_publishes_true_payload_to_set_topic() { - let recorder = Arc::new(RecordingMqttPublisher::new()); - let mut figma = Figma::new( - "node-1".into(), - config(), - recorder.clone() as Arc, - Some(tokio::runtime::Handle::current()), - ); - - with_test_ctx("node-1", |ctx| { + /// Unwrap the single recorded request as an MQTT publish, or panic. + fn publish_of(kind: CloudRequestKind) -> (String, String, Vec, bool) { + match kind { + CloudRequestKind::MqttPublish { broker_id, topic, payload, retain } => { + (broker_id, topic, payload, retain) + } + other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + } + } + + #[test] + fn dispatch_true_records_true_publish_to_set_topic() { + let mut figma = Figma::new("node-1".into(), config()); + + let mut reqs = recorded_cloud_requests("node-1", |ctx| { figma .dispatch("true", ComponentValue::Bool(true), ctx) .expect("dispatch ok"); }); - let published = wait_for_publishes(&recorder, 1, Duration::from_secs(1)).await; - assert_eq!(published.len(), 1); - assert_eq!(published[0].broker_id, "broker-1"); - assert_eq!(published[0].topic, "microflow/uid-1/app/variable/1-2/set"); - assert_eq!(published[0].payload, b"true"); - assert!(!published[0].retain); + assert_eq!(reqs.len(), 1); + let (broker_id, topic, payload, retain) = publish_of(reqs.remove(0)); + assert_eq!(broker_id, "broker-1"); + assert_eq!(topic, "microflow/uid-1/app/variable/1-2/set"); + assert_eq!(payload, b"true"); + assert!(!retain); } - #[tokio::test] - async fn dispatch_increment_publishes_summed_payload() { + #[test] + fn dispatch_increment_records_summed_payload() { let mut c = config(); c.resolved_type = "FLOAT".into(); - let recorder = Arc::new(RecordingMqttPublisher::new()); - let mut figma = Figma::new( - "node-1".into(), - c, - recorder.clone() as Arc, - Some(tokio::runtime::Handle::current()), - ); - - with_test_ctx("node-1", |ctx| { + let mut figma = Figma::new("node-1".into(), c); + + let mut first = recorded_cloud_requests("node-1", |ctx| { figma .dispatch("set", ComponentValue::Number(5.0), ctx) .expect("set ok"); }); - wait_for_publishes(&recorder, 1, Duration::from_secs(1)).await; + assert_eq!(first.len(), 1); + let _ = publish_of(first.remove(0)); - with_test_ctx("node-1", |ctx| { + let mut second = recorded_cloud_requests("node-1", |ctx| { figma .dispatch("increment", ComponentValue::Number(3.0), ctx) .expect("increment ok"); }); - let published = wait_for_publishes(&recorder, 2, Duration::from_secs(1)).await; - assert_eq!(published.len(), 2); - assert_eq!(published[1].payload, b"8"); + assert_eq!(second.len(), 1); + let (_, _, payload, _) = publish_of(second.remove(0)); + assert_eq!(payload, b"8"); } - #[tokio::test] - async fn dispatch_unknown_method_does_not_publish() { - let recorder = Arc::new(RecordingMqttPublisher::new()); - let mut figma = Figma::new( - "node-1".into(), - config(), - recorder.clone() as Arc, - Some(tokio::runtime::Handle::current()), - ); - let err = with_test_ctx("node-1", |ctx| { - figma + #[test] + fn dispatch_unknown_method_records_no_request() { + let mut figma = Figma::new("node-1".into(), config()); + + let mut errored = false; + let reqs = recorded_cloud_requests("node-1", |ctx| { + errored = figma .dispatch("definitely-not-a-method", ComponentValue::Bool(true), ctx) - .expect_err("should fail") + .is_err(); }); - assert!(err.to_string().contains("Unknown method")); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(recorder.recorded().is_empty()); + + assert!(errored, "unknown method should error"); + assert!(reqs.is_empty(), "a failed dispatch must record no cloud request"); } } diff --git a/crates/microflow-core/src/runtime/cloud/llm.rs b/crates/microflow-core/src/runtime/cloud/llm.rs new file mode 100644 index 00000000..d766c5db --- /dev/null +++ b/crates/microflow-core/src/runtime/cloud/llm.rs @@ -0,0 +1,221 @@ +//! LLM cloud node on core's [`Component`] trait. +//! +//! Sans-IO (ADR-0009): `dispatch("trigger")` emits `thinking = true` +//! synchronously, then records a [`CloudRequestKind::LlmGenerate`] for the host +//! to perform. The host resolves a provider, runs generation off-thread, and +//! feeds the result back via `FlowRuntime::inject_event` on this node's +//! `thinking`/`value`/`done`/`error` handles. The node holds no provider, no +//! Tokio handle, and no abort handle — cancellation (latest-wins) and provider +//! lookup now live in the host's `EffectsSink::perform_cloud`. +//! +//! # Handles +//! - `trigger` (input): any incoming value starts generation +//! - `{{var}}` (input): dynamic prompt template variables +//! - `thinking` / `done` / `value` / `error` (outputs) +//! +//! [`Component`]: crate::runtime::Component + +use crate::runtime::{ + CloudRequestKind, Component, ComponentBase, ComponentBuilder, ComponentValue, RuntimeContext, + RuntimeError, +}; +use std::borrow::Cow; +use std::collections::HashMap; + +pub use crate::config::llm::LlmConfig; + +pub struct Llm { + base: ComponentBase, + config: LlmConfig, + /// Stored values for `{{var}}` template slots in the prompt. + template_vars: HashMap, +} + +impl Llm { + /// Output handles. `pub` so the host's `perform_cloud` injects results on the + /// exact handles this node declares in [`Component::emits`] — one source of + /// truth for the LLM result contract (the `value` handle is the shared + /// [`ComponentBase::VALUE_HANDLE`]). + pub const E_THINKING: &'static str = "thinking"; + pub const E_DONE: &'static str = "done"; + pub const E_ERROR: &'static str = "error"; + + #[must_use] + pub fn new(id: String, config: LlmConfig) -> Self { + Self { + base: ComponentBase::new(id, ComponentValue::String(String::new())), + config, + template_vars: HashMap::new(), + } + } + + fn build_prompt(&self) -> String { + let mut prompt = self.config.prompt.clone(); + for (key, value) in &self.template_vars { + prompt = prompt.replace(&format!("{{{{{key}}}}}"), value); + } + prompt + } +} + +impl ComponentBuilder for Llm { + type Config = LlmConfig; + + fn build(id: String, config: LlmConfig) -> Result { + Ok(Self::new(id, config)) + } +} + +impl Component for Llm { + fn ports() -> &'static [&'static str] { + &["trigger"] + } + + fn emits() -> &'static [&'static str] { + &[ + Self::E_THINKING, + ComponentBase::VALUE_HANDLE, + Self::E_DONE, + Self::E_ERROR, + ] + } + + fn base(&self) -> &ComponentBase { + &self.base + } + fn base_mut(&mut self) -> &mut ComponentBase { + &mut self.base + } + fn component_type(&self) -> &'static str { + "Llm" + } + + fn dispatch( + &mut self, + method: &str, + args: ComponentValue, + ctx: &mut RuntimeContext, + ) -> Result<(), RuntimeError> { + match method { + "trigger" => { + // `thinking = true` fires synchronously (drained this turn); + // `thinking = false` + `value`/`done`/`error` re-enter later via + // the host after generation. `emit_with_value` always fires + // (no value-dedup) so a repeated trigger re-shows the spinner. + self.base + .emit_with_value(Self::E_THINKING, Cow::Owned(ComponentValue::Bool(true))); + let prompt = self.build_prompt(); + ctx.request_cloud(CloudRequestKind::LlmGenerate { + provider_id: self.config.provider_id.clone(), + model: self.config.model.clone(), + system: if self.config.system.is_empty() { + None + } else { + Some(self.config.system.clone()) + }, + prompt, + }); + } + var => { + let val_str = match &args { + ComponentValue::String(s) => s.clone(), + ComponentValue::Number(n) => n.to_string(), + ComponentValue::Bool(b) => b.to_string(), + _ => String::new(), + }; + self.template_vars.insert(var.to_string(), val_str); + } + } + Ok(()) + } + + fn destroy(&mut self) { + log::info!("[Llm] {} destroyed", self.base.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::cloud::test_support::recorded_cloud_requests; + use crate::runtime::{ComponentEvent, EventSink}; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + fn config() -> LlmConfig { + LlmConfig { + provider_id: "p".into(), + model: "test-model".into(), + prompt: "hello".into(), + ..LlmConfig::default() + } + } + + /// Unwrap the single recorded request as an LLM generation, or panic. + fn generate_of(kind: CloudRequestKind) -> (String, String, Option, String) { + match kind { + CloudRequestKind::LlmGenerate { provider_id, model, system, prompt } => { + (provider_id, model, system, prompt) + } + other @ CloudRequestKind::MqttPublish { .. } => panic!("expected LlmGenerate, got {other:?}"), + } + } + + #[test] + fn trigger_records_generate_and_emits_thinking() { + let mut llm = Llm::new("node-1".into(), config()); + let sink: EventSink = Rc::new(RefCell::new(VecDeque::new())); + llm.set_sink(sink.clone()); + + let mut reqs = recorded_cloud_requests("node-1", |ctx| { + llm.dispatch("trigger", ComponentValue::Bool(true), ctx) + .expect("trigger ok"); + }); + + // `thinking = true` was emitted synchronously on dispatch. + let events: Vec = sink.borrow_mut().drain(..).collect(); + assert!(events + .iter() + .any(|e| e.source_handle.as_ref() == "thinking" && e.value == ComponentValue::Bool(true))); + + assert_eq!(reqs.len(), 1); + let (provider_id, model, system, prompt) = generate_of(reqs.remove(0)); + assert_eq!(provider_id, "p"); + assert_eq!(model, "test-model"); + assert_eq!(system, None); + assert_eq!(prompt, "hello"); + } + + #[test] + fn forwards_system_prompt_when_set() { + let mut c = config(); + c.system = "you are terse".into(); + let mut llm = Llm::new("node-1".into(), c); + + let mut reqs = recorded_cloud_requests("node-1", |ctx| { + llm.dispatch("trigger", ComponentValue::Bool(true), ctx).unwrap(); + }); + + let (_, _, system, _) = generate_of(reqs.remove(0)); + assert_eq!(system.as_deref(), Some("you are terse")); + } + + #[test] + fn substitutes_template_vars_into_prompt() { + let mut c = config(); + c.prompt = "hello {{name}}".into(); + let mut llm = Llm::new("node-1".into(), c); + + let mut reqs = recorded_cloud_requests("node-1", |ctx| { + // Set the template var via the {{var}} input port, then trigger. + llm.dispatch("name", ComponentValue::String("world".into()), ctx).unwrap(); + llm.dispatch("trigger", ComponentValue::Bool(true), ctx).unwrap(); + }); + + // Only the trigger records a request; setting a template var does not. + assert_eq!(reqs.len(), 1); + let (_, _, _, prompt) = generate_of(reqs.remove(0)); + assert_eq!(prompt, "hello world"); + } +} diff --git a/crates/microflow-core/src/runtime/cloud/mod.rs b/crates/microflow-core/src/runtime/cloud/mod.rs new file mode 100644 index 00000000..ff4fe176 --- /dev/null +++ b/crates/microflow-core/src/runtime/cloud/mod.rs @@ -0,0 +1,55 @@ +//! Cloud nodes (`Mqtt`, `Llm`, `Figma`) on the [`Component`] trait. +//! +//! These nodes are **sans-IO** (ADR-0009): a `dispatch` records a `CloudRequest` +//! into the turn's `Effects` instead of spawning network work. The host's +//! `EffectsSink::perform_cloud` performs the call (desktop: `rumqttc` for MQTT/ +//! Figma, `reqwest` for LLM; browser: `fetch`/WSS); any result re-enters via +//! `FlowRuntime::inject_event` on the node's output handles. The nodes therefore +//! pull no tokio/reqwest/mqtt dependencies and live in `microflow-core` — gated +//! behind the `cloud` feature — so **both** hosts register them from one place +//! (`ComponentRegistry::register_all`) and the browser wasm build can run them. +//! They are unit-tested by asserting the recorded request (see +//! [`test_support::recorded_cloud_requests`]) rather than by spying on a service. +//! +//! [`Component`]: crate::runtime::Component + +pub mod figma; +pub mod llm; +pub mod mqtt; + +#[cfg(test)] +pub(crate) mod test_support { + use crate::firmata::FirmataClient; + use crate::runtime::{BufferBoardWriter, CloudRequestKind, RuntimeContext, ScheduleRequests}; + + /// Build a throwaway [`RuntimeContext`] and run `f` with it. Cloud nodes + /// ignore the board / clock, so the discarded buffer + client are just there + /// to satisfy the borrow. + pub fn with_test_ctx(node_id: &str, f: impl FnOnce(&mut RuntimeContext) -> R) -> R { + let mut client = FirmataClient::new(); + let mut out = Vec::new(); + let mut writer = BufferBoardWriter::new(&mut client, &mut out); + let mut reqs = ScheduleRequests::default(); + let mut ctx = RuntimeContext::new(&mut writer, 0.0, node_id, &mut reqs); + f(&mut ctx) + } + + /// Run `f` with a throwaway [`RuntimeContext`] and return the + /// [`CloudRequestKind`]s it recorded via `ctx.request_cloud` — the sans-IO + /// assertion surface for cloud nodes (ADR-0009): dispatch, then assert the + /// emitted request instead of spying on a recording service. + pub fn recorded_cloud_requests( + node_id: &str, + f: impl FnOnce(&mut RuntimeContext), + ) -> Vec { + let mut client = FirmataClient::new(); + let mut out = Vec::new(); + let mut writer = BufferBoardWriter::new(&mut client, &mut out); + let mut reqs = ScheduleRequests::default(); + { + let mut ctx = RuntimeContext::new(&mut writer, 0.0, node_id, &mut reqs); + f(&mut ctx); + } + reqs.cloud_requests.into_iter().map(|(_, kind)| kind).collect() + } +} diff --git a/apps/web/src-tauri/src/runtime/cloud/mqtt.rs b/crates/microflow-core/src/runtime/cloud/mqtt.rs similarity index 54% rename from apps/web/src-tauri/src/runtime/cloud/mqtt.rs rename to crates/microflow-core/src/runtime/cloud/mqtt.rs index f06eff72..b911c900 100644 --- a/apps/web/src-tauri/src/runtime/cloud/mqtt.rs +++ b/crates/microflow-core/src/runtime/cloud/mqtt.rs @@ -1,77 +1,34 @@ //! MQTT cloud node on core's [`Component`] trait. //! -//! - **Publish** (`direction = "publish"`): `dispatch("trigger")` spawns the -//! async [`MqttPublisher::publish`] onto the captured Tokio handle and returns -//! immediately — the sans-IO turn never blocks on the broker. +//! - **Publish** (`direction = "publish"`): `dispatch("trigger")` records a +//! [`CloudRequestKind::MqttPublish`] for the host's `EffectsSink::perform_cloud` +//! to perform — the sans-IO turn never touches the broker (ADR-0009). //! - **Subscribe** (`direction = "subscribe"`): describes its interest via //! [`subscriber_wiring`](Component::subscriber_wiring); the host routes broker //! payloads back via [`receive_raw_message`](Component::receive_raw_message), //! which emits on "value" downstream (matching the node's source handle). //! -//! [`Component`]: microflow_core::runtime::Component +//! [`Component`]: crate::runtime::Component -use crate::runtime::services::MqttPublisher; -use microflow_core::runtime::{ - Component, ComponentBase, ComponentValue, RuntimeContext, RuntimeError, SubscriberWiring, +use crate::runtime::{ + CloudRequestKind, Component, ComponentBase, ComponentBuilder, ComponentValue, RuntimeContext, + RuntimeError, SubscriberWiring, }; -use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::sync::Arc; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MqttConfig { - #[serde(default)] - pub broker_id: String, - #[serde(default)] - pub direction: String, - #[serde(default)] - pub topic: String, - #[serde(default = "default_qos")] - pub qos: String, - #[serde(default)] - pub retain: bool, -} - -fn default_qos() -> String { - "1".to_string() -} -impl Default for MqttConfig { - fn default() -> Self { - Self { - broker_id: String::new(), - direction: "subscribe".to_string(), - topic: String::new(), - qos: "1".to_string(), - retain: false, - } - } -} +pub use crate::config::mqtt::MqttConfig; pub struct Mqtt { base: ComponentBase, config: MqttConfig, - /// Shared MQTT publish handle. Used only by `direction = "publish"` nodes. - publisher: Arc, - /// Tokio handle injected by the host so `dispatch` (sync) can spawn the - /// async `publish` call. `None` disables publishing (logged). - rt_handle: Option, } impl Mqtt { #[must_use] - pub fn new( - id: String, - config: MqttConfig, - publisher: Arc, - rt_handle: Option, - ) -> Self { + pub fn new(id: String, config: MqttConfig) -> Self { Self { base: ComponentBase::new(id, ComponentValue::String(String::new())), config, - publisher, - rt_handle, } } @@ -92,11 +49,24 @@ impl Mqtt { } } +impl ComponentBuilder for Mqtt { + type Config = MqttConfig; + + fn build(id: String, config: MqttConfig) -> Result { + Ok(Self::new(id, config)) + } +} + impl Component for Mqtt { fn ports() -> &'static [&'static str] { &["trigger"] } + fn emits() -> &'static [&'static str] { + // Subscribe-side delivery emits the parsed payload on "value". + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -124,7 +94,7 @@ impl Component for Mqtt { &mut self, method: &str, args: ComponentValue, - _ctx: &mut RuntimeContext, + ctx: &mut RuntimeContext, ) -> Result<(), RuntimeError> { match method { "trigger" => { @@ -136,29 +106,13 @@ impl Component for Mqtt { self.base.value = args.clone(); - let payload = Self::encode_payload(&args); - let publisher = Arc::clone(&self.publisher); - let broker_id = self.config.broker_id.clone(); - let topic = self.config.topic.clone(); - let retain = self.config.retain; - let component_id = Arc::clone(&self.base.id); - - let Some(handle) = &self.rt_handle else { - log::error!( - "[MQTT] {component_id} no Tokio runtime available, cannot spawn publish" - ); - return Ok(()); - }; - - handle.spawn(async move { - log::info!( - "[MQTT] {component_id} publish → broker={broker_id} topic={topic} retain={retain}" - ); - if let Err(e) = publisher.publish(&broker_id, &topic, &payload, retain).await { - log::error!( - "[MQTT] {component_id} publish failed (broker={broker_id} topic={topic}): {e}" - ); - } + // Sans-IO: record the publish for the host to perform; the node + // never spawns or touches the broker (ADR-0009). + ctx.request_cloud(CloudRequestKind::MqttPublish { + broker_id: self.config.broker_id.clone(), + topic: self.config.topic.clone(), + payload: Self::encode_payload(&args), + retain: self.config.retain, }); Ok(()) @@ -187,7 +141,7 @@ impl Component for Mqtt { self.base.value = component_value.clone(); self.base - .emit_with_value("value", Cow::Owned(component_value)); + .emit_with_value(ComponentBase::VALUE_HANDLE, Cow::Owned(component_value)); } fn destroy(&mut self) { @@ -198,13 +152,11 @@ impl Component for Mqtt { #[cfg(test)] mod tests { use super::*; - use crate::runtime::cloud::test_support::with_test_ctx; - use crate::runtime::services::{RecordedPublish, RecordingMqttPublisher}; - use microflow_core::runtime::{ComponentEvent, EventSink}; + use crate::runtime::cloud::test_support::{recorded_cloud_requests, with_test_ctx}; + use crate::runtime::{ComponentEvent, EventSink}; use std::cell::RefCell; use std::collections::VecDeque; use std::rc::Rc; - use std::time::Duration; fn sink() -> EventSink { Rc::new(RefCell::new(VecDeque::new())) @@ -214,24 +166,8 @@ mod tests { sink.borrow_mut().drain(..).collect() } - async fn wait_for_publishes( - recorder: &RecordingMqttPublisher, - min: usize, - timeout: Duration, - ) -> Vec { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let snap = recorder.recorded(); - if snap.len() >= min || tokio::time::Instant::now() >= deadline { - return snap; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - - #[tokio::test] - async fn publish_node_dispatches_to_publisher() { - let recorder = Arc::new(RecordingMqttPublisher::new()); + #[test] + fn publish_node_records_publish_request() { let config = MqttConfig { direction: "publish".into(), broker_id: "broker-1".into(), @@ -239,51 +175,54 @@ mod tests { retain: true, ..MqttConfig::default() }; + let mut node = Mqtt::new("node-1".into(), config); - let mut node = Mqtt::new( - "node-1".into(), - config, - recorder.clone() as Arc, - Some(tokio::runtime::Handle::current()), - ); - - with_test_ctx("node-1", |ctx| { + let mut reqs = recorded_cloud_requests("node-1", |ctx| { node.dispatch("trigger", ComponentValue::Number(42.0), ctx) .expect("dispatch ok"); }); - let published = wait_for_publishes(&recorder, 1, Duration::from_secs(1)).await; - assert_eq!(published.len(), 1); - assert_eq!(published[0].broker_id, "broker-1"); - assert_eq!(published[0].topic, "sensors/light"); - assert_eq!(published[0].payload, b"42"); - assert!(published[0].retain); + assert_eq!(reqs.len(), 1); + match reqs.remove(0) { + CloudRequestKind::MqttPublish { broker_id, topic, payload, retain } => { + assert_eq!(broker_id, "broker-1"); + assert_eq!(topic, "sensors/light"); + assert_eq!(payload, b"42"); + assert!(retain); + } + other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + } } - #[tokio::test] - async fn subscribe_node_rejects_trigger_without_publishing() { - let recorder = Arc::new(RecordingMqttPublisher::new()); + #[test] + fn subscribe_node_rejects_trigger() { let config = MqttConfig { direction: "subscribe".into(), broker_id: "broker-1".into(), topic: "sensors/light".into(), ..MqttConfig::default() }; - - let mut node = Mqtt::new( - "node-1".into(), - config, - recorder.clone() as Arc, - Some(tokio::runtime::Handle::current()), - ); + let mut node = Mqtt::new("node-1".into(), config); let err = with_test_ctx("node-1", |ctx| { node.dispatch("trigger", ComponentValue::Bool(true), ctx) .expect_err("subscribe should refuse trigger") }); assert!(err.to_string().contains("subscribe")); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(recorder.recorded().is_empty()); + } + + #[test] + fn subscribe_node_records_no_request() { + let config = MqttConfig { + direction: "subscribe".into(), + ..MqttConfig::default() + }; + let mut node = Mqtt::new("node-1".into(), config); + + let reqs = recorded_cloud_requests("node-1", |ctx| { + let _ = node.dispatch("trigger", ComponentValue::Bool(true), ctx); + }); + assert!(reqs.is_empty(), "a rejected subscribe trigger must record no cloud request"); } #[test] @@ -294,8 +233,6 @@ mod tests { direction: "subscribe".into(), ..MqttConfig::default() }, - Arc::new(RecordingMqttPublisher::new()) as Arc, - None, ); let s = sink(); node.set_sink(s.clone()); diff --git a/crates/microflow-core/src/runtime/component.rs b/crates/microflow-core/src/runtime/component.rs index 73d7741f..4e92a3b3 100644 --- a/crates/microflow-core/src/runtime/component.rs +++ b/crates/microflow-core/src/runtime/component.rs @@ -41,6 +41,21 @@ pub trait Component { &[] } + /// Declared **Emit** names — the closed set of edge-output handles + /// (`source_handle`) this impl may emit on. The symmetric counterpart of + /// [`ports`](Component::ports). Mirrored to `node-components.json + /// impls[].emits[]` and asserted equal by the Catalog Parity Guard + /// (ADR-0007). Includes [`ComponentBase::VALUE_HANDLE`] for any impl that + /// emits via [`ComponentBase::set_value`]. Excludes `_`-prefixed Internal + /// Event / wakeup names. Default empty. + #[must_use] + fn emits() -> &'static [&'static str] + where + Self: Sized, + { + &[] + } + /// Reference to the shared [`ComponentBase`]. fn base(&self) -> &ComponentBase; @@ -169,6 +184,12 @@ pub struct ComponentBase { } impl ComponentBase { + /// The implicit **Emit** handle fired by [`set_value`](Self::set_value) when + /// the value changes. Centralized here so every value-emitting node lists the + /// same constant in its `Component::emits()` rather than a bare `"value"` + /// literal. See CONTEXT.md § Emit and ADR-0007. + pub const VALUE_HANDLE: &'static str = "value"; + #[must_use] pub fn new(id: String, initial_value: ComponentValue) -> Self { Self { @@ -178,11 +199,12 @@ impl ComponentBase { } } - /// Set the value and automatically emit a "value" event if it changed. + /// Set the value and automatically emit a [`VALUE_HANDLE`](Self::VALUE_HANDLE) + /// event if it changed. pub fn set_value(&mut self, value: ComponentValue) { if self.value != value { self.value = value; - self.emit("value"); + self.emit(Self::VALUE_HANDLE); } } diff --git a/crates/microflow-core/src/runtime/context.rs b/crates/microflow-core/src/runtime/context.rs index 1fa870e1..34b0f4f2 100644 --- a/crates/microflow-core/src/runtime/context.rs +++ b/crates/microflow-core/src/runtime/context.rs @@ -11,6 +11,7 @@ use crate::runtime::board::BoardWriter; use crate::runtime::value::ComponentEvent; use serde::Serialize; +use std::sync::Arc; /// Opaque handle to a scheduled wakeup, so the host can cancel a specific timer. pub type WakeupId = u64; @@ -26,9 +27,50 @@ pub struct Wakeup { pub delay_ms: u64, } +/// An outbound cloud call a node asked the host to perform (ADR-0009): the +/// sans-IO replacement for the old in-component `tokio::spawn`. A cloud node's +/// `dispatch` records one of these via [`RuntimeContext::request_cloud`] instead +/// of touching the network; the host's [`EffectsSink::perform_cloud`] performs +/// it, and any result re-enters through `FlowRuntime::inject_event` on `source`. +/// `source` (the node id) is the correlation key for that re-entry. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudRequest { + /// The node that issued the request; results re-enter via `inject_event` + /// targeting this id. + pub source: Arc, + #[serde(flatten)] + pub kind: CloudRequestKind, +} + +/// What kind of cloud I/O a [`CloudRequest`] is. Carries only plain data (no +/// service handles, no Tokio) so a cloud node stays fully sans-IO and unit- +/// testable by asserting the emitted request. The host maps each variant onto +/// its platform transport (desktop `rumqttc`/`reqwest`; browser WSS/`fetch`). +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CloudRequestKind { + /// Fire-and-forget MQTT publish (the MQTT publish node and Figma's set-back). + /// Nothing re-enters the runtime. + MqttPublish { + broker_id: String, + topic: String, + payload: Vec, + retain: bool, + }, + /// LLM text generation. The result re-enters on the node's `value` / `done` + /// / `error` / `thinking` handles via `inject_event`. + LlmGenerate { + provider_id: String, + model: String, + system: Option, + prompt: String, + }, +} + /// Everything the host must do after one runtime turn. Bytes go to the serial /// port, events to the UI stores, wakeups to host timers, cancellations clear -/// timers that are no longer wanted. +/// timers that are no longer wanted, cloud requests go to the network. #[derive(Debug, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Effects { @@ -36,6 +78,70 @@ pub struct Effects { pub component_events: Vec, pub wakeups: Vec, pub cancellations: Vec, + pub cloud_requests: Vec, +} + +/// The per-field hook surface a **Runtime Host** implements to apply one turn's +/// [`Effects`]. [`Effects::apply`] drives these hooks in the canonical order, so +/// the ordering *policy* lives here (core), once — not re-implemented across the +/// two hosts in two languages, where it already drifted (ADR-0008). The platform +/// *primitives* behind each hook stay genuinely per-host (Tokio `abort_handle` +/// vs `clearTimeout`); only the order is shared. +/// +/// Adding a field to [`Effects`] adds a hook here — a compile error in every +/// sink until handled, never a silently-dropped field. (ADR-0009's +/// `perform_cloud` will land as exactly such a hook.) +pub trait EffectsSink { + /// Write the turn's outbound serial bytes to the wire. Called at most once + /// per turn, and only when there are bytes ([`Effects::apply`] guards empty). + fn write_bytes(&mut self, bytes: &[u8]); + /// Cancel a previously-armed host timer by id — a wakeup no longer wanted. + fn cancel_wakeup(&mut self, id: WakeupId); + /// Arm a host timer that calls `FlowRuntime::wake(node_id, method)` after + /// `wakeup.delay_ms`. + fn arm_wakeup(&mut self, wakeup: &Wakeup); + /// Perform an outbound cloud call (ADR-0009); any result re-enters via + /// `FlowRuntime::inject_event`. Sequenced before `dispatch_event` so a + /// request issued this turn is launched before the turn's UI events leave. + fn perform_cloud(&mut self, request: &CloudRequest); + /// Deliver a component event to the UI (desktop Tauri `emit`, browser store + /// ingest). These leave the runtime and do not feed back this turn. + fn dispatch_event(&mut self, event: &ComponentEvent); +} + +impl Effects { + /// Apply this turn's effects to `sink` in the **canonical order** (ADR-0008, + /// extended by ADR-0009): `outbound_bytes → cancellations → wakeups → + /// cloud_requests → component_events`. + /// + /// - Bytes first: lowest wire latency for the turn's hardware writes. + /// - Cancel before arm: a cancel + re-arm of the same logical timer in one + /// turn must clear the old timer before the new one is set — the safe + /// default (and the browser host's pre-existing order). + /// - Cloud requests launched before UI events leave, so an outbound call + /// issued this turn is in flight before the turn's events exit the runtime. + /// - UI events last: they exit the runtime and never feed back this turn. + /// + /// The desktop host calls this directly; the browser reactor cannot (it is + /// TypeScript) and mirrors the same order + hook shape, held to it by a + /// shared conformance test. + pub fn apply(&self, sink: &mut S) { + if !self.outbound_bytes.is_empty() { + sink.write_bytes(&self.outbound_bytes); + } + for &id in &self.cancellations { + sink.cancel_wakeup(id); + } + for wakeup in &self.wakeups { + sink.arm_wakeup(wakeup); + } + for request in &self.cloud_requests { + sink.perform_cloud(request); + } + for event in &self.component_events { + sink.dispatch_event(event); + } + } } /// Per-turn collector of scheduling requests made by nodes during dispatch. @@ -47,6 +153,10 @@ pub struct ScheduleRequests { pub schedules: Vec<(String, String, u64)>, /// `(node_id, method)` — cancel any pending wake for this node + method. pub cancels: Vec<(String, String)>, + /// `(node_id, kind)` — outbound cloud calls a node asked the host to perform + /// this turn (ADR-0009). Resolved into [`Effects::cloud_requests`] when the + /// turn drains. + pub cloud_requests: Vec<(String, CloudRequestKind)>, } /// Capabilities handed to a component for the duration of one dispatch call: @@ -96,4 +206,124 @@ impl<'a> RuntimeContext<'a> { .cancels .push((self.node_id.to_string(), method.to_string())); } + + /// Record an outbound cloud call for the host to perform after the turn + /// drains (ADR-0009). The caller (a cloud node) is the implicit `source`, so + /// any result re-enters via `FlowRuntime::inject_event` on this node. The + /// node never touches the network — it stays sans-IO and testable. + pub fn request_cloud(&mut self, kind: CloudRequestKind) { + self.requests + .cloud_requests + .push((self.node_id.to_string(), kind)); + } +} + +#[cfg(test)] +mod apply_tests { + use super::*; + use crate::runtime::value::ComponentValue; + use std::sync::Arc; + + /// One recorded hook invocation — enough to assert order + that nothing + /// double-fires. The Rust side of the ADR-0008 conformance scenario; the + /// browser mirror lives in `apps/web/src/lib/firmata/__tests__/`. + #[derive(Debug, PartialEq)] + enum Call { + Write(usize), + Cancel(WakeupId), + Arm(WakeupId), + Cloud(String), + Event(String), + } + + #[derive(Default)] + struct Recorder { + calls: Vec, + } + + impl EffectsSink for Recorder { + fn write_bytes(&mut self, bytes: &[u8]) { + self.calls.push(Call::Write(bytes.len())); + } + fn cancel_wakeup(&mut self, id: WakeupId) { + self.calls.push(Call::Cancel(id)); + } + fn arm_wakeup(&mut self, wakeup: &Wakeup) { + self.calls.push(Call::Arm(wakeup.id)); + } + fn perform_cloud(&mut self, request: &CloudRequest) { + self.calls.push(Call::Cloud(request.source.to_string())); + } + fn dispatch_event(&mut self, event: &ComponentEvent) { + self.calls.push(Call::Event(event.source_handle.to_string())); + } + } + + fn event(handle: &str) -> ComponentEvent { + ComponentEvent { + source: Arc::from("n"), + source_handle: Arc::from(handle), + value: ComponentValue::Bool(true), + edge_id: None, + sequence: 0, + } + } + + #[test] + fn apply_drives_hooks_in_canonical_order() { + // The ADR-0008/0009 scenario: a turn that cancels one timer, re-arms + // another, writes bytes, issues a cloud call, and emits — all five fields + // present. The contract is the order (bytes → cancel → arm → cloud → + // event) and that each fires exactly once. + let effects = Effects { + outbound_bytes: vec![0x90, 0x01, 0x00], + component_events: vec![event("value")], + wakeups: vec![Wakeup { + id: 9, + node_id: "t".to_string(), + method: "_tick".to_string(), + delay_ms: 100, + }], + cancellations: vec![7], + cloud_requests: vec![CloudRequest { + source: Arc::from("llm"), + kind: CloudRequestKind::LlmGenerate { + provider_id: "p".to_string(), + model: "m".to_string(), + system: None, + prompt: "hi".to_string(), + }, + }], + }; + + let mut rec = Recorder::default(); + effects.apply(&mut rec); + + assert_eq!( + rec.calls, + vec![ + Call::Write(3), + Call::Cancel(7), + Call::Arm(9), + Call::Cloud("llm".to_string()), + Call::Event("value".to_string()), + ], + "effects must apply in the canonical order with no double-fire" + ); + } + + #[test] + fn apply_skips_write_when_no_outbound_bytes() { + // Empty outbound must not call `write_bytes` at all — the host's write + // path (port flush / `connection.write`) is skipped on idle turns. + let effects = Effects { + component_events: vec![event("value")], + ..Effects::default() + }; + + let mut rec = Recorder::default(); + effects.apply(&mut rec); + + assert_eq!(rec.calls, vec![Call::Event("value".to_string())]); + } } diff --git a/crates/microflow-core/src/runtime/control/counter.rs b/crates/microflow-core/src/runtime/control/counter.rs index e38f6818..2a7c1310 100644 --- a/crates/microflow-core/src/runtime/control/counter.rs +++ b/crates/microflow-core/src/runtime/control/counter.rs @@ -47,6 +47,10 @@ impl Component for Counter { &["increment", "decrement", "reset", "set"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/control/delay.rs b/crates/microflow-core/src/runtime/control/delay.rs index 16509fd0..8ca14843 100644 --- a/crates/microflow-core/src/runtime/control/delay.rs +++ b/crates/microflow-core/src/runtime/control/delay.rs @@ -21,6 +21,8 @@ pub struct Delay { } impl Delay { + const E_EVENT: &'static str = "event"; + #[must_use] pub fn new(id: String, config: DelayConfig) -> Self { Self { @@ -47,6 +49,12 @@ impl Component for Delay { &["trigger"] } + fn emits() -> &'static [&'static str] { + // Delay stores its input with a raw `base.value =` write (no set_value), + // so it never emits the implicit "value" — only the delayed "event". + &[Self::E_EVENT] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -81,7 +89,7 @@ impl Component for Delay { match method { // Delay elapsed: emit the stored value on the "event" handle. "tick" => { - self.base.emit("event"); + self.base.emit(Self::E_EVENT); Ok(()) } _ => Ok(()), diff --git a/crates/microflow-core/src/runtime/control/trigger.rs b/crates/microflow-core/src/runtime/control/trigger.rs index e85cfd77..f35bccb6 100644 --- a/crates/microflow-core/src/runtime/control/trigger.rs +++ b/crates/microflow-core/src/runtime/control/trigger.rs @@ -23,6 +23,8 @@ pub struct Trigger { } impl Trigger { + const E_BANG: &'static str = "bang"; + #[must_use] pub fn new(id: String, config: TriggerConfig) -> Self { Self { @@ -48,7 +50,7 @@ impl Trigger { let should_bang = self.check_difference(value_num); if should_bang { self.base - .emit_with_value("bang", Cow::Owned(ComponentValue::Number(value_num))); + .emit_with_value(Self::E_BANG, Cow::Owned(ComponentValue::Number(value_num))); } } @@ -92,6 +94,10 @@ impl Component for Trigger { &["value"] } + fn emits() -> &'static [&'static str] { + &[Self::E_BANG, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/generator/constant.rs b/crates/microflow-core/src/runtime/generator/constant.rs index 9f69ed0a..319c5387 100644 --- a/crates/microflow-core/src/runtime/generator/constant.rs +++ b/crates/microflow-core/src/runtime/generator/constant.rs @@ -26,6 +26,10 @@ impl Component for Constant { &[] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -48,7 +52,7 @@ impl Component for Constant { /// Emit the constant value once the node is built, so downstream nodes get /// it without any edge input. (The desktop relied on `set_event_sender`.) fn on_start(&mut self, _ctx: &mut RuntimeContext) -> Result<(), RuntimeError> { - self.base.emit("value"); + self.base.emit(ComponentBase::VALUE_HANDLE); Ok(()) } } diff --git a/crates/microflow-core/src/runtime/generator/interval.rs b/crates/microflow-core/src/runtime/generator/interval.rs index ef7c00de..2a9ebbb6 100644 --- a/crates/microflow-core/src/runtime/generator/interval.rs +++ b/crates/microflow-core/src/runtime/generator/interval.rs @@ -27,6 +27,8 @@ pub struct Interval { } impl Interval { + const E_EVENT: &'static str = "event"; + #[must_use] pub fn new(id: String, config: IntervalConfig) -> Self { Self { @@ -63,7 +65,7 @@ impl Interval { None => return, }; self.base - .emit_with_value("event", Cow::Owned(ComponentValue::Number(elapsed))); + .emit_with_value(Self::E_EVENT, Cow::Owned(ComponentValue::Number(elapsed))); ctx.schedule_wakeup("_tick", self.period_ms()); } } @@ -73,6 +75,12 @@ impl Component for Interval { &["start", "stop"] } + fn emits() -> &'static [&'static str] { + // Interval emits elapsed-ms on "event" via emit_with_value (no set_value), + // so it never emits the implicit "value". + &[Self::E_EVENT] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/generator/oscillator.rs b/crates/microflow-core/src/runtime/generator/oscillator.rs index 50414795..7d0fc0ec 100644 --- a/crates/microflow-core/src/runtime/generator/oscillator.rs +++ b/crates/microflow-core/src/runtime/generator/oscillator.rs @@ -60,7 +60,7 @@ impl Oscillator { } let elapsed = ctx.now_ms() - self.start_ms; let value = calculate_waveform(&self.config, elapsed); - self.base.emit_with_value("value", Cow::Owned(ComponentValue::Number(value))); + self.base.emit_with_value(ComponentBase::VALUE_HANDLE, Cow::Owned(ComponentValue::Number(value))); ctx.schedule_wakeup("_tick", REFRESH_MS); } } @@ -145,6 +145,10 @@ impl Component for Oscillator { &["start", "stop", "reset"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/input/button.rs b/crates/microflow-core/src/runtime/input/button.rs index 7af9639e..68164b86 100644 --- a/crates/microflow-core/src/runtime/input/button.rs +++ b/crates/microflow-core/src/runtime/input/button.rs @@ -36,6 +36,11 @@ pub struct Button { } impl Button { + const E_EVENT: &'static str = "event"; + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + const E_HOLD: &'static str = "hold"; + #[must_use] pub fn new(id: String, config: ButtonConfig) -> Self { Self { @@ -80,13 +85,13 @@ impl Button { if pressed { // Arm the hold wakeup; fires once after `holdtime` ms if still held. ctx.schedule_wakeup("_hold", self.config.holdtime); - self.base.emit("event"); - self.base.emit("true"); + self.base.emit(Self::E_EVENT); + self.base.emit(Self::E_TRUE); } else { // Released before (or after) hold — cancel any pending hold wakeup. ctx.cancel_wakeup("_hold"); - self.base.emit("event"); - self.base.emit("false"); + self.base.emit(Self::E_EVENT); + self.base.emit(Self::E_FALSE); } } } @@ -96,6 +101,16 @@ impl Component for Button { &["read"] } + fn emits() -> &'static [&'static str] { + &[ + Self::E_EVENT, + Self::E_TRUE, + Self::E_FALSE, + Self::E_HOLD, + ComponentBase::VALUE_HANDLE, + ] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -137,7 +152,7 @@ impl Component for Button { "hold" => { if self.is_pressed && !self.hold_emitted { self.hold_emitted = true; - self.base.emit("hold"); + self.base.emit(Self::E_HOLD); } Ok(()) } diff --git a/crates/microflow-core/src/runtime/input/hotkey.rs b/crates/microflow-core/src/runtime/input/hotkey.rs index dd2090bd..619572a1 100644 --- a/crates/microflow-core/src/runtime/input/hotkey.rs +++ b/crates/microflow-core/src/runtime/input/hotkey.rs @@ -17,6 +17,10 @@ pub struct Hotkey { } impl Hotkey { + const E_EVENT: &'static str = "event"; + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + #[must_use] pub fn new(id: String, config: HotkeyConfig) -> Self { Self { @@ -37,6 +41,15 @@ impl Component for Hotkey { &["key_event"] } + fn emits() -> &'static [&'static str] { + &[ + Self::E_EVENT, + Self::E_TRUE, + Self::E_FALSE, + ComponentBase::VALUE_HANDLE, + ] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -63,11 +76,11 @@ impl Component for Hotkey { "key_event" => { let pressed = args.is_truthy(); self.base.set_value(ComponentValue::Bool(pressed)); - self.base.emit("event"); + self.base.emit(Self::E_EVENT); if pressed { - self.base.emit("true"); + self.base.emit(Self::E_TRUE); } else { - self.base.emit("false"); + self.base.emit(Self::E_FALSE); } Ok(()) } diff --git a/crates/microflow-core/src/runtime/input/i2c_device.rs b/crates/microflow-core/src/runtime/input/i2c_device.rs index 87ddfb71..d9611b57 100644 --- a/crates/microflow-core/src/runtime/input/i2c_device.rs +++ b/crates/microflow-core/src/runtime/input/i2c_device.rs @@ -131,6 +131,10 @@ impl Component for I2cDevice { &["write", "trigger"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -173,7 +177,7 @@ impl Component for I2cDevice { _ => {} } ctx.board().i2c_write(i32::from(self.config.address), &data)?; - self.base.emit("value"); + self.base.emit(ComponentBase::VALUE_HANDLE); Ok(()) } "trigger" => { diff --git a/crates/microflow-core/src/runtime/input/motion.rs b/crates/microflow-core/src/runtime/input/motion.rs index e9992ebe..ca80d0c0 100644 --- a/crates/microflow-core/src/runtime/input/motion.rs +++ b/crates/microflow-core/src/runtime/input/motion.rs @@ -20,6 +20,10 @@ pub struct Motion { } impl Motion { + const E_EVENT: &'static str = "event"; + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + #[must_use] pub fn new(id: String, config: MotionConfig) -> Self { Self { @@ -37,8 +41,8 @@ impl Motion { if detected != self.motion_detected { self.motion_detected = detected; self.base.set_value(ComponentValue::Bool(detected)); - self.base.emit("event"); - self.base.emit(if detected { "true" } else { "false" }); + self.base.emit(Self::E_EVENT); + self.base.emit(if detected { Self::E_TRUE } else { Self::E_FALSE }); } } } @@ -48,6 +52,10 @@ impl Component for Motion { &["read"] } + fn emits() -> &'static [&'static str] { + &[Self::E_EVENT, Self::E_TRUE, Self::E_FALSE, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/input/proximity.rs b/crates/microflow-core/src/runtime/input/proximity.rs index d3ad5469..e9b299fd 100644 --- a/crates/microflow-core/src/runtime/input/proximity.rs +++ b/crates/microflow-core/src/runtime/input/proximity.rs @@ -111,6 +111,10 @@ impl Component for Proximity { &["read"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/input/sensor.rs b/crates/microflow-core/src/runtime/input/sensor.rs index 510392c0..b2e1f509 100644 --- a/crates/microflow-core/src/runtime/input/sensor.rs +++ b/crates/microflow-core/src/runtime/input/sensor.rs @@ -108,6 +108,10 @@ impl Component for Sensor { &["read"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/input/switch.rs b/crates/microflow-core/src/runtime/input/switch.rs index 167325ea..0fdfbf29 100644 --- a/crates/microflow-core/src/runtime/input/switch.rs +++ b/crates/microflow-core/src/runtime/input/switch.rs @@ -34,6 +34,10 @@ pub struct Switch { } impl Switch { + const E_EVENT: &'static str = "event"; + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + #[must_use] pub fn new(id: String, config: SwitchConfig) -> Self { Self { @@ -82,11 +86,11 @@ impl Switch { fn apply_state(&mut self, closed: bool) { self.is_closed = closed; self.base.set_value(ComponentValue::Bool(closed)); - self.base.emit("event"); + self.base.emit(Self::E_EVENT); if closed { - self.base.emit("true"); + self.base.emit(Self::E_TRUE); } else { - self.base.emit("false"); + self.base.emit(Self::E_FALSE); } } } @@ -96,6 +100,10 @@ impl Component for Switch { &["read"] } + fn emits() -> &'static [&'static str] { + &[Self::E_EVENT, Self::E_TRUE, Self::E_FALSE, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/mod.rs b/crates/microflow-core/src/runtime/mod.rs index 8ff79070..fb2bbcbf 100644 --- a/crates/microflow-core/src/runtime/mod.rs +++ b/crates/microflow-core/src/runtime/mod.rs @@ -23,20 +23,29 @@ pub mod router; pub mod value; pub mod wiring; -// Component node categories. `cloud` (external/) lands behind the feature gate. +// Component node categories. `cloud` (external/) is behind the feature gate — +// its nodes are sans-IO (they emit `CloudRequest`s the host performs), so the +// gate keeps them out of codegen-only consumers, not out of any host: both the +// desktop bin and the browser wasm build enable `cloud` (ADR-0009). pub mod control; pub mod generator; pub mod input; pub mod output; pub mod transformation; +#[cfg(feature = "cloud")] +pub mod cloud; + // Pin string-or-number serde helpers moved to the ungated `config::serde_utils` // (so shared configs can use them without the `runtime` feature); re-exported // here so existing `crate::runtime::serde_utils` references keep resolving. pub use crate::config::serde_utils; pub use board::{BoardWriter, BufferBoardWriter}; pub use component::{Component, ComponentBase, ComponentBuilder, EventSink, HardwareComponent}; -pub use context::{Effects, RuntimeContext, ScheduleRequests, Wakeup, WakeupId}; +pub use context::{ + CloudRequest, CloudRequestKind, Effects, EffectsSink, RuntimeContext, ScheduleRequests, Wakeup, + WakeupId, +}; pub use error::{HardwareError, RuntimeError}; pub use registry::ComponentRegistry; pub use router::{ComponentLookup, DispatchCall, EdgeTarget, FlowRouter}; @@ -414,14 +423,6 @@ impl FlowRuntime { self.current_sequence = sequence; } - /// Register an externally-built component factory (host-provided nodes — the - /// desktop injects its cloud nodes this way, keeping their async/network - /// impls and dependencies out of core). See - /// [`ComponentRegistry::register_factory`]. - pub fn register_node(&mut self, name: &str, factory: registry::Factory) { - self.registry.register_factory(name, factory); - } - /// Deliver an inbound external message (MQTT / Figma broker payload) to a /// subscribe component by id; it updates + emits, then the cascade drains. pub fn deliver_message(&mut self, component_id: &str, topic: &str, payload: &[u8]) -> Effects { @@ -604,17 +605,27 @@ impl FlowRuntime { } self.process_event(event, &mut out, &mut reqs); } + // Cloud requests a node asked for this turn (ADR-0009), resolved into the + // host-facing `Effects` shape. `source` (node id) is the correlation key + // for any result that re-enters via `inject_event`. + let cloud_requests: Vec = reqs + .cloud_requests + .drain(..) + .map(|(source, kind)| CloudRequest { source: Arc::from(source.as_str()), kind }) + .collect(); let (wakeups, cancellations) = self.resolve_schedule(reqs); // One wide event per turn that *did something* — drained an event, wrote - // bytes, (re)armed a timer, or hit a dispatch error. Truly-idle turns - // (pin scans with no change) stay silent to bound volume. The single line - // is self-explaining: what triggered the turn, what it produced, what failed. + // bytes, (re)armed a timer, issued a cloud call, or hit a dispatch error. + // Truly-idle turns (pin scans with no change) stay silent to bound volume. + // The single line is self-explaining: what triggered the turn, what it + // produced, what failed. let errors = self.tick_errors; self.tick_errors = 0; let produced = drained > 0 || !out.is_empty() || !wakeups.is_empty() || !cancellations.is_empty() + || !cloud_requests.is_empty() || errors > 0; if produced { tracing::debug!( @@ -629,7 +640,7 @@ impl FlowRuntime { "flow tick", ); } - Effects { outbound_bytes: out, component_events: events, wakeups, cancellations } + Effects { outbound_bytes: out, component_events: events, wakeups, cancellations, cloud_requests } } /// Gate stale events, branch internal/hardware callbacks, echo `set_value` diff --git a/crates/microflow-core/src/runtime/output/led.rs b/crates/microflow-core/src/runtime/output/led.rs index 956b2bae..710aa21c 100644 --- a/crates/microflow-core/src/runtime/output/led.rs +++ b/crates/microflow-core/src/runtime/output/led.rs @@ -62,6 +62,11 @@ impl Component for Led { &["true", "false", "toggle", "value"] } + fn emits() -> &'static [&'static str] { + // Only the implicit value emit (turn_on/off/toggle/brightness → set_value). + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/output/matrix.rs b/crates/microflow-core/src/runtime/output/matrix.rs index 843eaa13..4aaf6d6c 100644 --- a/crates/microflow-core/src/runtime/output/matrix.rs +++ b/crates/microflow-core/src/runtime/output/matrix.rs @@ -258,6 +258,8 @@ impl Matrix { impl Component for Matrix { fn ports() -> &'static [&'static str] { &["value", "reset", "reinitialize"] } + fn emits() -> &'static [&'static str] { &[ComponentBase::VALUE_HANDLE] } + fn base(&self) -> &ComponentBase { &self.base } fn base_mut(&mut self) -> &mut ComponentBase { &mut self.base } fn component_type(&self) -> &'static str { "Matrix" } diff --git a/crates/microflow-core/src/runtime/output/monitor.rs b/crates/microflow-core/src/runtime/output/monitor.rs index 7ed5d356..023db85d 100644 --- a/crates/microflow-core/src/runtime/output/monitor.rs +++ b/crates/microflow-core/src/runtime/output/monitor.rs @@ -32,6 +32,10 @@ impl Component for Monitor { &["value"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/output/piezo.rs b/crates/microflow-core/src/runtime/output/piezo.rs index c2479885..affa8f1e 100644 --- a/crates/microflow-core/src/runtime/output/piezo.rs +++ b/crates/microflow-core/src/runtime/output/piezo.rs @@ -192,6 +192,8 @@ impl Piezo { impl Component for Piezo { fn ports() -> &'static [&'static str] { &["trigger", "stop"] } + fn emits() -> &'static [&'static str] { &[ComponentBase::VALUE_HANDLE] } + fn base(&self) -> &ComponentBase { &self.base } fn base_mut(&mut self) -> &mut ComponentBase { &mut self.base } fn component_type(&self) -> &'static str { "Piezo" } diff --git a/crates/microflow-core/src/runtime/output/pixel.rs b/crates/microflow-core/src/runtime/output/pixel.rs index da8ffbc1..4347d86b 100644 --- a/crates/microflow-core/src/runtime/output/pixel.rs +++ b/crates/microflow-core/src/runtime/output/pixel.rs @@ -34,6 +34,8 @@ pub struct Pixel { } impl Pixel { + const E_EVENT: &'static str = "event"; + #[must_use] pub fn new(id: String, config: PixelConfig) -> Self { let len = config.length as usize; @@ -194,6 +196,10 @@ impl Component for Pixel { &["value", "color", "set", "reset"] } + fn emits() -> &'static [&'static str] { + &[Self::E_EVENT, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } @@ -225,7 +231,7 @@ impl Component for Pixel { index.min(self.config.presets.len() - 1) }; self.apply_preset(ctx, clamped)?; - self.base.emit("event"); + self.base.emit(Self::E_EVENT); Ok(()) } "color" => { @@ -263,7 +269,7 @@ impl Component for Pixel { } } } - self.base.emit("event"); + self.base.emit(Self::E_EVENT); Ok(()) } "set" => { @@ -283,12 +289,12 @@ impl Component for Pixel { } } self.update_value(); - self.base.emit("event"); + self.base.emit(Self::E_EVENT); Ok(()) } "reset" => { self.off(ctx)?; - self.base.emit("event"); + self.base.emit(Self::E_EVENT); Ok(()) } _ => Err(RuntimeError::ComponentError(format!("Pixel: unknown method '{method}'"))), diff --git a/crates/microflow-core/src/runtime/output/relay.rs b/crates/microflow-core/src/runtime/output/relay.rs index 5ed56e39..5c50f11b 100644 --- a/crates/microflow-core/src/runtime/output/relay.rs +++ b/crates/microflow-core/src/runtime/output/relay.rs @@ -53,6 +53,10 @@ impl Component for Relay { &["true", "false", "toggle"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/output/rgb.rs b/crates/microflow-core/src/runtime/output/rgb.rs index 670944b4..436e9faf 100644 --- a/crates/microflow-core/src/runtime/output/rgb.rs +++ b/crates/microflow-core/src/runtime/output/rgb.rs @@ -134,6 +134,10 @@ impl Component for Rgb { &["red", "green", "blue", "alpha", "off"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/output/servo.rs b/crates/microflow-core/src/runtime/output/servo.rs index bd3436d6..07e932df 100644 --- a/crates/microflow-core/src/runtime/output/servo.rs +++ b/crates/microflow-core/src/runtime/output/servo.rs @@ -70,6 +70,10 @@ impl Component for Servo { &["min", "max", "value", "rotate", "stop"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/output/stepper.rs b/crates/microflow-core/src/runtime/output/stepper.rs index 92ec2602..e522ba46 100644 --- a/crates/microflow-core/src/runtime/output/stepper.rs +++ b/crates/microflow-core/src/runtime/output/stepper.rs @@ -239,6 +239,8 @@ impl Stepper { impl Component for Stepper { fn ports() -> &'static [&'static str] { &["value", "to", "stop", "zero", "enable"] } + fn emits() -> &'static [&'static str] { &[ComponentBase::VALUE_HANDLE] } + fn base(&self) -> &ComponentBase { &self.base } fn base_mut(&mut self) -> &mut ComponentBase { &mut self.base } fn component_type(&self) -> &'static str { "Stepper" } diff --git a/crates/microflow-core/src/runtime/registry.rs b/crates/microflow-core/src/runtime/registry.rs index 6709a78b..d2967491 100644 --- a/crates/microflow-core/src/runtime/registry.rs +++ b/crates/microflow-core/src/runtime/registry.rs @@ -18,19 +18,26 @@ pub type Factory = Box Result, + /// Declared `(ports, emits)` per registration name, captured at + /// registration so the Catalog Parity Guard (ADR-0007) can assert Rust ≡ + /// `node-components.json` without re-listing every type. Populated by + /// [`register`](Self::register) for every node, cloud included. + declared: HashMap, } impl ComponentRegistry { #[must_use] pub fn new() -> Self { - let mut registry = Self { entries: HashMap::new() }; + let mut registry = Self { entries: HashMap::new(), declared: HashMap::new() }; registry.register_all(); registry } @@ -57,19 +64,20 @@ impl ComponentRegistry { fn register(&mut self, name: &'static str) { self.entries.insert(name.to_string(), make_factory::(name)); + self.declared.insert(name.to_string(), (B::ports(), B::emits())); } - /// Inject an externally-built component factory under `name`. Used by a host - /// to add nodes core doesn't ship (e.g. the desktop's cloud nodes, whose - /// closures capture the live MQTT/LLM services). Overrides any existing - /// entry of the same name. - pub fn register_factory(&mut self, name: &str, factory: Factory) { - self.entries.insert(name.to_string(), factory); + /// Declared `(ports, emits)` per registration name (incl. alias entry names), + /// captured at registration. Consumed by the Catalog Parity Guard (ADR-0007). + /// Excludes host-injected cloud nodes (registered via [`register_factory`]). + #[must_use] + pub fn declared(&self) -> &HashMap { + &self.declared } - /// Register every phase-1 (non-cloud) catalog entry. Several catalog entry - /// names share one impl (aliases). `Constant`, `Oscillator`, `RangeMap`, - /// `Smooth`, and `Function` (js) are registered once their ports land. + /// Register every catalog entry. Several catalog entry names share one impl + /// (aliases like `Vibration` → `Led`). `Function` (js) and the cloud nodes + /// (mqtt/llm/figma) are feature-gated. fn register_all(&mut self) { use crate::runtime::{control, input, output, transformation}; @@ -118,6 +126,16 @@ impl ComponentRegistry { self.register::("Smooth"); #[cfg(feature = "js")] self.register::("Function"); + + // cloud (sans-IO; the host's `EffectsSink::perform_cloud` does the I/O). + // Gated so codegen-only consumers stay lean; both hosts enable `cloud`. + #[cfg(feature = "cloud")] + { + use crate::runtime::cloud; + self.register::("Mqtt"); + self.register::("Llm"); + self.register::("Figma"); + } } } diff --git a/crates/microflow-core/src/runtime/transformation/calculate.rs b/crates/microflow-core/src/runtime/transformation/calculate.rs index 07121de4..f686cb52 100644 --- a/crates/microflow-core/src/runtime/transformation/calculate.rs +++ b/crates/microflow-core/src/runtime/transformation/calculate.rs @@ -62,6 +62,10 @@ impl Component for Calculate { &["value"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/transformation/compare.rs b/crates/microflow-core/src/runtime/transformation/compare.rs index 0f495dcf..ba071104 100644 --- a/crates/microflow-core/src/runtime/transformation/compare.rs +++ b/crates/microflow-core/src/runtime/transformation/compare.rs @@ -12,6 +12,9 @@ pub struct Compare { } impl Compare { + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + #[must_use] pub fn new(id: String, config: CompareConfig) -> Self { Self { @@ -23,7 +26,7 @@ impl Compare { pub fn check(&mut self, input: &ComponentValue) { let result = self.validate(input); self.base.set_value(ComponentValue::Bool(result)); - self.base.emit(if result { "true" } else { "false" }); + self.base.emit(if result { Self::E_TRUE } else { Self::E_FALSE }); } fn validate(&self, input: &ComponentValue) -> bool { @@ -74,6 +77,10 @@ impl Component for Compare { &["value"] } + fn emits() -> &'static [&'static str] { + &[Self::E_TRUE, Self::E_FALSE, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/transformation/function.rs b/crates/microflow-core/src/runtime/transformation/function.rs index 3c5617cd..8a86f97c 100644 --- a/crates/microflow-core/src/runtime/transformation/function.rs +++ b/crates/microflow-core/src/runtime/transformation/function.rs @@ -175,6 +175,10 @@ impl Component for Function { &["trigger"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/transformation/gate.rs b/crates/microflow-core/src/runtime/transformation/gate.rs index 72ba45ad..478efd4f 100644 --- a/crates/microflow-core/src/runtime/transformation/gate.rs +++ b/crates/microflow-core/src/runtime/transformation/gate.rs @@ -12,6 +12,9 @@ pub struct Gate { } impl Gate { + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + #[must_use] pub fn new(id: String, config: GateConfig) -> Self { Self { @@ -23,7 +26,7 @@ impl Gate { fn check(&mut self, inputs: &[bool]) { let result = self.passes_gate(inputs); self.base.set_value(ComponentValue::Bool(result)); - self.base.emit(if result { "true" } else { "false" }); + self.base.emit(if result { Self::E_TRUE } else { Self::E_FALSE }); } fn passes_gate(&self, inputs: &[bool]) -> bool { @@ -45,6 +48,10 @@ impl Component for Gate { &["value"] } + fn emits() -> &'static [&'static str] { + &[Self::E_TRUE, Self::E_FALSE, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/transformation/range_map.rs b/crates/microflow-core/src/runtime/transformation/range_map.rs index e0909a11..05a9785d 100644 --- a/crates/microflow-core/src/runtime/transformation/range_map.rs +++ b/crates/microflow-core/src/runtime/transformation/range_map.rs @@ -13,6 +13,8 @@ pub struct RangeMap { } impl RangeMap { + const E_TO: &'static str = "to"; + #[must_use] pub fn new(id: String, config: RangeMapConfig) -> Self { Self { @@ -48,7 +50,7 @@ impl RangeMap { ComponentValue::Number(input_num), ComponentValue::Number(normalized), ])); - self.base.emit_with_value("to", Cow::Owned(ComponentValue::Number(normalized))); + self.base.emit_with_value(Self::E_TO, Cow::Owned(ComponentValue::Number(normalized))); } } @@ -57,6 +59,10 @@ impl Component for RangeMap { &["value"] } + fn emits() -> &'static [&'static str] { + &[Self::E_TO, ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-core/src/runtime/transformation/smooth.rs b/crates/microflow-core/src/runtime/transformation/smooth.rs index 77ccb943..162ba8c7 100644 --- a/crates/microflow-core/src/runtime/transformation/smooth.rs +++ b/crates/microflow-core/src/runtime/transformation/smooth.rs @@ -70,6 +70,10 @@ impl Component for Smooth { &["value"] } + fn emits() -> &'static [&'static str] { + &[ComponentBase::VALUE_HANDLE] + } + fn base(&self) -> &ComponentBase { &self.base } diff --git a/crates/microflow-runtime-wasm/Cargo.toml b/crates/microflow-runtime-wasm/Cargo.toml index 1b7fce4c..905e1402 100644 --- a/crates/microflow-runtime-wasm/Cargo.toml +++ b/crates/microflow-runtime-wasm/Cargo.toml @@ -13,9 +13,11 @@ repository = "https://github.com/xiduzo/microflow" crate-type = ["cdylib", "rlib"] [dependencies] -# `runtime` + `js` (the `Function` node's embedded JS engine, boa) — but NOT -# `cloud` (no mqtt/llm/figma network nodes in the browser build). -microflow-core = { path = "../microflow-core", default-features = false, features = ["runtime", "js"] } +# `runtime` + `js` (the `Function` node's embedded JS engine, boa) + `cloud`. +# ADR-0009 made the mqtt/llm/figma nodes sans-IO — they emit `CloudRequest`s the +# browser host performs (LLM via `fetch`, MQTT/Figma via WSS) — so they carry no +# native deps and now belong in the wasm build, not just the desktop one. +microflow-core = { path = "../microflow-core", default-features = false, features = ["runtime", "js", "cloud"] } wasm-bindgen = "0.2" serde_json = "1.0" diff --git a/crates/microflow-runtime-wasm/src/lib.rs b/crates/microflow-runtime-wasm/src/lib.rs index 002f1697..22f98b12 100644 --- a/crates/microflow-runtime-wasm/src/lib.rs +++ b/crates/microflow-runtime-wasm/src/lib.rs @@ -8,10 +8,15 @@ //! `outboundBytes` to the port, pushing `componentEvents` to the UI stores, and //! arming/cancelling `setTimeout`s for `wakeups`/`cancellations`. //! +//! The cloud nodes (`Mqtt`/`Llm`/`Figma`) compile in via core's `cloud` feature +//! (ADR-0009): they are sans-IO, so they emit `cloudRequests` the browser host +//! performs (LLM via `fetch`, MQTT/Figma via WSS) and feeds back through +//! [`inject_event`](FlowRuntime::inject_event). +//! //! Every entry point returns the turn's `Effects` as JSON, ready to `JSON.parse`. use microflow_core::flow::FlowUpdate; -use microflow_core::runtime::{ComponentValue, Effects, FlowRuntime as CoreRuntime}; +use microflow_core::runtime::{ComponentValue, Effects, FlowRuntime as CoreRuntime, SubscriberWiring}; use wasm_bindgen::prelude::*; /// Install a panic hook so a Rust panic surfaces as a readable `console.error`. @@ -130,6 +135,78 @@ impl FlowRuntime { self.inner.set_now(now_ms); effects_json(&self.inner.dispatch(id, method, value)) } + + /// Re-enter an asynchronous cloud result as if `source` emitted `value` on + /// `handle` — the browser host's path for an LLM/MQTT result, mirroring the + /// desktop actor's `ActorMsg::Inject` → `FlowRuntime::inject_event`. Returns + /// the cascade `Effects` (e.g. an LLM `value` driving downstream nodes). + /// + /// # Errors + /// `JsError` if `value_json` is not a valid `ComponentValue`. + #[wasm_bindgen(js_name = injectEvent)] + pub fn inject_event( + &mut self, + source: &str, + handle: &str, + value_json: &str, + now_ms: f64, + ) -> Result { + let value: ComponentValue = serde_json::from_str(value_json) + .map_err(|e| JsError::new(&format!("invalid value: {e}")))?; + self.inner.set_now(now_ms); + effects_json(&self.inner.inject_event(source, handle, value)) + } + + /// The active subscribe components' broker wirings, as a JSON array of + /// `{ nodeId, kind, brokerId, topic }` (`kind` ∈ `plain`/`topicAware`/ + /// `displayEcho`). The browser host reconciles these into WSS subscriptions + /// and routes inbound payloads back via [`deliver_message`](FlowRuntime::deliver_message) + /// (the analog of the desktop `flow_update` reply + MQTT manager). + /// + /// # Errors + /// `JsError` only if the wiring list fails to serialize. + #[wasm_bindgen(js_name = subscriberWirings)] + pub fn subscriber_wirings(&self) -> Result { + let arr: Vec = self + .inner + .collect_subscriber_wirings() + .iter() + .map(|(node_id, wiring)| { + let kind = match wiring { + SubscriberWiring::Plain { .. } => "plain", + SubscriberWiring::TopicAware { .. } => "topicAware", + SubscriberWiring::DisplayEcho { .. } => "displayEcho", + }; + serde_json::json!({ + "nodeId": node_id, + "kind": kind, + "brokerId": wiring.broker_id(), + "topic": wiring.topic(), + }) + }) + .collect(); + serde_json::to_string(&arr) + .map_err(|e| JsError::new(&format!("failed to serialize wirings: {e}"))) + } + + /// Deliver an inbound broker payload (MQTT / Figma) to subscribe component + /// `id`, then return the cascade `Effects`. Mirrors the desktop + /// `ActorMsg::Deliver` path; the browser host calls this from its WSS message + /// callback for `plain`/`topicAware` wirings. + /// + /// # Errors + /// `JsError` only if the resulting `Effects` fails to serialize. + #[wasm_bindgen(js_name = deliverMessage)] + pub fn deliver_message( + &mut self, + id: &str, + topic: &str, + payload: &[u8], + now_ms: f64, + ) -> Result { + self.inner.set_now(now_ms); + effects_json(&self.inner.deliver_message(id, topic, payload)) + } } impl Default for FlowRuntime { @@ -179,4 +256,64 @@ mod tests { // No such component, but the call returns a well-formed empty Effects. assert!(json.contains("\"componentEvents\":[]"), "got: {json}"); } + + #[test] + fn inject_event_surfaces_value_as_component_event() { + // The cloud-result re-entry path: inject pushes the event into the drain, + // so it surfaces in `componentEvents` even with no node of that id. + let mut rt = FlowRuntime::new(); + let json = rt.inject_event("n", "value", "\"hi\"", 0.0).expect("inject ok"); + assert!(json.contains("\"componentEvents\""), "got: {json}"); + assert!(json.contains("hi"), "injected value should surface: {json}"); + } + + #[test] + fn cloud_node_builds_and_dispatch_records_cloud_request() { + // `cloud` feature is on: an Llm node builds in the browser runtime and a + // trigger records a `cloudRequests` entry for the host to perform. + let mut rt = FlowRuntime::new(); + let flow = r#"{ + "nodes": [ + {"id":"l","type":"Llm","data":{"instance":"Llm","providerId":"p","model":"m","prompt":"hi"},"position":{"x":0,"y":0}} + ], + "edges": [] + }"#; + rt.update_flow(flow, 0.0).expect("update ok"); + let json = rt.dispatch("l", "trigger", "true", 1.0).expect("dispatch ok"); + assert!(json.contains("llmGenerate"), "expected a cloud request: {json}"); + } + + #[test] + fn subscriber_wirings_reports_subscribe_topics() { + // An Mqtt subscribe node advertises a `plain` wiring the browser host + // turns into a WSS subscription. + let mut rt = FlowRuntime::new(); + let flow = r#"{ + "nodes": [ + {"id":"m","type":"Mqtt","data":{"instance":"Mqtt","direction":"subscribe","brokerId":"b","topic":"sensors/x"},"position":{"x":0,"y":0}} + ], + "edges": [] + }"#; + rt.update_flow(flow, 0.0).expect("update ok"); + let json = rt.subscriber_wirings().expect("wirings ok"); + assert!(json.contains("\"kind\":\"plain\""), "got: {json}"); + assert!(json.contains("sensors/x"), "got: {json}"); + assert!(json.contains("\"nodeId\":\"m\""), "got: {json}"); + } + + #[test] + fn deliver_message_routes_payload_to_subscribe_node() { + // Inbound broker payload → the subscribe node emits the parsed value. + let mut rt = FlowRuntime::new(); + let flow = r#"{ + "nodes": [ + {"id":"m","type":"Mqtt","data":{"instance":"Mqtt","direction":"subscribe","brokerId":"b","topic":"t"},"position":{"x":0,"y":0}} + ], + "edges": [] + }"#; + rt.update_flow(flow, 0.0).expect("update ok"); + let json = rt.deliver_message("m", "t", b"42", 1.0).expect("deliver ok"); + assert!(json.contains("\"componentEvents\""), "got: {json}"); + assert!(json.contains("42"), "delivered value should surface: {json}"); + } } diff --git a/docs/adr/0007-node-wire-interface-emit-contract.md b/docs/adr/0007-node-wire-interface-emit-contract.md new file mode 100644 index 00000000..3748bc0c --- /dev/null +++ b/docs/adr/0007-node-wire-interface-emit-contract.md @@ -0,0 +1,194 @@ +# ADR-0007 — Bidirectional node wire-interface contract: typed Emits + live catalog-parity guard + +- **Status:** accepted +- **Date:** 2026-06-21 +- **Deciders:** sander + +## Context + +A flow node's edge interface has **two** directions: the **Port** set (edge +inputs, `target_handle`) and the emit set (edge outputs, `source_handle`). The +**Component Catalog** (`apps/web/node-components.json`) is documented as the +single source of truth for a node — but it represents only the input half, and +even that half's cross-language guard is dead: + +- **Input `ports`** flow catalog `impls[].ports[]` → Rust `Component::ports()` + (`crates/microflow-core/src/runtime/component.rs:33`) → TS `COMPONENT_PORTS` / + `PortOf` (`codegen-node-registry.ts:23-76` → `_base/_base.types.ts`). The + Rust↔catalog drift assertion that `build.rs` used to generate into + `register_all_body.rs` is **dead**: after the re-host (ADR-0006) the registry + hand-registers nodes (`registry.rs:73-121`) and **nothing `include!`s the + generated file**. `build.rs` still writes it; no code reads it. The guard + silently stopped running. + +- **Output emits have no representation anywhere.** A component emits via + `ComponentBase::emit(handle: &str)` (`component.rs:181`), which pushes a + `ComponentEvent { source_handle: Arc, .. }` (`value.rs:100`). The handle + is a free string literal (`self.base.emit("event")`). The React node renders + `` with another free literal + (`handle.tsx`, `BaseHandle` does not constrain `id`). There is **no catalog + field, no codegen, no type, and no test** linking the two. A mismatch — + `emit("value")` vs `id="valued"`, or the documented MQTT `"message"`→`"value"` + rename — makes the edge route nowhere. The event is dropped silently (a warn at + most). This is a live correctness hole, not a hypothetical. + +Tally of the four cross-language agreements a node depends on: **one is live +(TS `COMPONENT_PORTS` codegen), zero are tested.** + +An audit of every component's emit set (recorded below in Decision D1) also +surfaced a subtlety: `ComponentBase::set_value()` **auto-emits `"value"`** when +the value changes. So `"value"` is an *implicit* emit for the 23 components that +mutate value through `set_value`, distinct from the explicit `emit("…")` calls +(e.g. Button's `event`/`true`/`false`/`hold`). Four components (`Delay`, `Mqtt`, +`Llm`, `Figma`) deliberately bypass `set_value` with a raw `base.value =` write +and emit their handles explicitly. + +The core crate today contains **zero `macro_rules!`**; the house style is flat +and explicit (`ports()` is a hand-written `&["true","false",…]` literal; +`register_all` is a hand-written list). Any mechanism this ADR adds must respect +that ethos. + +## Decision + +Make the catalog the node's **whole** wire interface, extend the input-side +discipline (ADR-0001) to the output side, and re-arm the dead guard as a *live* +test. + +- **D1 — Catalog gains `impls[].emits[]`**, symmetric with `ports[]`. The emit + set is the closed `source_handle` namespace a node may emit on a flow edge. + `"value"` is listed for every node that emits it (implicitly via `set_value` + or explicitly). Internal/wakeup names (`_hold`, `_tick`, `_debounce`) are + **not** emits — they are self-routed internal events (ADR-0001) and never + appear on an edge, so they are excluded. The authoritative initial sets (from + the audit) are e.g. `Button: [event,true,false,hold,value]`, + `Compare/Gate: [true,false,value]`, `Delay: [event]`, `Trigger: [bang,value]`, + `RangeMap: [to,value]`, `Llm: [thinking,value,done,error]`, + `Figma: [change,value]`, and `[value]` for the value-only sinks/sources. + +- **D2 — Rust emits are compile-checked via associated const handles, not raw + literals.** A `Component::emits()` associated function is added (parallel to + `ports()`, default `&[]`). Each component declares its emit handles as + associated `const`s and references them at every emit site **and** in + `emits()`: + + ```rust + impl Button { + const E_EVENT: &'static str = "event"; + const E_TRUE: &'static str = "true"; + const E_FALSE: &'static str = "false"; + const E_HOLD: &'static str = "hold"; + } + impl Component for Button { + fn emits() -> &'static [&'static str] { + &[Self::E_EVENT, Self::E_TRUE, Self::E_FALSE, Self::E_HOLD, + ComponentBase::VALUE_HANDLE] + } + // … + } + // emit site: + self.base.emit(Self::E_TRUE); // a typo'd `Self::E_TREU` does not compile + ``` + + The implicit `"value"` emit is centralized on the base: + `ComponentBase::VALUE_HANDLE: &'static str = "value"`, used by `set_value` and + listed in `emits()` by any value-emitting node. A declarative macro was + rejected — it would be the crate's first `macro_rules!` and break the flat, + explicit house style; associated consts are how the team already writes + `type Config` and `ports()`. (A per-component emit enum was also considered and + rejected for the same churn-without-house-fit reason; consts give the + "mistyped emit won't compile" guarantee the team asked for.) + +- **D3 — Codegen emits `COMPONENT_EMITS` + `EmitOf`**, an exact parallel of + `COMPONENT_PORTS`/`PortOf` in `codegen-node-registry.ts`. The shared + `Handle` component is then constrained so `id` on `type="source"` must be + `EmitOf` and on `type="target"` must be `PortOf`. React source handles + become type-checked against the same catalog row. + +- **D4 — One live parity test replaces the dead `build.rs` guard.** A build + script cannot introspect Rust trait impls (the old design *generated* + `assert_eq!` into compiled Rust). The modern guard is a Rust integration test + that loads `node-components.json` and asserts, for every impl, + `ports() ≡ catalog.ports` **and** `emits() ≡ catalog.emits`. It lives in the + **desktop** crate (`apps/web/src-tauri/tests/catalog_parity.rs`): desktop + already depends on `microflow-core` and can resolve the catalog path, so core + stays free of the `apps/web` layout. It hand-lists the same type→name mapping + as `register_all` (acceptable duplication; macro-shareable later). The existing + exhaustive-match idiom in `crates/microflow-core/src/codegen/parity.rs` is + extended so a new port/emit cannot be added without a conscious classification. + (Embedding the catalog into core via a cross-crate `include_str!` was rejected + — it couples core to the `apps/web` directory layout.) + +### Rollout (each phase compiles and ships) + +1. **Catalog + Rust declaration.** Add `emits[]` to every `impls[]` row. Add + `Component::emits()` (default `&[]`) and `ComponentBase::VALUE_HANDLE`; + migrate all ~30 components to const-based emits + an `emits()` body. Behaviour + unchanged. +2. **Guard.** Add `catalog_parity.rs` (ports + emits, both directions). Expect it + to surface pre-existing drift — fix what it finds (see Consequences). +3. **TS types.** Extend codegen with `COMPONENT_EMITS` + `EmitOf`; constrain + `Handle`. Run `bun run codegen`; fix the type errors the constraint surfaces + in node `.tsx` files — those are latent bugs. + +## Consequences + +**Positive** + +- The silent emit-drop bug class is closed at CI and at the type level, not on a + user's canvas. Renaming a handle is one edit the guard propagates or rejects. +- "What does this node emit?" is answered by `::emits()` and the catalog + `emits[]` — one declaration, mirrored to TS, asserted equal. +- Compile-checked emit sites: a mistyped `Self::E_*` does not compile. +- The dead ADR-0006 port-drift debt is repaid as a *better* guard — a live + CI test covering both directions, not dead build-script output. The dead + `build.rs` codegen for ports can now be deleted. +- Deletion test passes: delete `catalog_parity.rs` + `COMPONENT_EMITS` and the + cross-language drift hole reappears across catalog, ~30 Rust impls, and every + node `.tsx`. + +**Negative** + +- Phase 2 will likely surface existing drift (a component emitting a handle the + `.tsx`/catalog never declared a source `Handle` for; or a stale handle id). + Budget fix time — this is the guard doing its job, e.g. verifying every + `set_value`-driven `"value"` emit on an output node actually has a UI sink. +- Touches all ~30 component files (the const migration) plus 36 catalog rows. + Mechanical, one file each; mitigated by the phased rollout. +- `catalog_parity.rs` duplicates the `register_all` type list. Accepted; a shared + macro can fold it later if it drifts. + +**Neutral** + +- `ports()`-as-literal stays as-is on the input side; this ADR adds the symmetric + `emits()` and the guard that finally checks both. The frontend `PortOf` + already existed; `EmitOf` completes the pair. + +## Glossary + +New / updated terms recorded in `CONTEXT.md`: + +- **Emit** — a named edge-output slot (`source_handle`) a Component may emit on. + The closed set declared by `Component::emits()`, mirrored to catalog + `impls[].emits[]` and TS `COMPONENT_EMITS`/`EmitOf`. Symmetric with **Port**. + Excludes `_`-prefixed **Internal Event** names. +- **Catalog Parity Guard** — the live `catalog_parity.rs` test asserting Rust + `ports()`/`emits()` ≡ catalog, replacing the dead `build.rs` assertion. +- **Port** (updated) — note the build-time drift assertion is dead; the guard now + lives in `catalog_parity.rs`. + +## References + +- `apps/web/node-components.json` — `impls[].ports[]`; gains `emits[]`. +- `crates/microflow-core/src/runtime/component.rs` — `Component::ports()`; + `ComponentBase::emit`/`set_value`; gains `emits()` + `VALUE_HANDLE`. +- `crates/microflow-core/src/runtime/value.rs:100` — `ComponentEvent.source_handle`. +- `crates/microflow-core/src/runtime/registry.rs` — hand-registration; no `include!`. +- `apps/web/src-tauri/build.rs` — dead port codegen (to delete after Phase 2). +- `crates/microflow-core/src/codegen/parity.rs` — exhaustive-match guard idiom. +- `apps/web/scripts/codegen-node-registry.ts` — `COMPONENT_PORTS`/`PortOf`; + gains `COMPONENT_EMITS`/`EmitOf`. +- `apps/web/src/components/flow/nodes/../../handle.tsx` — `BaseHandle` to constrain. +- `apps/web/src-tauri/tests/catalog_parity.rs` — new live guard. +- [ADR-0001](0001-component-trait-flow-separation.md) — Port/Internal/Hardware split + this extends to the emit side. +- [ADR-0006](0006-rehost-runtime-on-core.md) — names the dead port-drift guard as debt. diff --git a/docs/adr/0008-effects-apply-policy.md b/docs/adr/0008-effects-apply-policy.md new file mode 100644 index 00000000..3cc5e859 --- /dev/null +++ b/docs/adr/0008-effects-apply-policy.md @@ -0,0 +1,124 @@ +# ADR-0008 — `Effects` apply-policy: canonical order behind a typed `EffectsSink` + +- **Status:** implemented (2026-06-21) +- **Date:** 2026-06-21 +- **Deciders:** sander + +> **Implemented.** Core: `EffectsSink` trait + `Effects::apply` (canonical order +> `outbound_bytes → cancellations → wakeups → component_events`) in +> `runtime/context.rs`, exported from `runtime/mod.rs`. Desktop: `Actor`'s `apply` +> now delegates to `Effects::apply(self)` with `impl EffectsSink for Actor` +> supplying the four primitives (`host.rs`). Browser: `applyEffects` + `EffectsSink` +> extracted to `apps/web/src/lib/firmata/effects-sink.ts`; `FlowReactor` implements +> it (its prior inline loop was already in canonical order). Conformance: the +> cancel + re-arm + emit + bytes scenario runs as `context::apply_tests` (Rust) and +> `__tests__/effects-sink.test.ts` (`bun:test`), both asserting order + no +> double-fire. Verified: core 365 pass, desktop `cargo check`/clippy clean, browser +> 2 pass, `tsc --noEmit` clean. + +## Context + +ADR-0006 made the runtime sans-IO: each turn folds into one +`Effects { outbound_bytes, component_events, wakeups, cancellations }` +(`crates/microflow-core/src/runtime/context.rs:38`, `#[serde(rename_all = +"camelCase")]`) that a **Runtime Host** applies. That seam is what makes the +engine testable — feed input, assert on the returned `Effects`. + +But `Effects` is a **passive DTO**. *How* a host applies it — the order and +semantics of the four fields — is written down nowhere, and the two hosts have +**already diverged**: + +- Desktop (`apps/web/src-tauri/src/runtime/host.rs:451` `apply`): + `outbound_bytes → component_events → wakeups → cancellations`. +- Browser (`apps/web/src/lib/firmata/flow-reactor.ts:81` `apply`): + `outbound_bytes → cancellations → wakeups → component_events`. + +This is benign **today** — within one turn `wakeups` carry freshly-allocated +`WakeupId`s and `cancellations` carry previously-issued ones, so they cannot +collide on the same id regardless of order. But it is an unguarded latent +hazard: the testability seam stops at the runtime boundary, the host's +*application* of effects is unspecified and unconformance-tested, and a future +field or ordering rule (e.g. "a cancellation and a re-arm of the same logical +timer in one turn must cancel-then-arm") would have to be re-implemented in two +languages with nothing asserting they agree. ADR-0009 is about to add a fifth +`Effects` field (`cloud_requests`); doing this first means that field lands +behind a policy rather than as a third ad-hoc loop. + +## Decision + +Make the apply **policy** a deep module in core, distinct from the platform +**primitives** (which are genuinely divergent — Tokio `abort_handle` vs +`clearTimeout` — and stay per-host). + +- **D1 — One canonical order, defined once:** + `outbound_bytes → cancellations → wakeups → cloud_requests (ADR-0009) → + component_events`. Bytes first (wire latency); cancel-before-arm (the safe + default, browser's current order); UI events last (they leave the runtime and + do not feed back this turn). + +- **D2 — A typed sink the order calls into.** In core, + `Effects::apply(&self, sink: &mut impl EffectsSink)` iterates in the canonical + order, calling one hook per field: `write_bytes(&[u8])`, + `cancel_wakeup(WakeupId)`, `arm_wakeup(&Wakeup)`, `dispatch_event(&ComponentEvent)` + (and `perform_cloud(&CloudRequest)` once ADR-0009 lands). The desktop host + implements `EffectsSink`; its `apply` shrinks to the four platform primitives + and no longer owns the order. **A new `Effects` field forces a new trait hook + → compile error**, not a silently-unhandled field. + +- **D3 — Browser mirrors the order, conformance-tested.** The browser host is + TypeScript and cannot call the Rust `apply`; the reactor implements the same + four-hook shape in the same canonical order. A shared conformance scenario + (cancel + re-arm + emit in one turn ⇒ no double-fire, all fields observed, + order preserved) runs in both `cargo test` (core, against a recording sink) and + `vitest` (browser). The win is a **named, tested ordering contract**, not DRY + code — the Rust↔TS boundary caps reuse, and this ADR states that honestly. + +### Rollout + +1. Core: `EffectsSink` trait + `Effects::apply`; desktop `apply` reimplemented + over it; order unit-tested in core against a recording sink. +2. Browser: reactor reordered to canonical + the four-hook shape; conformance + test added on both sides. + +## Consequences + +**Positive** + +- The ordering contract lives in one place (core), not smeared across two hosts + in two languages. +- New-field safety: adding `cloud_requests` (ADR-0009) is a compile error on the + desktop sink until handled, instead of a dropped field. +- Deletion test: removing `EffectsSink`/`apply` re-inlines the iteration order + into both hosts, where it already drifted once. + +**Negative** + +- A core method the desktop calls but the browser only mirrors — partial reuse. + Accepted: the alternative (no core method, just a documented order + tests) + gives the contract but not the compile-time new-field guard. The typed sink is + chosen for that guard; the browser is a mirror either way. +- One more indirection on the desktop apply path (a sink struct + trait + dispatch). Zero-cost in release; the explicitness is the point. + +**Neutral** + +- The browser reactor's reorder is observably equivalent today (the order + difference is benign per Context); this ADR makes the equivalence intentional + and tested rather than accidental. + +## Glossary + +New term recorded in `CONTEXT.md`: + +- **EffectsSink** — the typed per-field hook surface (`write_bytes`, + `cancel_wakeup`, `arm_wakeup`, `dispatch_event`, `perform_cloud`) that + `Effects::apply` drives in canonical order. Implemented by each **Runtime + Host**; a new `Effects` field adds a hook. + +## References + +- `crates/microflow-core/src/runtime/context.rs:38` — `Effects`; gains `apply` + `EffectsSink`. +- `apps/web/src-tauri/src/runtime/host.rs:451` — desktop `apply` (reimplemented over the sink). +- `apps/web/src/lib/firmata/flow-reactor.ts:81` — browser `apply` (reordered + four-hook). +- [ADR-0006](0006-rehost-runtime-on-core.md) — the `Effects` seam this deepens. +- [ADR-0009](0009-cloud-sans-io-capability.md) — adds `cloud_requests`, the field this policy will absorb. diff --git a/docs/adr/0009-cloud-sans-io-capability.md b/docs/adr/0009-cloud-sans-io-capability.md new file mode 100644 index 00000000..ef388b87 --- /dev/null +++ b/docs/adr/0009-cloud-sans-io-capability.md @@ -0,0 +1,237 @@ +# ADR-0009 — Cloud as a sans-IO capability: cloud I/O becomes an `Effect`, performed per-host + +- **Status:** accepted — **complete** (2026-06-22): Phases 1–3 implemented (cloud + nodes run in both hosts — LLM + MQTT + Figma in the browser). **Phase 4 (CORS + proxy) declined** — direct-only; see the Phase 4 note below. +- **Date:** 2026-06-21 +- **Deciders:** sander + +> **Phases 1–2 implemented (desktop cloud is now sans-IO).** Core: `CloudRequest` +> + `CloudRequestKind` + `Effects.cloud_requests` + `RuntimeContext::request_cloud` +> + `EffectsSink::perform_cloud` (driven by `Effects::apply` in canonical order +> `bytes → cancel → arm → cloud → event`); `FlowRuntime::register_cloud::` / +> `ComponentRegistry::register_cloud`. Desktop: the `Mqtt`/`Llm`/`Figma` nodes +> emit `CloudRequest`s (no Tokio/`reqwest`/`rumqttc`); the relocated I/O lives in a +> new `CloudPerformer` deep module on the actor (MQTT/Figma publish via +> `rumqttc`, LLM via `reqwest` with latest-wins cancellation), results re-entering +> as `ActorMsg::Inject`. `register_cloud_nodes` collapsed to three `register_cloud` +> lines; `CloudEmitter`/`ChannelEmitter`/`RecordingCloudEmitter` deleted; cloud +> node tests are now synchronous request assertions and the I/O regression net +> moved to `CloudPerformer` tests. Verified: core 365, desktop 58 + catalog_parity +> + clippy clean, browser conformance + `tsc` clean. **D4 signed off** (below): +> user-entered/keyless, direct-by-default, proxy is CORS-only. +> +> **Phase 3 — cloud relocated to core + LLM runs in the browser (2026-06-22).** +> The sans-IO cloud nodes + their POD configs moved out of the desktop crate into +> `microflow-core` (`runtime/cloud/{mqtt,llm,figma}.rs` behind the `cloud` feature; +> `config/{mqtt,llm,figma}.rs` ungated). They register in `ComponentRegistry:: +> register_all` like any built-in, so **both** hosts get them from one place — the +> host-injected `register_factory`/`register_node`/`register_cloud` machinery is +> deleted, and the Catalog Parity Guard reads cloud from `declared()` uniformly. +> The wasm crate enables `cloud` and exposes `injectEvent`; `FlowReactor. +> performCloud` performs `llmGenerate` directly (browser `fetch` to an +> OpenAI-compatible endpoint, mirroring the desktop `HttpLlmProvider`, with +> latest-wins `AbortController` cancellation), resolving the provider from the +> `llm-provider` store and re-entering `thinking`/`value`/`done`/`error` via +> `injectEvent`. Verified green: core 376, desktop 47 + catalog_parity + clippy, +> wasm crate 4, web 144 (+ `llm-client` transport tests) + `tsc`. +> +> **MQTT + Figma in the browser landed (2026-06-22).** Added `mqtt@5` and a +> per-broker MQTT-over-WSS connection manager (`lib/firmata/cloud/mqtt-client.ts`, +> the browser analog of the desktop `MqttManager`). `FlowReactor.performCloud` +> now publishes `mqttPublish` (Mqtt node + Figma set-value); on each `applyFlow` +> it reconciles the runtime's `subscriberWirings()` into WSS subscriptions (a +> pure mirror of the desktop `flow_update` dedup/diff in +> `cloud/mqtt-subscriptions.ts`), routes `plain`/`topicAware` inbound via the new +> `deliverMessage` binding, runs the Figma uid connect/disconnect handshake, and +> feeds display topics to the Figma store (the browser counterpart of the desktop +> "mqtt-message" event, via a new platform-agnostic `useFigmaStore.ingestMqttMessage`). +> Brokers resolve from `mqtt-broker` store; `url` must be a `ws://`/`wss://` +> endpoint (browsers can't open raw MQTT sockets). Verified: web 151 (+ reconcile +> tests) + `tsc` + **vite production build** (mqtt bundles, no polyfills), wasm +> crate 6. +> +> **Phase 4 (CORS proxy) — declined (2026-06-22, sander).** Browser cloud is +> **direct-only**; there is no server-side relay. A user who picks a CORS-strict +> LLM provider or a TCP-only broker is expected to make their *own* endpoint reach +> microflow — add microflow's origin to the provider's CORS allowlist, or expose a +> browser-reachable `wss://` broker. Rationale: keeps microflow's backend entirely +> out of the cloud data path (no third-party traffic or user keys ever transit our +> server), which is the natural endpoint of D4 (user-entered keys, direct calls). +> The cost is borne by the few strict endpoints, by their owner, once — not by our +> server forever. Reopen only if a must-support provider emerges that *cannot* be +> configured to allow a browser origin. + +## Context + +ADR-0006 re-hosted the engine on `microflow-core` and made it sans-IO: a node +never touches IO; it records [`Effects`] the host applies. The cloud nodes +(`Mqtt`, `Llm`, `Figma`) are the **one exception** — and the exception now blocks +the browser. + +How cloud works today: + +- The cloud nodes live in the desktop crate (`apps/web/src-tauri/src/runtime/cloud/`) + and hold both a capability handle **and a Tokio runtime handle** — + `Mqtt { publisher: Arc, rt_handle: Option }` + (`cloud/mqtt.rs:52`), `Llm { llm_registry, rt_handle, emitter: Option> }` + (`cloud/llm.rs:85`). Their `dispatch` **spawns an async task** on the injected + handle; the result re-enters via `CloudEmitter::emit` → + `ActorMsg::Inject` (`host.rs:94-115`) → `FlowRuntime::inject_event`. +- They are registered out-of-band through the `register_factory` escape hatch in + `host.rs::register_cloud_nodes` (`host.rs:117-196`) — hand-written closures that + re-do config deserialization and capture the live services. This keeps + `tokio`/`reqwest`/`rumqttc` out of core (the deliberate re-host trade). +- The wasm runtime (`crates/microflow-runtime-wasm/src/lib.rs:58-132`) exposes + `new/setPins/updateFlow/feedBytes/wake/dispatch` and **no `inject_event`**, has + no Tokio, and registers only the core (non-cloud) nodes. A browser flow + containing a cloud node deserializes fine, then fails at instantiation + (`ComponentNotFound`) — silently. **The catalog advertises cloud nodes on a + host that cannot build them.** + +Two problems compound: (a) the cloud node **initiates IO** (a sans-IO violation +that only works because desktop has Tokio), and (b) "one engine, two hosts" is +false for cloud. + +**Product decision (with sander):** cloud nodes *should* run in the browser. +There is no platform blocker — an LLM call is a `fetch`, MQTT and Figma are +protocols the browser speaks over WebSocket (WSS). The gap is architectural. + +## Decision + +Bring cloud onto the sans-IO model: **cloud I/O becomes an `Effect`**, performed +by each host's `EffectsSink` (ADR-0008), with results re-entering through the +existing `inject_event`. + +- **D1 — Cloud requests are an `Effect`, not an in-component spawn.** A cloud + node's `dispatch` records a `CloudRequest { correlation_id, kind, provider_id, + payload, .. }` into `Effects.cloud_requests` instead of spawning. The node holds + **no Tokio handle and no live service** — it is now fully sans-IO and unit- + testable by asserting the emitted request, exactly like every other node is + tested against `Effects.outbound_bytes`. + +- **D2 — The host performs the request and re-enters via `inject_event`.** The + result (or stream item) is fed back through the existing `inject_event` path — + the `CloudEmitter` shape is reused for *results*, not requests. + - **Desktop sink** (`EffectsSink::perform_cloud`): the *existing* `reqwest` + (LLM) / `rumqttc` (MQTT) / Figma-WSS code, **relocated** from inside the + components to the host sink. Same libraries, same behaviour; the in-component + spawn and the bespoke `register_cloud_nodes` closures are deleted. + - **Browser sink**: `fetch` (LLM) and MQTT/Figma over WSS, performed in the + reactor; results re-enter through a new wasm `injectEvent` / `resolveCloud` + binding. + +- **D3 — Inbound subscriptions stay descriptive and are honored per host.** + Long-lived subscriptions (MQTT subscribe, Figma streams) already return + `SubscriberWiring` data (`Component::subscriber_wiring`) and deliver via + `FlowRuntime::deliver_message` + `Component::receive_raw_message`. No new model: + each host sets up the subscription its own way (desktop broker pool; browser + WSS) and feeds messages back through `deliver_message`. The descriptive-wiring + pattern (CONTEXT.md § Wiring) already fits. + +- **D4 — Credentials are a per-host adapter choice, not an architecture fork.** + Same capability seam, two adapter strategies, selected per provider config: + - **Direct** (default): the user's own key, in the user's own browser, calls the + provider/broker directly (`fetch`/WSS). Correct for keyless/local endpoints + (Ollama, public brokers) and for user-entered keys. The real constraint is + **CORS**, not secrecy. + - **Proxy** (fallback): route through the existing `apps/server` tRPC backend + (`appRouter` gains `cloud.llm` / `cloud.publish` procedures) so the key never + leaves the server and CORS-blocked providers work. **(Declined — Phase 4 note: + browser cloud stays direct-only; the proxy adapter is not built.)** + + ✅ **Sign-off (sander, 2026-06-21):** **user-entered / keyless** — every key is + typed by the user into their own browser, or the endpoint is keyless/local + (Ollama, public brokers). The browser sink calls providers **directly** + (`fetch`/WSS) by default; the **proxy is a CORS-only fallback**, not a secret + vault. There are **no** server-managed or shared keys, so no provider is + proxy-only and the sink needs no per-provider "proxy-only" enforcement. (Should + a server-held key ever be introduced, that provider must become proxy-only — out + of scope here.) + +- **D5 — Cloud registration becomes uniform.** With cloud nodes sans-IO, the + per-node deserialize boilerplate in the closures disappears: a small + `register_cloud::(name)` helper reuses `make_factory`'s config path (the node + needs no build-time service injection — it emits requests). The catalog marks + cloud nodes (`impls[].host: "cloud"`, or reuse the existing `external` category) + so the **Catalog Parity Guard** (ADR-0007) and the editor know they are + host-performed, and **both** hosts register them. + +### Rollout (each phase compiles; desktop stays green throughout) + +1. **Core.** Add `CloudRequest` + `Effects.cloud_requests`; add + `EffectsSink::perform_cloud` (extends ADR-0008's sink); expose + `injectEvent`/`resolveCloud` on `microflow-runtime-wasm`. Move the cloud + config structs (serde-POD) into `microflow-core/src/config`; rewrite the cloud + components to emit `CloudRequest`s; register via `register_cloud`. +2. **Desktop sink.** Implement `perform_cloud` with the existing reqwest/rumqttc/ + Figma code relocated from the components. Regression net = the existing + `RecordingLlmProvider` / `RecordingMqttPublisher` tests. Delete the + in-component spawn and the `register_cloud_nodes` closures. +3. **Browser sink.** Implement `perform_cloud` (fetch / WSS) in the reactor; wire + `resolveCloud`; honor `subscriber_wiring` for inbound. Cloud nodes run in the + browser. +4. ~~**Proxy + policy.** Add `cloud.*` tRPC procedures; the browser sink selects + direct-vs-proxy per provider (D4).~~ **Declined** (see the Phase 4 note above): + browser cloud is direct-only; CORS-strict endpoints are the user's to allowlist. + The editor already does not mark cloud nodes browser-unavailable (Phase 3). + +## Consequences + +**Positive** + +- Cloud components become unit-testable without Tokio *or* a host — assert the + `CloudRequest` in `Effects` — the property ADR-0006 gave the rest of the engine. +- "One engine, two hosts" becomes true for cloud; browser gains MQTT/LLM/Figma. +- The last sans-IO violation (in-component spawn) is removed; the `register_factory` + closure special-case collapses into a typed `register_cloud`. +- Deletion test: removing `perform_cloud` re-spreads platform IO back into the + components in both hosts. + +**Negative / debt** + +- Phase 2 reworks **working, tested desktop cloud code**. Mitigated by relocating + (not rewriting) the reqwest/rumqttc bodies and leaning on the recording-adapter + tests as the regression net. +- **CORS** is the real practical blocker for browser-direct (Phase 3); the proxy + adapter (Phase 4) is the escape hatch — sequence P4 immediately after P3 if the + target providers are CORS-strict. +- **Figma** is the least-specified node here (exact REST-vs-WSS transport); + confirm with a short spike before Phase 3. +- An async result that arrives after its node/flow was removed must be dropped + safely (correlation-id no longer live) — both sinks must guard this, as the + desktop actor's `inject_event` already tolerates stale sources. + +**Supersedes** + +- The cloud half of the `register_factory` closure model and the in-component + Tokio spawn (ADR-0002's "trait dispatch over event-emission" still holds for + *what* the capability is; this ADR changes *where the IO happens* — host, not + component). ADR-0002's relocation banner already notes the cloud module moved; + this ADR moves its IO to the host sink. + +## Glossary + +New terms recorded in `CONTEXT.md` (and the stale §"Component Deps" / §"Runtime +Services" sections rewritten to post-re-host reality — see CONTEXT reconciliation): + +- **CloudRequest** — an outbound cloud call recorded as an `Effects` field; the + sans-IO replacement for the in-component async spawn. +- **perform_cloud** — the `EffectsSink` hook (ADR-0008) each host implements to + perform a `CloudRequest`; result re-enters via `inject_event`. +- **Cloud Adapter (direct / proxy)** — per-host, per-provider strategy for + performing a `CloudRequest`: straight to the provider, or via the `apps/server` + tRPC proxy. + +## References + +- `apps/web/src-tauri/src/runtime/cloud/{mqtt,llm,figma}.rs` — cloud nodes (config → core; IO → host sink). +- `apps/web/src-tauri/src/runtime/host.rs:94-196` — `ChannelEmitter`, `ActorMsg::Inject`, `register_cloud_nodes` (collapses to `register_cloud`). +- `crates/microflow-core/src/runtime/context.rs` — `Effects` gains `cloud_requests`; `EffectsSink::perform_cloud`. +- `crates/microflow-core/src/runtime/registry.rs` — `register_cloud::` helper. +- `crates/microflow-runtime-wasm/src/lib.rs` — gains `injectEvent`/`resolveCloud`. +- `apps/web/src/lib/firmata/flow-reactor.ts` — browser `perform_cloud`. +- `apps/server` + `packages/api` `appRouter` — `cloud.*` proxy procedures. +- [ADR-0006](0006-rehost-runtime-on-core.md) — sans-IO `Effects` seam this extends to cloud. +- [ADR-0008](0008-effects-apply-policy.md) — the `EffectsSink` this adds `perform_cloud` to. +- [ADR-0002](0002-per-capability-service-traits.md) — capability traits (still hold); IO location changes.