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.

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 07:42:09 +02:00
co-authored by Claude Opus 5
parent 93374a310e
commit 4941c2787c
12 changed files with 312 additions and 16 deletions
-1
View File
@@ -171,7 +171,6 @@ class ExecutionService:
except Exception as exc:
logger.error("Could not touch claimed work: %s", exc)
if now - last_reclaim < RECLAIM_INTERVAL_S:
continue
last_reclaim = now
+1 -1
View File
@@ -126,7 +126,7 @@ class WorkQueue(ABC):
def ack(self, item: WorkItem) -> None:
"""Mark an item done, so it is never redelivered."""
def touch(self, entry_ids: list[str]) -> None:
def touch(self, entry_ids: list[str]) -> None: # noqa: B027
"""Say these items are still being worked on, not abandoned.
A node with no timeout may run far longer than the reclaim window, and
+3 -1
View File
@@ -275,7 +275,9 @@ def node(
if device_policy not in ("require", "prefer"):
raise SyncError("device_policy is 'require' or 'prefer'")
if timeout is not None and timeout < 0:
raise SyncError(f"node timeout must be 0 or more (0 disables it), got {timeout}")
raise SyncError(
f"node timeout must be 0 or more (0 disables it), got {timeout}"
)
def decorate(fn: F) -> F:
required, _ = _ports(requires)
+197 -2
View File
@@ -1,4 +1,4 @@
"""The `fluksio sync`, `run`, `runs`, `sweep` and `login` commands.
"""The `fluksio sync`, `run`, `runs`, `sweep`, `status` and `login` commands.
Kept beside the SDK rather than in `fluksio.cli`: these are the client half of
the tool, and none of them needs the engine to be importable — `--local`, which
@@ -308,6 +308,33 @@ def _input_types(definition: dict[str, Any]) -> dict[str, str]:
}
def _ask_params(definition: dict[str, Any]) -> dict[str, Any]:
"""The run dialog, in a terminal: one line per declared input.
An empty answer leaves the input out, which is what keeps its declared
value — the same thing the browser's dialog does with a field nobody
filled in. So pressing Enter through the lot runs the defaults.
"""
params: dict[str, Any] = {}
for entry in definition.get("inputs") or []:
spec = entry.get("spec") or {}
name = str(spec.get("name", ""))
if not name:
continue
dtype = str(spec.get("dtype", "float"))
initial = entry.get("initial")
shown = "" if initial is None else json.dumps(initial)
prompt = f"{name} ({dtype})" + (f" [{shown}]" if shown else "") + ": "
answer = input(prompt).strip()
if not answer:
continue
try:
params[name] = _coerce(answer, dtype)
except ValueError as exc:
raise SyncError(f"'{name}' takes {dtype}: {exc}") from exc
return params
def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
"""Turn `--lr 0.05` into a typed parameter, using the flow's own inputs."""
types = _input_types(definition)
@@ -420,7 +447,18 @@ def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
params = _params(stored.get("definition") or {}, rest)
definition = stored.get("definition") or {}
params = _params(definition, rest)
# Nothing on the command line and somebody watching: ask, the way
# pressing Run in the browser asks. A scripted run — piped, or
# carrying parameters already — is left exactly as it was.
if (
not params
and not args.defaults
and (definition.get("inputs") or [])
and sys.stdin.isatty()
):
params = _ask_params(definition)
handle = client.submit(
args.flow, params, seed=args.seed, no_cache=args.no_cache
)
@@ -443,6 +481,147 @@ def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
return _fail(str(exc))
# ---------------------------------------------------------------------------
# Status
#
# The home screen's top half, in a terminal: how the engine is, what each flow
# is doing, and what failed recently. Drawn with rich, which is already here
# under fastapi's own CLI.
# ---------------------------------------------------------------------------
#: How often `--watch` asks again. The dashboard polls its summary every ten
#: seconds; nothing here moves faster than that.
WATCH_INTERVAL_S = 5.0
def _flow_state(flow: dict[str, Any]) -> str:
if flow.get("quarantined"):
return "quarantined"
if not flow.get("enabled", True):
return "stopped"
if flow.get("paused"):
return "paused"
return "running"
def _status_screen(client: Client) -> Any:
"""One frame: health, the flows, and the failures under them."""
from rich.console import Group
from rich.table import Table
from rich.text import Text
summary = client.summary()
flows = client.flows()
# Both, because an installation is usually one or the other: a live flow
# 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)
healthy = summary.get("status") == "ok"
head = Text()
head.append(
"healthy" if healthy else str(summary.get("status", "unknown")),
style="green" if healthy else "yellow",
)
problems = summary.get("problems") or []
if problems:
head.append(" " + " · ".join(str(p) for p in problems), style="yellow")
counts = summary.get("flows") or {}
nodes = summary.get("nodes") or {}
queue = summary.get("queue") or {}
lag = summary.get("loop_lag") or {}
facts = Text(
f"{counts.get('running', 0)}/{counts.get('total', 0)} flows running "
f"{nodes.get('total', 0)} nodes"
+ (f", {nodes['error']} failed" if nodes.get("error") else "")
+ f" queue {queue.get('pending', 0)} pending"
+ (f", {queue['parked']} parked" if queue.get("parked") else "")
+ f" loop lag {lag.get('ewma', 0.0) * 1000:.0f}ms",
style="dim",
)
table = Table(box=None, pad_edge=False, header_style="dim")
table.add_column("flow")
table.add_column("state")
table.add_column("nodes", justify="right")
table.add_column("")
for flow in sorted(flows, key=lambda one: str(one.get("name", ""))):
state = _flow_state(flow)
notes = []
if flow.get("error_count"):
notes.append(f"{flow['error_count']} in error")
if flow.get("has_draft"):
notes.append("unpublished changes")
table.add_row(
str(flow.get("title") or flow.get("name", "")),
Text(
state,
style={"running": "green", "paused": "yellow"}.get(state, "red"),
),
str(flow.get("node_count", 0)),
Text(" · ".join(notes), style="red" if flow.get("error_count") else "dim"),
)
parts: list[Any] = [head, facts, ""]
parts.append(
table if flows else Text("No flows yet. `fluksio sync` uploads yours.", "dim")
)
if runs:
parts += ["", Text("recent runs", style="dim")]
for row in runs:
state = str(row.get("status", ""))
parts.append(
Text(f" {str(row.get('id', ''))[-8:]} ")
+ Text(
f"{state:<9}",
style={"ok": "green", "cached": "cyan", "running": "cyan"}.get(
state, "red" if state == "error" else "yellow"
),
)
+ Text(
f"{str(row.get('flow', '')):<16}"
f"{(row.get('duration_ms') or 0) / 1000:7.1f}s",
style="dim",
)
)
if failures:
parts += ["", Text("recent failures", style="dim")]
for event in failures:
where = " ".join(
str(event.get(key, "")) for key in ("flow", "node") if event.get(key)
)
parts.append(
Text(f" {where} ", style="red")
+ Text(str(event.get("detail", ""))[:100], style="dim")
)
return Group(*parts)
def cmd_status(args: argparse.Namespace) -> int:
"""How the engine is doing, once or until Ctrl-C."""
from rich.console import Console
from rich.live import Live
console = Console()
try:
with _client_for(args) as client:
if not args.watch:
console.print(_status_screen(client))
return 0
if not sys.stdout.isatty():
return _fail("--watch needs a terminal; without one, drop it")
with Live(_status_screen(client), console=console) as live:
while True:
time.sleep(WATCH_INTERVAL_S)
live.update(_status_screen(client))
except KeyboardInterrupt:
return 130
except (SyncError, ApiError) as exc:
return _fail(str(exc))
def cmd_runs(args: argparse.Namespace) -> int:
try:
with _client_for(args) as client:
@@ -593,9 +772,25 @@ def add_parsers(subparsers: Any) -> None:
action="store_true",
help="execute every node, even one an earlier run already answered",
)
parser.add_argument(
"--defaults",
action="store_true",
help="take every input's declared value instead of asking for it",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_run)
parser = subparsers.add_parser(
"status", help="how the engine is doing, and what each flow is up to"
)
parser.add_argument(
"--watch",
action="store_true",
help="keep it on screen, refreshed until Ctrl-C",
)
with_engine(parser)
parser.set_defaults(func=cmd_status)
parser = subparsers.add_parser("runs", help="the runs an engine has recorded")
parser.add_argument("--flow", default="")
parser.add_argument("--limit", type=int, default=20)
+19
View File
@@ -194,6 +194,25 @@ class Client:
"""Retire the engine's workers, so the next run imports the code as it is."""
self._call("POST", "/modules/refresh")
def flows(self) -> list[dict[str, Any]]:
"""Every flow with its node and error counts, as the home screen lists."""
result = self._call("GET", "/flows/")
return list(result.get("data") or [])
# -- how the engine is doing -------------------------------------------
def summary(self) -> dict[str, Any]:
"""Health as the dashboard reads it: flows, nodes, queue, loop lag."""
result: dict[str, Any] = self._call("GET", "/observability/summary")
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(
"GET", "/observability/events", params={"kind": kind, "limit": limit}
)
return list(result or [])
# -- runs --------------------------------------------------------------
def submit(