Commit Graph
105 Commits
Author SHA1 Message Date
stroblme f569f325a7 Serve MCP and hooks with a secret through the portal tunnel
The connector now serves /mcp, its OAuth documents and /hooks/ besides
/api/v1/, refuses any dot segment (httpx resolves them, which carried
/api/v1/../../mcp onto the MCP mount), addresses calls to the hub's host,
and bridges served sockets by calling the app with a websocket scope.
A hook without a secret answers the tunnel with the 404 (or 1008) of a
wrong secret. Requests through the hub get the hub's issuer, resource and
consent page in the OAuth metadata and the 401, and the MCP host check
admits the hub while enrolled.
2026-10-15 11:00:19 +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 7ea53ccf17 DELETE /artifacts/{digest}: superuser only, 409 while referenced
The keep-set the hourly sweep uses is now referenced_artifacts, shared by
the sweep and the route. The volatile ring is not touched.
2026-10-15 10:48:59 +02:00
stroblme 1d66b30d0c List and revoke portal members under Admin → Users
GET /cloud/members asks the portal who it admits to this instance and
marks those with a local user. DELETE /cloud/members/{portal_sub} takes a
membership back. Admin → Users lists the members who have not arrived
yet, with Revoke, which also surfaces a membership left standing when a
local user was deleted while the portal could not be told.
2026-10-15 10:31:25 +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 e85b18092f Flow inputs carry an origin hint, run or panel
The Inputs editor and the canvas label show it, and the run forms leave
panel inputs out.
2026-10-15 10:08:46 +02:00
stroblme 43498037be PUT /panels/ carries a version and refuses a stale one with 409
The Panels dialog re-reads the list on 409. A panels.json without a
version reads as 0.
2026-10-15 10:07:50 +02:00
stroblme 290b9fa705 Superuser route to flush the metrics collector now
POST /observability/flush handles what the collector has queued and writes
it, so the soak harness no longer sleeps FLUSH_INTERVAL_S before clearing
its own rows.
2026-10-15 09:53:19 +02:00
stroblme 1f213460ad Audit shared-library source writes
A shared source goes live in every flow using it the moment it is saved, so
the write is recorded in the audit trail like a publish. Private source saves
stay drafts and are not audited.
2026-10-15 09:50:04 +02:00
stroblme 0004e4b9bd Retire a shared node in use with ?detach=true
DELETE /flows/library/{name} still answers 409 while nodes use the source.
With detach=true each of them keeps a copy of the code, in the published
flow and the draft, the source is deleted and the running users rebuild.
2026-10-15 09:48:47 +02:00
stroblme 96f4fa5a38 Warn when a node that runs no code names a device
A device on a node without source was accepted and silently ignored. The
build now reports it as ignored_device, an advisory issue.
2026-10-15 09:45:24 +02:00
stroblme 565d492f19 Give a rate-limit flush its own run, linked to the run that held the value
A flush ran its cascade under no id, so its executions and failures belonged
to no run. It now records a run with source "flush", and the holding run's id
travels on the queue item into a new `flow_run.parent` column, also on the
`/observability/runs` rows.
2026-10-15 09:37:30 +02:00
Melvin Strobl 2008150692 Round dashboards: one face at a time, a face per widget type, paged by swipe, dots and keys; slider reading; face row in the editor 2026-10-14 15:24:25 +02:00
Melvin Strobl be3446cb16 Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor 2026-10-14 14:23:55 +02:00
Melvin Strobl 659799d1e5 Panel I/O: PanelDef.io ports, scoped publish and upload, I/O section in the Panels dialog 2026-10-13 21:10:54 +02:00
stroblme ed8aee2387 Authenticate sockets by header or one-time ticket instead of ?token= 2026-10-07 08:58:22 +02:00
stroblme 953a983919 Bind a worker's name and labels into its token; list and revoke tokens by jti 2026-10-07 08:42:14 +02:00
stroblme 243d39b591 Give connectors a store that survives restarts, and a way to clear it
`ConnectorNode.store` keeps short strings per connector package in the
engine's database (`connector_state`, migration f8c3e5a91d27), so the nodes of
one package share them and `fluksio serve` needs nothing new: get, set and
delete, at most 64 kB a value and 256 keys a package. A superuser lists the
keys (never the values) and clears them through `/connectors/{node_type}/state`,
or under Stored state in a connector node's panel. Documented in the connector
contract.
2026-10-06 17:19:48 +02:00
stroblme 6802cd2203 Make changing engine-run code superuser-only
Writing node source, installing modules and any flow save that adds,
retypes or repoints a code node (Function, Command), edits a Command
node's settings or changes a node's resources.env now answer 403 for
everyone but a superuser. MCP tools and the assistant reach the same
routes, so they inherit the gate. Node types report runs_code.

The editor shows code read-only with a note, disables a Command node's
settings, leaves code nodes and reusable nodes out of the palette and
paste, and the Modules page offers no install.
2026-10-06 11:36:30 +02:00
stroblme 2914d860b1 Make self-service signup opt-in via SIGNUP_ENABLED
/users/signup answers 403 unless SIGNUP_ENABLED is set. A public
GET /utils/config/ tells the login page, which hides the sign-up link,
and the signup page explains that an administrator creates accounts.
2026-10-06 11:24:27 +02:00
stroblme afe0c94a24 Talk to wren nodes from the chat's Control tab, opened from Home 2026-10-05 20:59:11 +02:00
stroblme a677409079 Find a flow's declared inputs and outputs in the global search
/search lists each input and batch output under its qualified message,
and picking one opens the flow on the label's id, which the editor now
treats like a click on the label: it opens the flow's settings.
2026-10-05 18:15:35 +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 7240b7b823 Add the assistant: a chat that drives the MCP tools in-process
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.
2026-09-16 23:28:23 +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 831bda9c46 Sync only the declaring module before a run; skip git-ignored paths
`fluksio run` walked the working directory and imported every module in
it, which ran a research repository's analysis scripts before the run was
submitted. A flow's origin now records the file and module name that
declared it, and run/sweep import just that, under the same name so the
generated node bodies do not change. A flow with no record still walks,
now skipping what .gitignore covers.

Every code-defined flow reports `flow` changed once on its next sync,
since the origin gained two fields.
2026-09-16 16:55:02 +02:00
stroblme 0f040b6e1d Merge branch 'main' of git.stroblme.de:Fluksio/app
# Conflicts:
#	frontend/src/client/sdk.gen.ts
2026-09-07 08:41:45 +02:00
stroblme 6fdc30a4d1 Make lint-backend green, and regenerate the stale client
`wanted_names` never read its `previous` argument — the caller reassigns
`wanted` from the return value — so the parameter goes rather than gaining
an underscore; its one call site and four test calls follow. Sorts the
imports in test_artifacts.py. Both were failing `make lint-backend` at HEAD.

Regenerates the client, which 8c097d9 left behind when it corrected the
revoke_client docstring.
2026-09-06 15:29:35 +02:00
stroblme fa5ce4f694 Give both summaries a modified time, and the dashboard list a footprint
Home's "recently modified" order was a proxy — drafts first, then the
version counter, then the name. Both summaries now carry `updated_at`,
read as the mtime of the working copy: every write in the store commits
immediately, so a file's mtime is its commit time, and `git log -1 --
<path>` costs ~990ms across this instance's 15 documents (it walks the
history back to the last commit touching each one, so it is slowest for
the stalest) against ~1.8ms for the stats. No cache needed.

`DashboardSummary` also carries `footprint`: each widget as its type plus
the placement a panel resolves, which is the whole of what the mosaic
draws. That drops the document fetch per tile and with it the cap of
eight, past which tiles showed a name and nothing else. Verified against
this instance's 8 dashboards: the blocks are identical to what the old
client-side derivation produced.
2026-09-06 15:26:48 +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 10fe2fd760 A buttons widget: a grid of presses on one message
Several stateless instructions where a single button is one — six presets are
otherwise six tiles to place, six titles to read, and the message they share
repeated six times. Nothing is read back, as for a single button: what these
send is an instruction, and the last one sent is not a state to draw.

Auto-fit columns rather than a configured count: a tile is resized in the
editor and scaled again to whatever panel it hangs on, so how many fit is not
something the document can know.

The editor reads `cfg.buttons` raw rather than through `buttonsOf`, which
drops the blanks — a row being typed into is blank until the first keystroke.

Also carried along by the hooks: the generated SDK was stale (it had no
`RunsReadMetricNames`) and one pre-existing block in `test_panels.py` was
unformatted.
2026-09-04 08:44:16 +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 1faf7f7c11 Touch is a panel setting, and the rail grows with it
It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

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

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

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.
2026-08-31 19:04:20 +02:00
stroblme 6c8a101d24 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

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

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
2026-08-31 10:12:01 +02:00
stroblme f10da9545d Add a website widget, so a page nobody modelled as a message can hang on a wall
A tile that draws whatever an address serves: no binding, no flow, just an
iframe. Only http(s) loads — a `javascript:` src would run in the app's own
origin, and a dashboard is a document several people can edit.
2026-08-30 18:48:52 +02:00
stroblme d2464d4d28 Add a player widget, and let a slider be drawn as a fader
The player is the one tile that both reads and publishes, so it has two
bindings: it shows a `record` describing what is playing — title, artist,
album, status, and position and duration in seconds — and publishes transport
words back to one `str` message (`toggle`, `next`, `prev`, `seek:<seconds>`).
Those are a streamer's own vocabulary rather than this app's, which is what
lets one tile drive whatever is on the other end.

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

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

The fader beside it needed nothing new. `ui/core` has had `orientation` on the
slider all along and all three looks draw it; only the widget never passed it,
so a volume control — the one thing reached for without looking, where up is
louder — could not be a column. Now it can, and the tile's height is the track.
2026-08-30 14:17:16 +02:00
stroblme 1bb18e14ac Notify a phone that has this installation installed
A `webpush` alert channel, and the PWA it needs to arrive. The payload is
encrypted to the subscription (RFC 8291) and the request signed with this
installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush`
does the same in one call but brings `requests` and `aiohttp` with it, two
HTTP stacks beside httpx on a machine that may be a Raspberry Pi.

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

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

`WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume
with the alerts beside it. Off it, a rebuild would silently stop every phone
being notified: the key they subscribed against would be gone.
2026-08-30 12:12:37 +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 4a1ddcca3f Add a global search, and stop the sidebar logo squeezing
`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.
2026-08-28 22:19:08 +02:00
stroblme 4614230fd6 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.
2026-08-28 12:22:55 +02:00
stroblme 617e77c76b 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.
2026-08-28 10:35:00 +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 49b8985e71 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.
2026-08-27 09:32:08 +02:00
stroblme a1509f29da Media dtypes: image, audio and video as narrowed artifact references
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.
2026-08-26 23:44:55 +02:00
stroblme 0f80b60fe7 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).
2026-08-26 22:55:11 +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 cdb1bffdd4 Advisory issues read as advice rather than failure
`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.
2026-08-25 21:46:40 +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