Docs / docs (push) Successful in 37s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m35s
Playwright Tests / test-playwright (2, 2) (push) Failing after 17s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m25s
The site read as a design journal: rationale paragraphs, hedges
("deliberately", "on purpose", "genuinely"), meta-commentary about the docs
themselves, and one em-dash every ten lines carrying an aside.
Roughly twenty rationale blocks are gone or reduced to what a reader needs
in order to use the thing. Em-dashes go from 507 to 135, and what is left is
structural rather than prose: list and definition separators, table cells,
and four inside code blocks that quote what the CLI actually prints.
Also: api.example.com becomes api.fluksio.com (the emails stay, since
bootstrap.py really defaults to admin@example.com and RFC 2606 reserves it);
the mqtt table gains the two settings it had drifted behind on and inject's
wording matches the engine; llms.txt lists the two connector pages that were
in the nav but not in it; and the two device/device_policy notes now agree.
Builds clean under `zensical build --strict`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
205 lines
8.8 KiB
Markdown
205 lines
8.8 KiB
Markdown
# Flows, nodes and messages
|
|
|
|
Three ideas hold the system up. Almost everything else follows from them.
|
|
|
|
## A flow is a graph you did not draw
|
|
|
|
A **flow** is a set of **nodes**. Each node declares the messages it needs
|
|
(`requires`) and the messages it produces (`provides`). The graph is whatever
|
|
those declarations imply:
|
|
|
|
```python
|
|
# node "read"
|
|
def process():
|
|
return {"temperature": read_sensor()}
|
|
|
|
# node "decide"
|
|
def process(temperature, setpoint=21.0):
|
|
return {"heat": temperature < setpoint}
|
|
```
|
|
|
|
`decide` is downstream of `read` because it needs `temperature` and `read`
|
|
produces it. Nobody drew a wire.
|
|
|
|
The consequences:
|
|
|
|
- **Fan-in is free.** Two nodes providing `temperature` are two producers of
|
|
one message. The consumer does not change.
|
|
- **A node runs when something it reads was published.** Not merely when
|
|
something upstream of it ran: a node that produced nothing this time (held
|
|
back by a rate limit, say) leaves what reads it on the value it already
|
|
has, and so does everything behind that.
|
|
- **A wire cannot be wrong.** There is no wire. There is a name that either
|
|
matches or does not, and the canvas tells you at edit time which it is.
|
|
- **Layout is not a document.** The canvas computes the arrangement, so a flow
|
|
has no stored positions to maintain, merge or fight over.
|
|
- **Flows stay small.** A graph nobody can hand-arrange is one worth keeping
|
|
small, which is the intent. Several atomic flows that name each other beat
|
|
one flow with sixty nodes in it.
|
|
|
|
### Message names are namespaced
|
|
|
|
Inside flow `house`, a message named `temperature` is really
|
|
`house.temperature`. A bare name is qualified with its own flow; a dotted name
|
|
is used as written. That is how two flows share a value:
|
|
|
|
```python
|
|
# in flow `dashboard`, reading a message that flow `house` produces
|
|
def process(house_temperature): # port bound to "house.temperature"
|
|
...
|
|
```
|
|
|
|
The canvas draws messages arriving from another flow as labelled endpoints, so
|
|
you can see where they come from without opening the other flow.
|
|
|
|
## A node is a function with declared ports
|
|
|
|
Most nodes are **Function** nodes: a Python file defining `process(...)`. Its
|
|
arguments are its input ports by name; its return value is a dict keyed by
|
|
output ports.
|
|
|
|
```python
|
|
def process(reading, unit="C"):
|
|
return {"shown": reading if unit == "C" else reading * 1.8 + 32}
|
|
```
|
|
|
|
`reading` is a port. `unit` is a **setting**: a constant of this node's code,
|
|
typed into its panel and stored with the flow. Both arrive as arguments, which
|
|
is why a setting may not share a name with a port. See
|
|
[Where a node's values come from](values.md).
|
|
|
|
The rest of the node types are the ones that would be tedious or unsafe to
|
|
write yourself: MQTT, HTTP, InfluxDB, schedules, switches, notifications. Each
|
|
one is configured by filling in a form the editor generates from its parameter
|
|
schema, so they all behave the same way. The full list is in
|
|
[Node types](../reference/node-types.md).
|
|
|
|
### Ports are typed
|
|
|
|
A port declares a `dtype`: `float`, `int`, `str`, `bool`, `json`, `record`,
|
|
`list`, `series`, `artifact`, or one of the media types `image`, `audio` and
|
|
`video`. Every value that passes through is checked against it.
|
|
|
|
Types are not decoration. They are what lets the dashboard editor offer you
|
|
only the messages a gauge can actually draw, and what lets the canvas refuse a
|
|
binding before anything runs. See [Payload types](../reference/payload-types.md).
|
|
|
|
Everything on the wire is JSON. Bytes (a checkpoint, an image, a model)
|
|
travel as an `artifact`: the bytes go to a content-addressed store and the
|
|
message carries a small reference to them. The media types are that same
|
|
reference, saying what kind of bytes are behind it.
|
|
|
|
### Streaming ports
|
|
|
|
A port marked `stream` produces repeatedly *during* one execution rather than
|
|
once at the end: a training loss, a progress fraction, a frame from a camera, a
|
|
second of speech. A node publishes on one by being a generator and yielding, or
|
|
by calling `fluksio.emit`.
|
|
|
|
Each value is delivered to the nodes reading it, in the order it was produced,
|
|
so a recogniser slower than the microphone in front of it still sees every
|
|
chunk rather than only the newest. What is in state remains the latest value,
|
|
which is what everything else reads, and what a run keeps is the whole series.
|
|
|
|
An `interval` on a streaming port thins what reaches the canvas without
|
|
thinning the run's record of it.
|
|
|
|
### Nodes are pure
|
|
|
|
A node is called with the values of the messages it declares and returns the
|
|
values of the messages it provides. There is no context object, no global
|
|
store, no handle to reach for.
|
|
|
|
That is deliberate: a node with hidden state cannot run twice in parallel,
|
|
cannot be replayed, and cannot be moved to another machine. Plenty of real
|
|
automations do need to remember something, and there is a specific way to say
|
|
so; see [Keeping state in a flow](state.md).
|
|
|
|
## Two shapes of flow
|
|
|
|
Set `mode` on the flow:
|
|
|
|
| | `live` (default) | `batch` |
|
|
|---|---|---|
|
|
| Runs | continuously | once per run, on request |
|
|
| Started by | subscriptions, schedules, webhooks | `POST /runs/flows/{name}` |
|
|
| Ends | never | when the graph drains |
|
|
| Keeps | the last value of each message | a run record: params, result, metrics, artifacts |
|
|
| Is | a thermostat, an ETL job on a cron | an experiment, a CI-style job |
|
|
|
|
A batch flow is built and validated like any other, appears on the same canvas
|
|
and is type-checked the same way. It is simply never *activated*: no
|
|
subscriptions, no schedules, no webhooks. See
|
|
[Runs: pipelines that finish](runs.md).
|
|
|
|
## Editing is separate from running
|
|
|
|
Every flow has a published version and, while you are working, a draft.
|
|
|
|
- **Saving** writes the draft. The engine keeps running the published version.
|
|
- **Publishing** promotes the draft. The engine reloads and picks it up.
|
|
- **Discarding** throws the draft away.
|
|
|
|
The store is a git repository (`flow.json` for the structure, `nodes/*.py` for
|
|
the code) and each save is a commit. So a flow's history is readable with
|
|
ordinary git tooling, and copying a flow between instances is copying a
|
|
directory.
|
|
|
|
Saving carries the version you last saw. If someone else saved in between, you
|
|
get a 409 instead of quietly overwriting their work.
|
|
|
|
## What can be wrong, and when you find out
|
|
|
|
The canvas validates continuously and names problems on the nodes they belong
|
|
to:
|
|
|
|
| Issue | What it means |
|
|
|---|---|
|
|
| `unconnected_input` | a port needs a message nothing in reach provides |
|
|
| `missing_initial_value` | the message exists but has never held a value, and nothing will give it one. Not reported on a batch flow: its inputs arrive with the run |
|
|
| `cycle` | A waits for B and B waits for A — nothing could ever start |
|
|
| `self_loop_needs_initial` | a node reads a message it also writes, with no starting value |
|
|
| `node_error` | the node's code did not load: a syntax error, a missing import |
|
|
| `unauthenticated_hook` | advisory — a webhook with no shared secret is open to anyone |
|
|
| `node_unhealthy` | the node loaded but is not working: a connector that cannot reach its device, or whose last publication failed |
|
|
|
|
A flow with any of these except the advisory one and `node_unhealthy` does not
|
|
run. A node reporting itself down is a live condition, not a build error, so
|
|
the rest of the flow keeps going and the issue clears by itself once the node
|
|
reports well again. The health summary on Home counts them, so "why is nothing
|
|
happening?" has an answer that does not involve reading logs.
|
|
|
|
## What happens at runtime
|
|
|
|
- **A flow can be started and stopped.** Stopped means its subscriptions and
|
|
schedules are torn down.
|
|
- **A flow can be paused and stepped.** Paused holds messages instead of
|
|
running them; step releases exactly one. This is how you test something
|
|
before it moves a relay.
|
|
- **A failing node does not take the flow down.** It reports an error, keeps
|
|
its last error visible after it recovers, and can fire an alert.
|
|
- **A flow whose background tasks keep crashing is quarantined.** The engine
|
|
stops restarting them and says so, rather than spinning. Publishing a change
|
|
gives it another chance.
|
|
|
|
## Values that arrive from outside
|
|
|
|
Some messages are not computed by any node: a dashboard control writes them,
|
|
the API publishes them, a batch run passes them in. Declare those as the flow's
|
|
**inputs**, with the value they start from:
|
|
|
|
```json
|
|
{"inputs": [{"spec": {"name": "setpoint", "dtype": "float"}, "initial": 21.0}]}
|
|
```
|
|
|
|
Without that, the node reading `setpoint` waits for something nothing provides,
|
|
and the canvas says so. With it, the flow starts at 21.0 and whatever writes
|
|
the message afterwards takes over.
|
|
|
|
## Where to next
|
|
|
|
- [Where a node's values come from](values.md) — ports, settings, flow inputs
|
|
- [Keeping state in a flow](state.md) — the sanctioned way to remember something
|
|
- [Runs: pipelines that finish](runs.md) — the batch half of the engine
|
|
- [Writing node code](../code/nodes.md) — the practical side of `process()`
|