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
+101
View File
@@ -492,6 +492,107 @@ def test_the_seed_is_recorded_the_same_way_however_it_arrived():
session.commit()
def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
"""The way back from a run an engine restart interrupted.
Re-issuing the whole sweep is the blunt version; this keeps the group, so
what was missing is filled in rather than run twice.
"""
flow = FlowDef(
name="study",
mode="batch",
inputs=[
FlowInput(spec=MessageSpec(name="lr", dtype=DType.FLOAT), initial=0.01)
],
)
queue = _Collect()
service = RunService(controller=_OneFlow(flow), queue=queue)
made = []
with Session(db_engine) as session:
session.add(
Run(
id="abandoned-1",
flow="study",
status="abandoned",
params={"lr": 0.3},
group_id="sweep-9",
created_at=datetime.now(UTC),
)
)
session.add(
Run(
id="going-1",
flow="study",
status="running",
created_at=datetime.now(UTC),
)
)
session.commit()
try:
again = service.retry("abandoned-1", actor="someone@example.com")
made.append(again.id)
assert again.parent_id == "abandoned-1"
assert (again.params, again.group_id, again.cause) == (
{"lr": 0.3},
"sweep-9",
"retry",
)
assert queue.items[-1].run_id == again.id
# A run that has not finished is cancelled, not retried.
with pytest.raises(RunRejected):
service.retry("going-1")
with pytest.raises(RunRejected):
service.retry("never-existed")
finally:
with Session(db_engine) as session:
for run in session.exec(
select(Run).where(col(Run.id).in_([*made, "abandoned-1", "going-1"]))
).all():
session.delete(run)
session.commit()
def test_runs_left_queued_are_woken_by_the_next_engine():
"""The row is the record, and the work item is not part of its transaction.
An in-memory queue loses the item with the process and a stream item
nobody claimed is nobody's, so without this a run submitted just before a
restart waits for an engine that will never be told about it.
"""
queue = _Collect()
service = RunService(controller=_Unusable(), queue=queue)
with Session(db_engine) as session:
session.add(
Run(
id="orphan-1",
flow="study",
status="queued",
created_at=datetime.now(UTC),
)
)
session.add(
Run(
id="finished-1",
flow="study",
status="ok",
created_at=datetime.now(UTC),
)
)
session.commit()
try:
service._requeue_queued()
assert [item.run_id for item in queue.items] == ["orphan-1"]
assert queue.items[0].flow == "study"
finally:
with Session(db_engine) as session:
for run in session.exec(
select(Run).where(col(Run.id).in_(["orphan-1", "finished-1"]))
).all():
session.delete(run)
session.commit()
def test_a_key_nobody_used_submits_normally(
client, superuser_token_headers, monkeypatch
):
+72
View File
@@ -4,6 +4,8 @@ import subprocess
import sys
from pathlib import Path
import pytest
from fluksio.cli import load_or_create_secret_key
@@ -689,6 +691,41 @@ def test_the_dashboard_runs_the_engine_as_a_child_of_itself(monkeypatch) -> None
assert opened == []
@pytest.mark.anyio
async def test_the_dashboard_is_three_tabs_and_lets_go_of_its_threads(
tmp_path, monkeypatch
) -> None:
"""The tabs, and what `q` has to do before the screen goes.
Its two readers are worker threads on the event loop's own executor, which
is joined while the loop closes — so a reader still blocked on the log file
or the websocket holds the terminal after the dashboard has gone, which is
what `q` used to do.
"""
from textual.widgets import TabbedContent
from fluksio import cli
from fluksio.tui.app import ServeApp
# Nothing is started under this screen: the engine has its own tests.
monkeypatch.setattr(ServeApp, "start_engine", lambda self: None)
args = cli._parser().parse_args(["serve", "--data-dir", str(tmp_path / ".fluksio")])
app = ServeApp(args)
async with app.run_test() as pilot:
tabs = app.query_one(TabbedContent)
assert tabs.active == "overview-tab"
await pilot.press("2")
assert tabs.active == "runs-tab"
# The run keys live on the table, so they are in the footer only here.
assert "app.pick" in {binding[1] for binding in app.query_one("#runs").BINDINGS}
await pilot.press("3")
assert tabs.active == "logs-tab"
await pilot.press("q")
assert app.stop_log.is_set()
assert app.stop_stream.is_set()
def test_a_serving_engine_records_itself_until_it_stops(tmp_path) -> None:
"""A pid nobody is running is the same as no pidfile at all."""
import os
@@ -741,6 +778,41 @@ def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
assert probe_engine("http://127.0.0.1:1", "t") == "other"
def test_a_second_engine_for_one_directory_is_refused(tmp_path, monkeypatch, capsys):
"""One SQLite file, one engine — whatever port the second was asked for.
The refusal is worth more than the duplicate it prevents: the second
engine signs in on the way up, so `client.json` would point at a port
that dies with it and every later command would reach nothing.
"""
import json
import os
from fluksio import cli
data_dir = tmp_path / ".fluksio"
data_dir.mkdir()
(data_dir / "client.json").write_text(
json.dumps({"url": "http://127.0.0.1:8000", "token": "the-first-one"})
)
cli.write_pidfile(data_dir, 8000)
monkeypatch.setattr(cli, "probe_engine", lambda *a, **k: "ours")
args = cli._parser().parse_args(
["serve", "--plain", "--port", "8123", "--data-dir", str(data_dir)]
)
assert cli.cmd_serve(args) == 0
said = capsys.readouterr().out
assert "already serving" in said and str(os.getpid()) in said
# The credential still names the engine that is actually up.
stored = json.loads((data_dir / "client.json").read_text())
assert stored["url"] == "http://127.0.0.1:8000"
# A pid that is alive but is not an engine of ours is not a refusal.
monkeypatch.setattr(cli, "probe_engine", lambda *a, **k: "foreign")
assert cli.already_serving(data_dir, "127.0.0.1") == ""
def test_serve_moves_off_a_port_that_is_taken() -> None:
"""A first start should not die on somebody else's dev server."""
import socket
+114 -1
View File
@@ -25,7 +25,8 @@ from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.flow.panels import PanelDef, PanelsConfig, write_config
from fluksio.models import User
from tests.utils.portal import ISSUER, portal_token
from tests.utils.portal import ISSUER, jwks, portal_token
from tests.utils.user import create_random_user
def test_portal_token_is_refused_when_not_enrolled(
@@ -101,6 +102,85 @@ def test_status_reports_not_enrolled(
assert response.json()["enrolled"] is False
def test_enrolling_again_replaces_the_connection(
client: TestClient,
enrolled: User,
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""A new claim code re-pairs rather than being refused.
Deleting cloud.json by hand used to be the only way through, and it takes
every remote connection with it. The mappings of the portal being left go
too: nothing in the row says which portal issued the subject, so one kept
from the old one would resolve a stranger onto a local account.
"""
stale = create_random_user(db)
stale.portal_sub = "portal-user-7"
db.add(stale)
db.commit()
elsewhere = "https://other.example.test"
reply = Mock(
status_code=200,
json=Mock(
return_value={
"ws_url": f"{elsewhere}/api/v1/tunnel/attach",
"instance_id": "11111111-2222-3333-4444-555555555555",
"instance_token": "the-new-token",
"issuer": elsewhere,
"jwks": jwks(portal_key),
"owner_id": "portal-user-9",
}
),
)
with patch("fluksio.cloud.enroll.httpx.post", return_value=reply):
again = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": elsewhere, "claim_code": "ABCD-EFGH"},
)
assert again.status_code == 200, again.text
config = cloud_config.load()
assert config is not None
assert config.portal_url == elsewhere
assert config.token == "the-new-token"
db.expire_all()
assert db.get(User, enrolled.id).portal_sub == "portal-user-9"
# The other portal's mapping is not carried over to this one.
assert db.get(User, stale.id).portal_sub is None
def test_a_claim_the_portal_refuses_keeps_the_connection(
client: TestClient,
enrolled: User,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""Nothing is written until the portal has accepted the code.
That ordering is what makes replacing safe: a mistyped code leaves the
instance connected to the portal it was connected to.
"""
before = cloud_config.load()
with patch("fluksio.cloud.enroll.httpx.post", return_value=Mock(status_code=404)):
refused = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": ISSUER, "claim_code": "NOPE-NOPE"},
)
assert refused.status_code == 400, refused.text
after = cloud_config.load()
assert before is not None and after is not None
assert (before.instance_id, before.token) == (after.instance_id, after.token)
db.expire_all()
assert db.get(User, enrolled.id).portal_sub == "portal-user-1"
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
@@ -388,6 +468,39 @@ async def test_a_config_that_appears_while_running_is_dialled(monkeypatch) -> No
assert started
@pytest.mark.anyio
async def test_an_enrolment_replaced_while_running_is_redialled(monkeypatch) -> None:
"""`fluksio enroll` is its own process and cannot cancel the live link.
Without this the tunnel would stay up on the credential that was replaced,
since the watcher only ever started a link where there was none.
"""
from types import SimpleNamespace
from fluksio.cloud import connector
live = asyncio.create_task(asyncio.sleep(30))
app = Mock()
app.state = Mock(
cloud_task=live, cloud_connector=Mock(), cloud_identity=("old", "old-token")
)
started: list[object] = []
monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01)
monkeypatch.setattr(connector, "start", lambda one: started.append(one))
monkeypatch.setattr(
cloud_config,
"load",
lambda: SimpleNamespace(instance_id="new", token="new-token"),
)
watcher = asyncio.create_task(connector.watch_enrolment(app))
await asyncio.sleep(0.05)
watcher.cancel()
assert live.cancelled() or live.done()
assert started
@pytest.mark.anyio
async def test_a_config_that_cannot_be_read_is_not_dialled(monkeypatch) -> None:
"""Otherwise the connector gives up at once and this restarts it forever."""