Style the engine's own logs, notice enrolment while serving, say more in status
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m24s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m37s
pre-commit / pre-commit (push) Failing after 3m14s
Test Backend / test-backend (push) Successful in 2m15s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Failing after 1m3s

Four things from a testing pass.

`fluksio serve` printed its own lines through the root logger, which has no
handler and falls back to `INFO:fluksio.cloud.connector:...` — beside uvicorn's
aligned output it reads like something went wrong. The engine's loggers and
alembic's now use uvicorn's own handler. Named rather than configuring the
root: httpx logs every portal call at INFO and none of that is printed today.

`fluksio enroll` writes its config from another process, so an engine already
serving never learned it had been paired. It now looks for one every few
seconds and dials when it appears. `load()` rather than `exists()`, or a file
that does not parse would be restarted forever.

`fluksio status` says where the installation stands with its portal — never
paired, linked, or paired and unreachable, which is the one worth acting on.

`--seed` and `--timeout` had no help text at all. Both say what they are for
now, and the docs say what a seed is actually for: recorded on the run, part of
its input digest, and passed to an input named `seed` when the flow declares
one, so the number a run is labelled with is the one the code drew from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V5bsYGNxcgPs4xXmTPx69
This commit is contained in:
2026-08-25 09:29:22 +02:00
co-authored by Claude Opus 5
parent 7c5b212f43
commit 60757fa7fa
8 changed files with 231 additions and 29 deletions
+67 -6
View File
@@ -505,6 +505,22 @@ def _flow_state(flow: dict[str, Any]) -> str:
return "running"
def _portal_phrase(portal: dict[str, Any]) -> tuple[str, str]:
"""Where this installation stands with its portal, and how to colour it.
Three states worth telling apart: never paired, paired and linked, and
paired but not reaching it — the last being the one somebody needs to know
about, since the dashboard is served from the other end.
"""
if not portal.get("enrolled"):
return "no portal", "dim"
if portal.get("connected"):
host = str(portal.get("portal_url") or "").split("//")[-1].rstrip("/")
return f"portal {host}", "green"
trouble = str(portal.get("last_error") or "").strip()
return "portal unreachable" + (f" ({trouble[:60]})" if trouble else ""), "red"
def _status_screen(client: Client) -> Any:
"""One frame: health, the flows, and the failures under them."""
from rich.console import Group
@@ -517,6 +533,12 @@ def _status_screen(client: Client) -> Any:
# fails as an engine event, while a batch run fails on its own row.
failures = client.events(kind="failure", limit=5)
runs = client.runs(limit=5)
try:
portal = client.cloud_status()
except (SyncError, ApiError):
# Never enrolled, or an engine too old to answer. Neither is worth
# failing a status screen over.
portal = {}
healthy = summary.get("status") == "ok"
head = Text()
@@ -527,6 +549,8 @@ def _status_screen(client: Client) -> Any:
problems = summary.get("problems") or []
if problems:
head.append(" " + " · ".join(str(p) for p in problems), style="yellow")
phrase, style = _portal_phrase(portal)
head.append(" " + phrase, style=style)
counts = summary.get("flows") or {}
nodes = summary.get("nodes") or {}
@@ -758,14 +782,32 @@ def add_parsers(subparsers: Any) -> None:
"run", help="sync this directory, then start a run of one of its flows"
)
parser.add_argument("flow")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument(
"--seed",
type=int,
default=None,
help=(
"the run's seed: recorded on it, part of what tells two runs of one "
"configuration apart, and passed to an input named 'seed' when the "
"flow declares one"
),
)
parser.add_argument("--wait", action="store_true", help="block until it finishes")
parser.add_argument(
"--follow",
action="store_true",
help="wait, printing the numbers it reports as they arrive",
)
parser.add_argument("--timeout", type=float, default=0.0)
parser.add_argument(
"--timeout",
type=float,
default=0.0,
metavar="SECONDS",
help=(
"give up waiting after this long and leave the run going; 0, the "
"default, waits as long as it takes. Not the node timeout"
),
)
parser.add_argument(
"--no-sync",
action="store_true",
@@ -814,10 +856,29 @@ def add_parsers(subparsers: Any) -> None:
metavar="NAME=V1,V2",
help="an input and the values to try; repeat for a grid",
)
parser.add_argument("--seed", type=int, default=None)
parser.add_argument(
"--seed",
type=int,
default=None,
help="the seed every run in the sweep gets; vary it with --param seed=1,2",
)
parser.add_argument("--wait", action="store_true", help="block until all finish")
parser.add_argument("--timeout", type=float, default=0.0)
parser.add_argument("--no-sync", action="store_true")
parser.add_argument("--no-cache", action="store_true")
parser.add_argument(
"--timeout",
type=float,
default=0.0,
metavar="SECONDS",
help="give up waiting after this long; 0, the default, waits them out",
)
parser.add_argument(
"--no-sync",
action="store_true",
help="run what is already on the engine, without uploading first",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="execute every node, even one an earlier run already answered",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_sweep)
+5
View File
@@ -206,6 +206,11 @@ class Client:
result: dict[str, Any] = self._call("GET", "/observability/summary")
return result
def cloud_status(self) -> dict[str, Any]:
"""Whether this installation is enrolled with a portal, and linked."""
result: dict[str, Any] = self._call("GET", "/cloud/status")
return result
def events(self, kind: str = "failure", limit: int = 10) -> list[dict[str, Any]]:
"""What went wrong, or who changed what. Newest first."""
result = self._call(