03
Two modules, one workspace
A workspace rather than two loose projects, for a practical reason:
members share one target/, so both .wasm
files come out in the same directory.
mkdir -p hello-wasm/multimeter/src hello-wasm/plc/src
hello-wasm/Cargo.toml:
[workspace]
members = ["multimeter", "plc"]
resolver = "2"
hello-wasm/multimeter/Cargo.toml:
[package]
name = "multimeter"
version = "0.1.0"
edition = "2021"
[dependencies]
anvil-step = { path = "../../anvil-src/executors/rust/anvil-step" }
[lib]
crate-type = ["cdylib"]
The package name is the module name. It gives the
.wasm its file name, and the file name is the
multimeter/ you will write in the YAML. It is not
declared anywhere else. crate-type = ["cdylib"] is
required: without it you do not get a loadable component.
hello-wasm/plc/Cargo.toml is the same file with
name = "plc".
04
The step is a function
hello-wasm/multimeter/src/lib.rs:
use anvil_step::{step, Outcome};
/// Measures DC voltage on a channel.
#[step(outputs(channel_used: f64))]
fn measure_voltage(channel: Option<f64>) -> Outcome {
let channel = channel.unwrap_or(1.0);
Outcome::measured(4.8)
.message(format!("channel {channel}"))
.output("channel_used", channel)
}
anvil_step::export!();
hello-wasm/plc/src/lib.rs:
use anvil_step::{step, Outcome};
/// Reads the 24 V rail the PLC feeds.
#[step]
fn measure_voltage() -> Outcome {
Outcome::measured(23.7).message("24 V rail")
}
anvil_step::export!();
Both steps are called measure_voltage and they do
not collide. That is exactly what the module name buys:
multimeter/measure_voltage and
plc/measure_voltage are two different steps.
Every part of that signature tells Anvil something.
channel is the name a sequence will use;
f64 says it is a number; and wrapping it in
Option makes it optional. The doc
comment is the description the catalogue publishes.
Note what is absent: nowhere does this code say whether
4.8 V is acceptable. That is not the step's business.
export!() goes at the end of each module, once.
Outcome::measured(…) reports a number;
Outcome::passed, failed and
error report a verdict with no measurement. A step that
could not measure returns error, never
failed — a broken bench is not a bad unit.
05
Compile
cargo build --target wasm32-wasip2 \
--manifest-path hello-wasm/Cargo.toml
hello-wasm/target/wasm32-wasip2/debug/multimeter.wasm
hello-wasm/target/wasm32-wasip2/debug/plc.wasm
The first build takes about a minute: it compiles the SDK and its
dependencies. After that it is seconds.
06
Assemble the department
The executor serves what sits beside it, so a department is assembled
by putting the binary and the modules in one folder.
mkdir -p department
cp anvil-exec-wasm department/
cp hello-wasm/target/wasm32-wasip2/debug/*.wasm department/
And that folder is the executor. Copy it to another machine,
start it there, and the same sequence reaches it by naming an address
instead of a path — the steps do not change.
Trap. The executor serves everything in that folder,
so any .wasm left over from before is served as a module
too. Rename a package and you must delete the old file. The
--list below shows you immediately.
07
The signature is the catalogue
Ask the executor what it serves, without writing a sequence and
without running anything. You wrote each description in exactly one
place — the function — so it cannot drift from the code that runs.
./department/anvil-exec-wasm --list
multimeter sha256:df251ca8537e4ff951187465be8f4c31b…
…/department/multimeter.wasm
multimeter/measure_voltage(channel: number = optional)
Measures DC voltage on a channel.
outputs: channel_used
plc sha256:2e6ace99408b73165f564fd03960b6a1c…
…/department/plc.wasm
plc/measure_voltage()
Reads the 24 V rail the PLC feeds.
Each module with its SHA-256, each step with its signature. Note that
you never told it where to look: it reads the folder its own binary is
in. This is what you consult before writing the YAML,
instead of writing it blind.
08
The sequence is data
This is the file a test engineer writes and reviews — plain text, in a
diff, with no programmer in the loop. Save it as
hello.yaml.
name: hello_bench
executors:
- name: bench
type: wasm
path: department/anvil-exec-wasm
main:
- name: multimeter/measure_voltage
executor: bench
inputs:
channel: 3
limit:
type: range
min: 4.5
max: 5.5
- name: plc/measure_voltage
executor: bench
limit:
type: range
min: 23.0
max: 25.0
path is the executor's binary, and it is
relative to the YAML file, not to wherever you launch
anvil from. It is not a .wasm and not the
folder of modules — where those are is the executor's business.
limit is the acceptance criterion, and it lives here
rather than in the code.
Since 0.5.0 a sequence may also be saved as hello.yseq:
the same YAML, with an extension that says what the file is for.
./anvil hello.yseq runs it the same way.
If the executor runs on another machine, you declare
a connection instead — type: grpc with its
host and port — and nothing else changes.
The steps are still multimeter/measure_voltage. Over
there you start the same anvil-exec-wasm, from the folder
you copied.
09
Check before you measure
Anvil asks each executor what it serves and checks your sequence
against the answer — once, before the first step.
Mistype a name and see for yourself:
./anvil hello.yaml --validate --with-executors
- step 'multimeter/measure_voltage' (bench): it takes
no input called 'channell' (it takes: channel)
And the same for a module that does not exist:
- step 'multimetre/measure_voltage': executor 'bench'
does not serve it (it serves:
multimeter/measure_voltage, plc/measure_voltage)
Without that check the run would have started, the executor would have
dropped the unknown value, and the step would have
measured something else and reported a pass. The
message names what you actually have, because the mistake is nearly
always a typo.
--validate on its own reads and checks the file without
connecting to anything, which is what makes it usable in CI with no
hardware. --with-executors is the explicit opt-in for
when they are up.
10
Run it
The verdict is also the exit code — 0 passed, anything
else did not — so a sequence can gate whatever comes after it.
./anvil hello.yaml
=== hello_bench: pass ===
[pass] multimeter/measure_voltage: channel 3
[pass] plc/measure_voltage: 24 V rail
Diagnostics go to stderr and the report to
stdout, so 2>/dev/null leaves you the
report alone. Among those diagnostics is the SHA-256 of each module
that answered — today the start-up log is the only place that is
written down.
./anvil hello.yaml --json out.json --csv out.csv
11
Now change the criterion, not the code
Set max: 4.7 on the first step. Touch no Rust. Compile
nothing. Run it again:
=== hello_bench: fail ===
[fail] multimeter/measure_voltage: 4.8 …
$ echo $?
1
The step still measured 4.8 and still reported it happily. What
changed is the verdict, and the detail line names the value it read
and the range it was judged against.
That is the whole thesis in one edit. Whoever owns the tolerance can
change it without owning the code — and the change is a line in a
text file, with an author and a date, not a rebuilt binary nobody can
diff.
12
Iterating
Every time you touch a lib.rs: compile, and
deploy again. The executor serves what is in
department/, not what you just compiled.
cargo build --target wasm32-wasip2 \
--manifest-path hello-wasm/Cargo.toml
cp hello-wasm/target/wasm32-wasip2/debug/*.wasm department/
./anvil hello.yaml
One step more than you might expect, and it is the price of the YAML
knowing nothing about your build tree. Adding a module is adding a
folder to the workspace and copying its .wasm: the
executor is not touched, you only name the new step in the sequence.