Commit Graph
100 Commits
Author SHA1 Message Date
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 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
stroblmeandClaude Opus 5 40f8ad378d Ask a cluster for a machine when nothing here will do
Slurm is not a machine that attaches and stays; it is a queue somebody else
owns. So nothing here submits a node to it. It submits a job whose payload is an
ordinary worker dialling back in, and everything downstream — the protocol, the
artifacts, cancellation, the books — already worked and did not have to learn
what Slurm is.

The alternative, which Covalent takes, is to stage a serialized call and a
runner onto the login node, poll squeue and copy the result back: a second way
of running a node beside the one that exists. The cost of not doing that is one
assumption, that a compute node can open a connection outward. Where that is
false, _payload is the single method a staged variant would replace.

Clusters are configured in provisioners.json beside the alerts, since this is
infrastructure an operator writes rather than anything a flow says. The script
is generated with the system ssh and no new dependency, and prerun owns the
environment — deliberately no pip install, because what is on a cluster is
somebody's decision.

One outstanding request per profile, cancelled if it never attaches and on the
way out. Nothing autoscales.

The run gate needed the same hook: a run held before it starts never reaches the
placer's own wait, so it would have queued forever on a machine nothing had
asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 09:09:44 +02:00
stroblmeandClaude Opus 5 a82f88cf0a Name the sizes a node can ask for
Raw cpus and gpus are a property of the machines an installation has, so a node
written against a cluster quietly stops meaning anything when the cluster is
replaced. A node says "gpu-small" instead, and what that is stored here —
editable, and read again every time the node is built, so changing the flavor
changes what the next run gets.

Memory joins the schema properly (`ram`, in MB, accepting "2G"), along with
`duration_s` for how long a node is expected to take. That one is recorded and
shown and nothing else yet: a statement for whoever is planning around the node,
not a limit — the limit is still `timeout`.

A flavor and a number for the same thing is refused, compared by value so an
editor writing the whole object back with its defaults still round-trips. A name
nothing stores is refused at the save, which covers the canvas and `fluksio
sync` at once, and deleting one a node still asks for says which node.

Four sizes are seeded on an installation that has none, and never re-seeded:
re-adding one somebody deliberately removed is an argument nobody wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 08:59:10 +02:00
stroblmeandClaude Opus 5 6ff56533f5 Schedule a node across every machine, not just this one
The engine answered "where does this node run" twice, in two ways that could
not see each other: a device sent it to a worker carrying that label, and
resources were counted against the engine's own cores. Declaring both meant the
second answer won and nothing was counted at all — which the data-science
getting-started page and the worked example both do.

One question now, in flow/placement.py: of every machine attached, which could
grant what this node asked for, and which of those has it free. The books move
onto each machine — one accountant per worker, built from the inventory it
reported — and the waiting moves above them, where one condition variable can
be woken by a release anywhere or by a worker attaching. Locks go one way:
placer, then a machine's books, never back.

So a node asking for a card now finds the box that has one, rather than being
clamped down to none and run here. When nothing can grant the ask at all it is
still cut down and run — a flow written on a cluster has to work on a laptop —
but the ceiling is one real machine now, since taking the largest of each
dimension separately can describe a machine nobody has.

Two things fixed on the way. A device on a connector node held every batch run
of its flow forever, waiting for a worker that could never run an entry point.
And `prefer` falling back to the engine skipped the books, so the fallback held
nothing.

The bench flow's node has taken a `params` argument that with_settings has not
forwarded for some time, so the benchmark could not run at all: 62 ms median
submit-to-result with this, against the 61 ms on record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 08:49:36 +02:00
stroblmeandClaude Opus 5 1a9753fa9d Let a worker say what machine it is
A worker reported its labels and nothing about the machine behind them, so the
engine could route a node to a GPU box but not tell whether that box had a GPU
free. Inventory — cores, GPUs, memory — now arrives with the hello frame, and
the run frame carries back what the engine allocated for that call.

Which is protocol 2 on both ends. GPUs are never probed: asking a vendor tool
would make the one dependency two, so a GPU is what the batch job says it was
given or what --gpus says. A worker that reports nothing still attaches and is
scheduled by its label alone.

Two things a job scheduler needs: --max-idle stops a worker started for one job
rather than letting it hold its allocation to the walltime, and a refusal is now
fatal instead of a reconnect loop that reads as a hang in a job's log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 08:29:35 +02:00
stroblmeandClaude Opus 5 0ffcabfdb9 Media dtypes: image, audio and video as narrowed artifact references
Docs / docs (push) Successful in 30s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m7s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m54s
pre-commit / pre-commit (push) Failing after 4m24s
Test Backend / test-backend (push) Successful in 3m8s
Compose Smoke Test / test-compose (push) Successful in 40s
Playwright Tests / merge-reports (push) Successful in 1m33s
A port may now declare `image`, `audio` or `video`. Each is the artifact
reference the engine already had, narrowed by the `media_type` on it, so a
speech recogniser declares what it eats rather than taking any bytes at all and
finding out. Bytes still never travel as a message and nothing on the wire
stops being JSON: a camera publishes one reference per frame, a microphone one
per chunk, and a reference may carry a `meta` dict nothing here interprets.

Streaming media is therefore an ordinary streaming port — with one change to
what that means. An emission used to journal an item with no payload, so
downstream read whatever was current when the item was claimed; a consumer
slower than its producer saw only the newest chunk and the ones between were
lost. That is right for a training curve and wrong for a second of speech, so
an emission now journals a `kind="emission"` item carrying its values, and the
executor hands them to the nodes reading that message instead of writing them
to state again. The value in state stays the latest, which is what everything
else reads, and the wave is filtered by what actually changed rather than
walking everything reachable. No queue serialization change — the existing
`outputs` field carries it.

Continuous media makes the store's missing GC a real problem, so this closes
it: `sweep_artifacts` runs hourly, keeps every digest a `run_artifact` row
records or a live message holds, spares anything written in the last hour, and
stands aside entirely while a run is in flight, since a node may store a
checkpoint long before it returns the reference to it. That also collects the
orphans a deleted flow has always left behind. `ARTIFACT_GC_INTERVAL_S=0` turns
it off.

Around the edges: `GET /artifacts/{digest}` serves the media type the caller
passes and answers ranged requests, so a browser plays a clip rather than
downloading it; `PUT` spools to disk instead of holding the whole body in
memory, as does `save_artifact` given a path; a Media widget draws whatever its
message points at, and a wall panel may fetch the bytes its own tiles are
showing and nothing else; and a connector gets `save_artifact`, for a device
whose readings are bytes.

What this cannot do is live video: a frame every second or two is a glance, and
the honest answer above that is the camera's own stream, which the widget takes
as a URL and the browser plays from source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:44:55 +02:00
stroblmeandClaude Opus 5 8be7e424ba Cover the sink reaching a node the controller built
Docs / docs (push) Successful in 30s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m59s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m9s
pre-commit / pre-commit (push) Failing after 3m16s
Test Backend / test-backend (push) Successful in 2m48s
Compose Smoke Test / test-compose (push) Successful in 39s
Playwright Tests / merge-reports (push) Successful in 1m12s
The pieces were tested one at a time, so removing the one line in
`_build_node` that wraps a worker-backed function with its sink broke nothing
visible — while losing every generator node's last yield and returning None
in its place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:01:15 +02:00
stroblmeandClaude Opus 5 8f71b638b6 Make the example's __main__ actually run
It claimed to run the pipeline with no engine involved, and could not: the
node bodies it called save and load artifacts, which raise outside a node by
design. Each node is now a thin wrapper over a plain function — make_rows,
train_curve, score — and __main__ calls those, which is the split the sandbox
already demonstrates and the one worth copying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:57:36 +02:00
stroblmeandClaude Opus 5 4f3eaf950c Report a published node that has no code of its own
The store answers the new-node template when nothing was ever written for a
node, so such a node ran — returning {} on every call, reporting active and
ok, and saying nothing anywhere. Unreachable through `fluksio sync`, which
writes every body before it publishes; the editor end was open.

A run of a flow holding one is now refused, and the flow carries a
missing_source issue so it is visible before anybody runs it. A draft is
exempt: a node being written legitimately has no published body yet.

The generated client is regenerated for the new issue code, which also
catches up the drift left by earlier backend work (resources, code_digest,
idempotency_key).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:55:11 +02:00
stroblmeandClaude Opus 5 69fc0ceeb5 Format the generator test's sink helper
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:53:41 +02:00
stroblmeandClaude Opus 5 2e82367926 Keep the file name a node gave an artifact
A run_artifact row is keyed by the message the bytes left on, and that was
also the only name it could answer with — so an `@run:` reference resolved
through the row was the same bytes under a name its producer never chose.
The row now records the file name beside the message name; rows written
before the column answer as they always did.

The fallback also checks the bytes are still in the store, which the bare
digest spelling beside it has always done. A missing blob now fails at
submit rather than in the middle of the run that wanted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:45:11 +02:00
stroblmeandClaude Opus 5 001ec7b282 Key the stage cache by the node's input names, not the flow's
A cache key held qualified input names, so the same node reading the same
values through two flows keyed differently and only a node with no inputs
could ever hit across one. The fingerprint beside the key already says what
the node is, and it has been flow-agnostic since it moved ahead of
assign_flow — the names were the last thing tying an entry to one flow.

Inputs now reduce by the node's own name for them; a name belonging to
another flow keeps its prefix, since reading it is part of what the
execution is. Every stored entry misses once and is re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:41:35 +02:00
stroblmeandClaude Opus 5 647644ebbd Check a generator's yield at the yield that produced it
The worker held each yield one behind, because the last one is the node's
result when the generator returns nothing of its own. Only the engine knows
what ports a node declared, so the check happened when the *next* yield
arrived — a pass late, which for a training loop is however long one epoch
takes.

The worker now sends every yield as it happens and returns whatever its
generator returned; EmitSink holds the last one back and decides at the end
of the call what it was. Old "emit" frames are still handled, so a remote
agent that has not been restarted keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:39:22 +02:00
stroblmeandClaude Opus 5 f2dd1ffc34 Show resources on the example's training node
The example's `fit` is what a real training node looks like, so it is where
`resources={"cpus": 2}` belongs -- beside the `device="gpu"` it already
carries, since the two answer different questions about the same node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:46:27 +02:00
stroblme 15a3c2ce62 Document how a connector handles a device that decides its own ports
An undeclared key fails the whole reading, so a connector whose ports vary by
model has to narrow what it publishes to what was bound. Names the idiom and
the once-not-per-poll rule for saying a declared port is missing.
2026-08-26 21:43:59 +02:00
stroblmeandClaude Opus 5 608d30d884 Let a node say how much of the machine it takes
Five concurrent training nodes, each sizing its thread pool to every core,
left the engine's own event loop unscheduled: the API stopped answering
within 10 s and every client died. The same shape on a GPU deadlocked a run
for 21 minutes at 0% utilisation with nothing failing and nothing to read --
it just sat in `running`.

@node(resources={"cpus": 2}) is the declaration. The engine holds that much
for the length of the execution, so more of them than the machine has room
for wait their turn rather than oversubscribing it, and a `gpus` node holds
its card exclusively. FLOW_CPUS defaults to every core but two, and those two
are what keeps the engine answering.

Because a thread cap is read when the process imports the library, a warm
worker cannot be told a different one -- so an environment gets a pool of its
own and nodes deriving the same one share it, rather than paying a cold start
per call on exactly the nodes whose imports are slowest. XLA_FLAGS is never
derived: it is a composed, version-dependent string, so it travels in
resources.env where it is visible.

A node that declares nothing is not accounted for and behaves as it always
did -- it just gets FLOW_CPUS/FLOW_MAX_WORKERS as a thread cap, which is the
half of this that fixes the reported incident without anybody declaring
anything. An operator who set OMP_NUM_THREADS themselves still wins.

Resources are claimed strictly before a worker slot, so the two blocking
waits cannot deadlock. A node queued for them publishes node_queued and shows
on GET /workers/resources, because waiting and hanging looked identical.

Accounted, not enforced: no cgroups, no rlimits. Scheduling across machines,
flavours and enforcement are the next steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:36:59 +02:00
stroblmeandClaude Opus 5 4a38c6ed31 Name the code a run ran, and let an interrupted sync finish
Three faults with one root: the stored body of a code-defined node is an
import shim, and nothing that mattered was ever read from the code itself.

- The run stamp could not identify what ran. The shim imports whatever is on
  disk when the worker starts, and an uncommitted tree stamps <commit>-dirty
  for every run it ever produces. Run.code_digest hashes the repository's .py
  files, memoized on their stat state, and it is read again when the run is
  actually claimed -- so a sweep queued for hours records the code each of its
  runs executed, not the code that was there when it was submitted.
- The stage cache adopted code that was too new. The fingerprint hashed the
  shim, which is invariant under any edit to the imported function or anything
  it calls into, so a re-run was served from cache and answered without the
  outputs the edit added. It now carries the repo digest and the node's
  declared ports. Every fingerprint changes once, which invalidates the
  existing cache; a canvas flow has no repository and keys as before.
- An interrupted sync looked like a hand-edited canvas. The engine answers a
  new-node template for a node with no stored body, and the template carries
  no marker, so the drift check read "somebody edited this" and demanded
  --force -- for the one state that re-running the sync is the fix for.
  NodeSource.missing states the fact, and sync skips those and reuses the
  bodies it read instead of asking for each one twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:27:10 +02:00
stroblmeandClaude Opus 5 1f7c6646f1 Survive a busy engine: retry, idempotent submit, resilient waiting
A driver script died of one slow answer: httpx.ReadTimeout out of
RunHandle.refresh() with a 30 s read timeout and no retry anywhere, which
cost a sweep 78 of its 84 runs.

- Split the timeout (5 s connect, 120 s read): a wrong URL fails at once,
  and a busy engine gets longer than the slowest thing it does on purpose
  (a 60 s compile, a 15 s rebuild wait).
- Retry idempotent calls three times on a transport error or 502/503/504.
  503 is the engine's own "ask again" — it is what RebuildBusy answers.
- Submit carries a key the engine stores with the run, so a retry after a
  timeout returns that run instead of starting a second. A sweep keys every
  entry, so a half-created one recreates only what is missing.
- wait() and --follow tolerate five failed polls in a row; a 404 still stops
  at once, because that is an answer rather than a gap.
- CLI says "engine not answering" and names the run still on the engine,
  instead of printing a traceback.
- runs: clamp the params column to 80 characters; events() takes the
  flow/since/until the endpoint already had; RunHandle.failures answers
  "what killed this run" from the run's own node rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:19:09 +02:00
stroblme 2cf45e4f39 Release v0.1.3
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m38s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m40s
pre-commit / pre-commit (push) Failing after 2m44s
Test Backend / test-backend (push) Successful in 2m15s
Compose Smoke Test / test-compose (push) Successful in 31s
Publish / publish (push) Successful in 50s
Playwright Tests / merge-reports (push) Successful in 1m31s
2026-08-26 16:05:04 +02:00
stroblmeandClaude Opus 5 a71e0b3375 Lay flows out so edges stop crossing where they need not
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m17s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 18m52s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
The canvas placed nodes with dagre, which orders nodes but knows nothing
about ports: an edge leaves a particular handle and lands on a particular
one, so two producers feeding a consumer in the other order crossed just
before they landed however well the nodes were arranged. The graph was not
a multigraph either, so two messages between one pair of nodes collapsed
into one before crossings were counted, and the endpoint labels were laid
out as 220-wide cards with every handle stacked on a single pixel.

ELK's layered algorithm replaces it. Every declared port is handed to it as
a fixed point on the node's rim, at the fraction `portFraction` puts the
handle at — which the node components now render from the same function —
so what the crossing count is minimised over is what ends up on the screen.
Measured on this installation's flows, with the labels included: `home`
goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to
5 and 59 to 32, and every graph is the same size or smaller.

The engine answers asynchronously and is a chunk of its own, so positions
became state: the canvas draws nothing until the first layout lands, and an
edit keeps the arrangement it had rather than flashing through the corner.
The entry chunk is untouched and the flow route's own chunk came down 167
to 121 kB, since dagre used to be in it.

Two things the crossings made worse come with it. A hovered edge resolves
to full strength so one line can be followed through a busy rank, and a
feedback edge — one whose target the layout put behind its source — swings
out into a lane beside the graph instead of being drawn through everything
between its ends, which is what "House history" was reported for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6vyQvfX249hsv9YPmsYYg
2026-08-26 15:37:36 +02:00
stroblmeandClaude Opus 5 b80b92aad7 Local test targets read the running stack, not the deployment's domain
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Successful in 1m57s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
pre-commit / pre-commit (push) Failing after 3m32s
Test Backend / test-backend (push) Successful in 2m18s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m27s
`make test-frontend` built PLAYWRIGHT_BASE_URL from DOMAIN in .env, which in a
checkout configured for a deployment is that deployment's domain — so the suite
that creates and deletes flows, dashboards and users was pointed at
app.fluksio.com, held local only by --add-host and tests/guard.ts.

The hostname now comes off the running stack (the frontend container's own
Traefik rule), so a name no local container answers to cannot be reached at
all, and the local targets default to *.localhost instead of reading .env.
Target-specific on purpose: an exported DOMAIN outranks --env-file in compose
interpolation and would put the production targets on localhost.

`rebuild-frontend` replaces the raw compose line CLAUDE.md spelled out, taking
the same domain so the baked VITE_API_URL cannot disagree with what Traefik
serves. Also: a coverage HTML report that cannot be written no longer fails
test-backend after a green suite, and both artifact actions in playwright.yml
drop to @v3, which is the only version without the github.com-only guard that
failed every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:48:05 +02:00
stroblmeandClaude Opus 5 bda67d1c0d Let the slider keep the drag the browser wanted to take
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m38s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m41s
pre-commit / pre-commit (push) Failing after 2m47s
Test Backend / test-backend (push) Successful in 2m19s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m4s
Dragging the brightness slider on the wall panel set the handle and
published nothing; tapping a point on the track worked. The control is a
native range input laid transparent over the drawn track, and only the
release publishes — but the input never said the drag was its own. On a
touch panel a sideways swipe is a pan, or a back-navigation, so the
browser took the pointer over mid-drag and ended it in `pointercancel`.
The value had followed the finger and was never sent.

`touch-action: none`, as the colour disk beside it has always had. The
release also answers `pointercancel` and `lostpointercapture` now, which
covers a mouse let go outside the input and leaves no way for a draft to
sit there unpublished.

Two checks: a drag across the track reaches the engine, and the input
still owns its gesture. The second fails against a build without the
CSS, which is what makes it worth having.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012n6CehUsHYYUXJ48rxaD18
2026-08-26 13:32:22 +02:00
stroblme 95aae7b95f Show what differs between the runs being compared
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m38s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m40s
pre-commit / pre-commit (push) Failing after 2m44s
Test Backend / test-backend (push) Successful in 2m17s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m3s
2026-08-26 13:18:16 +02:00
stroblme 0578a30a3a Name the comparison's X and Y beside their pickers, not in every option
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m47s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m14s
Test Backend / test-backend (push) Successful in 2m24s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Failing after 1m7s
2026-08-26 12:20:38 +02:00
stroblmeandClaude Opus 5 266acd1661 Document the cascade guarantee and the cascade ceiling
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m46s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m42s
pre-commit / pre-commit (push) Failing after 2m55s
Test Backend / test-backend (push) Successful in 2m23s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m3s
A node runs when something it reads was published, not merely when something
upstream of it ran — worth saying now that it is true, since it is what a
reader relies on when a rate-limited port goes quiet. Plus `FLOW_MAX_CASCADES`
in the engine's configuration table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 10:27:23 +02:00
stroblmeandClaude Opus 5 1430c4060b Give the engine bench a make target
And stop its drain loop asking for the whole of `stats()` every twenty
milliseconds — four round trips a poll, competing with the engine for the
connection it is measuring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 10:16:04 +02:00
stroblmeandClaude Opus 5 180da3d640 Stop paying five Redis round trips and a global lock per message
The engine was I/O-bound on its own state backend. `RedisState.lock()` is one
key — `pipeline:_lock` — for the whole process, taken five times a message at
two round trips each, and every cascade and every node read queued behind it.
Inside it, reading a node's inputs was three round trips per input (an EXISTS
for `in`, then EXISTS and GET for the value), writing was two updates that a
single transaction already gives, and the version counters went one INCR at a
time.

Replaced with the atomic command that was always available: `get_present` is
one MGET and tells a missing key from one holding null, so the lock it used to
be read under bought nothing; value and timestamp land in one `update`, which
is a MULTI/EXEC; `increment_multi` pipelines the counters. `values()` — what
every websocket snapshot calls — is two reads whatever the message count
instead of two per message.

Beside that: every webhook did its blocking XADD on the asyncio event loop
(MQTT already used `to_thread`); the per-execution `NodeOutcome` was built and
validated even with no run watching; `_minute` built a tz-aware datetime per
event on the loop thread to key a dict, and now keys on an int; `move_due`
promoted delayed items one round trip each, every second; `FLOW_MAX_CASCADES`
makes the in-flight ceiling a setting rather than a constant.

`orjson` replaces stdlib json where a message pays for it — state, the
journal, the engine side of the worker pipe. `fluksio-worker` stays
dependency-free, and the run-cache digest stays on stdlib so no stored key is
invalidated. A non-finite number now stores as `null` rather than the bare
`NaN` that was never JSON.

Measured with `scripts/bench_engine.py` against a real Redis, 200 messages:
a five-node chain went from 43.9 to 103.1 msg/s with p50 latency 2110ms →
782ms and p95 3913ms → 1439ms; one source into twenty consumers went from 5.4
to 33.7 msg/s. In memory, twenty consumers went from 187 to 448 msg/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 10:12:25 +02:00
stroblmeandClaude Opus 5 a9136c7811 A rate limit now thins the work, not only the messages
The limit was applied in `apply_outputs`, which the executor reaches after the
item is off the queue — so a subscriber told to publish every 15s still cost a
queue entry, a `cascade_started`, a run record and a walk of everything
reachable from it per inbound message. Seven relay nodes behind one inverter
ran 192 times a minute to publish six.

Two halves, matching the two shapes it takes:

`trigger()` now keeps a value whose every port is inside its window and
journals nothing at all. The window split came out of `_throttled` as a
read-only `_window_split`, so the question is asked the same way in both
places and the exact split is still made once, at claim time.

A cascade carries the names it actually published, and the wave runs only the
nodes something in that set feeds. A node whose triggering inputs were all
held back is completed without running, which frees its own consumers to be
judged the same way — the case where a node re-published 619 messages a minute
off inputs that changed six times. Redeliveries and emissions carry no such
set and still walk everything, since one has a half-finished wave to finish
and the other is the value already being in state.

Skipping a node can make one ready that the scheduling pass has already walked
past, so `submit_ready` runs to a fixpoint. That also closes the same latent
hole on the replay path, where a done-marker skip could strand a join with no
future outstanding to come back for it.

Measured with the new `scripts/bench_engine.py`, 500 messages through the
house's shape: a limited source went from 500 cascades / 3500 node runs /
5009 events to 1 / 7 / 19, publishing the same 8 values; an unlimited source
into limited relays took the node reading them from 500 runs to 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 09:55:56 +02:00
stroblmeandClaude Opus 5 5726c80948 Say how much work is waiting, not just how much is running
`RedisWorkQueue.stats` read XPENDING, which counts entries delivered to a
consumer and not yet acknowledged — work in progress. Entries sitting in the
stream undelivered were counted nowhere, so an engine hours behind reported
itself idle: on the house, `pending: 4` while the group's lag was 1554.

The group's own `lag` is the missing number. `backlog` now carries it on both
queues (`len(_items)` in memory), leads the health tile, and a sustained one
publishes `engine_degraded` from the timer thread — named with the flow most
of the waiting work belongs to, sampled from the undelivered tail, since that
is the actionable half. It is a summary problem rather than a /utils/health
503: a backlog should not restart the container.

Also drops the keyspace `scan_iter` `stats()` did per poll to count parked
items — it walked every state and idempotency key twice per ten seconds — for
a set the park/unpark path maintains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 09:43:23 +02:00
stroblmeandClaude Opus 5 dd7db026e1 Announce an acknowledged node failure on the bus
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m38s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m38s
pre-commit / pre-commit (push) Failing after 2m46s
Test Backend / test-backend (push) Successful in 2m17s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Failing after 1m2s
Clearing a node's last error on the engine published nothing, so a second
browser kept the marker until its next snapshot. One event carries the
qualified node; the receiving client drops the marker without refetching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 22:33:09 +02:00
stroblmeandClaude Opus 5 5ba379490a Dash the transmit ring so it is not the selection ring
Both were a 2px inset --primary ring, so a widget mid-publish and one
picked in the editor looked the same. Status has three colours and none
of them means "picked", so the stroke tells them apart instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 22:17:15 +02:00
stroblmeandClaude Opus 5 f567c98de4 Say a dashboard is read-only in the editor's view mode too
The lock notice was on `PanelSurface` alone, so /dashboards/{name} showed
disabled controls with nothing naming the state. Same pill, not a second
one; edit mode keeps its controls live, so it stays out of there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 22:15:09 +02:00
stroblmeandClaude Opus 5 9ad44fd3f6 Draw the edge sparkline only for a value it can plot
The popover showed a non-numeric value twice: only numbers are recorded,
so MessageSparkline fell through to a collapsed ValuePreview and the
inspector rendered its own expanded one below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 22:13:32 +02:00
stroblmeandClaude Opus 5 1148e54c9e A run says whether the dashboard, the CLI or a script asked for it
`POST /runs/flows/{name}` hardcoded `cause: "api"`, so every row in the
history claimed the same origin. The body now carries an optional `cause`,
closed to the values the column knows — the dashboard sends nothing and stays
"api", `fluksio run` says "cli", and the SDK client says "sdk".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 22:06:29 +02:00
stroblmeandClaude Opus 5 b194071ca5 Pick the run this test made, not the first one running
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m56s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m48s
pre-commit / pre-commit (push) Failing after 3m2s
Test Backend / test-backend (push) Successful in 2m26s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Failing after 2m0s
The suite runs in a random order and a run left marked running by an
earlier module lands in the same list, so asserting on data[0] failed on
whichever seed put that module first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:52:48 +02:00
stroblmeandClaude Opus 5 3af5342a2b Advisory issues read as advice rather than failure
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m37s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 35s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
`ADVISORY_ISSUES` moves next to `ValidationIssue` in pipeline.py, and the
model derives an `advisory` flag from its own code, so the distinction the
engine already made ships to the client instead of being re-guessed there.

The dock keeps its summary in `--destructive` only when a real fault is
among the issues and paints an advisory row `--muted-foreground`; the
canvas leaves advisories off a node's dot and border entirely, since node
status has three colours and no warning tier.

biome checks the generated `openapi.json`, which nothing formats since the
SDK script dropped its format pass — ignore it like the other generated
files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:46:40 +02:00
stroblmeandClaude Opus 5 4a2337f4de Drop the unreachable 404 from save_dashboard
Docs / docs (push) Successful in 31s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m39s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m40s
pre-commit / pre-commit (push) Failing after 2m54s
Playwright Tests / merge-reports (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 1m10s
Compose Smoke Test / test-compose (push) Canceled after 0s
write_draft creates a first draft for a name that does not exist yet, so
it raises only StaleVersion — the DashboardNotFound arm never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:36:02 +02:00
stroblmeandClaude Opus 5 49e6bc6c4e Stop the SDK generator reformatting the whole frontend
biome.json excludes src/client, so the trailing format pass never
touched a generated file — it only rewrote unrelated sources on every
backend commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:33:11 +02:00
stroblmeandClaude Opus 5 d3a0b4c3a4 Panel spec keeps the installation's other panels
Saving panels replaces the whole list, so the spec's setup deleted every
panel it had not created — and with it the credential of the screen on
it. It now appends to the stored list and puts the original back in
teardown, which also removes its own panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:30:35 +02:00
stroblmeandClaude Opus 5 65f859c04d Land a paired panel on its own screen instead of a new code
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:24:41 +02:00
stroblmeandClaude Opus 5 a3a234756c A batch flow's input is a run parameter, not a missing value
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m59s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m40s
pre-commit / pre-commit (push) Failing after 2m49s
Test Backend / test-backend (push) Successful in 2m20s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Failing after 1m6s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:51:54 +02:00
stroblmeandClaude Opus 5 3503512d05 Report how long a run has been going, not just how long it took
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m39s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m39s
pre-commit / pre-commit (push) Failing after 2m51s
Playwright Tests / merge-reports (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 1m4s
Compose Smoke Test / test-compose (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 055f4d7d97 Filter the icon grid, and ask before removing a panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 476dc6a1e9 Make the brand CTA visible and fix two more v3 variable classes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 df22475f54 Claim only what the cascade pool can run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 68711e2ee7 Start the docs site with the integrated local stack
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00