Add stateful multi-signal inputs + explicit output to the script stage#8
Conversation
The script stage's object form now takes named `inputs` (selectors over signal id/name, topic filter, and envelope identity) and an optional `output`. The new MultiScriptStage caches the latest value/quality/ timestamps per input, partitioned by source device, and evaluates the script when a matched input's value or quality changes — binding the full snapshot as `inputs` and the firing input as `trigger` in both engines — gated until every required input is initialized. With `output.topic`, each successful result is published as a new envelope (producer = the processor with instance = route id, correlation_id = the triggering message's uuid) instead of mutating the trigger in place; without it the in-place contract is unchanged, as are both legacy script forms. Startup validation rejects reserved-class output topics, output topics the route's own subscribe filters would re-consume, unknown selector fields, ambiguous duplicate selectors, and a route publish.topic alongside a stage output topic. Docs: multi-signal section in the scripting guide, selector/output reference tables, ScriptResult envelope reference, an OEE sample route, and a how-to. Closes #5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019emrYEw39h1jJbsA6C8oBZ
Live-broker E2E — PASS (24/24 checks)Ran the full HOST/dual-MQTT path from the validation matrix: the real Two multi-signal routes exercised end to end:
Verified over the wire:
Processor log clean (no WARN/ERROR); graceful shutdown. Broker + processor torn down after. |
The existing gating/unchanged tests used scripts that read their inputs,
so a missing or unchanged input could yield no output either because the
stage withheld the invocation OR because the script errored/returned
nothing — the two layers weren't distinguished.
Add two tests driven by a constant-returning script (`#{ "fired": true }`)
that never inspects its inputs, so it cannot be the layer deciding to
emit. Output therefore appears iff the stage invokes the script, proving
the stage itself gates until all required inputs are initialized and
suppresses invocation when no value/quality changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019emrYEw39h1jJbsA6C8oBZ
Per explicit design direction: the core stage must not withhold script invocation for incomplete inputs — completeness is the script writer's call. Flip the `required` default from true to false, so by default the stage runs the script on the first (and every) matched change with whatever inputs are currently present; an unarrived input is simply absent from the `inputs` snapshot and the script guards itself. Keep `required: true` as a per-input opt-in for stage-level gating (the "need them all" convenience), and gate only on those inputs. Tests: replace the gating tests with ones that isolate the mechanism via a constant script — default-off fires on the first input (no core gate), `required:true` withholds until all such inputs arrive. OEE/ratio example scripts now guard presence themselves. Docs: scripting guide, configuration reference, how-to, sample, and explanation updated — completeness is the script's job by default, `required:true` opts into stage gating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019emrYEw39h1jJbsA6C8oBZ
Correction: gating is the script's responsibility (default off) — re-validated liveAn earlier revision had the core stage withhold script invocation until all required inputs were initialized (following issue #5's original wording). That was wrong per explicit design direction: completeness is the script writer's responsibility, not the core's. Fixed. Change: Tests rewritten to isolate the mechanism with a constant (input-ignoring) script:
68 default / 81 Live E2E re-run — PASS (27/27), log cleanAdded Phase 0 as direct proof the core does not gate: an unguarded |
Implements #5: stateful, named multi-signal inputs for script pipeline stages, with an optional explicit output topic for the derived result (the OEE use case).
What changed
scriptstage's object form gainsinputs(name → selector:signalId/signalName/topicfilter, narrowed by envelope-identitydevice/component/instance, per-inputrequired) andoutput(topic, optional envelopename/version). Both legacy forms ("script": "<expr>",{"file": …}) parse and behave exactly as before. Unknown selector fields and duplicate selectors fail the route at build time.MultiScriptStage(src/proc/multi.rs) — caches the latest value/quality/source-timestamp/receive-time per input, partitioned by source device, and evaluates when a matched input's value or quality changes, only after every required input is initialized. The script getsinputs.<name>.{value,quality,timestamp,recvMs,topic}andtrigger.{name,value,quality,…}in both Rhai and Lua, alongside the ordinary per-message scope.output.topic, each successful evaluation publishes a new envelope: producer identity = the processor with instance = route id,correlation_id= the triggering message'suuid; the trigger is consumed, never republished. Withoutoutput, the in-place result contract is unchanged.publish.topic+ stageoutput.topictogether.ScriptResultenvelope reference, sample §12 (Lua OEE route), and a how-to.Acceptance criteria → tests (
src/proc/multi.rs,src/config.rs,src/app.rs)Validation
cargo test: 65 passed (default features), 78 passed (--features scripting-lua)cargo clippy --all-targets: clean on both feature sets🤖 Generated with Claude Code
https://claude.ai/code/session_019emrYEw39h1jJbsA6C8oBZ