Commit Graph
100 Commits
Author SHA1 Message Date
stroblmeandClaude Opus 5 65272a135f Adopt a member the portal vouches for
A share link admits somebody at the portal, so this instance first hears of
them when they arrive rather than when a superuser types their code in.
An unmapped portal identity is now checked once against the portal's own
list of who may reach this instance and given an ordinary local account
only if the portal vouches for it.

Asking rather than believing the token is the point: a token stays signed
and valid until it expires, so trusting its claims would let one rebuild
the account somebody deleted here and deleting a user would stop being the
whole of the revocation.

The account-making itself moved out of the route, since both ways in build
the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GPMNwB2mGBP5j7dXRopcPH
2026-09-02 17:50:29 +02:00
stroblmeandClaude Opus 5 058f16ec1d Close eight open SDK tasks: the pidfile, the log, cards, names and a live curve
Each was a loose end recorded under `### SDK` in the notepad.

`serve` takes its own pidfile down on SIGTERM. uvicorn restores the handler it
found and re-raises the signal it stopped on, so the default handler ended the
process without unwinding and the `finally` never ran — which is what a stop
sends, and what left `serve.pid` behind.

`serve.log` is cut back past 5 MB by the engine rather than by the screen that
started it, so an adopted engine is bounded too. Gated on its own stdout being
an appended regular file, which is what makes the cut safe: the kernel then
puts the next write at the new end.

Cards are counted from `/dev/nvidia[0-9]*`, so `FLOW_GPUS`/`--gpus` of 0 means
"work it out" the way `FLOW_CPUS` always has. The engine counts, not the
accountant — a remote worker builds one of those from its own inventory, and
detecting there would hand it the engine host's cards. The worker counts last:
what a batch job says it was granted still wins.

`GET /runs/metrics/names` is the distinct over a selection that `--list` and
the terminal's metric picker were approximating by reading the newest run that
had measured anything, which missed a name only an older run ever wrote.

`MetricSink` announces each batch it has written (`run_metric`, carrying the
names). Not a per-point event: one covers up to 500 points or two seconds of
them, and the rows stay the record. The terminal comparison fills in as the
first readings land instead of staying blank until reopened, and the browser
refetches the run and any comparison rather than the list behind them.

`retry --group` pages the list route by `before` instead of stopping at 500.

The terminal dashboard takes the terminal's colours (`ansi-dark`), and the web
UI can re-pair from Settings without disconnecting first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRQ9bmTvCbqCwXo9mxZzzV
2026-09-02 16:40:51 +02:00
stroblmeandClaude Opus 5 3e4224df53 New run: reuse asText rather than a second copy of it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
2026-09-02 15:20:31 +02:00
stroblmeandClaude Opus 5 5b122341d5 New run: start a run from the app, on the working copy
The site promises simulated inputs and mocked sensor values, and nothing in
the app was that. A run already is: the values are the caller's, the state is
the run's own namespace, and nothing it computes reaches the live flow. What
was missing was a screen to do it from, and the draft flag being honoured.

`/runs/new` is a flow, a field per declared input, a seed and Run; `/runs`
stays the log. A comma-separated list in a number field expands into the grid
`fluksio sweep --param` builds and goes to the sweep route, so launching one
no longer needs a terminal. Only numbers split: a comma in a string is
content, and one in JSON is syntax.

`RunCreate.draft` was validated at submit and dropped before the run
executed, so "try the working copy" ran the published one. `Run.draft` is a
column now, the driver reads the same copy the submit checked, and a retry
carries it. `FlowSummary.mode` came with it so the rail can say which flows
are batch before one is picked.

Also here: a Retry button on a finished run, which the route has always had
and the UI never did, and parameter cells truncated to their column with the
full value on hover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
2026-09-02 15:10:24 +02:00
stroblmeandClaude Opus 5 d471614e6a Push a frame instead of storing and fetching it
The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.

`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.

The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.

Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YC4u66vjzW54fnHu5Juhh9
2026-09-02 10:15:14 +02:00
stroblmeandClaude Opus 5 518231aa39 Touch is a panel setting, and the rail grows with it
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.

The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
2026-08-31 19:04:20 +02:00
stroblmeandClaude Opus 5 8a94bf10d7 Close the nine open SDK tasks: one engine per directory, a tabbed dashboard, re-pairing, run recovery
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 17s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m30s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m19s
serve: refuse a second engine for one data directory whatever port it was
asked for, using the pidfile and a token this directory signed. The check
runs before the database is touched and before the credential is written,
which is what left every later CLI call pointing at a dead port.

The terminal dashboard is three tabs (Overview, Runs, Logs) with the toolbar
following the focused pane, the engine's output goes to serve.log rather than
down a pipe, and closing the screen stops both reader threads so the prompt
comes back. It adopts a running engine on every start, so stop/start and
restart work on one it did not start, and a stop waits for the process to be
gone before the next start. Enrolment reports itself in the modal.

enroll: a new claim code replaces the pairing instead of being refused. The
code is redeemed before anything is written, mappings to a portal being left
are cleared, and a running engine redials when the stored enrolment changes.

runs: an engine re-queues the runs left `queued` by the one before it, and
`fluksio retry <id>` / `retry --group <sweep>` submits an interrupted run
again with the same inputs and group, recorded through Run.parent_id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9BoNGq6V9MdRWAte7JBuC
2026-08-31 17:37:13 +02:00
stroblmeandClaude Opus 5 bdad6d7fc2 Make the docs state things rather than argue them
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
2026-08-31 10:49:58 +02:00
stroblmeandClaude Opus 5 2422a9b22b Move the remaining panel explanations into tooltips
A node setting's description comes off the backend JSON schema, so every
node type's panel still printed a paragraph under each field. It moves to
the field label, which is the change that makes the node panel read as a
list rather than a page.

Alerts, Modules and Remote access follow the same shape. A page keeps a
one-line description under its h1 — a page is arrived at rather than
scanned — and the rest goes behind the heading's tooltip.

Verified on the running stack: the tooltip opens on tap in the phone sheet
and on hover on the desktop panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:38:16 +02:00
stroblmeandClaude Opus 5 954d90fbf7 Move panel explanations into tooltips
The node, flow, widget and dashboard panels explained themselves in
paragraphs under every control. They now carry a caption and an info
tooltip, so a panel reads as a list of settings.

InfoTip, PanelSection and FieldLabel live beside PANEL_SECTION in
SidePanel.tsx, which the panels already share. InfoTip holds its own open
state because a Radix tooltip ignores a touch pointer, and below md the
whole panel is a full-screen sheet — hover-only help would leave a phone
with the caption and nothing else.

Dashboard/panels.tsx drops from 30 helper paragraphs to 5; the ones left
are empty states and status, not explanation. Em-dashes are out of the
user-facing strings, except where one separates the halves of a select
item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:24:26 +02:00
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00
stroblme 6534855492 up
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m10s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m32s
Test Backend / test-backend (push) Failing after 2m31s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m21s
Signed-off-by: stroblme <stroblme@posteo.de>
2026-08-31 09:24:41 +02:00
stroblmeandClaude Opus 5 c09095d369 Do not fail a node because the engine's own stdout is gone
The log tee wrote through to the real stream unguarded, and the worker
pool tees a returned call's logs there after reading its result and
before handing it back — so a dead stdout, which `fluksio serve` makes
possible by running the engine as a child of the dashboard holding that
pipe, failed the node with its outputs already in hand. The capture half
runs first, so swallowing the write loses nothing.

Also: `flow_events` catches the RuntimeError a peer leaving mid-send
raises, which is a disconnect by another route, and the remote agent no
longer raises out of the task when its subprocess died before it could
be written to — the read below reports that and ends the call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXQv6KNyyvY7Z1etYTUUAd
2026-08-31 07:52:33 +02:00
stroblmeandClaude Opus 5 9a5371d4c7 Keep a failed node's traceback on the run
The worker already sent it and the log panel already got it; the failure
outcome kept the one-line error and the node's stdout and dropped the
rest, so reading a failure back meant reproducing it under `run --local`.
It rides in the node's logs now — no schema change, and the API row, the
run detail page and `RunHandle.failures` carry it as they are.

`_record_node` keeps the tail of the log cap rather than the head, so a
chatty node cannot push the traceback past it, and `fluksio run` prints
what each node said when a run does not end ok.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXQv6KNyyvY7Z1etYTUUAd
2026-08-31 07:52:24 +02:00
stroblmeandClaude Opus 5 4ac3de38e2 Let a port be named after the function it feeds
The generated node body imported the function under its own name and then
gave `process` the ports as parameters, so a port named like its function
shadowed the import and the call became a value calling itself —
`TypeError: 'str' object is not callable`, with the downstream node's
missing arguments as the knock-on. The import is aliased now.

Only the two names the body needs itself, `settings` and `_impl`, are
refused at sync. Every shim's text moves once, so the next sync reports
every code-defined node updated and each cached node misses a single time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXQv6KNyyvY7Z1etYTUUAd
2026-08-31 07:52:16 +02:00
stroblmeandClaude Opus 5 c20f6a1b68 Add a website widget, so a page nobody modelled as a message can hang on a wall
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m24s
Playwright Tests / test-playwright (2, 2) (push) Failing after 14s
pre-commit / pre-commit (push) Failing after 2m34s
Test Backend / test-backend (push) Failing after 2m35s
Compose Smoke Test / test-compose (push) Failing after 14s
Playwright Tests / merge-reports (push) Failing after 2m21s
A tile that draws whatever an address serves: no binding, no flow, just an
iframe. Only http(s) loads — a `javascript:` src would run in the app's own
origin, and a dashboard is a document several people can edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KocbsBHWme1kfCHVhrgBnw
2026-08-30 18:48:52 +02:00
stroblmeandClaude Opus 5 302be52921 Let a jsonl export keep a record a value
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m30s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m34s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Failing after 2m49s
`--format jsonl` existed, but `_cell` ran json.dumps at row-build time,
before a format was chosen — so a nested value was a string by then and
jsonl only re-escaped it, leaving a consumer against csv's 128KB field
limit either way. Stringifying moved to the csv writer, so csv is
byte-identical and jsonl carries json. The runs TUI followed, or a record
would draw as a Python repr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:36:10 +02:00
stroblmeandClaude Opus 5 5818e5122a Guard locked through the desktop grid too
`mobile.spec.ts` covers the stacked editor, which draws through
`DashboardView` on both sides. Desktop `?edit=true` swaps in `GridLayout`
directly and never mounts `LockedProvider`, so the branch the stacked fix
was matched to had no guard of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:32:12 +02:00
stroblmeandClaude Opus 5 e4428efb8d Draw a node that is down as a troubled neuron
`brain_graph` read `self.issues` alone, which is what a build found — so a
node that loaded and then lost its device was a well neuron on Home, and
that is what someone comes to this view to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:25:14 +02:00
stroblmeandClaude Opus 5 8b65e1ecfd Give the frontend's check scripts something that runs them
Eight `*.check.ts` scripts existed and nothing ran any of them, so a
helper's arithmetic was only checked by hand. `make test-checks` runs
every one and `make test` now includes it — they need no stack, so they
sit between the backend suite and Playwright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:19:20 +02:00
stroblmeandClaude Opus 5 671452e5eb Let a Home tile lead where its figure does, and drop the logo nobody saw
`Tile` takes an optional link: Nodes and Flows running to /flows, Runs
running to /runs, Failures down to the list further down the same page.
Queue backlog and loop lag have no screen of their own and stay plain.

`Logo`'s responsive variant carried a second `<img>` for the collapsed
rail, but AppSidebar hides the whole logo when collapsed, so an ancestor
`display: none` meant it never rendered in any state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:18:01 +02:00
stroblmeandClaude Opus 5 33d3e71b20 Drop the influx clients nothing ever held
`__slots__` and `__init__` carried `_write_client` and `_query_client` as
lazy state no method read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:06:07 +02:00
stroblmeandClaude Opus 5 0c17f1fea9 Draw something for an icon or colour name nothing maps to
An unknown name indexed the map, got undefined and drew nothing — the same
as no icon at all, so a typo was invisible. It resolves to a placeholder
glyph and the default tint now, and warns once per bad name: both lookups
sit in a render, so a bound widget would otherwise warn per value.

An empty name still draws nothing, which is what "no icon" means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 16:29:46 +02:00
stroblmeandClaude Opus 5 7f78d541ab Let a widget's glow fade out instead of stopping at the tile's edge
`overflow: hidden` on the frame and its body cut a control's 22px
`--gl-glow` dead at the clip edge, and the gauge's own `<svg>` cut its
drop-shadow at the viewBox — the arc's top sits ~3.5 units from it.
`overflow: clip` with a margin wide enough for the blur (1.5rem on the
body, which already sits 1rem inside the frame; 0.5rem is all that leaves
the tile, which is the gutter).

That means the body no longer scrolls by default. `.dui-frame-scroll` was
already wired through from `scrolls()` into every look's Surfaces with no
CSS rule behind it, so the class is what opts markdown, agenda and
notification back into a real scroller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 16:16:06 +02:00
stroblme 4c4920bda8 Keep the lan overlay when the frontend is rebuilt
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m31s
Compose Smoke Test / test-compose (push) Failing after 10s
Playwright Tests / merge-reports (push) Failing after 2m22s
`rebuild-frontend` already took the domain off the running stack, for exactly
the right reason — `VITE_API_URL` is baked in at build time, so rebuilding
under a different one leaves the SPA calling an API that answers elsewhere. It
then dropped `compose.lan.yml` regardless of whether the stack had been started
with it, which is the same mistake with a worse blast radius: that overlay is
what publishes the host port and what builds with an *empty* VITE_API_URL, and
it is the only way a wall panel reaches the app at all, since a screen on the
LAN cannot resolve app.${DOMAIN}. Rebuilding without it unpublished the port
and baked in a name that device cannot resolve — the panel went dark for an
hour and it took a log to see why.

Recognised by the published host port, because that is the one thing only that
overlay adds. Same shape as the domain sniffing above it: what is running is
the authority, not what happens to be typed on the command line.
2026-08-30 15:07:15 +02:00
stroblme 67c35093e6 Add a player widget, and let a slider be drawn as a fader
The player is the one tile that both reads and publishes, so it has two
bindings: it shows a `record` describing what is playing — title, artist,
album, status, and position and duration in seconds — and publishes transport
words back to one `str` message (`toggle`, `next`, `prev`, `seek:<seconds>`).
Those are a streamer's own vocabulary rather than this app's, which is what
lets one tile drive whatever is on the other end.

The position counts forward in the browser between readings, so the bar moves
at one second while the device behind it is polled at whatever rate suits it;
every reading that arrives is taken as the truth and the count restarts there.
That is also why this is one record rather than five messages — a tile drawn
from five would redraw itself five times, and show a new title against the old
duration in between.

Being both is why `INPUT_WIDGETS` does not gain it: what that set means is "the
message this widget publishes is its only binding", which is exactly what a
player is not. Its reading is checked the usual way and its `target` separately.

The fader beside it needed nothing new. `ui/core` has had `orientation` on the
slider all along and all three looks draw it; only the widget never passed it,
so a volume control — the one thing reached for without looking, where up is
louder — could not be a column. Now it can, and the tile's height is the track.
2026-08-30 14:17:16 +02:00
stroblmeandClaude Opus 5 f370601aec Draw curves in the terminal, and stop waiting five seconds to hear
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 18m28s
Playwright Tests / test-playwright (2, 2) (push) Successful in 5m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 5m50s
`space` ticks runs on the serve dashboard's table and `enter` compares them:
one metric in braille, five distinct hues, and the table of what actually
differs under it. Both halves are routes that already existed — the browser's
own comparison endpoint for the curves, the runs export for the table, whose
input columns are filtered to the ones that vary.

The palette is hue rather than the web's lightness ramp on purpose: five steps
of one brand hue collapse to a single colour on a 16-colour tty.

The screen also subscribes to the engine's event bus over the same websocket a
browser uses, so a run that starts and finishes inside a tick is seen rather
than only recorded. `a` lists what a run left behind and fetches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014SmyLMSqcJQ8tUL2qLj21s
2026-08-30 12:51:08 +02:00
stroblmeandClaude Opus 5 45cc7504e1 Notify a phone that has this installation installed
A `webpush` alert channel, and the PWA it needs to arrive. The payload is
encrypted to the subscription (RFC 8291) and the request signed with this
installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush`
does the same in one call but brings `requests` and `aiohttp` with it, two
HTTP stacks beside httpx on a machine that may be a Raspberry Pi.

The manifest and the worker are hand-written rather than `vite-plugin-pwa`:
there is nothing worth precaching when the page carrying the credential is
`no-store`, so the worker handles `push` and `notificationclick` and nothing
else. `registration.scope` is the app's root in both places it runs, which is
why the payload carries no URL.

A run finishing in error is the first event worth waking someone for; `ok`
and `cancelled` describe to nothing, so a nightly batch that works stays
quiet. The events were already on the bus — only the filter changed.

`WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume
with the alerts beside it. Off it, a rebuild would silently stop every phone
being notified: the key they subscribed against would be gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EbeFPm6WNC3YD9vrqqT3a
2026-08-30 12:12:37 +02:00
stroblmeandClaude Opus 5 989d008d37 Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 15m39s
Playwright Tests / test-playwright (2, 2) (push) Successful in 10m17s
pre-commit / pre-commit (push) Failing after 2m28s
Test Backend / test-backend (push) Failing after 2m29s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Successful in 5m36s
The two sides both touched `submit_ready`'s readiness check, for unrelated
reasons, so the conflict is textual rather than semantic and both changes
stand:

- `831a537` completes a node that is not ready instead of passing over it,
  so a producer that can never run stops stranding its consumers.
- the audit branch has `_is_node_ready` return the values it read, so the
  node runs on them instead of asking state for the same keys again.

Merged as: read once, keep the values whether or not the answer is yes, and
take the not-ready branch from `831a537`. Its reasoning holds under the
merge — by the time readiness is consulted, `in_degree` is zero and every
in-wave producer has finished, so the answer cannot change later in the
wave.

Also fixes a fixture this branch added: the module-scoped row cleanup in
`tests/conftest.py` assumed a schema, and `tests/flow` overrides `db` with a
no-op because those tests need no database. It only showed when that
directory ran on its own.

746 tests green, and each directory green alone. Engine throughput is
unchanged by the merge (559 msg/s on the memory backend, against 639 before
it and 262 at the start of the audit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:22:12 +02:00
stroblmeandClaude Opus 5 1873787ee6 Compress what the browser downloads, and stop one tile taking the page
**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.

**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.

**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.

Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.

Render cost, two that showed up in the audit:

- `LogsPanel` was rendered unconditionally by the dock and decided inside
  itself whether to draw, so with the panel *shut* it still subscribed to
  the log store and re-filtered five hundred lines per line a flow
  published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
  number from it, so a flapping node re-rendered the component that draws
  Home's two uPlot charts — each of which rebuilds its series on every
  render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
  group. One pass, and each group offers at most twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:00:02 +02:00
stroblmeandClaude Opus 5 57eace2226 Bound what the API accepts, and close the holes the audit found
**SQLite is the database, and now says so.** `metric_minute` and every run
table are written with `sqlalchemy.dialects.sqlite.insert(...)
.on_conflict_do_update` and with `max(a, b)`, neither of which another
dialect has — so pointing `DATABASE_URL` at Postgres migrated cleanly,
served, logged in, and then lost every observability flush into the
collector's hold buffer and failed every run. It refuses at startup
instead. (The Postgres in the compose stack is Umami's; the engine's own
database has been a file beside the flows since 2026-08-21.)

**Every integer query parameter is bounded.** The caps were written as
`min(limit, 500)`, which a negative walks straight through — `?limit=-1`
compiles to `LIMIT -1` and SQLite returns the whole table. Ten signatures,
now `Query(ge=…, le=…)`. `hours=0` still means an hour, which
`_window_hours` was already deliberate about.

**Exports are capped at 10 000 runs** and say so with `X-Truncated`. The
filters bounded a sensible request and nothing bounded an unfiltered one,
which read every row into memory before a byte was streamed. `_series`
resolves cached curves in two queries rather than a `Run` lookup and a
`RunMetric` query per restored node — a comparison of twenty runs was
calling that twenty times over.

**`PUT /artifacts` has a size limit** (`MAX_ARTIFACT_BYTES`, 2 GiB, 0 to
disable), checked against `Content-Length` and again against the stream for
a chunked body, and its writes moved off the event loop.

**`/observability/timeseries` takes `since`/`until`**, the same window
`/runs` and `/events` take, capped at 2000 points — `hours=720&bucket_s=60`
was 43 200 of them in one array. It is also what a dragged chart needs to
re-fetch at its own resolution rather than magnifying buckets it has.

**Composite indexes** for the three list screens: `run(flow, created_at)`
and `(status, created_at)`, `flow_run(flow, started_at)`,
`engine_event(type, ts)`. Every index was single-column, so SQLite picked
one and sorted the rest by hand. Verified against a copy of a live database
(250k `flow_run` rows): the planner takes all four.

**Redis clients have socket timeouts.** A Redis that stops answering
without closing the connection hung the caller until the kernel gave up —
including `/utils/health/`, whose job is to notice.

**The panels file is written under one lock.** `save_panels` and
`unpair_panel` are both read-modify-write, and a save that read before an
unpair wrote put the old nonce back — silently un-revoking a screen that
had just been unpaired. The nonce carry-forward was written to make that
impossible; the gap between its read and its write is where it happened.

**Startup releases what it acquired.** Everything past `event_bus.bind`
registers how to close itself and the `finally` walks that list backwards;
a failure part-way through used to reach none of the shutdown steps and
leave the worker pool's subprocesses and every background task behind —
under `--reload`, once per bad edit. `modules.reconcile` moved into the
background: `uv` gets five minutes twice over, the healthcheck allows
eighty seconds, and the autoheal restarted the container before it could
finish installing.

`delete_run` takes SQLite's write lock up front (`core.db.writing`) rather
than upgrading a deferred transaction and losing to whichever flush
committed in between. `modules.sync` is serialised — two applies mutated
one venv at once. The proxied-call and stream dicts are bounded, and a
reused id cancels its predecessor instead of dropping the reference.

Security, found in passing and small enough to fix here:

- **`/secrets/` required only a signed-in user.** The names alone say what
  this installation talks to, and `PUT /{name}` takes any name, so any
  account could overwrite the credential a flow authenticates with.
  Superuser now — which `/search` already assumed and said so.
- **`POST /login/access-token` had no rate limit.** Argon2 is deliberately
  expensive and the route is unauthenticated and runs in the shared
  threadpool. Ten *failed* attempts per address per five minutes; a
  successful sign-in spends nothing.
- **a password reset link worked repeatedly for 48 hours.** The token now
  carries a digest of the password hash it was minted against, so it stops
  verifying once it has set one. No table of spent tokens needed.
- **enrolment accepted `http://`**, sending the claim code and then this
  installation's credential in clear. https, or a local address.
- the rate limiter read `request.client.host`, which behind Traefik is the
  proxy — so every per-address limit was one global bucket and one caller
  could lock out everyone. It reads the forwarded address, and its
  bucket table is capped rather than growing one key per address forever.
- SMTP has a timeout and sends after the response, so an unreachable mail
  host cannot pin a threadpool worker, and a reply's timing no longer says
  whether the address exists.

Test suite: engine-written rows are cleared between modules. A `FlowRun`
left `running` by one module turned up in another's query. Per-test
rollback is not available here — the module-scoped `client` runs the real
lifespan and its collector and run service write through sessions of their
own — so this bounds it where the writes come from. Three consecutive
green runs, orders randomised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 20:40:05 +02:00
stroblmeandClaude Opus 5 1069247085 Coalesce the event bus, and fix the socket that ended on a client frame
A three-node cascade publishes 13-16 events and each one crossed to the
event loop on its own. They are one `call_soon_threadsafe` now — whatever
was published between two turns of the loop goes over together — and every
subscriber still receives every event, oldest still dropped first when one
falls behind.

The socket end of the same path:

- **any frame from the client ended its stream.** `receive_text` was
  awaited once, outside the loop, so a keepalive — or anything else a
  client decided to say — satisfied it and was read as the client going
  away. It is recreated per iteration; only a disconnect ends the stream.
- events go out in one frame per wave (`{"type": "batch", "events": [...]}`,
  capped at 64), serialised once with orjson rather than per client with
  the stdlib's `json.dumps` through `send_json`. The client unpacks a batch
  and still understands single frames, so an older engine behind a newer
  bundle keeps working.
- authenticating and building the snapshot happen on a thread. Both were on
  the event loop: one is a database round trip, the other reads the whole
  of state, per connect and again per `dashboard_changed` per panel.

`Pipeline.values()` — what that snapshot is — no longer SCANs the whole
Redis namespace. It scanned five bookkeeping keys for every message to find
the messages; `RedisState` keeps a set of the names beside them and answers
from it. Maintained wherever a message is written, so a seeded value or a
deleted flow keeps it exact.

On the client, while in the same file:

- a `node_health` event invalidates the flow's detail. The canvas draws
  health from the server-derived `issues`, so a node going down or
  recovering only showed on mount, navigation or a rebuild. The store had
  a health map of its own that nothing ever read; it and `useNodeHealth`
  are gone rather than wired up, since the server's view is the one the
  canvas already uses.
- a reconnect invalidates the five key families this socket feeds instead
  of the entire cache, and the backoff is jittered. The usual reason a
  socket dropped is the engine restarting, so every tab and every wall
  panel refetched everything, together, at the moment it was least able to
  answer.
- a frame that will not parse costs the frame, not the connection. It was
  the one unguarded `JSON.parse` in the app; an exception there escaped to
  `window.onerror` and left whatever it had already applied behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 20:08:50 +02:00
stroblmeandClaude Opus 5 da528340a9 Cut the round trips a message costs the engine
Measured with `make bench-engine` against a real Redis: 103.6 -> 164.4
messages a second on a five-node chain (p50 latency 2125 -> 1171 ms) and
34.8 -> 63.2 on a fan-out of twenty. Against the memory backend, which is
what a pip install runs on, 262 -> 626.

The two that bought most of it:

- `StateBackend.record` puts a published value, its timestamp, its series
  and its version counter in one round trip. They were four calls building
  four pipelines, and a value crossing an edge pays them twice. A released
  rate-limit hold rides along instead of a DEL per port.
- the readiness check reads a node's inputs and hands them to the node,
  rather than reading the triggering ones to count them and having the node
  read the same keys again a moment later.

`apply_outputs` was a second copy of `_record_outputs` and is now the same
code plus the event that distinguishes it.

The rest, each small:

- `_derive` builds a node-by-id map and a `consumes` index, so dispatching
  an item and publishing a value stop scanning every node in the
  installation.
- `read_all` is memoised against the store revision — it sits on the
  publish path, so a dashboard slider was reading and validating every
  flow file per value. Same mechanism `_wiring` already uses.
- the `message_value` source block is built once per node instead of per
  emission.
- both timer threads ask the queue to promote only when something is
  actually due, which takes an idle engine from ~4 Redis round trips a
  second to one.
- the shared httpx client is bounded (32 connections, one retry); its
  default pool is 100 with no per-host cap, so one slow endpoint could
  take it and every other sender node with it.
- the MQTT and delay nodes no longer log a line per message at INFO.

Robustness, in the same pass:

- `MemoryWorkQueue._done` was a set nothing ever removed from — one entry
  per non-idempotent node per item, for the life of the process, in the
  default configuration. Capped, the way the Redis side expires its
  markers.
- a saturated engine can claim from the due lane past the cascade limit.
  The capacity gate sits in front of the claim, so the due lane's priority
  — decided inside it — did not apply while every slot was held: a motor's
  stop was not behind the long nodes, it was unread. Only after a slot has
  genuinely failed to free for half a second, and briefly, so the backlog
  is not starved in turn.
- `reclaim_stale` dispatches through that same gate. It could return sixty
  entries and push in-flight far past the limit the gate exists to hold.
- a flow's nodes are stopped together rather than one after another. Each
  gets `NODE_STOP_TIMEOUT`, so a flow whose broker was unreachable took
  five seconds per node — long enough to outlast `REBUILD_WAIT` and 503
  the deploy.
- the worker pool and the HTTP client are closed on a thread, not on the
  event loop, and a run closes the state backend it built (on Redis, a
  client and a connection pool per run).
- the five background tasks say something when they die. Each catches
  exceptions inside its loop, so one raised anywhere else left the engine
  serving with no metrics, no alerts or no artifact sweep, silently.

`tests/flow/test_round_trips.py` counts the state operations one message
costs — four, where it was about eleven — because none of the above would
fail a behavioural test if it were undone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 19:58:39 +02:00
stroblmeandClaude Opus 5 b4e6a0df11 Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m44s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m48s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
2026-08-29 16:42:07 +02:00
stroblmeandClaude Opus 5 8dbec0b579 Say what each dependency is for, and stop crediting fastapi for rich
The list read as a set of independent choices with two comments on it, one
of which was wrong: rich is not here "underneath fastapi's CLI" any more, it
is what textual is built on and what `fluksio status` imports directly.

Each line now says what imports it. The four that look redundant —
python-multipart, email-validator, jinja2, httpx — are also inside
`fastapi[standard]`, so they install nothing extra; they stay because the
code imports them directly and that should not rest on somebody else's
extra keeping its contents. email-validator in particular cannot move to
the server extra: `EmailStr` is on the user model, read on every start,
whether or not mail is ever sent.

Comments only. The resolved lock is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 16:30:47 +02:00
stroblmeandClaude Opus 5 68d2565054 Say at startup when a flow wants a card, and record one seed rather than two
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m46s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 1m57s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m19s
Three things the first pass left.

`serve` now names the flows asking for a GPU when the engine has none
declared. The placer already warned, but into the log, where a fresh install
that forgot `--gpus` does not read it — and the cost of missing it is GPU
nodes running concurrently, which is what the declaration exists to prevent.

The seed was the one field an export still had to coalesce: `--seed 1`
filled the run-level column and left `param.seed` blank, while a declared
seed filled the parameter and left the column blank. It is resolved like
every other input now, and the column carries the seed the run actually used
however it arrived — including when a parameter outranks the run's own,
where the column used to report the one that lost.

And the docs say plainly that declaring the card is what buys the worker
retirement: a node that imports jax without `resources={"gpus": 1}` never
gets CUDA_VISIBLE_DEVICES, so nothing marks its worker as one holding a card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 15:18:42 +02:00
stroblmeandClaude Opus 5 6111b90747 Document the dashboard, the artifacts command and what moved with them
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m56s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m46s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m23s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 1m18s
Covers this round of CLI work: the serve dashboard and its keys, the refusal
to start a second engine for one installation, --plain, --gpus and the
declared-not-detected card count, `fluksio artifacts`, the age column,
`fluksio.logger` inside a node, the server extra, per-directory study module
names, and an export whose columns no longer move with the selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:24:24 +02:00
stroblmeandClaude Opus 5 a4ea1dd0d6 Open a dashboard when serve is run at a terminal
`fluksio serve` printed a log stream and nothing else, so watching an engine
meant a second terminal running `status --watch`, and stopping or pairing it
meant a third. At a terminal it now opens a dashboard: the health and flow
overview `status` draws, the recent runs as a table, and the engine's own
output in a pane below — which is what the earlier decision against this
was protecting, and it is still all there.

The engine is a child process running `serve --plain`, not a thread, so it
outlives the dashboard: q leaves it running and says so, s and r stop and
restart it, c cancels the selected run and e pairs with a portal. An engine
already serving this directory is adopted rather than duplicated, and it can
be stopped from here only because the pidfile and the token together prove
it is this installation's.

`--plain` and no terminal both keep the old behaviour, which is what the
container and CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:21:13 +02:00
stroblmeandClaude Opus 5 c2312e6632 Name the engine already on the port instead of quietly taking the next one
`serve` moved off a port something else held and said so, which made
starting a second engine for one installation look like it had worked —
two engines on one SQLite file, which is not a supported shape. A serving
engine now records its pid beside its data, and a taken default port is
asked who it is: this directory's own engine is named and nothing is
started, another installation's Fluksio is named and the move happens
anyway, and anything else reads as it did before.

Whose engine it is is settled by the token, which is signed with this
directory's secret key — so a foreign one is only ever named, never
stopped from here. A directory with no credential yet asks
unauthenticated, and its answer is foreign, which is the side that stops
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:14:30 +02:00
stroblmeandClaude Opus 5 22c682e505 Put the deployment-only dependencies behind a server extra
A data-science environment installing fluksio waited for lxml, aiohttp and
the rest of a connector stack it has nothing to talk to. Outbound mail,
error reporting and the MQTT and InfluxDB clients moved to
`fluksio[server]`, which the image installs; each import is guarded and
names the extra. `tenacity` had no import site at all and is gone.

23 fewer packages and the compiled ones among them — a bare `pip install
fluksio` still serves, runs every python node, and registers the mqtt and
influxdb node types, which only need the library when one is actually
built. sentry-sdk arrives anyway underneath `fastapi[standard]`; what
changed there is that nothing of ours requires it.

The dev environment keeps every extra: the suite exercises the connectors
and strict mypy checks their call sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:06:50 +02:00
stroblmeandClaude Opus 5 743432205e Say when a run finished and when a failure happened
`fluksio status` listed recent runs and recent failures with no time on
them, so a red line said nothing about whether it was from a minute ago or
last week. Both carry an age now, spelled the way a duration is, and the
runs listing gained one too. The fields were already on the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:55:52 +02:00
stroblmeandClaude Opus 5 8bd30db016 Declare this machine's GPUs from serve, and refuse a bad limit as a flag
GPU count is not detected, so FLOW_GPUS was 0 on a fresh install and a node
asking for one was silently clamped to zero and ran concurrently with every
other. Setting the variable serialised them, but it was an environment
variable only — `serve` had --max-runs and --max-workers and no --gpus.
The clamp warning now names the flag when nothing here declares a card.

The same flags are written into the environment before the settings are
built, so a value they refused died in a pydantic import naming no flag.
They are checked where they are typed instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:54:33 +02:00
stroblmeandClaude Opus 5 53b49e5f68 Retire the GPU workers when a run that held a card finishes
A worker that has run a jax node keeps holding the GPU after the run: XLA
preallocates most of the VRAM at import and never releases it, so the next
process OOMs on preallocation while a warm idle worker sits on the card.
Pools are kept warm on purpose — a library reads its environment at import,
so a warm worker cannot be re-told — but the end of a run is a point where
the memory should go back, and the environments carrying a GPU assignment
are exactly the pools that ran on one. Idle ones go now, busy ones when
they return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:53:11 +02:00
stroblmeandClaude Opus 5 de87151c60 Name a discovered module for where it sits under the synced directory
`fluksio sync dev` refused a one-file-per-directory layout: s1..s4 each
holding `study.py` stopped the walk with "two files would both import as
'study'". The suggested `__init__.py` per study directory fixed discovery
and broke the bare `from study import ...` a test beside it does. A file is
now named for its path under the directory being synced — `dev/s1/study.py`
imports as `s1.study`, the directories between being namespace packages —
so nothing collides and nothing has to be added. A file at the root keeps
its bare name, and the generated shim follows: `import_root` walks one
directory per dotted segment and lands on the same root.

The collision message stays for the spelling that can still reach it: two
files named on the command line, each rooted at its own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:52:08 +02:00
stroblmeandClaude Opus 5 81649dbfca Refuse a __main__ node where its body is written, not at import
A module defining nodes could not be run directly: the decorator refused
`__main__` while the module body was still executing, so a `if __name__ ==
"__main__"` self-check beside the nodes was impossible and the checks had
to live in a separate pytest file. The refusal now fires where the
generated body is written — document() and shims(), both, since sync writes
the document first — and the decorator hands the function back as it always
did. Syncing a __main__-defined node is still refused, with the same words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:50:35 +02:00
stroblmeandClaude Opus 5 cfb3941cf3 Give a node's fluksio module the logger the SDK exports
Inside a worker `import fluksio` is the reporter, which had emit and the
artifact calls but no logger — so `fluksio.logger.info(...)`, written
against the SDK's top-level export, died with AttributeError after the
training it was reporting on had already succeeded. Its records go to the
same capture a print does; the handler resolves sys.stderr per record
because a call runs under redirect_stderr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:49:25 +02:00
stroblmeandClaude Opus 5 9286573f38 Fetch a run's artifacts from the CLI
`save_artifact` had no download counterpart: the run detail listed a run's
files and nothing in `fluksio --help` fetched one. `fluksio artifacts RUN`
lists them, `fluksio artifacts RUN NAME` writes one — under the name the
node saved it as, since the message name is chosen for the graph. The run
detail now carries that filename, which it held in the table and did not
report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:48:23 +02:00
stroblmeandClaude Opus 5 73cd37a608 Export every recorded input, not only the ones that vary
`export runs` dropped a `param.*` column whose value was constant across the
exported runs, so a downstream filter broke depending on which runs the
selection happened to hold. Every input the selection recorded is a column
now; `--params` still narrows it to a sweep's axis. The metrics default is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:47:00 +02:00
stroblmeandClaude Opus 5 c050a7a52c Record the inputs a run actually starts from, not only the ones passed
A run submitted without explicit inputs recorded `params = {}`: Port
initials filled the values at node level and were never written back, so an
exported row had a blank `param.*` cell and the runs listing could not tell
a run that took every default from one submitted with those same numbers.
Declared initials are now folded in at submit, explicit values winning, and
the run-level seed still wins over a declared one.

`params_digest` is computed over the resolved values, so it changes shape
once: a run recorded before this does not dedupe against a newer identical
submit, and its stage-cache entries miss once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:45:49 +02:00
stroblmeandClaude Opus 5 2d654fd943 Refuse an unknown run input by name before reading its value
`fluksio run --param lr=0.002` died with a bare JSONDecodeError: `--param`
is not a `run` flag, so it became an input named `param` whose value
`lr=0.002` was json-decoded. The name check ran after the coercion, so the
decode error always won. Names are now checked first, and a coercion error
is a SyncError naming the input and its type, the way `_ask_params` has
always done it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 13:44:43 +02:00
stroblmeandClaude Opus 5 7e506b26c0 Act on a run, and read Home top-down
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s
Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".

Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.

Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
2026-08-29 08:37:11 +02:00
stroblmeandClaude Opus 5 4215e057d1 Add a global search, and stop the sidebar logo squeezing
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s
`GET /api/v1/search/` hands the client one flat index of everything worth
jumping to — flows and the nodes inside them, dashboards and the widgets on
them, panels, secrets, modules, workers and alert channels — and cmdk matches
it in the browser, so results narrow while typing without a round trip per
keystroke. A node hit is the one thing no list endpoint could answer: it opens
its flow with that node in focus.

The panel is reached from **Search** above Documentation in the sidebar, or
⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two.

The panels dialog gains an address (`/dashboards?panels`) so a panel hit has
somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width
animates while the logo is already back, and a flex item short of room is
squeezed rather than clipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
2026-08-28 22:19:08 +02:00
stroblmeandClaude Opus 5 971bd430c7 Count the runs in flight on Home, where an installation has any
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m13s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m50s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Successful in 2m43s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m16s
The tile only exists on an installation that has run something, which is the
same "nothing has been run yet" the Runs screen goes by. The count comes from
/runs/overview, already grouped by status in the database and already polled by
the Runs screen, so Home shares its cache entry rather than paging rows or
growing a second endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 21:20:16 +02:00
stroblmeandClaude Opus 5 21caa354e7 Stop two docstrings describing a schema that is gone
Pages and sections left in 7ff29ca. DashboardView still promised SectionDef
stayed and that the editor wrote back into the first section; the mosaic
still said "the first page's widgets". Comments only — the code was right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 20:41:29 +02:00
stroblmeandClaude Opus 5 7efa75e242 Refuse a zero concurrency limit instead of reading it as the default
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m16s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m48s
pre-commit / pre-commit (push) Failing after 2m32s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 1m9s
FLOW_MAX_WORKERS, FLOW_MAX_CASCADES and FLOW_MAX_RUNS are all pool sizes,
so 0 says neither "none" nor "unlimited" — it is a pool that cannot be
built. They are PositiveInt now, so a 0 fails at startup naming the
setting rather than being swallowed by `max_cascades or MAX_CASCADES`.
The consuming fallbacks take only None as "nobody said": explicit
`is None` in the executor, and no clamp on RunService.parallel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 20:00:08 +02:00
stroblmeandClaude Opus 5 1feaa1f6cb Keep the lock off the stacked editor while a dashboard is arranged
The phone path draws through DashboardView in both modes, so a locked
dashboard came out with dead controls while it was being arranged — the
canvas path hands editing to its own grid and never mounts the lock.
DashboardView takes `editing` and skips LockedProvider for it; reading a
locked dashboard stacked still locks. Guarded in mobile.spec.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 19:27:55 +02:00
stroblme 9d11e01a48 Point the range picker at the shared segmented control
RangePicker carried a byte-for-byte copy of ui/segmented.tsx's markup; it
now renders that component instead.

The dashboard's own segmented widget stays where it is: it is not a copy
but a look-aware control, drawn per look in
Dashboard/ui/{fluksio,glass,material}/Controls.tsx over the shared
useSegmented behaviour. Note that in segmented.tsx in place of the stale
size-prop plan.
2026-08-28 18:49:29 +02:00
stroblmeandClaude Opus 5 831a537980 Stop a quiet producer vetoing a noisy one in the same wave
Both boilers on the house had been unable to switch on since the Node-RED
transition, and the reason was here rather than in their logic: the command
reached `boiler.water_boiler` and stopped, because `dmx.switches` never ran.

A wave orders nodes by a dependency count, and two things decremented that
count only on success:

- a node that published nothing — rate limited, unchanged, or failed — never
  freed its consumers. `dmx.switches` reads both boilers through `rbe` nodes,
  so the kitchen one being unchanged, which it is nearly always, held the main
  one's command back. The encoder ran about four times an hour, and only when
  the lights happened to change in the same wave.
- a node that could not run at all never freed them either, permanently.
  `plugs.pump_run` waits on a watering pulse that only exists at 02:00, so
  every wave it appeared in took its consumers out with it.

Freeing a consumer is not the same as running it: `untouched` already refuses
to run anything whose inputs nothing refreshed, and that is the accurate test.
The dependency count is ordering, not permission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
2026-08-28 16:53:49 +02:00
stroblmeandClaude Opus 5 d436597db9 Announce the pending screen once, not twice
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m41s
pre-commit / pre-commit (push) Failing after 2m29s
Test Backend / test-backend (push) Successful in 2m33s
Compose Smoke Test / test-compose (push) Successful in 37s
Playwright Tests / merge-reports (push) Successful in 1m8s
The loader carries its own role="status" and label, so the <output> around
it was a second live region saying the same word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 15:38:25 +02:00
stroblmeandClaude Opus 5 498c2eb221 Use the brand loading mark instead of lucide's spinner
Copies index/frontend's FluksioLoader component and its
`.fluksio-loader-line` rule into the app (the shadcn duplication model, so
the CSS travels with the component) and swaps every Loader2 pending state
over: the router's pending screen, the flow dock's Run and Publish, the
dashboard editor's Publish, the overview toolbar's Publish-all and
LoadingButton. One loading mark across both shells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 15:36:54 +02:00
stroblme 565d6d2433 Docs: warn about the half-filled bucket, and say which node schedules
Say on the querying-chart page that a flow doing its own binning has to drop
the newest bucket, as the observability endpoints already do, and make plain
that `inject` is the scheduler while `trigger` is a debounce and hold.
2026-08-28 14:55:06 +02:00
stroblmeandClaude Opus 5 304c90ea76 Check that a thinned tick row still ends where the range does
fitMarks picks a stride that divides the interval count, so the last label
lands on the end rather than short of it. Five marks cannot reach that guard
— find() settles on a divisor first — so the check carries a six-mark case
that does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 14:23:28 +02:00
stroblmeandClaude Opus 5 42048881f7 Hold the tile title still in edit mode, and thin crowded slider ticks
The grip's `-m-1 p-1` pair cancelled on three sides only: `.dui-frame-head`
sets `margin-top: -0.5rem` unlayered in core.css, which beats a Tailwind
utility, so the padding above went uncancelled and the title dropped 4px the
moment the editor opened. The grip's geometry moves into core.css beside the
head's own half step, where one declaration cancels all four sides.

Slider tick labels were placed in percent with nothing measured, so five
four-character labels crowded on a tile narrower than the default four
columns. A ResizeObserver on the slider reports its width and every Nth label
is kept, N a divisor of the interval count so the first and last stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 14:20:02 +02:00
stroblmeandClaude Opus 5 841209a630 Tell mypy what the closure already knows about placer and pool
Both calls sit in a closure where a narrowing of `Placer | None` and
`PythonWorkerPool | None` will not carry across the function boundary. The
`remote.run_on` line below them already carried the same ignore; these two
close out `make lint-backend`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 13:40:16 +02:00
stroblmeandClaude Opus 5 45348d2e0a Check the colour disc's gesture: one publish, on release
The wheel's arithmetic is checked in color.check.ts; nothing checked what a
drag across it sends. A new Playwright spec drags from the centre out to
three o'clock, counts the panel's publishes off the wire, and holds the disc
to one message at the release rather than one per pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 13:37:55 +02:00
stroblmeandClaude Opus 5 7600aaf7ea Guard what a dashboard save does not change
`PUT /dashboards/{name}` had no backend test: three cover what it promises —
a first draft for a name nobody has used (200, not the 404 that came off in
4a2337f), an update that keeps everything the edit did not name, and a save
based on a version someone moved past.

The Playwright half is the same property through the editor. Pages and
sections are gone since 7ff29ca, so what the editor never draws — a widget's
md/sm placements and the dashboard-wide settings — is what a save has to
carry, and dragging one widget is what the spec makes it carry it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 12:59:08 +02:00
stroblmeandClaude Opus 5 70e542ec3c Surface a failing connector poll as node health and a flow issue
The poll loop remembered what it read rather than what it published, so a
value the node could not publish counted as said: the next poll skipped it,
succeeded, and health went back to ok with the port still dark. Remember it
only after inject returns, and report ok last.

A node reporting itself down is now derived into its flow's issues on read
and counted on the health summary, so the canvas marks it and Home says so.
Being down does not stop the flow, and the issue clears by itself when the
node reports well again. The repeating poll warning is logged once per
outage rather than once per tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 12:22:55 +02:00
stroblmeandClaude Opus 5 f5ea960e24 Let a connector's teardown cancellation through too
ConnectorNode.stop cancelled its poll task and then caught CancelledError
around the await — the fourth site of the trap 93e4527 closed elsewhere,
swallowing a cancellation aimed at whoever asked for the teardown. It now
calls the shared Node._cancel_task, which keeps retrieving whatever the
loop raised on its way out, as the old `except (CancelledError, Exception)`
did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 11:56:19 +02:00
stroblmeandClaude Opus 5 f00045d6b6 Give the Influx and MQTT nodes their two missing knobs
The Influx client was built with no timeout, so every query and write fell
through to influxdb-client's own 10 s default — invisible to a flow and
unchangeable. The param is in seconds like its peers; the client counts in
milliseconds, so the call sites convert.

The publisher backlog was a module constant, read once at import. It is the
depth at which the oldest payload is dropped and the node goes degraded, and
a node that bursts wants more than one that trickles, so it moves to Params
and is read where the queue is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 11:52:43 +02:00
stroblme 192999f178 Bound MQTT broker operations with a per-node timeout
Without one, aiomqtt's disconnect acknowledgement has no deadline, so a
subscriber cancelled while its socket is dead never finishes unwinding and
teardown abandons the task. The knob is per node because brokers differ.
2026-08-28 11:46:40 +02:00
stroblme 93e45274d0 Let a teardown's cancellation through, and reap the workers it leaves
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m12s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m16s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 1m15s
Node stop paths cancelled their background task and then caught
CancelledError around the await, which swallows a cancellation aimed at
the caller — the trap Supervisor._cancel already documents. One shared
Node._cancel_task now waits the way the supervisor does; mqtt's publisher
and subscription and delay's cron call it.

The api container also collected zombie python workers: orphaned when
--reload replaces the process holding their handle, they reparent onto a
PID 1 that reaps nothing but its own. `init: true` on the backend service.
2026-08-28 11:27:01 +02:00
stroblmeandClaude Opus 5 7e8fd1182e Put the dashboard panels away on a click off the widgets
Mirrors the flow editor's onPaneClick: clicking the canvas margin or the
empty surface closes the widget settings and the dashboard panel. Widget
frames, grid resize handles and menus portalled out of the canvas keep it
open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 11:19:30 +02:00
stroblme e18a669aa0 Write a run's elapsed time once, in the duration formatter's units
The runs table drew when a run started and how long it took in two
notations ('56m ago | 55.5 min'), and for a run still going those are the
same reading twice. `dur` now steps past the minute into hours and days,
the ago reading is derived from it, and a running run writes only the
elapsed one.
2026-08-28 11:02:59 +02:00
stroblmeandClaude Opus 5 dfa0d44589 Back to development: 0.1.4+dev
A tag is the only thing that is a version. Between two of them the tree is a
build nobody released, and until now it went on claiming to *be* the release
it came after — which is how 0.1.4 came to name both the build before the
per-node digest and the build after, with the mismatch messages unable to tell
them apart.

`+dev` is a local version: it says "0.1.4, plus changes", it does not have to
guess what the next release will be numbered, and PyPI refuses one, so a dev
build cannot be published by accident. `scripts/release.sh` in the workspace
root writes it after each tag; this is that commit, by hand, for the release
that predates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-28 11:01:25 +02:00
stroblmeandClaude Opus 5 8f1e685526 Let a quarantine expire, and stop two tasks spending one budget
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m1s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m44s
pre-commit / pre-commit (push) Failing after 2m50s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m9s
A house's inverter broker dropped at 04:27 and the power flow was quarantined
20 seconds later. Quarantine was terminal — the supervised task returned and
only a publish or an engine restart could bring it back — so five hours of
power and battery readings are missing, and what ended it was an unrelated
`git pull` restarting uvicorn.

Two changes, both in that path:

- the failure budget is per task, not per flow. `power` runs an MQTT subscriber
  and a Victron keepalive publisher against the same broker; they died together
  and spent one shared budget in 41s, giving up before the 60s backoff step was
  ever reached.
- quarantine is now a rest. The task sits out 5min, then 15, then an hour, and
  each time gets its budget back and tries again, so a broker that comes back
  is picked up without anyone watching. `quarantined` reads from whichever
  tasks are currently resting.

The alert for it says when it will try again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
2026-08-28 10:35:13 +02:00
stroblmeandClaude Opus 5 e40bbb9245 Regenerate the frontend client for 0.1.4
Mechanical: the checked-in client was still 0.1.0's, so it was missing
HealthSummary.version and NodeDef's code_files/code_digest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
2026-08-28 10:35:00 +02:00
stroblmeandClaude Opus 5 9387755e59 Find a study wherever it is, and start on a port that is free
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m10s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m3s
Test Backend / test-backend (push) Successful in 2m32s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m9s
Three things the one-folder-per-study layout ran into.

**Discovery walks down.** A plain directory is now walked all the way, so
`fluksio sync dev` finds `dev/s1_baseline/study.py` and naming each study is
no longer the price of the layout. Hidden directories, `__pycache__`,
`node_modules` and virtualenvs are left alone, and a package is taken whole.

Two files that would import under one module name are refused, naming both:
Python keeps one module per name, so the second would silently *be* the first
— and a node's generated body imports by that name, so a worker would run the
wrong study's code. The message says the fix, which is an `__init__.py` per
study directory. A module that raises while importing is now a sentence
naming the file rather than an importlib traceback.

**`run` and `sweep` sync downwards too**, so the flow is found from the
repository root without the sync-then-`--no-sync` two-step. A study that will
not import is a warning rather than a stopped run, since a walk meets every
study and a half-finished one two directories away is not this run's problem.
The upload was already a no-op for a flow nothing changed in, so what the walk
costs is import time — `--sync PATH` narrows it, and skipping unchanged
subtrees would need a cache keyed on file state that is deliberately not here.

**`serve` moves off a busy default port** — 8001, 8002, up to twenty — says
which it took, and writes that one into `client.json`. A port given with
`--port` still fails when it is taken, because naming one is asking for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-28 08:59:14 +02:00
stroblme 9dc1fe0a84 Release v0.1.4
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m8s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m29s
pre-commit / pre-commit (push) Failing after 2m19s
Test Backend / test-backend (push) Successful in 3m9s
Compose Smoke Test / test-compose (push) Successful in 43s
Publish / publish (push) Successful in 54s
Playwright Tests / merge-reports (push) Successful in 1m12s
2026-08-27 23:16:19 +02:00
stroblmeandClaude Opus 5 f8a7dfb101 A sync that changed nothing says so again
Docs / docs (push) Successful in 32s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m58s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m9s
pre-commit / pre-commit (push) Failing after 2m23s
Test Backend / test-backend (push) Successful in 3m17s
Playwright Tests / merge-reports (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 28s
Comparing the per-node digest against an engine that does not record one is
comparing against nothing, and reporting every node as changed on every sync
for ever — which is what a client newer than its engine did, since `NodeDef`
drops fields it has never heard of. A node is named now only when both sides
carry a digest, so a no-op sync is `unchanged` again and the signal one syncs
for is back.

That silence had also been the only sign of the mismatch, so sync now names
it: one line saying the engine stored no record of what a node's code reaches,
with both versions in it and what to run. Bumped to 0.1.6 — the digest changed
the stored document's shape, and a version that does not move makes two
different engines indistinguishable, which is the thing it was made
load-bearing for a day ago.

`— draft` was printed whenever there was simply nothing to publish, which
reads as work left unfinished. It is said only when a draft is genuinely
there, and `— published` when one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-27 22:58:31 +02:00
stroblmeandClaude Opus 5 91ef2bbe9a Key a node on the code it reaches, not on the repository around it
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m13s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m52s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Successful in 2m30s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 1m53s
`sync` follows each node function's imports through the project's own modules
— stopping at the standard library, at anything installed, and at Fluksio
itself, whose checkout would otherwise be most of every digest — and records
the file list with what it hashed to. The engine hashes those files again when
the run is claimed, so the fingerprint is live rather than a snapshot, and
falls back to what sync recorded when it cannot see them: a remote worker's
runs used to share one empty digest, and therefore one key.

Three things follow. Editing a helper a node calls into re-runs that node, as
before. Editing something the node never reaches no longer re-runs anything —
a notebook two directories away was invalidating every arm. And
`Run.code_digest` is now the hash of its nodes' digests, so it is neither
looser nor tighter than "the code behind these numbers", which is what makes
it worth joining an exported table on.

`sync` says so too: it compares the per-node digest against the stored one, so
a helper edit prints `train: updated (flow, fit)` instead of `unchanged`. The
digest is read when the document is built rather than when the flow is
declared, so a second `sync()` in one process sees an edit between them.

Also: `fluksio runs` shows only the inputs that differ from what the flow
declares, fitted to the terminal, so a flow taking a few kB of json no longer
wraps every line.

Every existing cache entry misses once — the fingerprint changed shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-27 22:35:23 +02:00
stroblmeandClaude Opus 5 4479eeb726 Follow a record into its fields, name the metrics, name the version
Docs / docs (push) Successful in 38s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m56s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m7s
pre-commit / pre-commit (push) Failing after 2m17s
Test Backend / test-backend (push) Successful in 2m54s
Compose Smoke Test / test-compose (push) Successful in 44s
Playwright Tests / merge-reports (push) Successful in 1m17s
Three things the first export pass got wrong for a real study.

**Dotted paths.** A node returns a record, not a scalar — the numbers arrive
inside `final_metrics` — so `--metrics final_metrics.train_loss` yielded an
empty column and `--metrics final_metrics` yielded the whole record in one
cell. Both sides of the wide table now take dotted paths, and the defaults
reach the same depth: every number a result carries is a column named by its
path, and inputs are compared leaf by leaf, so two configurations differing in
one field give that field as the axis rather than two blobs that are merely
not equal. Lists stay whole — a curve belongs in the long table.

**`--list`.** Metric names are flow-qualified, so `--name train_loss` matched
nothing and said only that. `fluksio export metrics --list` prints the names
the selection carries, and an empty export made with `--name` points at it.

**A version to compare.** The CLI ships ahead of the engine and a stale one
answered a flat 404 with nothing anywhere in the API to tell how old it was.
The engine reports `version` on `/observability/summary`, `fluksio status`
prints it, and a 404 from export now names both versions — or says "older"
when the field itself predates the engine. Bumped to 0.1.5, which is what
makes the number worth reading.

Also formats `flow/metrics.py`, which had been committed unformatted and was
the last `ruff format --check` failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-27 20:42:19 +02:00
stroblmeandClaude Opus 5 51464941ac Export runs and their curves as tables an analysis reads
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m11s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m17s
pre-commit / pre-commit (push) Failing after 2m44s
Test Backend / test-backend (push) Successful in 3m0s
Compose Smoke Test / test-compose (push) Successful in 41s
Playwright Tests / merge-reports (push) Successful in 8m14s
`fluksio export metrics` is the long table — a row per run, metric and step —
and `fluksio export runs` the wide one, a row per run with the inputs that
*vary* across the selection as columns beside its final numbers, status,
duration and the commit and digest of the code it ran. Both carry the run id
on every row, which is the join back to the run page and what makes an
exported file auditable. `Client.export_metrics`/`export_runs` answer the same
rows to a notebook.

The engine streams csv or jsonl from two routes declared above `/{run_id}`;
parquet is a client-side conversion behind the new `fluksio[parquet]` extra,
so nobody pays for pyarrow who does not want dtypes kept. The long export
reads each run through `_series`, so a cached node's curve comes with it, and
`--stride` thins each series rather than the concatenation of all of them.

Two things they needed on the way: `GET /runs` takes `?since=` and `?before=`,
so a long history pages by the last row's own timestamp instead of an offset
that shifts under it; and a read that reaches no engine now says so in half a
second rather than seven, because `runs`, `flavors`, `export` and an unwatched
`status` pass `retries=0`. Everything that submits keeps them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
2026-08-27 17:43:30 +02:00
stroblmeandClaude Opus 5 96cf1fc0c8 Accept the hub's shape of a panel token
Docs / docs (push) Successful in 31s
Playwright Tests / test-playwright (1, 2) (push) Successful in 5m15s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m16s
pre-commit / pre-commit (push) Failing after 2m19s
Test Backend / test-backend (push) Successful in 2m51s
Compose Smoke Test / test-compose (push) Successful in 39s
Playwright Tests / merge-reports (push) Successful in 1m12s
A screen paired through the portal asked the household for a fresh code on
every boot: it looks for the panel in a `panel` claim, and the hub — which
knows nothing of this installation's users — puts it in `sub` and says so with
`scope`. A local token carries no scope, so the two cannot be confused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:48 +02:00
stroblmeandClaude Opus 5 f855170562 A documentation link in the sidebar, and the footer points home
Documentation above Settings, opening in a new tab — the docs are read beside
the work, not instead of it. The footer's "Fluksio - <year>" links to
fluksio.com; the sidebar logo deliberately keeps going Home, since navigating a
running installation away to a marketing page is not what pressing it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:39 +02:00
stroblmeandClaude Opus 5 651399b078 The dashboard settings button toggles
It only ever opened, so the way out of the settings panel was somewhere else.
Pressing it again closes it, and it carries the pressed state the other dock
toggles do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:39 +02:00
stroblmeandClaude Opus 5 f6349b83db Place brain neurons on whole pixels
The force simulation settles on arbitrary floats, and a neuron on a half pixel
has its ring, its gap and its disc rounded one edge at a time — which on the
smallest ones is a visibly off-centre ring. The size is already whole, so the
position was the last fractional term.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:39 +02:00
stroblmeandClaude Opus 5 721b89f5eb Un-nest the dock panels: crisp in Firefox, and side by side
The console panel lived inside the bar, so its centring translate and its
backdrop filter were both nested inside the bar's own — a half-pixel offset
under a blurred layer, which is what Gecko was rasterising the text through.
Panels and bar are siblings in one column now.

That column is also what the issues list needed: it was a popover anchored to
its trigger, opaque where the console is frosted and landing on top of it when
both were open. It is the same panel now, the same width, in the same row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:26 +02:00
stroblmeandClaude Opus 5 fd69654857 Slider value beside the track, the way a bar row reads
It was stacked above the slider, spending a whole row on a number that fits
next to one — and on a tile with ticks that made three rows for one control.
Held to the control's own height so it sits on the track's midline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:26 +02:00
stroblmeandClaude Opus 5 16abbdb834 Tile head at the radius centre, and touch tightens text not the frame
Two geometry fixes in one file. The title sat below the corner radius rather
than reading from its centre; pulled up half a step, the body gets that half
step back. And touch grew the padding as well as the controls, so turning a
panel touch-friendly asked for every tile to be resized — it holds the padding
now and closes up the rows instead.

Note: in edit mode the grip's own padding puts the title back down by 4px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:14 +02:00
stroblmeandClaude Opus 5 530dd3587c Draw the error series terracotta on Home
Failures were slot 1 of a ramp that carries identity by lightness alone, so at
a 2px stroke the line that matters read as one picture with avg and lag. It
takes --brand-secondary now — the same terracotta the brain graph above it
already spends on a flow that cannot run. A palette entry naming a token is
resolved off the chart's element, since a canvas cannot parse var().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:14 +02:00
stroblmeandClaude Opus 5 5da5606f79 Settings as panels, the way the portal does it
Four tabs over three cards was navigation for its own sake, and the two shells
disagreed about what a settings screen looks like. It is one grid of cards now,
matching the portal: the account card carries changing a password and deleting
the account in its footer, appearance is a card with one row, and remote access
— an operator's concern, not a personal preference — is a card of its own for a
superuser.

SettingRow, alert-dialog and UserAvatar come across from the portal, so an
account renders the same face in both shells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:04:42 +02:00
stroblmeandClaude Opus 5 6d09500a73 Fold the connection banner into the notification stack
The offline pill was drawn inside a `flex h-0` container with align-items at
its default stretch, so the pill was stretched to a zero-height box while still
carrying its padding — the "box too small" it was reported as. It is a
persistent notification now, keyed on one id so a poll that keeps confirming
"still offline" replaces the card instead of restacking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:00:56 +02:00
stroblmeandClaude Opus 5 44c8825927 Replace sonner toasts with one top-centre notification stack
The app said things in two places: sonner's bottom-right toasts and the
custom top-centre chrome. This is the second of those, generalised — a
module-level store anyone can call, drawn as a stack of frosted cards that go
on their own unless raised as persistent, with room for buttons and for a
message longer than a line.

useCustomToast keeps its signatures, so the forty-odd call sites behind it and
handleError are untouched. The component and its store are byte-identical with
the portal's copy and checked by `make design-check`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:00:47 +02:00
stroblmeandClaude Opus 5 121cb2e8f0 Record batch runs beside cascades so Home lists them
A batch run opens no cascade, and FlowRun was written only from
cascade_started — so `fluksio run` showed on /runs and in `fluksio status` and
was simply absent from Home. The collector now folds the run_started and
run_finished events RunService already published. Such a record is exempt from
the staleness sweep in both places: a training step of an hour is a normal one,
and only run_finished ends it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:54:15 +02:00
stroblmeandClaude Opus 5 6bc71ddaf3 Record flags in history and draw them as steps
A bool was excluded from the history as "not a measurement", so a true/false
port had no curve in the node panel and none on an edge — only the word. It is
recorded as 0/1 now and drawn as steps, since a bezier through two states
slopes through readings that never happened. The axis is pinned to 0..1, so a
flag that was never on sits at the floor rather than mid-box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:54:06 +02:00
stroblmeandClaude Opus 5 168aadb217 Hold a chart's last bin back until all of its data arrived
The newest metric bucket is upserted every flush while its minute runs, and
both rollup endpoints summed it in — so every curve on Home ended on a fall
that was only the clock. The timeseries now stops at the last closed bin, and
the flow rollups' window ends on the last closed minute, so all sixty slices
are whole ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:53:55 +02:00
stroblmeandClaude Opus 5 37a7df9d24 Let a sweep run more than four at a time, and name the run a failure was in
Docs / docs (push) Successful in 29s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m33s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m3s
pre-commit / pre-commit (push) Failing after 3m9s
Test Backend / test-backend (push) Successful in 2m46s
Compose Smoke Test / test-compose (push) Successful in 39s
Playwright Tests / merge-reports (push) Successful in 1m47s
Concurrent runs sat at 4 whatever FLOW_MAX_CASCADES said: that setting bounds
cascades, and the run drivers read a hardcoded MAX_PARALLEL nobody could reach.
FLOW_MAX_RUNS is the knob they read now, --max-runs/--max-cascades/--max-workers
are the same three as flags on serve, and the engine says which numbers it
started with — which is the only way to tell that a settings file was read.

Events keep the run they happened in. The payload always carried it and the
persist path dropped it, so reading one run's failures meant filtering the
engine-wide list; a batch run's id reaches those events now too, since a run
has no journaled item to name itself by.

Also: a provisioner's 0 means "no deadline" rather than "cancel on the next
reconcile", and a command that reaches no engine says how to start one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sbYeYaVgYQqm1sbx7wPdL
2026-08-27 14:17:51 +02:00
stroblmeandClaude Opus 5 c3675688c8 Wait for a deadline instead of polling for one
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m23s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m0s
pre-commit / pre-commit (push) Failing after 4m31s
Test Backend / test-backend (push) Successful in 2m55s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m11s
The timer thread promoted due work on a fixed one-second tick, so every
delayed item was 0-1000ms late whatever the load — measured on the house
at 705ms mean on a rollershutter stop, which is 2-4% of a 26-second
travel and accumulates in the position the motor node believes it is at.
It now sleeps to the soonest deadline and is woken when a nearer one is
scheduled, which measures 0.9ms end to end through Redis.

A promoted timer also went to the back of the queue. It goes into a due
lane of its own that `claim` reads first, so work that has waited out a
deadline is not held up by work that is merely queued.

Beside it, in the same code: seeding a message now bumps its version, so
a re-put flow's synchronous nodes no longer wait forever on a value that
is sitting in state; the consumer group drops the consumers of engines
that are gone (138 had accumulated on this installation); and the cast
that closes the long-standing `xclaim` mypy error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:51:58 +02:00
stroblmeandClaude Opus 5 e2f97d36e4 Document the scheduler
Docs / docs (push) Successful in 32s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m56s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m0s
pre-commit / pre-commit (push) Failing after 3m8s
Test Backend / test-backend (push) Successful in 2m45s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 09:36:39 +02:00
stroblmeandClaude Opus 5 7d87dfffdd Put resources where somebody would look for them
`resources` was reachable from the SDK and the API and nowhere else, and
`node_queued` was published to nothing at all — so a node waiting for a machine
looked exactly like a node that had hung, which is the failure the event was
added for.

The node panel gets a Resources section: a size by name, the numbers for a node
that wants its own, and how long it is expected to take. Picking a flavor drops
the numbers, because saying both is two answers and the engine refuses it.

A queued node draws a neutral dot rather than one of the three status colours —
it is not running, it did not go well, and it did not go wrong; it is idle with
a reason, which the tooltip gives.

And a Workers screen, which is the first UI for any of this: every machine the
engine can reach with what is free of each, what is attached, what a cluster
has been asked for, and the sizes, editable. `fluksio status` grew a line of
the same.

The demo's training node already preferred a GPU worker, which was exactly the
declaration that used to be dropped, so it now says how much of that machine it
takes as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 09:32:08 +02:00