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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user