Commit Graph
34 Commits
Author SHA1 Message Date
stroblme a95be36dba Un-key stage-cache rows written before the current key shape
CACHE_SHAPE_SINCE marks the last change that moved every cache key. The
run service's periodic sweep clears cache_key on rows of runs created
before it, which nothing can match any more. Their outputs stay.
2026-10-15 10:54:22 +02:00
stroblme 6fa44bacf3 Flow delete offers to delete its runs, history included
DELETE /runs?flow= removes every Run row of the flow but left its flow_run
rows, so a deleted flow kept appearing in the history. history=true now
drops those too, and the flow delete dialogs (editor and bulk) send it when
"Also delete its runs" is ticked.
2026-10-15 10:52:33 +02:00
stroblme 599e01b0bf Run child tables cascade from run.id
Migration ccb21eacf5d5 deletes rows whose run is gone, then adds foreign keys
with ON DELETE CASCADE from run_node, run_metric and run_artifact to run.id.
Deleting runs is now the one statement. A node or metric written after its
run was deleted is dropped with an info line. Migrations run with foreign keys
off, since a batch rebuild of run would otherwise cascade into every child row.
2026-10-15 10:32:33 +02:00
stroblme 21cfed7d39 GET /runs/{id}/nodes/{node}/outputs serves what a node returned
The stage cache's stored outputs, as kept (up to 256 kB of JSON). A node
that kept none answers 404 saying why. Listed in the API docs.
2026-10-15 10:23:39 +02:00
stroblme e75b0297fd A restored node whose source run is gone says its curve was lost
The run detail works out curve_lost per node when read, from whether its
cached_from run still exists. The run page names the deleted run in place
of the generic NO_CURVE note.
2026-10-15 10:22:09 +02:00
stroblme caa2cff6b1 A run keeps the inputs its flow declared at submit
Run.declared (migration 2e7ca7d10044) snapshots the declarations on submit
and import. The run detail's Inputs panel reads them, and a run from before
falls back to the flow's current declarations.
2026-10-15 10:20:29 +02:00
stroblme 1813b5d722 Runs export: a truncated one counts the selection into X-Total-Count; the SDK and fluksio export warn how many runs were left out 2026-10-14 23:29:48 +02:00
stroblme 929933d7c3 Record which inputs a run's caller passed, and mark defaults from it
`Run.passed` (migration d5a3c8e1f246) keeps the names the caller chose, so
the run detail and the comparison table can mark a value as the flow's own
again; rows from before it fall back to the names their params lack. Retries
hand on their parent's list, imports record their entry's.

Also: a cancel of a queued run no longer leaves its id in `_cancelled`, a
linked comparison no longer crashes before the list loads, and the pre-commit
failures are fixed (stale client, `text-[10px]`, worker formatting).
2026-10-05 15:01:33 +02:00
stroblme bca28dbf75 Keep a run's lease while its pipeline builds; allow cancelling it then
A claimed run only joined the leased set once its pipeline was built, so a
build waiting for a busy worker pool got the run swept as abandoned. Cancel
now also stops such a run, and a terminal status being overwritten is logged.
2026-10-05 11:23:54 +02:00
stroblme af18d134a4 Add POST /runs/import for runs executed elsewhere
A history recorded before the move (MLflow experiments) had nowhere to
go, since runs were only made by running a flow. Each entry is stored as
it ended: parameters completed and checked like a submit, cause
"import", empty commit and code digest, metrics and artifacts written
as given, nothing queued or announced. Run.external_id (new migration)
makes a second import a no-op and is an export column so a row joins
back. The request is all or nothing and capped at 200 entries;
Client.import_runs pages a longer history.
2026-09-16 17:01:07 +02:00
stroblme 02907bbe27 Leave a dotted key out of the runs export header
_leaves named a column for a key like `-1.0` that _dig can never read
back, so the export showed an always-empty column. Skip such a key.
2026-09-16 16:43:52 +02:00
stroblme c9172adfaa Keep a flow's runs when the flow goes
A run record is a record of what ran, so deleting a flow no longer sweeps its
history: `_forget_runs` is gone from the flow-delete path, and `DELETE /runs/{id}`
is the only thing that removes a run one at a time. The in-flight guard stays —
that is about work, not history.

`DELETE /runs?flow=` is the counterpart to the list's flow filter and what a
reseed needs, sharing the four statements with the single-run delete and
refusing the same way while a run of that flow is still going.

A run whose flow is gone reads as one: `useFlowInputs` reports the 404 rather
than an empty declaration set, so the run page says "flow deleted", explains it,
and disables Retry, which the route would refuse anyway.
2026-09-06 14:53:23 +02:00
stroblme 5c97baa285 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.
2026-09-02 16:40:51 +02:00
stroblme 954496de0d 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.
2026-09-02 15:10:24 +02:00
stroblme c781c199f3 Close the nine open SDK tasks: one engine per directory, a tabbed dashboard, re-pairing, run recovery
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.
2026-08-31 17:37:13 +02:00
stroblme 65c8cc1eb6 Let a jsonl export keep a record a value
`--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.
2026-08-30 17:36:10 +02:00
stroblme d553606873 Say at startup when a flow wants a card, and record one seed rather than two
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.
2026-08-29 15:18:42 +02:00
stroblme ab4ffefa0f 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.
2026-08-29 14:06:50 +02:00
stroblme a07e52def1 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.
2026-08-29 13:47:00 +02:00
stroblme c3ffa82792 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.
2026-08-29 13:45:49 +02:00
stroblme 8fa66039ce Act on a run, and read Home top-down
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.
2026-08-29 08:37:11 +02:00
stroblme 8738e9d922 Follow a record into its fields, name the metrics, name the version
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.
2026-08-27 20:42:19 +02:00
stroblme 8f66b6f90b Export runs and their curves as tables an analysis reads
`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.
2026-08-27 17:43:30 +02:00
stroblme 081d499edc Let a sweep run more than four at a time, and name the run a failure was in
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.
2026-08-27 14:17:51 +02:00
stroblme 5e20fdec43 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.
2026-08-26 22:45:11 +02:00
stroblme c3dcefb04c 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.
2026-08-26 21:19:09 +02:00
stroblme 801bab07e1 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".
2026-08-25 22:06:29 +02:00
stroblme 4cdcf19de1 Report how long a run has been going, not just how long it took 2026-08-25 18:33:28 +02:00
stroblme 28a00a55d5 Scroll the runs table, pick a range, and choose what a comparison plots against 2026-08-25 16:32:34 +02:00
stroblme 8d5cf0a71a A cached node keeps its curve, and any input can name a run's output
A cache hit still replays no emissions — those values were the story of an
execution that is not happening — but the run they were recorded in is now
written on the row (`run_node.cached_from`), and the metrics endpoints read the
series back from there. So a reused run answers `run.metrics("train.loss")`
with the same points the run that trained did, rather than looking like a run
that produced no numbers at all. Pointed at rather than copied: a sweep of 500
reusing one frozen node would otherwise duplicate its curve 500 times.

That needed the cross-flow restore fixed first. The cache key has no flow in
it while the stored outputs are named for the flow that produced them, so
`quick.prepare` getting a hit from `train` wrote `train.dataset` into `quick`'s
state and the next node was called without its argument. One rule now covers
both halves: `requalify` reads a name owned by one flow as the same name in
another, applied to the restored outputs, to the node id behind the pointer,
and to the series names on the way out. Reuse across flows is kept.

Also: `@run:<id>.<output>` and a bare `sha256:` digest resolve on every input,
not only artifacts. Chaining a run's json config into the next one from a shell
meant pasting the whole object inline, and the CLI could not even send the
spelling — `_coerce` died in `json.loads` before the engine saw it. Both
spellings are reserved on every input now, `str` included, and `_from_run`
returns whatever the run's result holds rather than only a reference.
2026-08-25 15:12:28 +02:00
stroblme 8aee18c551 Count runs per flow, and page the run list by offset 2026-08-25 11:34:07 +02:00
stroblme e27080af26 Add fluksio status, and ask for a run's parameters at a terminal
Two halves of the same gap: the CLI could start work but not show you any.

`fluksio status` draws the home screen's top half in a terminal — health and
what is wrong with it, every flow with its state and node count, and the
recent runs and failures under them. `--watch` keeps it there. Rich does the
drawing; it was already installed under fastapi's own CLI, and is named now
because a command depends on it.

`fluksio run` with no parameters at a terminal asks for them, one line per
declared input with its declared value in brackets — so Enter through the lot
is what running the defaults looks like, and an artifact input takes the
`@run:` spelling the engine now resolves. A scripted run is untouched: passing
any parameter, or piping the command, skips the questions, as does --defaults.
2026-08-25 07:42:09 +02:00
stroblme 0fb1646aa8 Refuse what a node cannot publish, and stop timing out work that is fine
Four things the python SDK turned up, each fixed where every client sees it.

A key no port declares is now an error rather than a silent drop, on the
return, the yield and the emit alike — the contract the docs already stated.
The SDK reads literal yields at sync time, so a typo fails before anything
runs, and an emission of one fails the call rather than being logged where
nobody looks.

NaN and infinity are refused at the port. JSON cannot spell either, so one
that travelled came back as a 500, a socket frame that stopped the canvas, or
a metric batch the database dropped whole.

An artifact input takes `@run:<id>.<output>` or a bare digest, resolved on the
engine — so the CLI, the run dialog and a python caller mean the same thing,
and a sweep can pass one at all.

Node timeouts are off by default. The clock measured silence, which a training
node is full of, and remote workers had already stopped enforcing it — their
heartbeat reset it. Now a heartbeat proves the agent rather than the node,
ninety seconds of nothing fails the call either way, and the engine touches
work it is still running so a long node is not redelivered at sixty seconds.
2026-08-25 07:30:14 +02:00
stroblme ea5fad2fcd Stage caching for batch runs, and an engine that lives in the command
A code node in a batch run is now fingerprinted by its source, its raw
settings and the values it reads — an artifact input counting as its digest,
which is what the content addressing was always for. A run that finds the key
restores what the earlier one returned and skips the node, recorded as
`cached`. The run history is the cache: `run_node.outputs` beside the
`cache_key` the schema already had, no second store. On for code nodes, never
for the built-in and connector types that have side effects; off per node with
`@node(cache=False)` and per run with `--no-cache`.

Emissions are not replayed on a hit, so a cached training node returns its
result without redrawing its curve. Recorded in NOTEPAD.md with the two other
deliberate limits.

`fluksio run --local` boots the real app in the command's own process and
drives it through its ASGI interface behind the ordinary client, so a run no
longer needs a `serve` terminal beside it — same data directory, same history,
and the cache carries between the two. It always waits, because the engine it
starts lives exactly as long as the command.

Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists,
`run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited
run rather than abandoning it, coloured statuses on a terminal, and `name`
made optional on the metrics endpoint so a follower can ask for every series.
2026-08-24 20:31:31 +02:00