Commit Graph
155 Commits
Author SHA1 Message Date
stroblmeandClaude Opus 5 a8065ad91c Give the work queue its own Redis root, and pin what the suite runs as
The queue shared the `pipeline:` prefix with flow state, so `RedisState.clear()`
could DEL the queue stream and `keys()` enumerated queue entries — only callers
filtering `__`-prefixed names kept it safe. It moves to `queue:` without a
migration: whatever is in flight at the upgrade is dropped once, documented in
DEPLOY.md rather than papered over.

Alongside it: `pool_pre_ping`, so a connection idle across a Postgres restart
costs a round trip instead of a failed request; the test suite pins
ENVIRONMENT=local and DOMAIN=localhost itself rather than inheriting a
deployment's .env; and `depth` leaves the queue stats, where it reported the
capped journal length as if it were a backlog.

ALERTS_FILE and PANELS_FILE now point at /data. They defaulted to a path on no
volume, so alert routing and every wall-panel pairing were living in the
container's writable layer and vanishing on each rebuild. Carrying the existing
files across is a manual step; DEPLOY.md has it.

development.md was still the upstream template — compose.override.yml,
localhost.tiangolo.com, `docker compose watch` as the dev flow — and said
nothing about the Playwright suite. Rewritten against what the Makefiles
actually do. deployment.md was template text too, duplicating the root
DEPLOY.md, and is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 10:10:57 +02:00
stroblmeandClaude Opus 5 a8d1b3927e Bound a panel credential where the route check cannot reach
Three things the security pass on the portal pairing turned up. The first two
were already true of a screen on the local network; what changed is that a
panel credential is now presentable from the internet, which is what makes
them worth closing rather than recording.

The artifact endpoint authenticates for itself, because a worker's credential
has to open it and that token is no use anywhere else. It resolved the caller
without handing over the request, so the one credential that is scoped by
route was judged by no route at all — a panel could read and write the store
as whoever approved it. It passes the request it already holds now.

The websocket has no route to judge either, and there the bound has to be on
what is sent: a panel is given the values its own dashboards draw and nothing
else — no node status, no logs, no shape of the graph. The keys stay in the
message, emptied, because a screen on a wall runs the bundle it was paired
with. `messages_for` reads that set off the published dashboards, and is the
walk the `/messages/` allowlist has wanted for a while.

And locality is no longer a header anyone can type. The marker the connector
stamps is a value minted per process, so reaching this API directly cannot buy
a device the credential meant for one that cannot reach it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
2026-08-21 00:09:18 +02:00
stroblmeandClaude Opus 5 4c8339e643 Pair a wall panel through the portal
A screen somewhere this installation is not reachable from asks the portal for
a code instead, and the portal mints its credential — because a token signed
here is one such a device could never present.

Where it was minted changes nothing about what it may do. The panel gate moved
off the branch that decodes a local panel token and onto whatever claims name
a panel, so the portal's and this installation's are bounded by the same check
against the same panel's dashboards. A token of that scope naming no panel is
refused rather than left holding the account it borrows.

The connector marks what arrives on its socket, since that is the only thing
that makes it true, and the approval screen now names what is holding a code —
approving adopts whatever answers, so it is worth a look first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
2026-08-20 23:42:58 +02:00
stroblme 906203da1e Let a connector write, and publish strings bare
Two things stopped the engine commanding this house. ConnectorNode hardwired
its node function to a no-op, so an input message reaching a connector was
discarded and Art-Net's packet builder was unreachable; write() now carries
the input ports, which is additive so the contract version holds. And the MQTT
publisher JSON-encoded every payload, so "ON" went on the wire quoted and the
devices on a shared broker, which speak bare values, ignored it.

seed_house_control.py is the rig: a flow that drives the washing machine plug,
a dimmer and a colour fixture over MQTT, carries the same two as DMX on an
Art-Net node with transmit still off, and a dashboard to drive it by hand.
2026-08-20 21:57:27 +02:00
stroblmeandClaude Opus 5 3508713e85 Node settings arrive as keyword arguments, not a params dict
A python node's settings are constants of its own function, so they are passed
the way its ports are: by name. The controller binds them to the compiled
function, the `params` field is gone from the worker and remote protocols, and
a setting sharing a port's name is reported as a node error rather than
shadowing it. The panel's scaffold follows suit and keeps the header in step
with both ports and settings.

The demo's `pace` moves from a flow input to a setting of the training node,
which is what it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
2026-08-20 17:47:45 +02:00
stroblmeandClaude Opus 5 ce77262f81 Point the panel link at the installation, not at the browser's origin
Both links out of the dashboard editor were built root-relative, so a portal
serving the app under `/i/{id}` got a URL to itself: the hub has no route
there and answers a bare 404. That is what a device link and "open what a
wall panel sees" both landed on.

They want different answers. The view link is for the person already looking,
so it takes the router's basepath — `appPath` in `lib/portal` is the same
prefix the router applies to every `Link`, for the places that step outside
it. The device link is for a screen, which cannot go through the portal at
all: the shell is served only to a portal session, and the credential that
page carries is the portal's rather than the panel's. So the server now says
where it answers, and `FRONTEND_HOST` is that answer — the same setting the
password-reset links already use.

Also fixes the panel branch in the query error handler, which compared a raw
pathname and so never fired under a portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
2026-08-20 17:24:20 +02:00
stroblmeandClaude Opus 5 ffae24c16b Panels: per-device dashboard sets, paired by code
A panel is one screen and the ordered set of whole dashboards it shows, so a
hallway tablet and a workshop tablet carry different sets without either
dashboard knowing about the other. More than one and the device draws a rail
to switch between them — the same rail the editor puts on screen, because the
wall has it and it takes room off the canvas.

A screen has no keyboard, so it pairs rather than logs in: it shows a
six-character code, somebody approves it against a panel from the dashboards
overview, and the credential that mints reaches that panel's published
dashboards and the message endpoints its widgets speak, and nothing else.
Deleting the panel revokes it.

Closes the per-device view and the kiosk credential; supersedes the
multi-page/multi-section UI, since a page is now a dashboard of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
2026-08-20 16:23:36 +02:00
stroblmeandClaude Opus 5 ace980b685 Carry per-node emit counts in the live snapshot
The brain graph counts node_executed events client-side and the websocket is
torn down on every shell change, so anything a flow published during the
navigation gap was lost. The bus now keeps a session tally per qualified node
and the snapshot hands it back, letting a reconnecting client catch up.

Both the route and the tunnel connector build that snapshot from one helper
so the portal cannot drift from the direct connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 11:32:44 +02:00
stroblmeandClaude Opus 5 dbaa3518b9 Bump dashboards: bar, icon, forecast and clock widget types
Plumbing only: the widget-type literal and its dtype table on both sides,
the regenerated client, a curated lucide map and four stubs the renderers
are wired to. Also a latching switch and a segmented dropdown, both a
`style` on the control that already publishes and reads back, plus the
option editor a dropdown never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 08:31:36 +02:00
stroblmeandClaude Opus 5 6be718f672 Health: count only flows the engine acts on, format durations with their unit
The "running" tile counted paused and invalid flows as running, so it read
"5/5" beside "1 flow(s) cannot run". Its note is now additive rather than a
precedence chain, so a quarantine no longer hides the invalid count.

Adds dur() beside si(): a ms reading picks its own unit, so a slow run reads
"1.24 s" instead of "1.2k ms".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 08:24:57 +02:00
stroblmeandClaude Opus 5 f4b81507d1 Optional remote access: dial out to a Fluksio portal
An installation can be enrolled with a portal by redeeming a claim code, after
which it holds one authenticated websocket open and answers proxied API calls
over it. Requests are dispatched into this process's own ASGI app, so the HTTP
trigger routes flows install at runtime are visible to it, and the live flow
stream is bridged straight off the event bus.

decode_token grows the third branch its docstring anticipated: tokens signed by
the enrolled portal resolve to the local account that performed the enrolment,
verified against a JWKS pinned at that moment. With no enrolment the branch
raises immediately, so an offline installation is unchanged and untouched.

Disconnecting deletes one file, which is the entire local revocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
2026-08-19 16:28:38 +02:00
stroblmeandClaude Fable 5 eaabb405d9 An example to evaluate: a training run, its dashboard, and two bugs it found
make seed-demo builds demo_training — prepare on the engine, a GPU-bound
train, evaluate back here — and a panel that draws the loss curve while the
training is still going. It is the session's whole argument in one flow: batch
runs with parameters and a result, a generator yielding on a declared port
rather than logging, fluksio.emit from inside a callback, artifacts carrying
the dataset and the weights between machines, and a sweep whose configs are
isolated from each other. The train node prefers its label rather than
requiring it, so it runs before a GPU box exists and says which machine and
which numeric backend it actually used.

Building it turned up two real bugs. A run waited for a worker its flow only
*preferred*, because required_labels ignored device_policy — so the example
hung on a label it did not need. And a run's seed never reached the flow, so
sweeping over seeds ran the same experiment N times; it now fills an input of
that name when the flow declares one, which is what the field looked like it
did all along.

Pressing Run on a batch flow now submits a run rather than taking the old
non-durable path — that button is the first thing anyone evaluating will press,
and it was quietly doing something else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 22:04:14 +02:00
stroblmeandClaude Fable 5 774b03953a A node's numbers leave through its ports, not a logging call
The first cut had node code call fluksio.log_metric, which was a second,
undeclared way for data to leave a node: invisible to validation, absent from
the canvas, and stored where the graph could not see it. That is precisely the
MLflow discrepancy this framework exists to avoid, so it is gone.

A node that produces values over time is a generator. Every yield is a dict
keyed by output port, published the instant it happens — same port, same type
check, same place on the canvas as any other value — and what it returns is
its result. A port doing this declares stream: true, and a run keeps every
number one takes, so experiment tracking is a consequence of the graph rather
than an API beside it: a chart binds to a training curve the way it binds to a
temperature. fluksio.emit writes the same ports imperatively, for where a
yield cannot reach — inside a training framework's callback.

In a live flow an emission also wakes what is downstream, as a subscriber
publishing does; in a run it does not, because a run's graph is scheduled once
and mid-node cascades would leave 'finished' with nothing to mean. The
enqueued item carries no payload: the value is already in state, and one
carrying it would re-apply an old emission after the node returned.

Verified on the stack: 30 loss values arrived live on the flow socket during a
run, attributed to the node that produced them, and the same node run on the
remote worker streamed its curve back across the socket.

Also caches remote compile results per worker, so attaching a GPU box does not
put a network round trip in every rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 20:53:49 +02:00
stroblmeandClaude Fable 5 eb2d098d7c Remote workers: a GPU box dials in and runs the nodes bound to it
The engine runs where the automations are and the GPU is somewhere else,
usually behind a different network — so the worker connects out and the engine
answers over the socket it was given. Nothing has to expose Redis, and the
same connection works through the tunnel the hosted access will use.

What travels is the protocol the local pool already speaks, so a node cannot
tell which kind of worker it is on. A node declares device: gpu and
device_policy, the label is resolved per call (a worker attaching later needs
no rebuild), and a run whose labels nothing carries waits in the queue saying
what it waits for rather than failing — submit from the couch, the GPU box
picks it up when it is switched on.

Two things had to move with it. Compiling now happens on the machine that will
run the node: a node importing torch is correct on the GPU box and a missing
module on the engine, so checking it here failed nodes that were fine. And the
artifact endpoint accepts a worker's own credential, because storing a
checkpoint is exactly what that credential is for — and only that.

Verified against the real split: the training ran on this host (its checkpoint
names the machine and a numpy the engine does not have), streamed 40 metric
points back mid-run, and the evaluate node read the checkpoint on the engine.
Cancel kills the remote training; pulling the worker fails the run in six
seconds instead of waiting out its ten-minute timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 17:52:42 +02:00
stroblmeandClaude Fable 5 d27704a2bc Artifacts: bytes a node produced, addressed by their content
A checkpoint is not a message. DType.ARTIFACT carries a reference — digest,
size, media type, name — so everything on the wire stays JSON and thirty
megabytes never sit in Redis, which answers the vision's open binary-payload
question by narrowing it: inline codecs would only serve payloads too small to
be worth a round trip, and nothing asks for that.

The store is content-addressed rather than per-run, for three reasons that all
pay later: a sweep whose fifty configs share one preprocessed input stores it
once, a reference stays valid however it is passed around because it names
content instead of a location, and the digest is what a stage cache will
compare — so building it in now is what keeps that from being a change to the
message contract.

Node code calls fluksio.save_artifact/load_artifact and cannot tell whether it
is writing the engine's own directory or putting bytes over HTTP, which is
what will let the same flow run on a remote worker unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 17:09:27 +02:00
stroblmeandClaude Fable 5 9b48f1593e Runs report while they run, and the reader frames lines properly
A training loop has numbers worth keeping thousands of steps before it has a
result. Node code now imports fluksio and calls log_metric/progress, which
sends a line back without ending the call; the engine writes those to
run_metric in batches from the run's own driver rather than folding them off
the event bus, which drops what it cannot keep up with.

Two things fall out. Each event resets the worker deadline, so a node's
timeout measures silence rather than duration — which is what lets a two-hour
training keep a liveness contract instead of racing it. And the worker pool's
_running is now keyed by (run, node), so cancelling one config of a sweep
kills that training and leaves the rest alone.

Fixes a latent framing bug: read_line returned whatever a read had taken,
which was fine while a worker only ever sent one line per request and
unparseable as soon as it sent several. It now keeps the remainder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 17:02:03 +02:00
stroblmeandClaude Fable 5 db60b289e7 Runs: a flow taken from its inputs to its outputs, once
A cascade has no end worth recording; a run does. Parameters go in, the graph
executes until it drains, and the result is kept — which is what an ML
experiment is and what a CI-style job is, so both are one entity.

Each run gets a state backend namespaced to itself, so two runs of one flow
cannot overwrite each other's messages; that is a constructor argument rather
than a change to the pipeline, because every key the engine keeps already goes
through the state backend. Its record is written by the driver thread rather
than folded off the event bus, which drops what it cannot keep up with. Its
own Redis stream wakes an engine up, and from the claim onwards the database
row is the truth: redelivering hours of training because an acknowledgement
was late is not recovery, so a stale lease is what marks a run whose engine
died.

Flows gain mode: batch, which are built and validated but never activated, and
nodes gain a device label for the worker that must run them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 16:55:29 +02:00
stroblmeandClaude Opus 5 bb90a24b90 Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
2026-08-17 17:35:14 +02:00
stroblmeandClaude Opus 5 413501c6ce flow: structured dtypes, and the widgets that read them
A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:06:45 +02:00
stroblmeandClaude Opus 5 a1265450df Health: a flow that cannot run says so, and the brain marks which neurons
A dependency loop is flagged on the canvas and was invisible everywhere else:
/observability/summary answered "ok" with an empty problems list while the
published flow could not run at all. It now reports the flows validation
blocks, and the brain graph carries the reason on each neuron the issue names
so the view built to find broken wiring can show it.

Node errors stay counted once, as the nodes that failed to load, and an
advisory like an unauthenticated webhook marks nothing — it is worth saying,
but the flow still runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
2026-08-17 14:39:07 +02:00
stroblmeandClaude Opus 5 9351a86eec Overviews: icon toolbar, dashboard drafts, publish all
Both overviews carried the same toolbar twice, left-aligned, with a search
field permanently taking a row of width. One `OverviewToolbar` now serves
them: the search folds into an icon and expands again on click (Escape puts
it away and hands focus back), create is a `+`, and everything sits right of
the page. Each page keeps its own create dialog — the toolbar only renders
the trigger — so the testids the runtime spec and the capture script drive
stayed where they were.

Dashboards get the flow store's draft/publish split. The editor autosaves
`dashboard.draft.json` beside `dashboard.json`; `/view/{name}`, `bindings_for`
and `history_requirements` keep reading the published file, so a wall panel
sees an edit only once someone publishes it. `POST /dashboards/{name}/publish`
and `/discard` mirror the flow routes down to the version precondition and the
409, `GET /dashboards/{name}?draft=true` is what the editor asks for, and the
dock grows the same Publish button — which flushes a queued save first, so an
autosave in flight is not published around. Creating a dashboard still writes
the published file directly: an empty document on a panel is harmless, and it
keeps the store free of a never-published case.

"Publish all" is a checkmark in the toolbar, live only when something actually
has `has_draft`. A summary carries no version and publish needs the one it is
based on, so each document's detail is read immediately before its publish —
honest against a stale list, and no version-less backend path to maintain.
Failures are counted rather than swallowed: three of five fails says so and
names the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
2026-08-17 11:57:11 +02:00
stroblmeandClaude Opus 5 2554488a73 Backend: real alert test results, state cleanup on delete/rename, node trigger errors, queue and collector fixes
`AlertManager.send` swallowed every delivery failure, so the alerts screen's
Test button answered 200 whatever happened — the one thing it exists for. It
takes `raise_on_error` now, which only the test route passes; the per-channel
loop keeps the swallow, because one dead channel must not stop the others
hearing about the same fault. A refused delivery answers 502 with whatever the
sender said.

Renaming a flow left its values under the old name for good: the delete path
already swept them, the rename path never did. It calls the same `forget_flow`,
which covers the messages and the `__ts__`/`__version__`/`__history__`
bookkeeping keyed by message name. Cleanup, not migration — they repopulate
under the new name on the next run.

Triggering a node by hand ran `Node.__call__` with nothing catching it, so a
node that raised produced a 500 and a stack trace in the server log, and
nothing at all on the canvas. `Pipeline.publish_error` is the reporting half of
`_execute_node` lifted out; both paths go through it, so a manual failure now
reads the same on the canvas and in the metrics as a queued one. The route
answers 400 with the node's error.

`MemoryWorkQueue.stats()` counts claimed-but-unacknowledged work rather than
reporting zero, so the health tile means something without Redis. The metrics
collector's held tracebacks are capped at `DETAIL_CAP` and swept on the same
`RUN_STALE_S` cutoff the open runs use, instead of one untruncated traceback
per node kept for the life of the process — a traceback still survives the
flush between the log and the failure it belongs to.

`GET /observability/events` takes `since`/`until`, the window `/runs` already
took, so a failures list can cover the span the charts beside it are drawn from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
2026-08-17 11:33:25 +02:00
stroblmeandClaude Fable 5 0b91379914 Let a pinned minute read runs from the whole day
/observability/runs gains since/until, so the throughput chart's pin asks
the server for its minute instead of filtering a fixed recent list. This
engine writes ~60 runs a minute, so any minute but the newest read empty.

since is inclusive and until exclusive, matching the minute buckets the
charts are drawn from. Hover stays the client-side preview it was:
scrubbing a day would otherwise be a request per minute rested on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-17 00:27:40 +02:00
stroblmeandClaude Fable 5 83c30aa1c7 Take the engine off the path node code imports from, and let it stop
The worker script is handed to the interpreter by path, so app/flow was
sys.path[0] for every node: `import queue` got the engine's. It now drops
its own directory before anything else imports, and runs with the
deployment's credentials scrubbed out of its environment.

Also: reload builds off the event loop, the pool wakes what is blocked on
it when it stops, a refused metrics flush is kept for the next one rather
than dropped, and the cascade events are paired through failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 23:46:08 +02:00
stroblmeandClaude Fable 5 4bcd38354b Draw every flow as one graph, merged on what it talks to
A node type can now say which outside thing its parameters point at, and
nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single
neuron on a new /brain canvas. That makes the wiring which runs between
flows through a broker visible for the first time; no single flow's canvas
can show it. The key is read off stored parameters, so a credential
reference never reaches an id.

Layout is a d3 force simulation settled once and then frozen, lit by the
socket the editor already listens to: a neuron pulses when any node behind
it publishes, and its connections light as values pass.

Fixes the message pulse while here: interpolating the stroke against the
edge's `color-mix()` resting colour went through oklab and left the gamut,
which turned every pulse on both canvases fluorescent yellow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 22:59:31 +02:00
stroblmeandClaude Fable 5 af3ba51571 Keep the engine's own history, and a screen that reads it
A second bus subscriber folds executions, errors, timings and queue lag
into per-minute rollups, keeps failures with their traceback and an audit
trail of who published what, and records one row per cascade — manual runs
and previews included, under an id of their own that writes no idempotency
markers. Read back through /observability/*, which always answers 200 so a
degraded engine still renders its own health screen.

Also fixes two things found on the way: node-health alerts read `status`
where the engine publishes `health`, so a device dropping never alerted
anyone, and the Redis queue reported `parked: 0` whatever was held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 22:29:32 +02:00
stroblmeandClaude Fable 5 f300c43f3a Run python nodes out of process, with modules of their own
User code no longer execs in the engine. A pool of persistent worker
subprocesses speaks one JSON object per line; the controller installs a
proxy as the node's function, so every execution path funnels through it
and the pipeline is untouched. A crash costs one subprocess, a per-node
timeout is a kill, and cancelling from the canvas is that same kill.

The workers run a venv of the user's own on the data volume, filled from
a pip manifest versioned beside the flows. Applying it retires the
workers and rebuilds, so a package lands without restarting the engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 21:43:36 +02:00
stroblmeandClaude Fable 5 b5949938f0 Format four files ruff had drifted from
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:47:16 +02:00
stroblmeandClaude Fable 5 a733e0d582 Format the provenance test so ruff stops failing on it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:44:23 +02:00
stroblmeandClaude Fable 5 93b4a9a0b1 Step a paused flow, and deliver what a rate limit held back
Three things a pause and an interval were quietly losing:

- A rebuild builds a fresh pipeline, so nothing is paused any more and no
  resume ever comes for what the old one parked. Release it on rebuild.
- POST /flows/{name}/step takes the oldest parked item and runs that one wave
  while the flow stays paused, so a held-back cascade can be walked through.
  Nothing parked answers plainly rather than failing.
- A per-port interval was leading-edge only: a producer going quiet inside the
  window left the consumer on the value before it. The held value is kept and
  a flush item scheduled on the queue's existing timer, so the window ends with
  a delivery. One timer in flight per node, and none without a queue to run it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:44:17 +02:00
stroblmeandClaude Fable 5 0b2d8e587a Say what a node returned when it is not a dict of ports
Outputs are keyed by port, so a bare value cannot be one. The single mapping
point every caller routes through raised a bare AttributeError from
retval.items(); it now names the problem, and the message reaches the node the
way its other errors do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:44:06 +02:00
stroblmeandClaude Fable 5 f239c884b6 Add OAuth client list and revoke endpoints
Superuser-only management for agents that registered themselves: list them
with whether anyone approved them, and withdraw one without rotating the
signing key and cutting off every other agent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:29:47 +02:00
stroblmeandClaude Fable 5 75c26ef000 Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done
nothing. The canvas pulsed on the message's timestamp alone, and a message
has no idea who published it — so it credited whichever node happened to
be drawn as a producer.

That was never only about dashboards. Two nodes producing one message
pulsed both their edges whichever fired, and a message produced in another
flow changed with nothing on screen to account for it at all.

Values now carry their cause: a node, a dashboard widget, another flow, an
agent or an API caller. An edge pulses only for the producer that actually
published, and the edge inspector says where a value came from when it did
not come from a node.

What is not a node in this flow is now drawn as one — a label rather than
a card, because a dashboard with twenty tiles would otherwise bury the
logic the canvas exists to show. That covers cross-flow wiring too, which
is the link in/out affordance that has been missing.

They are never part of the document. They join at render, after everything
that reads or writes the canvas nodes, so an autosave, an undo or a delete
cannot reach them — with a Playwright test that drags a node and asserts
the stored flow still holds exactly what it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 15:51:54 +02:00
stroblmeandClaude Fable 5 3fa9141eb9 Scope the node-type fixture check to the built-ins
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Installing the connectors made this fail, which is the test working: it
insists every offered type is exercised. But a connector is a separate
package with its own tests, so it is not this suite's to cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 11:16:41 +02:00
stroblmeandClaude Fable 5 895bd89b2b Dashboards as documents, and messages as something to bind to
A dashboard is its own document rather than widgets placed in a flow.
Node-RED's dashboard tab is 260 nodes, about forty of them pure layout,
which is exactly what the small-graph principle exists to avoid — and
since the graph is already wired by message name, a widget can bind to a
name without belonging to any flow.

Stored beside the flows in the same repository, sharing their write lock
and commit, under a directory the flow listing ignores. No draft/publish
split: nothing executes a dashboard, so edit mode is its own staging area.

Two things it needs from the engine. A message catalog spanning every
flow, because a wall panel shows the heating next to the solar and the
flow-scoped API is the wrong shape for that. And a way to put a value in
without owning a node — a slider is a real value that happened to come
from a person — which runs whatever consumes it and applies the same type
check a node's output gets. Only a message some flow declares can be
published to; flows own the namespace.

Charts also need more past than the 120 points a sparkline wanted, so a
chart widget declares its depth and the engine keeps that message's
series that deep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 09:20:49 +02:00
rootandClaude Fable 5 d9150c118d Add the vocabulary a real installation is mostly made of
Every one of these could be written as a function node, and until now had
to be. But nine node types account for most of what an actual Node-RED
installation contains — inject alone is placed 58 times, change 62 — and
a rule you fill in reads better on a canvas, and survives editing better,
than the same five lines repeated eighty times.

Inject, switch, change, filter-unchanged, join, trigger, command, file
and ntfy. They configure themselves through the editor's generated form,
so none of them needed frontend work beyond an icon.

The stateful ones (filter-unchanged, trigger) keep what they remember in
the engine's own state under a reserved prefix, never as a message. That
is the line: code you write is a pure function of its inputs, node types
the engine ships may remember things.

Verified against a live instance — a hook feeding change into
filter-unchanged into switch scales a raw reading, swallows a change
inside the deadband, and routes the rest to the right branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 08:08:40 +02:00
rootandClaude Fable 5 dbdbcc1091 Tell someone when the engine breaks
Everything that goes wrong already travelled the event bus, but the only
subscriber was the editor's websocket — so a flow quarantined at three in
the morning was invisible until someone opened the browser.

An alert manager now watches the same bus and forwards failures to ntfy,
email or a webhook. Most of what it does is decline to send: the same
node failing every second is one alert with a count of what followed, a
connection flapping up and down is muted until it settles, and nothing
gets past ten notifications an hour. Verified against a live instance —
six identical failures produced one alert carrying the real traceback
message.

Channels and rules are configured through the API, with a test send so a
channel can be proven before anything depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 08:01:50 +02:00
rootandClaude Fable 5 04329149b3 Journal work before running it, so a crash stops losing messages
Execution was fire-and-forget: an MQTT message or webhook ran a cascade
on a ThreadPoolExecutor built for that one wave, and an engine that died
halfway through simply lost whatever was in flight. Concurrent triggers
each built their own pool, so load meant unbounded threads.

Every external trigger is now journaled to a Redis Streams queue before
anything runs, and acknowledged only once its cascade finishes. A
consumer thread drives cascades on one long-lived pool while node bodies
run on another, so a cascade cannot starve the nodes it is waiting for.
A reaper reclaims what a dead consumer never acknowledged — verified end
to end: work journaled while the engine was stopped runs on restart, and
work abandoned mid-cascade comes back as a second delivery.

At-least-once needs a guard, so nodes that reach outside are marked
non-idempotent and skipped on a redelivery they already completed.
Without Redis the queue degrades to an in-memory one that does not
pretend to be durable, and interactive callers still run inline.

Also fixes two things this turned up: a delay node was sleeping on a
worker thread, where a handful of them could occupy the whole pool, and
webhooks 404'd whenever MCP was enabled because the app mounted at /
answered first for every path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:57:07 +02:00
rootandClaude Fable 5 2cc25970e3 Let a flow keep state in the messages it already has
Logic nodes are pure functions with no state handle, but real automations
count things and remember the last reading. The shape for that is a
message a node both reads and writes: the graph already declines to make
a node depend on itself, so this worked by accident. It is now defined,
tested, and checked — a node that is the only writer of a message it
reads is told at edit time that it needs a starting value, rather than
silently never running.

Feeding a value back between two nodes was still a cycle, and rejected.
An input can now be marked non-triggering: read when the node runs, never
the reason it runs, and no dependency either way. That is what a back
edge actually means.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:37:46 +02:00
rootandClaude Fable 5 f8693daad6 Split the node types into a package, and stop reconnecting per message
nodes.py had grown to 2k lines holding every integration behind a single
blanket mypy exemption. It is now a package split by the outside world
each node talks to, so the exemption shrinks to the four integration
modules; base and mlp are type-checked, which turned up a dozen missing
annotations.

The senders opened a fresh connection — and, in the MQTT case, a fresh
thread pool and event loop — for every single message. HTTP senders now
share one pooled client, and a publisher holds one broker connection for
its lifetime, fed from a bounded queue that drops the oldest value when
the broker cannot keep up.

An HTTP sender also no longer trips over a JSON reply that is not an
object: outputs are keyed by port, so a bare scalar is a valid reply with
nothing to publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:33:10 +02:00
rootandClaude Fable 5 e7e48c4f13 Supervise the loops a flow starts, and give up loudly
A node's subscription, schedule or poll loop was a bare asyncio task: one
that raised outside its own retry handling was simply gone, and the node
went on being listed as running while nothing listened any more.

Those loops now run under a supervisor that restarts them with a growing
delay and quarantines a flow that burns through five restarts in five
minutes — a flow crash-looping every second is worse than one that is
visibly stopped, and the dashboard can now say which.

The MQTT subscription loses its private five-second reconnect in the
process: one backoff policy per socket, and it belongs to the supervisor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:23:02 +02:00
rootandClaude Fable 5 5462842b8a Supervise the engine's host: deep health, loop watchdog, one worker
The API image ran four uvicorn workers, and each one built a full flow
controller — four sets of MQTT subscriptions, cron ticks and webhooks.
Runs one worker now; scaling out is the worker split, not more processes.

Adds a loop-lag watchdog and a deep /utils/health/ that fails when the
event loop is wedged or Redis is unreachable, the two failure modes a
process-alive check never sees. Autoheal restarts on that signal, behind
a compose profile because it mounts the Docker socket.

The private user-seeding routes now need an explicit opt-in rather than
just ENVIRONMENT=local, so a deployment that kept the default never
exposes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:14:28 +02:00
Melvin StroblandClaude Fable 5 8d82d6c4ec Let agents drive the flow API over MCP
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization
server in front of it: an agent registers itself, sends a human to the browser
to approve it, and exchanges the resulting code for a token. PKCE is required,
codes are single-use and stored only as hashes, the browser is redirected to
the URI that was registered rather than the one asked for, and refresh tokens
rotate so that replaying a spent one revokes the whole line.

Twenty tools cover reading, building, publishing and running flows, and each
one calls the same REST endpoint the dashboard calls, in-process, carrying the
caller's own token. That keeps one description of what a flow is and what may
be done to it — validation, the draft/publish split, the version check — and
means an agent can do nothing a person could not do in the browser.

Agent tokens are RS256 with a keypair of their own rather than the secret that
signs browser sessions, so deleting the key withdraws every agent without
logging anyone out, and deps.decode_token grew the branch that trusting a
second issuer will need when the hosted login arrives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 00:22:41 +02:00
Melvin StroblandClaude Fable 5 3724b68f23 Add the connector contract, reusable nodes and per-port intervals
Connectors are the device-facing node class third parties write, so the
surface they build against is versioned and documented: ConnectorNode carries
a declared contract version, a polling loop that publishes only what changed
and reports health around it, and parameters whose credential fields are
marked x-secret so the editor offers the secrets store instead of a text box.
They are found through the fluksio.node_types entry point group, with the
package's own metadata as the manifest. docs/connectors/ has the contract and
the authoring guide; connector-skeleton/ is a working one to copy.

The controller no longer knows what any node type is: start, stop and
report_health are protocol methods on Node, and the built-ins were migrated to
them first, so the hooks a connector implements are the ones the engine has
been driving all along.

Marking a node reusable moves its source to _lib/ and points the node at it by
name. Other flows instantiate it with their own ports and settings, one fix
reaches all of them, and a shared source still in use cannot be deleted.

Ports gained an interval: an output publishes, and an input wakes its node, at
most every n seconds. State keeps the latest value, so only the delivery is
skipped, and pressing Run is never throttled.

Also fixes autosave sending no version on its first save of a session, which
made every flow saved more than once conflict with itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:57:44 +02:00
Melvin StroblandClaude Fable 5 7344eac262 Start, stop and pause flows, and show what their nodes print
Flows can now be taken off the engine and put back. Stopped state lives in a
runtime.json beside the flow, not in the flow document: the canvas autosaves
that document, so a stopped flow would otherwise start itself again on the
next edit. A stopped flow gets no subscriptions, schedules or webhooks, its
nodes are skipped by the scheduler, and running it answers 409. Pausing holds
a flow's nodes while its values keep arriving, so the canvas still shows what
is coming in.

Node code is user code and print is how it says things, so stdout is teed
through a contextvar sink active only during a node execution — one event per
execution, capped, so a chatty node cannot outrun the stream. A node that
fails sends its traceback the same way, trimmed to the author's own frames.
The dock gains a logs panel and a pause control; the dashboard replaces its
placeholder with what is running, stopped or failing; the edge inspector can
send the last message again.

Single-stepping is deferred and noted: the scheduler keeps no progress between
calls, so a step button would re-run the same node rather than advance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:35:08 +02:00
Melvin StroblandClaude Fable 5 606ab3c423 Separate editing from running with a draft/publish split
Edits autosave to flow.draft.json and nodes.draft/ instead of the files the
engine reads, so the pipeline keeps running the published version until
someone publishes. Every save carries the version it was based on: a second
client editing the same flow is refused with 409 and offered the choice
between their version and its own, rather than silently overwriting.

Draft saves no longer rebuild the pipeline; validation and node status for a
draft come from a throwaway build that never touches live state.

Also fixes a latent bug where an empty state backend is falsy, so Pipeline
quietly built itself a second, private state and left message history empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:13:15 +02:00
Melvin StroblandClaude Opus 5 3b72d17ca7 Record a short value history per message
The panel is going to sparkline how a value moved, and nothing kept more
than the latest one. Emissions now append to a capped list beside the
value — 120 points, enough to fill a sparkline without making Redis a
time-series store — and `/flows/{flow}/history/{message}` hands it back
oldest first.

Only numbers are recorded, bools included as neither, so a string
message costs nothing at all. The response carries `numeric` so an empty
series reads as "not plottable" rather than "nothing yet". The append is
one pipelined round-trip per emission batch and sits outside the lock,
since the list is append-only.

MemoryState keeps the same window in a deque, so the endpoint answers
without Redis too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
2026-08-15 21:39:59 +02:00
Melvin StroblandClaude Opus 5 ca7a16c8bc Guard webhooks with a per-hook secret, and allow rotating the app key
Trigger hooks are mounted unauthenticated because devices cannot present
a JWT. They now take a secret as a trailing path segment, so a device
needs one URL and no header support. The value never enters the
registered route, only a {secret} template, and is compared with
compare_digest; a mismatch is a bare 404 so the endpoint does not
confirm which hooks exist. Pointing the parameter at the encrypted store
keeps the literal out of flow.json. Hooks without a secret keep working
and now raise a validation issue saying so.

Rotating SECRET_KEY made the stored secrets unreadable for good, since
the Fernet key derives from it. scripts/rotate_secret_key.py re-encrypts
with the new key and refuses if the old one does not decrypt. Now that
recovery exists, an unreadable store fails loudly instead of coming back
empty and leaving flows short of credentials with no visible cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
2026-08-15 21:19:32 +02:00
Melvin StroblandClaude Opus 5 82356810ce Give the tests their own database and drop the template specs
pytest was deleting every user on teardown against the development
database. The engine is built at import time, so tests/__init__.py pins
POSTGRES_DB before app.core.config loads; the fixture creates the schema
and drops the database again, guarded against a name that is not _test.

Playwright now defaults at the integrated stack, which is the origin the
API allows, so a bare `bunx playwright test` works without a dev server.
The four template specs that asserted copy we no longer ship are gone,
along with the helpers they were the last callers of. The admin edit
assertion was page-wide and only ever passed on a fresh database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
2026-08-15 21:19:12 +02:00
Melvin StroblandClaude Fable 5 fd666743d2 Add flow settings, pulse emitting nodes, and simplify node state
- One dot per node now carries the whole story: primary while running, sage
  after a good run, red when anything is wrong, with the explanation on hover.
  The corner badge is gone, along with the second way of saying the same thing.
- A node that publishes something flashes a ring, so a running flow is legible
  without reading the edge values. Nodes that consume but publish nothing stay
  quiet, which is why the event carries an output count.
- Flow settings open in the same panel its nodes use, from a pencil in the
  dock: the title, the name, and deleting the flow. NodePanel and FlowPanel
  share the panel chrome rather than each drawing their own.
- Renaming is a server operation, because a flow's name is the namespace of its
  messages: the directory moves and every other flow reading `old.message` is
  repointed, instead of being left pointing at a flow that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
2026-08-15 19:46:23 +02:00