Skip to main content

Module working_together

Module working_together 

Source
Expand description

§Working together

Plugins share state and talk through four channels on the account, and a UI plugin works with the daemon half of itself, its companion. This page says which to use, and how a UI plugin that runs in several viewers at once stays correct.

The worked example is a pair: together_ui.rs and together_companion.rs (standard-plugin new <id> --kind companion scaffolds it).

§Four channels

ChannelSDKStoredDelivered asUse it for
Valuesvalues()Yes, durable, per accountEvent::ValueChanged after values().watch(prefix)State: the latest build status, settings a plugin learned, anything a viewer opened later must see
Livelive()No, latest value onlyEvent::Live after live().subscribe(prefix); Event::LiveDeleted when the publisher withdraws a key (live().delete(key))Fast-changing readings where only the newest matters: a progress figure, a sensor reading. At most 16 KiB each: publish one key per item (a build, a pane) and only the items that changed, rather than one snapshot of everything. Withdraw a key that no longer applies (a pane that closed) rather than publishing null
Eventsevents()NoEvent::Plugin after events().on(pattern)Something happened: “the status changed”, “a build started”. Listeners that are not running miss it
Callscalls()NoThe companion’s DaemonPlugin::callAsking the companion to do something, and getting an answer

Keys, live keys and event names are namespaced by plugin id: a plugin uses <id>.* freely; global.*, another plugin’s namespace and system.* need grants (grants). Payloads are JSON; the SDK serializes typed values with serde.

A common shape combines two: the writer stores the state as a value and emits an event saying it changed; readers read the value when they start and re-read it on the event. A viewer opened later still sees the state, and nobody polls.

§Writes before the account connection opens

A plugin can start before its account connection opens (a daemon plugin does after every daemon start), and writes made then fail. Treat a failed values().set or live().publish as not written: remember the last payload that succeeded, and publish again on later events (an account change, the interest, the next reading). Make the publish idempotent: it builds the payload, compares it with the last one written, and writes only when they differ. issue-counts’s daemon half does this (agent guide).

§Companions

A companion is a daemon plugin (manifest kind companion) with the same id as a UI plugin. The UI half runs in every viewer and shows things; the companion runs in standardd and does things:

  • It holds the grants a UI plugin should not: process.exec:, file access, panes, HTTP.

  • It does work once. The UI half asks (grant call:<id>); the companion answers in DaemonPlugin::call, and starts longer work as a task (cx.spawn) that runs when the host next drives it. The UI half asks in one of three ways:

    SDKWaitsAnswer
    calls().send(method, request, target)NoNone: the outcome arrives another way (a value, a live message)
    calls().call_async(method, request, target)NoEvent::CallResult { id, result } with the returned CallId, after 10 s at most (call_async_timeout for another bound, 30 s at most)
    calls().call(method, request, target)Yes, 10 s at most (call_json_timeout, 30 s at most)The return value; a timeout is Error::Unavailable("call_timeout")

    Prefer send and call_async in a UI plugin: event and frame keep their budgets and the plugin keeps painting while the companion works. call blocks the plugin’s thread (never the viewer’s), and in a browser without JSPI it answers unavailable; send and call_async work in every browser. At most 32 sends and async calls of one plugin are in flight at once; past that they answer Error::RateLimited.

  • It publishes results as values and events the UI halves read.

  • It reads outside sources only while a viewer shows the UI (Context::interest(), daemon plugins). The UI half never polls it: no timer, heartbeat or repeated send to keep it reading. A call goes out for what the user did; everything else arrives as values, live messages and events.

Target picks which daemon answers or hears:

TargetReaches
Target::SingletonThe plugin’s singleton daemon instance: one per account, for work that must happen once
Target::Machine(id)The daemon on one machine (work that belongs to that machine’s files or panes)
Target::DaemonsEvery daemon (fleet-wide events)
Target::ViewersEvery viewer (a companion announcing a change to the UI halves)
Target::AllEvery listener

A daemon knows its own machine (Context::machine_id()), so a companion that publishes per machine keys its values and live messages by it (<id>.status.<machine>) and the UI half matches them against account::state().machines. A viewer knows its own machine too (view::machine_id(), none in a browser) and its instance (view::instance_id(), one per running viewer).

A daemon plugin enabled on several machines runs one instance per machine (the fleet); the singleton is the one instance the account elects for account-wide work, on the machine that holds its lease (daemon plugins).

§Running in several viewers

The same account can be open in several viewers at once: a laptop, a desktop, a browser tab. Each runs its own instance of every UI plugin, so a UI plugin must be side-effect free: painting, reading, and listening are fine; anything that changes the account or the world must not simply run in every instance.

Three tools keep it correct:

  1. view::is_driving() is true in the viewer the user is controlling. User-initiated effects (a click that starts a build) run only there; Event::Driving reports changes.
  2. claims().claim(key, ttl_ms) (or claims().once(key, ttl_ms, effect)) makes an effect exactly-once across instances: only the instance whose claim succeeds performs it. Use it even behind is_driving(), since two viewers can briefly both believe they drive. Keys live in the plugin’s own namespace; no grant.
  3. A companion for everything else: effects that are not user-initiated (reacting to an event, a schedule) belong in a daemon, which runs once.

The double-play rule is the check for getting this wrong: a UI plugin that listens to shared events and holds a grant that acts on the account (fetch:, values.write:, events.emit:, call:) would perform the action once per viewer per event. standard-plugin check warns about the combination (grants).

The account keeps a claim for seven days, first writer wins, whatever ttl_ms asks; a later claim of the same key from the viewer machine that holds it renews it (true). release changes nothing on the account yet.