diff --git a/CONTEXT.md b/CONTEXT.md index aa8ab05..86e018e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,14 +18,15 @@ The single source of truth for every flow component the UI exposes and the runti | `impls[].name` | Rust | The Rust struct name (also derives `Config`). | | `impls[].category` | Rust | Module path under `runtime/`: `input`, `output`, `control`, `transformation`, `generator`, `external`. | | `impls[].requiresHardware` | Rust | If true, registry calls `Component::initialize(board)` when board connected. | -| `impls[].ports` | Rust + UI | The declared **Port** set this impl accepts on `dispatch`. Asserted equal to `::ports()` at registry construction; consumed by frontend codegen to emit `COMPONENT_PORTS` and `PortOf`. See § Port. | +| `::ports()` / `emits()` | Rust (source) | The declared **Port** / **Emit** sets — the single source of truth. **No longer catalog fields**: generated from the Rust consts into `wire-interface.generated.json`, thence `COMPONENT_PORTS`/`COMPONENT_EMITS` + `PortOf`/`EmitOf`. See § Port / § Emit / § Generation. | ### Generation -The catalog drives both registries: +The catalog drives the frontend; the **wire interface flows the other way**, from Rust: -- `apps/web/scripts/codegen-node-registry.ts` reads `entries` → writes `apps/web/src/components/flow/nodes/_REGISTRY.ts` and `_base/_base.types.ts`. Run via `bun run codegen` in `apps/web`. -- `apps/web/src-tauri/build.rs` still reads `entries` + `impls` → writes `$OUT_DIR/register_all_body.rs`, but **nothing includes it anymore**. After the re-host the `ComponentRegistry` lives in `crates/microflow-core/src/runtime/registry.rs` and hand-registers nodes in `register_all`; the generated file — and the `ports()`-vs-catalog drift assertion it carried — is dead build output pending cleanup ([ADR-0006](docs/adr/0006-rehost-runtime-on-core.md)). +- `apps/web/scripts/codegen-node-registry.ts` reads `entries` (+ `impls[].usesHostAdapter`) from the catalog **and** the Port/Emit sets from `apps/web/wire-interface.generated.json`, then writes `apps/web/src/components/flow/nodes/_REGISTRY.ts` and `_base/_base.types.ts`. Run via `bun run codegen` in `apps/web`. +- `apps/web/wire-interface.generated.json` is generated **from Rust** `::ports()`/`emits()` by the **Catalog Parity Guard** (`apps/web/src-tauri/tests/catalog_parity.rs`) when run with `BLESS_WIRE_INTERFACE=1`; otherwise the same guard asserts the committed file is current, so a stale mirror fails CI rather than shipping wrong handle types. `bun run catalog:sync` (in `apps/web`) blesses + re-codegens in one step. Port/Emit thus have **one** source — the compile-checked Rust consts — and no hand-authored catalog mirror to drift. +- `apps/web/src-tauri/build.rs` no longer generates anything (it is just `tauri_build::build()`). The old `register_all_body.rs` codegen and its port-drift assertion were dropped in the re-host ([ADR-0006](docs/adr/0006-rehost-runtime-on-core.md)); the `ComponentRegistry` now hand-registers nodes in `register_all` (`crates/microflow-core/src/runtime/registry.rs`). ## Component Registry @@ -51,7 +52,7 @@ 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↔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`). +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 the **single source of truth** for the wire interface: the live **Catalog Parity Guard** ([ADR-0007](docs/adr/0007-node-wire-interface-emit-contract.md)) generates it — ports **and** Emits — from the Rust consts into `wire-interface.generated.json` (the successor to the `build.rs` port codegen dropped in the re-host, [ADR-0006](docs/adr/0006-rehost-runtime-on-core.md)). The frontend codegen (`apps/web/scripts/codegen-node-registry.ts`) reads that file to emit `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 Rust source. 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`). @@ -59,11 +60,11 @@ Distinct from **Emit** names (edge *outputs*), **Internal Event** names (never o ## 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). +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]` — the single source — generated from Rust into `wire-interface.generated.json` and thence to the frontend as `COMPONENT_EMITS` / `EmitOf` (so React source `` ids are type-checked against the same Rust source). 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. +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`) generates `emits()` into `wire-interface.generated.json` and asserts the committed file is current. ## Internal Event @@ -76,7 +77,7 @@ Board-reader-driven event delivered to a **HardwareComponent** in response to Fi - `_pin_change` (from the **Board IO Loop**'s `PinChangeCallback`) → `on_pin_change(value)`. `value` is `Bool` for digital pins, `Number(u16)` for analog. - `_i2c_reply` (from `I2cReplyCallback`) → `on_i2c_reply(value)`. `value` is `Array` of byte values. -Emission of these reserved handles is the runtime's responsibility (in `FlowRuntime::install_pin_change_callback` / `install_i2c_reply_callback`); no flow edge may emit them. A third reserved name `"stepper_reply"` is referenced in `Stepper::call_method` and the module docstring but has no current emission path — forward-looking placeholder pending stepper sysex wiring. +Emission of these reserved handles is the runtime's responsibility (in `FlowRuntime::install_pin_change_callback` / `install_i2c_reply_callback`); no flow edge may emit them. The two handle names plus the `_` prefix are centralized as consts in `runtime/mod.rs`'s `reserved_handles` module — referenced at each emit and `dispatch_internal_event` match site rather than written as bare string literals. A third reserved name `"stepper_reply"` is referenced in `Stepper::call_method` and the module docstring but has no current emission path — a forward-looking placeholder pending stepper sysex wiring, intentionally left out of `reserved_handles` until it routes anywhere. ## FlowRouter diff --git a/apps/web/node-components.json b/apps/web/node-components.json index 004be77..b36157e 100644 --- a/apps/web/node-components.json +++ b/apps/web/node-components.json @@ -153,409 +153,161 @@ { "name": "Button", "category": "input", - "requiresHardware": true, - "ports": [ - "read" - ], - "emits": [ - "event", - "true", - "false", - "hold", - "value" - ] + "requiresHardware": true }, { "name": "Calculate", "category": "transformation", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Compare", "category": "transformation", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "true", - "false", - "value" - ] + "requiresHardware": false }, { "name": "Constant", "category": "generator", - "requiresHardware": false, - "ports": [], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Counter", "category": "control", - "requiresHardware": false, - "ports": [ - "increment", - "decrement", - "reset", - "set" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Delay", "category": "control", - "requiresHardware": false, - "ports": [ - "trigger" - ], - "emits": [ - "event" - ] + "requiresHardware": false }, { "name": "Figma", "category": "external", "requiresHardware": false, - "usesHostAdapter": true, - "ports": [ - "true", - "false", - "toggle", - "set", - "increment", - "decrement", - "reset", - "red", - "green", - "blue", - "opacity" - ], - "emits": [ - "change", - "value" - ] + "usesHostAdapter": true }, { "name": "Function", "category": "transformation", - "requiresHardware": false, - "ports": [ - "trigger" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Gate", "category": "transformation", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "true", - "false", - "value" - ] + "requiresHardware": false }, { "name": "Hotkey", "category": "input", "requiresHardware": false, - "usesHostAdapter": true, - "ports": [ - "key_event" - ], - "emits": [ - "event", - "true", - "false", - "value" - ] + "usesHostAdapter": true }, { "name": "I2cDevice", "category": "input", - "requiresHardware": true, - "ports": [ - "write", - "trigger" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Interval", "category": "generator", - "requiresHardware": false, - "ports": [ - "start", - "stop" - ], - "emits": [ - "event" - ] + "requiresHardware": false }, { "name": "Led", "category": "output", - "requiresHardware": true, - "ports": [ - "true", - "false", - "toggle", - "value" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Llm", "category": "external", "requiresHardware": false, - "usesRuntimeContext": true, - "ports": [ - "trigger" - ], - "emits": [ - "thinking", - "value", - "done", - "error" - ] + "usesRuntimeContext": true }, { "name": "Matrix", "category": "output", - "requiresHardware": true, - "ports": [ - "value", - "reset", - "reinitialize" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Monitor", "category": "output", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Motion", "category": "input", - "requiresHardware": true, - "ports": [ - "read" - ], - "emits": [ - "event", - "true", - "false", - "value" - ] + "requiresHardware": true }, { "name": "Mqtt", "category": "external", "requiresHardware": false, - "usesHostAdapter": true, - "ports": [ - "trigger" - ], - "emits": [ - "value" - ] + "usesHostAdapter": true }, { "name": "Oscillator", "category": "generator", - "requiresHardware": false, - "ports": [ - "start", - "stop", - "reset" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Piezo", "category": "output", - "requiresHardware": true, - "ports": [ - "trigger", - "stop" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Pixel", "category": "output", - "requiresHardware": true, - "ports": [ - "value", - "color", - "set", - "reset" - ], - "emits": [ - "event", - "value" - ] + "requiresHardware": true }, { "name": "Proximity", "category": "input", - "requiresHardware": true, - "ports": [ - "read" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "RangeMap", "category": "transformation", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "to", - "value" - ] + "requiresHardware": false }, { "name": "Relay", "category": "output", - "requiresHardware": true, - "ports": [ - "true", - "false", - "toggle" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Rgb", "category": "output", - "requiresHardware": true, - "ports": [ - "red", - "green", - "blue", - "alpha", - "off" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Sensor", "category": "input", - "requiresHardware": true, - "ports": [ - "read" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Servo", "category": "output", - "requiresHardware": true, - "ports": [ - "min", - "max", - "value", - "rotate", - "stop" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Smooth", "category": "transformation", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "value" - ] + "requiresHardware": false }, { "name": "Stepper", "category": "output", - "requiresHardware": true, - "ports": [ - "value", - "to", - "stop", - "zero", - "enable" - ], - "emits": [ - "value" - ] + "requiresHardware": true }, { "name": "Switch", "category": "input", - "requiresHardware": true, - "ports": [ - "read" - ], - "emits": [ - "event", - "true", - "false", - "value" - ] + "requiresHardware": true }, { "name": "Trigger", "category": "control", - "requiresHardware": false, - "ports": [ - "value" - ], - "emits": [ - "bang", - "value" - ] + "requiresHardware": false } ] } diff --git a/apps/web/package.json b/apps/web/package.json index ebcc2ce..dcf450a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,8 @@ "tauri": "tauri", "desktop:dev": "tauri dev", "desktop:build": "sh -c 'set -a && . ./.env && set +a && tauri build'", - "codegen": "bun run scripts/codegen-node-registry.ts" + "codegen": "bun run scripts/codegen-node-registry.ts", + "catalog:sync": "BLESS_WIRE_INTERFACE=1 cargo test --manifest-path src-tauri/Cargo.toml --test catalog_parity && bun run codegen" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/apps/web/scripts/codegen-node-registry.ts b/apps/web/scripts/codegen-node-registry.ts index 421f663..e0e02a9 100644 --- a/apps/web/scripts/codegen-node-registry.ts +++ b/apps/web/scripts/codegen-node-registry.ts @@ -1,4 +1,5 @@ import manifest from "../node-components.json"; +import wireInterface from "../wire-interface.generated.json"; import { writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -19,34 +20,26 @@ const usesHostAdapter = new Map( ); const entryUsesAdapter = (e: { impl: string }) => usesHostAdapter.get(e.impl) ?? false; -// Map impl name -> declared Port set. Variants (e.g. Potentiometer over -// Sensor) inherit their parent impl's ports. See CONTEXT.md § Port. -const implPorts = new Map( - impls.map((i) => [ - i.name, - Object.freeze(((i as Record).ports as string[] | undefined) ?? []), - ]), -); -const entryPorts = (e: { impl: string; name: string }): readonly string[] => { - const ports = implPorts.get(e.impl); - if (!ports) throw new Error(`Entry ${e.name} references unknown impl ${e.impl}`); - 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; +// Per-entry Port / Emit sets, GENERATED from the Rust impls' ports()/emits() +// into wire-interface.generated.json — the single source of truth for the wire +// interface (see src-tauri/tests/catalog_parity.rs). Keyed by entry name, so +// variants (e.g. Potentiometer over Sensor) already carry their parent impl's +// interface; there is no hand-authored impls[].ports/emits mirror to drift. +// See CONTEXT.md § Port / § Emit. +type WireInterface = Record; +const wire = wireInterface as WireInterface; +const wireOf = (e: { name: string }) => { + const w = wire[e.name]; + if (!w) { + throw new Error( + `Entry ${e.name} is missing from wire-interface.generated.json — regenerate it: ` + + `BLESS_WIRE_INTERFACE=1 cargo test --manifest-path src-tauri/Cargo.toml --test catalog_parity`, + ); + } + return w; }; +const entryPorts = (e: { name: string }): readonly string[] => wireOf(e).ports; +const entryEmits = (e: { name: string }): readonly string[] => wireOf(e).emits; // _base/_base.types.ts const typeNames = entries.map((e) => ` "${e.name}"`).join(",\n"); @@ -64,7 +57,8 @@ const emitsObjectLines = entries return ` ${e.name}: ${literal} as const,`; }) .join("\n"); -const baseTypesContent = `// GENERATED — edit node-components.json, then run \`bun run codegen\` +const baseTypesContent = `// GENERATED — do not edit. Sources: node-components.json (entries/metadata) + +// wire-interface.generated.json (ports/emits, from Rust). Run \`bun run codegen\`. export const COMPONENT_TYPES = [ ${typeNames}, @@ -77,11 +71,10 @@ export function isComponentType(value: string): value is ComponentType { } /** - * Declared **Port** set per Component (catalog-driven). Mirrors - * \`impls[].ports[]\` in \`node-components.json\` and the Rust impl's - * \`Component::ports()\` const. The Rust registry asserts equality at - * construction; this object is the single source of truth for what target - * handles a ReactFlow edge may carry. Empty array for components with no + * Declared **Port** set per Component. GENERATED from the Rust impl's + * \`Component::ports()\` via \`wire-interface.generated.json\` — the single + * source of truth (see \`src-tauri/tests/catalog_parity.rs\`). Type-checks the + * target handles a ReactFlow edge may carry. Empty array for components with no * edge inputs (e.g. \`Constant\`). See CONTEXT.md § Port. */ export const COMPONENT_PORTS = { @@ -98,12 +91,11 @@ export type PortOf = T extends ComponentType : 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. + * Declared **Emit** set per Component. GENERATED from the Rust impl's + * \`Component::emits()\` via \`wire-interface.generated.json\` — the single + * source of truth, kept current by the Catalog Parity Guard + * (\`src-tauri/tests/catalog_parity.rs\`). Type-checks the source handles a + * ReactFlow edge may originate from. See CONTEXT.md § Emit. */ export const COMPONENT_EMITS = { ${emitsObjectLines} @@ -122,7 +114,7 @@ writeFileSync(join(nodesDir, "_base/_base.types.ts"), baseTypesContent); // _REGISTRY.ts const lines: string[] = [ - "// GENERATED — edit node-components.json, then run `bun run codegen`", + "// GENERATED — do not edit. Source: node-components.json. Run `bun run codegen`.", 'import type { NodeTypes } from "@xyflow/react";', 'import type { ComponentType } from "./_base/_base.types";', 'import type { NodeHostAdapter } from "./_base/host-adapter";', diff --git a/apps/web/src-tauri/tests/catalog_parity.rs b/apps/web/src-tauri/tests/catalog_parity.rs index a70f6bd..e61167c 100644 --- a/apps/web/src-tauri/tests/catalog_parity.rs +++ b/apps/web/src-tauri/tests/catalog_parity.rs @@ -1,93 +1,92 @@ -//! Catalog Parity Guard (ADR-0007). +//! Catalog Parity Guard (ADR-0007) + wire-interface generator. //! -//! 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). +//! Each node's Rust `ports()`/`emits()` consts are the **single source of +//! truth** for its wire interface. This test keeps the frontend in lockstep by +//! *generating* — not hand-mirroring — the catalog's wire data: //! -//! 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). +//! - With `BLESS_WIRE_INTERFACE=1` it (re)writes +//! `apps/web/wire-interface.generated.json` from +//! `ComponentRegistry::declared()`. The frontend codegen +//! (`scripts/codegen-node-registry.ts`) reads that file to emit +//! `COMPONENT_PORTS` / `COMPONENT_EMITS`, so the TS literal-unions derive +//! from Rust *by construction* — there is no hand-authored +//! `impls[].ports/emits` mirror left to drift. (This is the live successor +//! to the `build.rs` port-drift codegen dropped in the re-host, ADR-0006.) +//! - Without the env var it asserts the committed sidecar is current, so a +//! stale file fails CI instead of silently shipping wrong handle types. +//! - It always asserts every catalog `entries[].name` is a registered +//! (buildable) node — no orphan entries, no orphan registrations. //! //! 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`. +//! test. This crate is the one place that enables every core feature at once +//! (`js`, so `Function` is present; `cloud`, so `Mqtt`/`Llm`/`Figma` are), so +//! `declared()` here is the complete node set — aliases (`Vibration`/`Force`/…) +//! included. -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; use microflow_core::runtime::ComponentRegistry; -use serde_json::Value; +use serde_json::{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") -} +const MANIFEST: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../node-components.json"); +const SIDECAR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../wire-interface.generated.json"); -fn set(items: &[&str]) -> BTreeSet { - items.iter().map(|s| (*s).to_string()).collect() +fn read_json(path: &str) -> Value { + let raw = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}")); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path} is valid JSON: {e}")) } -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() +/// `{ entryName: { "ports": [...], "emits": [...] } }`, sorted by name, built +/// from the Rust impls' declared wire interface. Keyed by registration name, so +/// alias entries (`Force`/`Vibration`/…) carry their parent impl's interface — +/// matching what the codegen looks up per `entries[].name`. Handle order is the +/// Rust declaration order from `ports()`/`emits()`. +fn wire_interface_from_rust() -> Value { + let registry = ComponentRegistry::new(); + let mut by_name: BTreeMap = BTreeMap::new(); + for (name, (ports, emits)) in registry.declared() { + by_name.insert(name.clone(), json!({ "ports": ports, "emits": emits })); + } + serde_json::to_value(by_name).expect("wire interface serializes") } #[test] -fn catalog_matches_rust_ports_and_emits() { - let cat = catalog(); +fn wire_interface_sidecar_matches_rust() { + let expected = wire_interface_from_rust(); - // 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))); + if std::env::var_os("BLESS_WIRE_INTERFACE").is_some() { + let pretty = serde_json::to_string_pretty(&expected).expect("serialize wire interface"); + std::fs::write(SIDECAR, format!("{pretty}\n")) + .unwrap_or_else(|e| panic!("write {SIDECAR}: {e}")); + eprintln!("blessed {SIDECAR}"); + return; } - // (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())" - ); - } + let on_disk = read_json(SIDECAR); + assert_eq!( + on_disk, expected, + "wire-interface.generated.json is stale.\n \ + Regenerate from Rust: \ + `BLESS_WIRE_INTERFACE=1 cargo test --manifest-path apps/web/src-tauri/Cargo.toml --test catalog_parity`\n \ + then `bun run codegen` in apps/web.", + ); +} - // (2) No-orphan / buildability: registered names ≡ catalog entry names. +#[test] +fn every_catalog_entry_is_buildable() { + let cat = read_json(MANIFEST); 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(); + + let registry = ComponentRegistry::new(); + let registered: BTreeSet = registry.declared().keys().cloned().collect(); + assert_eq!( - registered, - entry_names, + 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/nodes/_REGISTRY.ts b/apps/web/src/components/flow/nodes/_REGISTRY.ts index 39d2219..cecbdb2 100644 --- a/apps/web/src/components/flow/nodes/_REGISTRY.ts +++ b/apps/web/src/components/flow/nodes/_REGISTRY.ts @@ -1,4 +1,4 @@ -// GENERATED — edit node-components.json, then run `bun run codegen` +// GENERATED — do not edit. Source: node-components.json. Run `bun run codegen`. import type { NodeTypes } from "@xyflow/react"; import type { ComponentType } from "./_base/_base.types"; import type { NodeHostAdapter } from "./_base/host-adapter"; 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 e19faf9..acadd15 100644 --- a/apps/web/src/components/flow/nodes/_base/_base.types.ts +++ b/apps/web/src/components/flow/nodes/_base/_base.types.ts @@ -1,4 +1,5 @@ -// GENERATED — edit node-components.json, then run `bun run codegen` +// GENERATED — do not edit. Sources: node-components.json (entries/metadata) + +// wire-interface.generated.json (ports/emits, from Rust). Run `bun run codegen`. export const COMPONENT_TYPES = [ "Button", @@ -47,11 +48,10 @@ export function isComponentType(value: string): value is ComponentType { } /** - * Declared **Port** set per Component (catalog-driven). Mirrors - * `impls[].ports[]` in `node-components.json` and the Rust impl's - * `Component::ports()` const. The Rust registry asserts equality at - * construction; this object is the single source of truth for what target - * handles a ReactFlow edge may carry. Empty array for components with no + * Declared **Port** set per Component. GENERATED from the Rust impl's + * `Component::ports()` via `wire-interface.generated.json` — the single + * source of truth (see `src-tauri/tests/catalog_parity.rs`). Type-checks the + * target handles a ReactFlow edge may carry. Empty array for components with no * edge inputs (e.g. `Constant`). See CONTEXT.md § Port. */ export const COMPONENT_PORTS = { @@ -104,12 +104,11 @@ export type PortOf = T extends ComponentType : 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. + * Declared **Emit** set per Component. GENERATED from the Rust impl's + * `Component::emits()` via `wire-interface.generated.json` — the single + * source of truth, kept current by the Catalog Parity Guard + * (`src-tauri/tests/catalog_parity.rs`). Type-checks the source handles a + * ReactFlow edge may originate from. See CONTEXT.md § Emit. */ export const COMPONENT_EMITS = { Button: ["event", "true", "false", "hold", "value"] as const, diff --git a/apps/web/wire-interface.generated.json b/apps/web/wire-interface.generated.json new file mode 100644 index 0000000..7c31728 --- /dev/null +++ b/apps/web/wire-interface.generated.json @@ -0,0 +1,363 @@ +{ + "Button": { + "emits": [ + "event", + "true", + "false", + "hold", + "value" + ], + "ports": [ + "read" + ] + }, + "Calculate": { + "emits": [ + "value" + ], + "ports": [ + "value" + ] + }, + "Compare": { + "emits": [ + "true", + "false", + "value" + ], + "ports": [ + "value" + ] + }, + "Constant": { + "emits": [ + "value" + ], + "ports": [] + }, + "Counter": { + "emits": [ + "value" + ], + "ports": [ + "increment", + "decrement", + "reset", + "set" + ] + }, + "Delay": { + "emits": [ + "event" + ], + "ports": [ + "trigger" + ] + }, + "Figma": { + "emits": [ + "change", + "value" + ], + "ports": [ + "true", + "false", + "toggle", + "set", + "increment", + "decrement", + "reset", + "red", + "green", + "blue", + "opacity" + ] + }, + "Force": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Function": { + "emits": [ + "value" + ], + "ports": [ + "trigger" + ] + }, + "Gate": { + "emits": [ + "true", + "false", + "value" + ], + "ports": [ + "value" + ] + }, + "HallEffect": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Hotkey": { + "emits": [ + "event", + "true", + "false", + "value" + ], + "ports": [ + "key_event" + ] + }, + "I2cDevice": { + "emits": [ + "value" + ], + "ports": [ + "write", + "trigger" + ] + }, + "Interval": { + "emits": [ + "event" + ], + "ports": [ + "start", + "stop" + ] + }, + "Ldr": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Led": { + "emits": [ + "value" + ], + "ports": [ + "true", + "false", + "toggle", + "value" + ] + }, + "Llm": { + "emits": [ + "thinking", + "value", + "done", + "error" + ], + "ports": [ + "trigger" + ] + }, + "Matrix": { + "emits": [ + "value" + ], + "ports": [ + "value", + "reset", + "reinitialize" + ] + }, + "Monitor": { + "emits": [ + "value" + ], + "ports": [ + "value" + ] + }, + "Motion": { + "emits": [ + "event", + "true", + "false", + "value" + ], + "ports": [ + "read" + ] + }, + "Mqtt": { + "emits": [ + "value" + ], + "ports": [ + "trigger" + ] + }, + "Oscillator": { + "emits": [ + "value" + ], + "ports": [ + "start", + "stop", + "reset" + ] + }, + "Piezo": { + "emits": [ + "value" + ], + "ports": [ + "trigger", + "stop" + ] + }, + "Pixel": { + "emits": [ + "event", + "value" + ], + "ports": [ + "value", + "color", + "set", + "reset" + ] + }, + "Potentiometer": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Proximity": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "RangeMap": { + "emits": [ + "to", + "value" + ], + "ports": [ + "value" + ] + }, + "Relay": { + "emits": [ + "value" + ], + "ports": [ + "true", + "false", + "toggle" + ] + }, + "Rgb": { + "emits": [ + "value" + ], + "ports": [ + "red", + "green", + "blue", + "alpha", + "off" + ] + }, + "Sensor": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Servo": { + "emits": [ + "value" + ], + "ports": [ + "min", + "max", + "value", + "rotate", + "stop" + ] + }, + "Smooth": { + "emits": [ + "value" + ], + "ports": [ + "value" + ] + }, + "Stepper": { + "emits": [ + "value" + ], + "ports": [ + "value", + "to", + "stop", + "zero", + "enable" + ] + }, + "Switch": { + "emits": [ + "event", + "true", + "false", + "value" + ], + "ports": [ + "read" + ] + }, + "Tilt": { + "emits": [ + "value" + ], + "ports": [ + "read" + ] + }, + "Trigger": { + "emits": [ + "bang", + "value" + ], + "ports": [ + "value" + ] + }, + "Vibration": { + "emits": [ + "value" + ], + "ports": [ + "true", + "false", + "toggle", + "value" + ] + } +} diff --git a/crates/microflow-core/src/runtime/mod.rs b/crates/microflow-core/src/runtime/mod.rs index 21435bd..ab5c0ef 100644 --- a/crates/microflow-core/src/runtime/mod.rs +++ b/crates/microflow-core/src/runtime/mod.rs @@ -62,6 +62,30 @@ use crate::flow::{FlowEdge, FlowNode, FlowUpdate}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +/// Reserved runtime handle names — the `source_handle`s the executor treats as +/// plumbing rather than edge **Port**s. All are [`INTERNAL_PREFIX`]-prefixed +/// (see `CONTEXT.md` § Internal Event / Hardware Callback), so +/// [`FlowRuntime::process_event`] can branch them off the Port path in one +/// check; the two hardware-callback names additionally route to typed +/// [`HardwareComponent`] methods rather than the generic `dispatch_internal` +/// catch-all. Naming them here keeps the reserved set discoverable in one place +/// instead of as bare string literals at each emit and match site. +/// +/// A third hardware handle, `"stepper_reply"`, is reserved for stepper sysex +/// replies but has **no emission path yet** (see [`output::stepper`] and +/// `CONTEXT.md` § Hardware Callback); it is intentionally not named as a const +/// here until that wiring lands, so an unused const can't masquerade as a live +/// route. +mod reserved_handles { + /// Marks a `source_handle` as runtime plumbing (Internal Event or Hardware + /// Callback), never an edge Port. Stripped before `dispatch_internal`. + pub(crate) const INTERNAL_PREFIX: char = '_'; + /// Board digital/analog pin change → [`super::HardwareComponent::on_pin_change`]. + pub(crate) const PIN_CHANGE: &str = "_pin_change"; + /// Board I2C reply → [`super::HardwareComponent::on_i2c_reply`]. + pub(crate) const I2C_REPLY: &str = "_i2c_reply"; +} + /// Adapter that lets [`FlowRouter`] read the runtime's component map without /// seeing its shape. Lives for the scope of one `route` call. struct ComponentMapLookup<'a> { @@ -534,7 +558,7 @@ impl FlowRuntime { for component_id in listeners { self.sink.borrow_mut().push_back(ComponentEvent { source: Arc::clone(component_id), - source_handle: Arc::from("_pin_change"), + source_handle: Arc::from(reserved_handles::PIN_CHANGE), value: value.clone(), edge_id: None, sequence: self.current_sequence, @@ -560,7 +584,7 @@ impl FlowRuntime { for component_id in listeners { self.sink.borrow_mut().push_back(ComponentEvent { source: Arc::clone(component_id), - source_handle: Arc::from("_i2c_reply"), + source_handle: Arc::from(reserved_handles::I2C_REPLY), value: ComponentValue::Array(data.clone()), edge_id: None, sequence: self.current_sequence, @@ -597,12 +621,12 @@ impl FlowRuntime { source = %event.source, handle = %event.source_handle, seq = event.sequence, - internal = event.source_handle.starts_with('_'), + internal = event.source_handle.starts_with(reserved_handles::INTERNAL_PREFIX), "drain", ); // Internal/hardware events (`_`-prefixed) are runtime plumbing the // UI never renders — keep them out of `component_events`. - if !event.source_handle.starts_with('_') { + if !event.source_handle.starts_with(reserved_handles::INTERNAL_PREFIX) { events.push(event.clone()); } self.process_event(event, &mut out, &mut reqs); @@ -667,7 +691,7 @@ impl FlowRuntime { // Internal-event branch (underscore-prefixed handle): hardware callbacks // (`_pin_change`/`_i2c_reply`) → typed methods, other `_method` → // `dispatch_internal`. Never flows through the router (source == target). - if event.source_handle.starts_with('_') { + if event.source_handle.starts_with(reserved_handles::INTERNAL_PREFIX) { self.dispatch_internal_event(&event, out, reqs); return; } @@ -735,10 +759,10 @@ impl FlowRuntime { let mut writer = BufferBoardWriter::new(&mut self.client, out); let mut ctx = RuntimeContext::new(&mut writer, self.now_ms, id.as_ref(), reqs); let result = match handle.as_ref() { - "_pin_change" => component + reserved_handles::PIN_CHANGE => component .as_hardware_mut() .map_or(Ok(()), |hw| hw.on_pin_change(event.value.clone(), &mut ctx)), - "_i2c_reply" => component + reserved_handles::I2C_REPLY => component .as_hardware_mut() .map_or(Ok(()), |hw| hw.on_i2c_reply(event.value.clone(), &mut ctx)), other => component.dispatch_internal(&other[1..], event.value.clone(), &mut ctx), @@ -994,7 +1018,7 @@ mod tests { // A leftover board event from flow version 3 (< current 5) must be gated. rt.sink.borrow_mut().push_back(ComponentEvent { source: Arc::from("sw"), - source_handle: Arc::from("_pin_change"), + source_handle: Arc::from(reserved_handles::PIN_CHANGE), value: ComponentValue::Bool(true), edge_id: None, sequence: 3, @@ -1043,7 +1067,7 @@ mod tests { // Drive a sensor reading; the gate passes and the led turns on. rt.sink.borrow_mut().push_back(ComponentEvent { source: Arc::from("sensor"), - source_handle: Arc::from("_pin_change"), + source_handle: Arc::from(reserved_handles::PIN_CHANGE), value: ComponentValue::Number(500.0), edge_id: None, sequence: rt.current_sequence, diff --git a/docs/adr/0010-subscription-diff-stays-per-host.md b/docs/adr/0010-subscription-diff-stays-per-host.md new file mode 100644 index 0000000..bde9228 --- /dev/null +++ b/docs/adr/0010-subscription-diff-stays-per-host.md @@ -0,0 +1,79 @@ +# ADR-0010 — Subscription diff stays per-host; only winner-selection is core policy + +- **Status:** accepted (2026-06-26) — records a deliberate non-change +- **Date:** 2026-06-26 +- **Deciders:** sander + +> **Decision: keep the split.** Winner-selection (`reconcile_desired`) is core +> policy, shared by both **Runtime Host**s. The desired→live subscription diff +> and the broker I/O stay per-host. An architecture review proposed hoisting the +> diff into core too; **rejected** — it operates on host-local state and the gain +> is marginal. The rationale already lives at `subscriptions.rs:13`; this ADR +> records it so future reviews don't re-suggest the hoist. + +## Context + +Subscription reconciliation has two parts: + +1. **Winner-selection** — collapse many subscribe-nodes to one *desired* + subscription per `(broker, topic)`, picking a deterministic winner on + collisions (routing kinds beat display-echo; ties break on the lower node id). +2. **Diff-against-live** — compare the desired set to what the broker is + *currently* subscribed to, and issue the subscribe / unsubscribe / announce + calls. + +Part 1 is drift-dangerous: if the desktop and browser picked different winners +they would disagree on which node owns a topic. It previously lived in two +languages (desktop `DesiredSub::beats`, browser `beats`/`reconcileDesired`) kept +in lockstep only by a comment, and is now single-sourced in core as +`microflow_core::runtime::reconcile_desired` ([ADR-0009](0009-cloud-sans-io-capability.md); +commit 67b05b9). + +An architecture review then proposed extending the same move to part 2 — a core +`reconcile_plan(desired, live) → Plan { subscribe, unsubscribe, announce }` that +both hosts apply — so each host shrinks to a thin applier, mirroring how +[ADR-0008](0008-effects-apply-policy.md) hoisted the `Effects` apply-order. + +## Decision + +**No. Part 2 stays per-host.** The split is: + +- **Winner-selection → core** (`reconcile_desired`). Shared, because divergence + between hosts is a correctness bug. +- **Diff-against-live + broker I/O → per-host.** The diff operates on each host's + *live* subscription set, which lives inside its broker client (`rumqttc` on the + desktop, `mqtt.js` in the browser) — host-local state core does not, and should + not, hold. Centralizing the diff would marshal that live set across the wasm + boundary on every `flow_update` to run a ~10-line set difference. The + determinism that made part 1 worth sharing has no analogue in a set diff. + +This is already the documented rationale at +`crates/microflow-core/src/runtime/subscriptions.rs:13`; this ADR promotes that +comment to a recorded decision. + +## Consequences + +**Positive** + +- Core stays sans-IO and holds no per-host live subscription state. +- Effort matched to risk: the dangerous policy (winner-selection) is shared; the + trivial one (set diff) is not. + +**Negative** + +- A ~10-line set-diff exists in both languages (`commands.rs` desktop, + `mqtt-subscriptions.ts` browser). Accepted: low risk, no determinism concern, + no cross-host contract to break if they diverge superficially. + +**Revisit if** + +- A host's live subscription set must cross into core for some *other* reason — + then the marshalling cost is already paid and folding the diff in becomes cheap. + +## References + +- `crates/microflow-core/src/runtime/subscriptions.rs:13` — the in-code rationale this ADR formalizes. +- `apps/web/src-tauri/src/runtime/commands.rs` — desktop diff-against-live (`rumqttc`). +- `apps/web/src/lib/firmata/cloud/mqtt-subscriptions.ts` — browser diff-against-live (`mqtt.js`). +- [ADR-0008](0008-effects-apply-policy.md) — the policy-in-core / primitives-per-host pattern that deliberately does **not** extend to the subscription diff. +- [ADR-0009](0009-cloud-sans-io-capability.md) — single-sourced `reconcile_desired`. diff --git a/turbo.json b/turbo.json index fc93cd1..e9b1031 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "ui": "tui", "tasks": { "codegen": { - "inputs": ["node-components.json"], + "inputs": ["node-components.json", "wire-interface.generated.json"], "outputs": [ "src/components/flow/nodes/_base/_base.types.ts", "src/components/flow/nodes/_REGISTRY.ts"