Close the nine open SDK tasks: one engine per directory, a tabbed dashboard, re-pairing, run recovery
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 17s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m30s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m19s

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9BoNGq6V9MdRWAte7JBuC
This commit is contained in:
2026-08-31 17:37:13 +02:00
co-authored by Claude Opus 5
parent bdad6d7fc2
commit 8a94bf10d7
14 changed files with 873 additions and 140 deletions
+75 -17
View File
@@ -808,36 +808,49 @@ def _status_screen(client: Client) -> Any:
parts.append(
table if flows else Text("No flows yet. `fluksio sync` uploads yours.", "dim")
)
# Tables rather than padded strings: a flow named longer than the column
# used to push everything after it out of line.
if runs:
parts += ["", Text("recent runs", style="dim")]
recent = Table(box=None, pad_edge=False, show_header=False)
recent.add_column("")
# Minimums rather than widths: the column keeps its shape when every
# value is short and grows for the one that is not.
recent.add_column("", min_width=9)
recent.add_column("", min_width=14)
recent.add_column("", justify="right", min_width=7)
recent.add_column("", justify="right", min_width=8)
for row in runs:
state = str(row.get("status", ""))
parts.append(
Text(f" {str(row.get('id', ''))[-8:]} ")
+ Text(
f"{state:<9}",
recent.add_row(
f" {str(row.get('id', ''))[-8:]}",
Text(
state,
style={"ok": "green", "cached": "cyan", "running": "cyan"}.get(
state, "red" if state == "error" else "yellow"
),
)
+ Text(
f"{str(row.get('flow', '')):<16}"
f"{_dur(row.get('duration_ms')):>8}"
f" {_ago(row.get('finished_at') or row.get('created_at')):>9}",
style="dim",
)
),
Text(str(row.get("flow", "")), style="dim"),
Text(_dur(row.get("duration_ms")), style="dim"),
Text(
_ago(row.get("finished_at") or row.get("created_at")), style="dim"
),
)
parts += ["", Text("recent runs", style="dim"), recent]
if failures:
parts += ["", Text("recent failures", style="dim")]
broken = Table(box=None, pad_edge=False, show_header=False)
broken.add_column("")
broken.add_column("", justify="right")
broken.add_column("")
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(f"{_ago(event.get('ts')):>9} ", style="dim")
+ Text(str(event.get("detail", ""))[:100], style="dim")
broken.add_row(
Text(f" {where}", style="red"),
Text(_ago(event.get("ts")), style="dim"),
Text(str(event.get("detail", ""))[:100], style="dim"),
)
parts += ["", Text("recent failures", style="dim"), broken]
return Group(*parts)
@@ -986,6 +999,38 @@ def cmd_runs(args: argparse.Namespace) -> int:
return 0
#: The most runs of one group a retry looks at, which is the list route's own
#: ceiling. ponytail: a sweep larger than this needs paging, not a bigger number.
MAX_GROUP = 500
#: What a retry leaves alone. Everything else in a group — error, cancelled,
#: abandoned — is what "the ones that did not make it" means.
KEPT = frozenset({"ok", "cached", "queued", "running"})
def cmd_retry(args: argparse.Namespace) -> int:
"""Run the same thing again, one run or a group's unfinished ones."""
try:
with _client_for(args) as client:
ids = list(args.run_id)
if args.group:
ids += [
str(row["id"])
for row in client.runs(limit=MAX_GROUP, group=args.group)
if str(row["status"]) not in KEPT
]
if not ids:
return _fail("nothing to retry; name a run or a group with runs in it")
for run_id in ids:
made = client.retry(run_id)
_say(f"{made['id']} queued (retry of {run_id})")
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
return 0
def _artifacts(client: Client, args: argparse.Namespace) -> int:
"""List a run's files, or write one of them here."""
handle = RunHandle(client, args.run_id, client.run(args.run_id))
@@ -1401,6 +1446,19 @@ def add_parsers(subparsers: Any) -> None:
with_engine(parser, local=True)
parser.set_defaults(func=cmd_runs)
parser = subparsers.add_parser(
"retry", help="run something again: one run, or a group's unfinished ones"
)
parser.add_argument("run_id", nargs="*", help="the runs to retry")
parser.add_argument(
"--group",
default="",
metavar="ID",
help="every run of this sweep that did not end ok",
)
with_engine(parser)
parser.set_defaults(func=cmd_retry)
parser = subparsers.add_parser(
"artifacts", help="the files a run produced; name one to download it"
)