Sidecars And External Processes
A sidecar is a native process declared in Rust and supervised by the host.
Dokime integrations keep their suites and typed domains in the same codebase and export
them from the loaded sidecar file before initialization. The traits under
dokime::plugin are the authoring API and the #[dokime::…] attributes are the
path most authors take to them; they live behind the default-on derive cargo
feature, the way serde ships its derives. The sidecar crate depends on dokime.
Start with your first plugin and cargo dokime new. The
embedded starter is a complete small bench that uses every contract on this page
and nothing else, so the fastest way to read them is to generate one. This page
explains those contracts and the richer examples you can adopt as your plugin
grows.
Declaration Shape
Section titled “Declaration Shape”A plugin is two halves. What the host must know before the plugin runs is literal data on the attribute, because a declaration scan reads it out of a binary it never executes. What carries values or code is the trait implementation beside it.
#[dokime::sidecar]binds the artifact id, the Cargo-derived version and description, the restart policy, and the catalogs of types this plugin contributes.impl dokime::Plugindeclares the types —Config,Error,Resources— then the settings, then the lifecycle:initialize,resources,cleanup.- Every contribution is a type with an attribute over its own inherent
impl, listed in a catalog enum the sidecar attribute names. #[dokime::requires(id, version)]declares an artifact dependency.
#[dokime::sidecar( id = "device-bench", suites(suites::Suites), samplers(samplers::Samplers), listeners(listeners::Listeners), routes(routes::Routes), restart = "never",)]pub struct Device { sensor: Arc<Sensor>,}
impl dokime::Plugin for Device { type Config = Config; type Error = Error; type Resources = Resources;
fn settings() -> Settings { Settings::new() .text_area("sensor_port", "Sensor port") .described("Serial port of the bench sensor.") .default("/dev/ttyUSB0") .required() }
async fn initialize(config: Config, ctx: &dokime::plugin::Context) -> Result<Self> { Ok(Self { sensor: Arc::new(Sensor::open(ctx.setting("sensor_port")?, config.ambient_c)?), }) }
fn resources(&self) -> Resources { Resources { sensor: Arc::clone(&self.sensor) } }
async fn cleanup(self) { self.sensor.close(); }}There is no second lifecycle block and no generated shim between the two: the
harness and the host both call the trait. cleanup takes self by value
because the process is ending, and answers nothing — a failure there has nowhere
left to travel, so log what could not be closed. permissions, prompts,
documents and appearance are the trait’s other definitions and default to
empty.
A type belongs in an associated type, so config = … and error = … on the
attribute are refused, naming the associated type to write instead.
A sidecar id and a route id are wire identities: lowercase ASCII letters,
digits, dots and hyphens, starting with a letter. An id defaulted from a Rust
type name is checked before it is forwarded, so a PascalCase route type is
refused on the line it is written, naming id = …. Write ids explicitly, and
namespace them by plugin — thermo.status, not status.
The host inventories every catalog in a declaration pass that never runs
initialize. Nothing may be registered there: initialize opens handles and
reads the settings and secrets the host admitted, and nothing else. The suite
collection supplies the runtime factories, and the SDK creates their execution
owner once per initialized plugin.
Execution evidence travels through the drain protocol. Build and install the executable through a plugin bundle, rather than copying a native file into runtime storage.
One error type
Section titled “One error type”A plugin declares one error and answers with it everywhere. src/error.rs holds
a concrete thiserror enum and the alias every fallible signature uses:
#[derive(Debug, thiserror::Error)]pub enum Error { #[error("sensor: {0}")] Sensor(#[from] crate::sensor::Error),
#[error("pulse sample {index} carries no numeric `{column}`")] Unmeasured { index: usize, column: &'static str },}
pub type Result<T, E = Error> = std::result::Result<T, E>;impl dokime::Plugin names it once, as an associated type. It must implement
std::error::Error + Send + Sync + 'static:
impl dokime::Plugin for Device { type Config = Config; type Error = Error; type Resources = Resources; // …}initialize, unbound routes, steps, criteria, samplers, listeners and cleanup
answer with Result<T>; the macros read the type through the compiler, so any
Result alias works. A route bound to a domain declaration is the one boundary
that answers with a foreign type, ActionDispatcherError. The plugin’s own
cleanup answers nothing: at shutdown there is no frame left to carry a failure
home, so what could not be closed is logged there.
What a failure carries
Section titled “What a failure carries”The SDK turns an author error into one dokime::plugin::Diagnostic at the
boundary where it calls into authored code: the return of initialize, a step,
a criterion, a sampler’s start or sample, a listener, a route, cleanup.
The diagnostic carries the error’s own Display, its full source() chain, the
#[track_caller] location of that boundary, a span trace naming the execution,
template, step or stream and plugin, and a backtrace. Capture is unconditional
and RUST_BACKTRACE is never consulted, because a failure that reproduces once
is exactly the failure whose backtrace nobody thought to ask for.
dokime::plugin::sidecar::run also installs a panic hook, so a panic inside a
step becomes the same diagnostic and the execution still reports one terminal
outcome instead of a dead process.
error: step `hold` failed in thermo.soak.hold caused by: sensor: read timed out after 1s caused by: serial port: Resource temporarily unavailable (os error 11) at plugins/thermo/src/suites/soak/hold.rs:71 in step step=hold in execution execution=0199 template=thermo.soak.hold plugin=thermo-bench backtrace: 0: thermo_bench::suites::soak::hold::Soak::hold 1: dokime::execution::run::walkThose caused by lines are the chain, so give the enum real variants with
#[from] sources. .map_err(|e| Error::Something(e.to_string())) folds the
cause into the message and leaves the operator, and the stored failure record,
with one line. The stored form is redacted: home directories and account names
are stripped and paths are made repository-relative.
Mini Sidecar Example
Section titled “Mini Sidecar Example”plugins/mini is a shipped example with a vertical suite module, one implicitly
sidecar-executed workflow, and one process-wide sampler that keeps its own
machine reader between ticks. Inspect it for suite and sampler composition after
running the generated starter. The following repository check applies to a
Dokime source checkout:
mise exec -- cargo test -p mini-sidecarThe check builds and tests the direct declaration. It creates no manifest or archive.
Standalone plugins use one crate. App-backed plugins use app-runtime for
application definitions, app for its thin developer facade, and app-plugin
for the Dokime adapter. The full application example is plugins/synthetic,
which demonstrates that composition and a simulated device bench. These shipped
examples live under plugins/; a generated plugin can live in its own repository.
Parameters are a type
Section titled “Parameters are a type”A template’s inputs are one struct, written once. The field’s type is the
parameter’s type, the field’s name is its id, and #[parameter(…)] carries the
rest:
#[dokime::parameters]pub struct SoakParameters { #[parameter(label = "Target temperature", unit = "°C", default = 60.0)] pub target_c: f64, #[parameter(label = "Samples to hold", default = 5, min = 1)] pub hold_samples: u64, #[parameter(label = "Operator")] pub operator: Option<String>, #[parameter(label = "Heater ports", min_items = 1, max_items = 4)] pub heater_ports: Vec<String>, #[parameter(label = "How the sweep runs")] pub sweep: Sweep,}
#[dokime::choice]#[derive(Clone, Copy, Debug, PartialEq)]pub enum Sweep { Fast, #[choice(label = "Slow and careful")] Slow,}The template names it once and every stage is handed the decoded value:
#[dokime::template(id = "bench.soak.hold", label = "Hold", parameters = SoakParameters)]impl Hold { async fn initialize( parameters: &SoakParameters, ctx: &mut InitializationContext<'_>, ) -> Result<Self, Error> { … }
#[dokime::step(label = "Hold")] async fn hold(&mut self, ctx: &mut StepContext<'_>) -> Result<(), Error> { let wanted = ctx.parameters().hold_samples; … }
#[dokime::criterion(label = "Stayed in band")] fn in_band(ctx: &CriterionContext<'_>) -> Result<bool, Error> { let target = ctx.parameters().target_c; … }}Option<T> is the optional form and Vec<T> a list; #[dokime::choice] on an
enum is a closed set of values and Vec<Enum> a multiple choice. Any other
field type is a validated value: implement dokime::ParameterValue for it,
name the base the host carries, and refine it — the refusal message is what an
operator reads beside the field.
impl dokime::ParameterValue for Celsius { type Base = f64;
fn refine(base: f64) -> Result<Self, String> { dokime::parameters::refined(base) }}Lists and spaces
Section titled “Lists and spaces”A space and a list are different declarations, and the difference is how many
cases they generate. A field whose default is a space —
#[parameter(default = ParameterDefault::space(["fast", "slow"]))] mode: String
— is an axis: previewing the cycle produces one case per value. A Vec<T> field
is one value belonging to one case. A list is never expanded into cases, however
long it is.
A list’s element type is T, min_items = n and max_items = n bound its
length, Option<Vec<T>> lets it be absent, unit = ".." describes the
elements, and a #[dokime::choice] element makes every element a choice. An
operator edits it in the GUI’s list editor — one typed input per element, with
add, remove, and reorder — because the definition serializes as
value_type: "array" alongside its element type and bounds. A list coerces
every element to the element type and refuses a bare scalar rather than
wrapping it into a one-element list. Parameter::list(name, label, element) is
the same declaration for a host or generator that builds one at runtime.
min_items and max_items are part of the declaration, so the operator’s form
shows them and the host refuses a case that leaves them before a run starts.
min and max are checked once, when the execution starts, because a
ParameterDefinition carries no numeric bound yet. Either way a case outside
them is refused by the field that does not fit rather than run until whichever
step reads it first fails. A template with no
parameters names none and is handed (). The raw resolved map stays reachable
as ctx.raw_parameters() for the rare reader that works over whatever a case
carried rather than over what this template declared.
Each field also carries the handle a plot references it by —
SoakParameters::target_c() — so a rename moves the reference with it instead
of leaving a string behind that nothing checks. dokime::Parameter remains the
programmatic path for a host or a generator that builds declarations at runtime.
Continuous observations
Section titled “Continuous observations”A process-wide stream is a Sampler: an instance with a lifetime, not a
function on a timer. The row type and the sampler that measures it live together
in one file under src/samplers/, and src/samplers/mod.rs holds only the
catalog enum.
use dokime::plugin::Tick;
/// The row declares the channel, which is the only place its id is written.#[dokime::channel(id = "device.temperature", label = "Temperature")]pub struct TemperatureRow { #[row(unit = "s")] pub elapsed_s: f64, #[row(unit = "°C")] pub celsius: f64,}
pub struct Temperature { sensor: Arc<Sensor>,}
#[dokime::sampler(interval_ms = 100)]impl Temperature { async fn start(resources: &Resources) -> Result<Self> { Ok(Self { sensor: Arc::clone(&resources.sensor) }) }
async fn sample(&mut self, tick: &Tick) -> Result<TemperatureRow> { Ok(TemperatureRow { elapsed_s: tick.elapsed().as_secs_f64(), celsius: self.sensor.read_temperature().await?, }) }}#[dokime::samplers]pub enum Streams { Temperature,}The plugin names that enum once, on #[dokime::sidecar(samplers(Samplers))].
Discovery is the enum: it lists types, so splitting the producers across files
costs nothing.
start runs when the host takes the first lease on the channel, sample once
per tick on the sampler’s own task, and stop when the last lease ends. What a
measurement needs between ticks lives on the struct — a device reader, a
connection, a running count — rather than behind interior mutability in the
bundle every execution shares. A sampler that opened a port, a file or a child
process closes it in async fn stop(self), which runs on that sampler’s own
task before the plugin is handed back its own handles. A stateless sampler is a
unit struct, and that is the whole cost of the shape.
The attribute names neither an id nor a label: Sampler::Row declared the
channel and INTERVAL is the cadence, so there is one declaration and it lives
with the producer. #[dokime::sampler] over an inherent impl block is sugar for
dokime::Sampler; a sampler with something to say about its own lifetime writes
the trait out by hand.
sample is async, which is what lets a sampler read a device that answers
slowly. A call that overruns its cadence delays only its own next tick, and the
instants it missed are skipped rather than burst. Every sampler owns its task,
so one slow device never holds up another channel, and a failure in start or
sample ends that one channel and is captured as a diagnostic. Sampling waits
one full interval before its first row.
The host’s standing demand is one lease on every channel, and repeating it is
not a second lease. A channel nobody is watching holds nothing open; leasing it
again builds a fresh instance rather than reviving the old one. Shutdown stops
and joins every running sampler before the plugin’s cleanup runs, so whatever
start opened is closed before the plugin closes its own.
A template’s own sampler is a different thing. It measures one attempt’s
handles, is captured for that attempt without being asked for, and has no lease
to live by, so it stays an associated function inside the #[dokime::template]
block and keeps the id, label and interval_ms form.
Periodic Event Watchers
Section titled “Periodic Event Watchers”A watcher checks on a cadence and publishes records when something changes.
A quiet check returns Ok(()) without publishing; the author owns the baseline,
change detection, and any debounce. Its authoring forms mirror samplers:
a global watcher owns an instance, and a template watcher is an associated
function over that attempt’s resources.
struct ConnectionWatch { device: Device, connected: bool,}
#[dokime::watcher(interval_ms = 100)]impl ConnectionWatch { async fn start(resources: &Resources) -> Result<Self, Error> { let device = resources.device.clone(); let connected = device.connected().await?; Ok(Self { device, connected }) }
async fn check( &mut self, publisher: &dokime::plugin::Publisher, _tick: &dokime::plugin::Tick, ) -> Result<(), Error> { let connected = self.device.connected().await?; if connected != self.connected { publisher.publish(ConnectionChanged { connected }).await?; self.connected = connected; } Ok(()) }}
#[dokime::watchers]enum Watchers { ConnectionWatch,}Here ConnectionChanged is a #[dokime::record] type. Name the catalog with
watchers(Watchers) and the event with records(ConnectionChanged) on
#[dokime::sidecar]. The watcher starts with the plugin, without a telemetry
lease or execution. Its records take the same host publication path as a
listener’s records. An optional async fn stop(self) releases anything acquired
in start; the SDK gives it one second before dropping the future.
Inside a runnable #[dokime::template] block, write a function instead:
#[dokime::watcher(interval_ms = 100)]async fn watch_connection( resources: &OwnResources, publisher: &dokime::plugin::Publisher, _tick: &dokime::plugin::Tick,) -> Result<(), Error> { if let Some(connected) = resources.take_connection_change().await? { publisher.publish(ConnectionChanged { connected }).await?; } Ok(())}Declare the emitted record in the template’s records(ConnectionChanged),
or on its suite when every template shares it.
Keep persistent observation state in OwnResources, using interior mutability
where needed. The watcher starts after resources and traceability are ready,
and settles before cleanup and criteria. Its records belong to this execution:
its steps can await them through ctx.listen::<ConnectionChanged>() and its
criteria can read them through ctx.records::<ConnectionChanged>(). They are
delivered locally once; the host’s echo does not deliver them a second time.
Ordinary records published by a step still do not satisfy that step’s own inbox.
Both forms wait one interval before their first check, run checks sequentially, and skip missed ticks. A check may publish zero or several records. An error or panic logs a diagnostic and stops only that watcher, without restarting it or failing the procedure automatically. Shutdown cancels a pending async check; synchronous blocking work still needs the device API’s own cancellation support.
Channels a step fills in
Section titled “Channels a step fills in”Not everything arrives on a cadence. For data that comes on the device’s schedule, declare the channel on its row type and push from a step:
#[derive(Clone, Debug)]#[dokime::channel(id = "device.events", label = "Device Events")]struct EventRow { #[row(role = state)] event: String,}#[dokime::channel] declares the columns as well as the channel, so a row type
that names its home does not also derive dokime::TelemetryRow — writing both
is refused.
A sampler’s row declares its home too. Sampler::Row is the channel
declaration, so a process-wide sampler writes its id nowhere else. A template’s
own sampler names its channel on the #[dokime::sampler] beside it, and the row
declares the same home when a step needs to wait on those rows by type:
ctx.listen::<Row>() only means one channel when the type says which. A shape
with no home is still publishable under the id at the call; it is simply not
listenable, because no one channel is meant.
A template lists a pushed channel in #[dokime::template(channels(EventRow))],
which is what binds the contract to that template. A step then publishes one row
with ctx.publish(row), or holds a handle open with
ctx.channel::<EventRow>().open() and pushes into it for as long as the handle
lives.
Where one row shape serves several channels, derive dokime::TelemetryRow on it
and declare each channel as a type of its own:
#[dokime::channel(id = "device.protection.samples", label = "Protection", row = SampleRow)]struct ProtectionSamples;A step then says which channel a reading is evidence for:
ctx.channel::<SampleRow>().into::<ProtectionSamples>(). There is no string
anywhere — a channel type whose declared shape is not this one does not compile
at that call, and a shape that declared a home of its own cannot be carried by a
channel type at all.
To read a channel another sidecar owns, declare the columns you read and mark it
foreign:
#[dokime::channel(id = "hnm.gateway.rssi", foreign)]struct GatewayRssi { #[row(unit = "dBm")] rssi: f64,}The contract this writes is marked foreign and carries only the columns named
here, which is what lets the host check at admission that they are a subset of
the owner’s with matching types; the flag reaches the host today and that check
is not written yet. A foreign channel is read and never published: ctx.publish
of one, and ctx.channel of one, do not compile.
A record is a type
Section titled “A record is a type”A record used to be spelled three times: a string id, a contract built field by
field in a suite’s records(), and a request filled in at the step. Now the
struct is the declaration, and it lives in the file of whatever publishes it —
beside the step that files it.
#[dokime::record(id = "thermo.soak.held", label = "Hold reached", severity = info)]pub struct HoldReached { pub samples: u64, #[record(unit = "°C")] pub celsius: f64, pub unit: Option<String>,}The fields are the contract’s fields, resolved by type rather than by how they
are spelled, and the contract is strict: a detail the struct does not declare is
not one that can be published. Name it once on the template that publishes it,
#[dokime::template(records(HoldReached))]; a suite names a record only when
every template it owns publishes it, and #[dokime::sidecar(records(...))]
names the ones a plugin publishes with no run behind them.
One verb publishes evidence
Section titled “One verb publishes evidence”Which kind of evidence a value is was never the author’s decision — it followed from how the value was declared — so it is a question the compiler answers now:
ctx.publish(HoldReached { samples, celsius, unit }); // a recordctx.publish(EventRow { event: "clamped".into() }); // a measurementA shape that declared no home is refused at the call rather than at admission.
The severity on the attribute is the declared default; an occurrence that knows
better says so at the call with value.severity(RecordSeverity::Error).
Inside a step, publication is buffered into the run’s own drain and cannot fail — the buffer is the run’s and the run already owns it.
One way to wait
Section titled “One way to wait”ctx.listen::<T>() answers a standard stream, for a declared record type or for
a row type whose channel was declared. The adapters are the ones Rust already
has, imported once:
use dokime::prelude::*;
let connected = ctx .listen::<dokime::host::ToolConnectionAcquired>() .filter(move |unit| unit.resource_id == serial) .timeout(Duration::from_secs(30)) .next() .await .transpose()?;ctx.info(format!("unit connected"));
// This attempt's own sampler: an exact number of rows rather than a length of// time, so what the run measured does not depend on how quickly it got going.let held: Vec<SoakRow> = ctx.listen::<SoakRow>().take(samples).collect().await;.timeout(d) yields Result<T, Elapsed> per item, which is what makes it read
as the deadline it is; Elapsed reaches your own Error through #[from], so
? is all a step writes. A bare .next() waits for as long as the run does,
because how long a bench may take is not the SDK’s to decide, and cancellation
ends the step that is waiting.
Where a subscription starts follows from what it listens to rather than from an exception. A channel is a stream of measurements and has no “before” to reach: a row already measured is already the run’s evidence, which a criterion reads. An execution’s record inbox is a bounded queue the host handed that run, so it starts where the run started and a record filed before the step reached its await still satisfies it. A record the execution published itself is passed over: it comes back through the host like any other, and answering a step with its own words would settle the question it asked somebody else.
A criterion reads both kinds of evidence typed — ctx.records::<HoldReached>()?
for records, ctx.samples::<SoakRow>() for measurements, and
ctx.samples::<dokime::host::System>() or ctx.samples::<ProtectionSamples>()
where the channel is the runtime’s or one of several a shape serves. Rows come
back as the shape that channel declared, so a criterion reads row.celsius
rather than matching a Value out of a map.
Records a plugin listens for
Section titled “Records a plugin listens for”Plenty of what a bench has to react to belongs to no run. A unit is usually
connected before the attempt that tests it is scheduled, and the right answer
is often to get ready for the next one. That is a listener: a type with hear,
run on the plugin’s own task for every matching record the host files, whichever
execution it belongs to or none at all.
use dokime::records::host::ToolConnectionAcquired;
pub struct UnitConnected;
#[dokime::listen]impl UnitConnected { async fn hear(resources: &Resources, acquired: ToolConnectionAcquired) -> Result<()> { resources.sensor.prepare_for(&acquired.resource_id).await?; Ok(()) }}#[dokime::listeners]pub enum Listeners { UnitConnected,}The record is named by its type, in hear’s own argument, so the id the host
forwards, the id matched at run time and the fields the body reads are one
declaration — and a field the record does not declare is a compile error. The
host’s own records live under dokime::records::host, which is why this one
fires without a second plugin installed beside it.
Naming that catalog on #[dokime::sidecar(listeners(Listeners))] is also what
declares the id to the host, which has to know what to forward to a sidecar that
has not started yet. #[dokime::listen] over the inherent impl block is sugar
for dokime::Listener; a hand-written implementation is the same thing spelled
out. A listener reads the
plugin’s process-wide resources and never a template: that belongs to one
attempt, and a listener belongs to the process. Each firing runs on its own
task and is handed its own record, so a slow listener holds up neither the next
record nor a run waiting on one, and what a listener changes it changes through
interior mutability in the resources every execution and every sampler already
reads.
A listener sees records in the host’s admission order, not in publication order across plugins; there are no sequence numbers. Listening is live: a plugin the host restarted under its restart policy resubscribes and hears what is filed from then on, and is not replayed what it missed.
A listener that says something back declares &Publisher between its resources
and its record in hear; one that only reads its resources leaves it out and is
called without one. It has no run to publish into, so what it is handed instead is
the road out of the process — publisher.publish(value).await, taking the same
declared values ctx.publish does. That one is awaited and answers, because the
queue it lands in is the host’s and can be closed; the asymmetry mirrors who owns
the buffer. The host admits it against the
contracts #[dokime::sidecar(records(...))] declared, stamps the publishing
plugin as its source, and forwards it to every installed plugin’s listeners and to every live
step listening for it — a process-scoped record has no execution scope at all,
which is what makes it visible that widely.
Domain routes
Section titled “Domain routes”Route signatures can import Call, Result, and Status from
dokime::plugin::routes. A status handler returns
Status::ready().field("connected", true); SDK errors convert through ?.
A route is a type with handle, in its own file under src/routes/, with the
action declaration it binds beside it:
pub struct Reset;
#[dokime::route(id = "device.reset", deadline_ms = 2_000, action = declaration)]impl Reset { async fn handle( plugin: &mut Device, context: dokime::domains::ActionDispatchContext, input: Option<dokime::core::Value>, idempotency_key: Option<String>, ) -> Result<dokime::core::Value, dokime::domains::ActionDispatcherError> { // Validate input and use the host-supplied context and key. plugin.reset(context, input, idempotency_key).await }}#[dokime::routes]pub enum Routes { Reset,}declaration() returns the existing ActionContribution; the SDK sets its
entrypoint to this route’s actual method. #[dokime::sidecar(routes(Routes))]
collects the declarations and serves the handlers, and the route manifest the
host scans out of the built binary is made from that same list. No host adapter
or separate Plugin::actions() inventory is needed.
#[dokime::route] over the inherent impl block is sugar for dokime::Route.
The four bindings a route may carry do not share one handler shape, so the
attribute reads whichever shape the block wrote. A plain route answers its own
reply; route(status) is a read-only handler taking ActionDispatchContext and
returning DispatcherStatus; route(options = declaration) takes an optional
Value and returns Vec<OptionProviderOption>. A mutating health action must
not be exposed as settings status. Settings only calls an explicitly declared
status route and keeps its two-second timeout.
The host validates bindings against that executable’s complete served inventory before admission. Actions, options and settings use registration schema 2, which identifies the SDK domain request/reply carrier; older live transport registrations are refused. Static contributions have no invocation method. Previously persisted catalogs remain readable. User input is separate from trusted host context and idempotency, and each route retains its deadline.
Sidecar-Executed Suites
Section titled “Sidecar-Executed Suites”A suite runs entirely inside the sidecar that registered it. The
sidecar streams telemetry and records back to the host over a standard
start/drain/cancel protocol while the case executes. plugins/synthetic
is the reference implementation — its synthetic.device.steady suite measures one execution-owned AC DUT and executes
inside the same sidecar that also serves the read-only synthetic.status route and boots
an embedded Harmos app.
Crate layout
Section titled “Crate layout”plugins/synthetic/ app/src/lib.rs # thin developer facade -> app-runtime app-runtime/src/state.rs # durable application state app-runtime/src/transactions/ # committed application changes app-runtime/src/app.rs # Harmos identity, aliases, and the entry verbs app-plugin/src/lib.rs # the catalogs, the settings, the lifecycle app-plugin/src/listeners/connection.rs # what the bench hears between runs app-plugin/src/routes/status.rs # read-only plugin readiness app-plugin/src/suites/steady/measurement.rs # the measuring procedureEvery suite the plugin names executes in the plugin. Their existing IDs preserve
recorded history; the feature modules describe what each workflow does. A
standalone plugin needs only one crate, as shown in plugins/mini.
1. Declare the suite
Section titled “1. Declare the suite”Every suite is sidecar-executed; the workflow defaults to the suite id. Close
template composition with a #[dokime::templates] enum:
#[dokime::templates]pub enum SteadyTemplates { Steady,}
#[dokime::suite( id = STEADY_SUITE, label = "Steady-State Testbench", templates(SteadyTemplates),)]impl SteadySuite {}The block is empty. There is no records() and no telemetry() here: a record
is declared in the file of whatever publishes it and named on that template’s
attribute, a channel is declared by its producer, and the suite publishes what
its templates declared. The plugin names the closed suite set once, on
#[dokime::sidecar(suites(Suites))].
The host dispatches whichever sidecar registered the owning suite. There is no execution argument, separate sidecar id, or native adapter.
2. Declare strict telemetry and record contracts
Section titled “2. Declare strict telemetry and record contracts”The producer declares the contract, and the declaration is closed. A template’s
own sampler declares its channel on the #[dokime::sampler] beside it; a channel
a step pushes into declares itself on its row type with #[dokime::channel].
Either way the columns come off the struct’s fields and their #[row(…)]
attributes, validation is strict, and a value the struct does not declare is not
one that can be published. declared already carries the canonical timestamp
column (Number, role Time) and defaults default_x to it, so a row declares
its own elapsed time only when it wants a second time axis.
Those contracts cover the rows the template pushes itself. The global channels
it wants captured alongside them — the host’s, or another plugin’s — are named
by type on the template attribute, and Synthetic’s Steady names all six the
host offers:
#[dokime::template( id = STEADY_TEMPLATE, label = "Steady-State Measurement", plugin = crate::Resources, samples( host::System, host::Network, host::Wifi, host::WifiLatency, host::WifiChannelOccupancy, host::Bluetooth, ), channels(SupplyEventRow), records(Initialized, Started, Completed),)]No cadence appears there, because none of it is the requester’s to choose: each channel has one sampler, that sampler declared how often it measures, and every execution leasing the channel shares that one running instance and its tick — so two runs reading the same channel read rows that agree. A faster read of the same thing is a second declared channel, not an override. The host takes the leases for the execution’s duration, starts an instance that is not already running in whichever process owns it, captures its rows into that run’s evidence, forwards them across the sidecar boundary when the channel belongs to another sidecar, and stops the instance when the last lease is released; a dashboard watching the same channel is one more lease. A template that names a channel no installed plugin publishes is refused at admission, naming the plugin, the template and the channel.
3. Add a document with live plot cards
Section titled “3. Add a document with live plot cards”A template’s document is one ordered list of sections, and every one of them is visible: there are no views and nothing to select. Order is the layout, and the host puts the steps and criteria in front of what the template wrote and the records, artifacts, media, traceability and notes behind it.
A plot names its columns through the row type that declared them, so a renamed column is a compile error rather than an empty plot:
Section::new("synthetic.device.steady.signals", "AC Voltage").plot( Plot::time_series("AC Voltage") .y(SampleRow::voltage_v()) .y(SampleRow::current_a()) .marker_records([ marker("synthetic.device.steady.started"), marker("synthetic.device.steady.completed"), ]),)Those handles resolve their channel through the home the row declared with
#[dokime::channel]. One measurement shape published on several suite-owned
channels declares no home, so it names the channel at the plot instead —
SampleRow::voltage_v().on(Channel) — which is the same rule
ctx.channel::<SampleRow>().into::<Channel>() applies to pushing those rows,
and the same one a sampler of that shape applies with
#[dokime::sampler(channel = Channel, …)]. A host channel the runtime owns is a
type too: host::System::cpu_load_percent().
4. Compose routes and lifecycle
Section titled “4. Compose routes and lifecycle”The SDK owns the standard start, drain, cancel, and respond routes, including replay, cancellation, prompts, criteria, evidence, and shutdown. The plugin stores only its own resources. An unbound route answers its own typed payload:
pub struct Status;
#[dokime::route(id = "device.status")]impl Status { async fn handle(plugin: &mut Device, request: StatusRequest) -> Result<StatusReply> { plugin.read_status(request).await }}Each route lives in its own file and the catalog enum lists the types.
The SDK emits one complete static inventory for the final sidecar identity from
those catalogs: standard execution routes, telemetry start only when a streams
catalog is named, and the authored handlers. Co-linked descriptors alone
advertise nothing. Inspection runs no initialization or registration code, and
refuses binaries without a matching complete inventory.
The macro generates a route descriptor, so authors do not implement transport
traits or wrap requests in local carrier types.
RecordPublishRequest is the wire shape the drain carries; it is not an
authoring type and does not appear in a step, a criterion, a suite or a test.
Leave scope and source unset on every TelemetrySample/RecordPublishRequest
JSON value pushed into a drain reply. The host overwrites both with the owning
artifact plus the scheduled cycle, case, execution, and template scope before it
validates the strict contracts from step 2 — a sidecar cannot claim evidence for
another artifact or execution.
Report the terminal SidecarExecutionOutcome exactly once, as one drain item;
the host stops polling once it observes it. Alongside passed and message it
carries criteria, where each declared criterion reports passed, failed, or
indeterminate — the third for a criterion the run could not evaluate, which is
not the same claim as a measured failure.
Steps act through StepContext: emit records, push samples, ask declared
operator prompts, and stream evidence files. The SDK records each declared step
as it starts, and a criterion reads that durable evidence back with
ctx.steps_reached(), so a step cannot claim a step it did not reach. For
bounded work inside the current step, wrap any exact-size iterator with
ctx.progress("Measuring samples", samples). The loop advances progress
automatically after each body completes and exposes an ETA after three
completions. That latest value is process-local operator state, not telemetry or
report evidence, and disappears when the execution ends.
Every context logs. ctx.trace(...),
ctx.debug(...), ctx.info(...), ctx.warn(...) and ctx.error(...) take
&self where the rest of the grant takes &mut self, because a log line is a
send onto the host’s own queue and a hook handed a shared context — resources,
traceability — has as much to say about what it is doing as a step does. They
exist on CriterionContext too, where the line is attributed to the criterion
that wrote it, and on the plugin Context handed to initialize, where they go
to the plugin’s own diagnostic stream: there is no run behind that one, so the
host mirrors it on the sidecar tracing target under the plugin’s id, before
the readiness gate. Their severity survives cursor replay and is stored as a
level prefix
in the host-owned {workflow}/stream.log. The host also mirrors them to tracing
at the matching level; tracing filters do not control execution-log capture.
Logging an error does not change the test outcome. Every runnable template defines an async initialize
constructor taking its declared parameters and &mut InitializationContext and
returning Result<Self, Error> — a template that names no parameters takes only
the context. It builds the attempt’s data from those inputs, the settings and the
secrets — never anything ephemeral; static catalog inspection never calls it. The SDK does not
construct templates with Default, and a template does not implement Clone.
Criteria are associated functions taking only read-only CriterionContext;
streams observe through the attempt’s own handles and the tick they were asked on.
The host durably stores every artifact and emits artifact.created with its own
artifact id.
Streaming execution artifacts
Section titled “Streaming execution artifacts”ctx.artifacts() — available from InitializationContext, StepContext, and
CleanupContext — hands evidence files to the host as they are produced. There
is no staging directory: bytes become bounded pieces of the execution protocol
as they arrive, so a large file never exists twice and a long encode starts
reaching the host while the step is still running.
use std::io::Write;
// One call, for content the step already holds.ctx.artifacts().write("evidence/pulse.csv", bytes)?;
// A writer, for content produced a piece at a time.let mut trace = ctx.artifacts().create("evidence/trace.csv")?;writeln!(trace, "sample,value")?;for (sample, value) in readings { writeln!(trace, "{sample},{value}")?;}trace.finish()?;
// A file some other tool wrote, streamed without reading it into memory.ctx.artifacts().import(&recorded_path, "evidence/capture.mp4")?;The name is the relative, /-separated path the artifact is filed under. It
must be non-empty and may not be absolute or name a . or .. segment; an
invalid name is refused before any piece is sent. The media type is read from
the name’s extension, and a name that does not say what the content is — no
extension, or one the media type cannot be read from — is refused at the call
too. There is no typed override: the name is the artifact’s one statement about
itself, and an override is how that statement starts to be a lie. Name opaque
bytes .bin, which says exactly that.
ArtifactWriter implements std::io::Write, so any encoder that writes to a
sink can write straight into the protocol. finish() sends the artifact’s final
piece; dropping an unfinished writer sends it too, so returning early from a step
still hands over what was produced. flush() sends whatever is buffered as one
piece; chunk boundaries otherwise belong to the writer, and each piece costs a
frame in the execution’s bounded output buffer, so flushing after every small
write spends that budget without making the artifact arrive sooner.
abandon() is the opposite statement — the content is known to be incomplete —
and it says so on the wire rather than going quiet. It sends one terminal piece
that carries no content and marks the transfer aborted, dropping bytes the writer
still held rather than handing over a little more of a file that is not evidence.
The host answers immediately: it releases what it assembled, gives back the open
artifact slot and the execution byte budget that transfer had spent, never opens
an upload, and writes one WARN line naming the file to the execution’s own log.
That release is what lets a run give up on more streams than it may keep open at
once and still produce evidence afterwards. An artifact the execution neither
finishes nor abandons is discarded too, but only when the run ends, and its WARN
line says the transfer never completed rather than that its producer gave up.
The host stores each artifact under a safe basename it derives from the name, so
a /-separated name organizes the run’s own output rather than the artifact’s
stored identity. It also bounds what one execution may hand over — a size per
artifact, a size per execution, and a bounded number of artifacts left open at
once — so finish a writer before opening the next one where the work allows it.
A directory, for a producer that never heard of Dokime
Section titled “A directory, for a producer that never heard of Dokime”The writer above is for a producer that knows Dokime. Plenty do not: a Harmos job, a vendor tool, a script, an instrument’s own recorder. Each writes files where it is told to and hands back nothing but a path, so what it needs handed to it is a directory:
let evidence = ctx.artifacts().directory("capture")?;recorder.record_into(&evidence).await?; // knows nothing about Dokimestd::fs::write(evidence.join("notes.txt"), summary)?;The path is the execution’s own, under the sidecar’s temporary working area and
keyed by the execution. Everything under it is filed as
<name>/<path within the directory> — capture/notes.txt above — on the terms
create states: the media type is read off the extension, and each file spends
one of the execution’s artifact transfers. Asking twice for one name answers the
same path, from resources() through cleanup, so a step and the teardown that
follows it write into one place.
Nobody announces a file, so the SDK watches. On the host’s own drain cadence it stats every regular file under every registered directory and sends what changed:
- A file that grew sends the bytes it grew by, as the next piece of the transfer it already owns — so a log written over a twenty minute soak reaches the host while the soak is running. Growth is coalesced until it is worth a piece: every piece costs a frame in the execution’s bounded output buffer and a replay fingerprint the host keeps for the life of the transfer.
- A file that shrank, or whose size stayed put while its modification time
moved, was rewritten rather than appended to. It is sent again from the start
behind a
replacemarker and the host drops what it had, so the stored artifact is what the file says rather than every version of it end to end. - The final sweep runs after
cleanup, so evidence written while releasing what the attempt held is evidence like any other. It finalizes every file and then removes the directory.
The rule has one honest blind spot: a rewrite that lands larger between two ticks looks exactly like an append and is sent as one. Telling the two apart would mean re-reading and re-hashing every watched file on every tick, which is a guarantee no evidence file has asked for. A tool that rewrites its output in place is noticed the moment that output is not strictly longer.
A file the watch will not file says so once, by name, as a WARN diagnostic on
the execution’s own log, and is skipped from then on: one past the size limit an
artifact may carry, and one whose name does not say what it is — a lock file, a
.tmp, anything without a readable media type. Symbolic links are neither
followed nor filed. A directory is for a producer’s output rather than for a
filesystem: its files share the bounded number of transfers one execution may
open, and the stored artifacts are indistinguishable from ones handed over
through create.
5. Optionally embed a Harmos runtime
Section titled “5. Optionally embed a Harmos runtime”Synthetic embeds a Harmos app for plugin readiness and orderly shutdown. Its
Testbench state belongs to the process; the read-only synthetic.status route
reports readiness without changing it. Boot the app in initialize():
async fn initialize(_config: Config, _ctx: &dokime::plugin::Context) -> Result<Self> { let runtime = app::launch(Testbench::default()).await?; Ok(Self { runtime, /* ... */ })}? is enough because Error declares a #[from] variant for the launch
failure, which keeps the application’s own reason in the diagnostic chain.
app.rs is the file to read first in an embedded app. It holds the identity
struct and its impl harmos::Application, the Origin/Builder/Runtime/
Journal aliases no other module has to spell, and the entry verbs: builder
declares the app and registers its catalogs exactly once, launch stands it up
live, and simulate — behind the sim feature — stands the same declaration up
deterministically on nothing but a seed. Both verbs funnel through builder, so
a test exercises the application the sidecar actually runs.
Each execution owns a separate simulated DUT from the reusable app-runtime
domain model. Its steps and SDK sampler share the same device handle, so control
and fault injection affect the measured voltage and current. Samples do not
commit mutations to the process readiness state.
Shared plugin resources
Section titled “Shared plugin resources”A native plugin declares type Resources in its dokime::Plugin implementation
and exports the bundle from the synchronous resources(&self) on that same
trait. Use () when it needs no shared resources:
pub struct Resources { pub app: app_runtime::Client,}
impl dokime::Plugin for Bench { type Config = Config; type Error = Error; type Resources = Resources;
// …
fn resources(&self) -> Resources { Resources { app: app_runtime::Client::new(&self.runtime) } }}
// Inside a #[dokime::template(…, plugin = Resources, parameters = SoakParameters)] impl:async fn initialize( parameters: &SoakParameters, ctx: &mut dokime::execution::InitializationContext<'_>,) -> Result<Self, Error> { ctx.plugin().app.ensure_ready().await?; Ok(Self { target_c: parameters.target_c })}
/// The handles this one attempt holds, opened after initialize and dropped/// after cleanup returns.async fn resources( &self, ctx: &dokime::execution::ResourceContext<'_>,) -> Result<HeaterResources, Error> { Ok(HeaterResources { heater: tokio::sync::Mutex::new(Heater::open(&ctx.parameters().port).await?), app: ctx.plugin().app.clone(), })}The SDK obtains this bundle once after plugin initialization and shares it across
all executions of that instance. Resource types must be Send + Sync + 'static;
neither bundle needs to implement Clone. A template names the plugin’s bundle
once, on its attribute (plugin = Resources), and every stage grant is then
written bare: StepContext<'_>, CleanupContext<'_>. Omitting the attribute
means ().
Two handles are in reach and the verb tells them apart. ctx.plugin() is the
plugin’s bundle — shareable clients and pools, read by every execution, by every
listener, and by every process-wide sampler’s start. ctx.resources() is the attempt’s own, declared
by the template’s resources hook: an exclusive port, a file, a child process, a
session. The engine holds it behind one Arc for the life of the attempt, hands
the same value to every sampler of that template, and drops it once cleanup has
returned — so a sampler never needs a clone of the template a step is holding
&mut self over, and concurrent access is the author’s business through interior
mutability. The plugin still owns runtime shutdown. Cancelling a step does not
implicitly cancel detached application jobs; cleanup must cancel and settle any
such jobs owned by that execution.
Resources are local Rust values, never transported in settings, serialized into
execution snapshots, or recovered from historical evidence. Static catalogs and
CriterionContext do not expose them.
Not every handle lives as long as the attempt does. A watch one step starts and
another stops, a recording that spans three steps, a job the procedure begins
and cleanup has to end because nothing else will — each is one value, absent
until something puts it there and taken out exactly once by whoever ends it.
dokime::Slot<T> is that place:
pub struct SoakResources { /// Started by the step that begins the soak, stopped by cleanup: a Harmos /// job handle does not cancel when it is dropped. capture: dokime::Slot<app::JobHandle>,}
// In the step that starts it.ctx.resources().capture.set(ctx.plugin().app.start_capture().await?);
// In cleanup, however the run ended.if let Some(capture) = ctx.resources().capture.take() { capture.cancel().await?;}It is a Mutex<Option<T>> with new, set, take and is_set, because
resources() is &self everywhere it is reached — a sampler on its own task
reads the same value the step does. set answers whatever was there, so a
second start hands the first handle back to be dealt with rather than losing it;
take empties the slot, so a cleanup that runs after a step already stopped the
handle is a no-op rather than a double stop. It tolerates a poisoned lock, on
the rule the rest of the SDK follows: a panic in one step must not turn a handle
somebody still has to stop into a handle nobody can reach.
Keep the harmos::Runtime<P> handle on Synthetic. The SDK joins samplers and
runs execution cleanup before the plugin’s own cleanup. Each template’s cleanup
powers down its DUT through CleanupContext, which exposes captured inputs, secrets,
logging and reason() (Completed, Failed, or Cancelled). A final normal step
publishes measurement decisions before artifact work; cleanup releases resources
after all samplers have joined and logs diagnostics.
Criteria read captured evidence after the attempt’s handles are released, so decisions
must be recorded during steps instead of read from live device state. Cleanup runs once
for every template whose resources opened, including failed or cancelled procedures.
A failure in initialize or resources has nothing open to release; the constructor
must release its own partial acquisitions. The plugin’s cleanup(self) then commits
Stop to the app and awaits runtime shutdown, taking the plugin by value because it
is the last holder.
Stating what is under test
Section titled “Stating what is under test”A template says what it is testing through one more hook, run after resources and
before the first step so it can read a serial number or a firmware revision off the
device it describes:
use dokime::traceability::{TraceabilityRow, TraceabilitySection};
async fn traceability( &self, ctx: &dokime::execution::TraceabilityContext<'_>,) -> Result<Vec<TraceabilitySection>, Error> { let identity = ctx.resources().heater.lock().await.identity().await?; Ok(vec![ TraceabilitySection::new("dut", "Device Under Test") .row(TraceabilityRow::text("serial_number", "Serial number", identity.serial)) .row(TraceabilityRow::text("firmware", "Firmware", identity.firmware)), ])}Sections travel the drain, and the host stamps their scope, their time and the
provenance they are filed under. A failure here fails the attempt before its first
step: traceability that cannot be established is not evidence to run on. The
Traceability card renders each section with the label and rows the template wrote,
and the report renders them as tables — so identity never has to be smuggled through
records or record= log lines for a card to reassemble.
What renders
Section titled “What renders”The host’s generic sidecar executor polls drain on a fixed cadence (every 100ms
in the shipped executor) between calls. Drained samples and records publish
through the ordinary telemetry/record path with execution scope now stamped;
the step the run has reached mirrors onto the executing case. Plot cards
over the suite’s telemetry channel render those samples live in the one ordered
document, for both the single execution and the owning cycle.
A leased global channel is not the execution’s own output: its rows are produced
once by whoever owns the channel — the host’s sampler, or a sidecar’s
process-wide stream — and captured by the host into every execution holding a
lease, and they render in the same telemetry views. What the run held is written
down beside them, as a derived Leases traceability section naming each
channel, its owner, the window the attempt held it for and who else was reading
the same rows. A channel whose owner restarted while the attempt held it carries
a gap row there: rows are missing for that interval, the run continues, and
the section is what says the run did nothing wrong.
Testing a template
Section titled “Testing a template”A template is the part of a plugin worth a test on its own: it reads parameters,
opens handles, states what it is testing, measures and decides. None of that
needs a host — it needs the things a host would have supplied.
dokime::plugin::testing::Harness is those things as builder calls.
use dokime::plugin::testing::Harness;use dokime::host::ToolConnectionAcquired;
#[tokio::test]async fn the_soak_holds_in_band() { let report = Harness::template("thermo.soak.hold") .parameter("hold_samples", 3) .setting("sensor_port", "sim") .secret("cloud_token", "sim-token") .publish(ToolConnectionAcquired { resource_id: "sim-1".into(), resource_kind: "chamber".into(), connection_transport: None, connection_endpoint: None, status_message: None, }) .answer("thermo.soak.checkpoint", "continue", [("note", "chamber steady")]) .run::<thermo_bench::Plugin>(thermo_bench::Config::default()) .await .expect("the soak runs");
assert!(report.passed(), "{}", report.diagnostics()); assert_eq!(report.traceability("dut").row("firmware").text(), "sim-1.0"); assert_eq!(report.samples::<thermo_bench::suites::soak::SoakRow>().len(), 3);}The harness plays the host and plays it the way the host does. .parameter
names one value of the case and the rest are resolved from the template’s own
declared defaults, exactly as admission resolves them. .setting and .secret
are admitted over the plugin’s declared catalog. .publish files a record with
the harness’s own provenance stamped on it, because a party naming its own
provenance is a party that can lie about it — which is also what lets a step
waiting on that id be answered by it. .answer decides one operator question by
the prompt type the template declared; a prompt no test answers is continued from
the template’s own declaration, so an unattended run is not a hung one.
Report reads back in the vocabulary a criterion reads evidence in: passed(),
criteria(), samples::<C>() as the shape that channel declared — the same read
ctx.samples::<C>() hands a criterion — records::<R>() decoded through the
same declaration the run
published through, traceability(section).row(id).text(), artifacts()
reassembled from the pieces they crossed the protocol in, and diagnostics() —
the failure’s message, cause chain, location, span trace and backtrace, plus the
execution log, which is what a criterion that decided against the run leaves
behind instead.
A run that fails is a report whose verdict is false, not an error: an authored
failure is evidence, and the test was written to read it. HarnessError is for
a run that could not be made to happen at all — a template no suite declares, a
case that will not resolve, a plugin that refused to initialize, a run that
outlasted .timeout(d).
dokime::plugin::sidecar::Instance stays underneath and public. Drive it
directly for a test about the wire itself — replay, cancellation, an unknown
token — because those are questions about the protocol rather than about a
template.
Validation
Section titled “Validation”mise run check:rustmise run build:plugins:devmise run smoke:runtimeSee Installed Artifact Workflow for how the suite joins the rest of the artifact domains and Installed Artifact for the shipped default composition.