Three fewer things to remember

**The portal link puts itself back up.** It already retried a connection that
raised, but a session that ended *cleanly* — a portal restarting, a proxy
closing an idle socket — returned normally and went straight back round the
loop with no wait at all, so an engine could spin against a portal that was
merely saying goodbye politely. Every ending now reconnects on a delay, and
the delay turns on whether the attempt got as far as attaching: one that stood
up and dropped is a network event and retries at once, one that never stood up
waits longer each time. Jittered, so a portal coming back is not met by every
installation it serves in the same instant. Ping timeouts are named rather
than defaulted, since they are what bounds how long a suspended laptop's dead
socket looks alive, and the keepalive task is awaited so the reason a link
went reaches the log instead of the garbage collector.

**`fluksio enroll <code>`** is the whole command now; hub.fluksio.com is the
default and `--portal` names another. The one command run before anything
works should not need two flags.

**`fluksio run` syncs first.** The reason a run exists is usually the edit
before it, so remembering to sync was remembering to do something the computer
could do — including the worker refresh, which is what makes an edit to your
own package take effect at all. `--no-sync` opts out for a tight loop.

That last one needed discovery fixed: it only ever looked at top-level `*.py`,
so a repository whose code is in a package — the ordinary shape — found
nothing from its own root. It now descends into the packages it holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
This commit is contained in:
2026-08-24 18:17:16 +02:00
co-authored by Claude Fable 5
parent 99f6530698
commit 7e4f03369b
8 changed files with 294 additions and 29 deletions
+37
View File
@@ -89,3 +89,40 @@ def test_ignoring_itself_leaves_an_existing_gitignore_alone(elsewhere: Path):
client.ignore_self(directory)
assert (directory / ".gitignore").read_text() == "mine\n"
def test_a_repository_whose_code_is_in_a_package_is_discovered(
elsewhere: Path, monkeypatch: pytest.MonkeyPatch
):
"""`fluksio sync` in a repo root has to find `myresearch/`, not just *.py.
This is the ordinary layout, and it is what `run` leans on to sync without
being told where to look.
"""
from fluksio.sdk import FLOWS
from fluksio.sdk.cli import discover
package = elsewhere / "mystudy"
package.mkdir()
(package / "__init__.py").write_text("")
(package / "pipeline.py").write_text(
"from fluksio import Flow, Port, node\n"
"\n"
"@node(provides=[Port('score', 'float')])\n"
"def scoring():\n"
" return {'score': 1.0}\n"
"\n"
"study = Flow('study', nodes=[scoring], outputs=['score'])\n"
)
# The things a repository root also holds, none of which is importable.
(elsewhere / ".venv").mkdir()
(elsewhere / ".fluksio").mkdir()
(elsewhere / "data").mkdir()
FLOWS.clear()
try:
found = discover(["."])
finally:
FLOWS.clear()
assert [flow.name for flow in found] == ["study"]
+21
View File
@@ -116,3 +116,24 @@ def test_serve_uses_the_installation_the_directory_belongs_to(
# And `--data-dir` still names any directory outright.
named = cli._data_dir(str(tmp_path / "named"))
assert named == (tmp_path / "named").resolve()
def test_enrolling_needs_only_a_claim_code() -> None:
"""The default portal is what makes first-run one flag rather than two."""
from fluksio.cli import DEFAULT_PORTAL, _parser
args = _parser().parse_args(["enroll", "ABC-123"])
assert args.portal == DEFAULT_PORTAL
assert DEFAULT_PORTAL.startswith("https://")
# And a portal of your own still wins.
mine = _parser().parse_args(["enroll", "ABC-123", "--portal", "https://hub.me"])
assert mine.portal == "https://hub.me"
def test_run_syncs_by_default_and_can_be_told_not_to() -> None:
from fluksio.cli import _parser
assert _parser().parse_args(["run", "train"]).no_sync is False
assert _parser().parse_args(["run", "train", "--no-sync"]).no_sync is True
+95
View File
@@ -0,0 +1,95 @@
"""The portal link goes back up on its own, whatever took it down.
A laptop that suspends, a wifi that changes, a portal that restarts: all of
them end the same session, and none of them should need anybody to notice.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from fluksio.cloud import config as cloud_config
from fluksio.cloud.connector import BASE_BACKOFF_S, MAX_BACKOFF_S, CloudConnector
class _Link:
"""A connector whose sessions do whatever a test says, instantly."""
def __init__(self, connector: CloudConnector) -> None:
self.connector = connector
self.waits: list[float] = []
self.sessions = 0
def run(self, outcomes: list[Any]) -> list[float]:
"""Drive `serve_forever` through `outcomes`, then let it return."""
async def session(_config: Any) -> bool:
outcome = outcomes[self.sessions]
self.sessions += 1
if self.sessions >= len(outcomes):
# The next iteration reads the config and stops.
cloud_config.load = lambda: None # type: ignore[assignment]
if isinstance(outcome, Exception):
raise outcome
return bool(outcome)
async def sleep(seconds: float) -> None:
self.waits.append(seconds)
self.connector._session = session # type: ignore[assignment]
with_patched = asyncio.sleep
asyncio.sleep = sleep # type: ignore[assignment]
try:
asyncio.run(self.connector.serve_forever())
finally:
asyncio.sleep = with_patched # type: ignore[assignment]
return self.waits
@pytest.fixture
def link(monkeypatch: pytest.MonkeyPatch) -> _Link:
monkeypatch.setattr(cloud_config, "load", lambda: object())
return _Link(CloudConnector(app=None)) # type: ignore[arg-type]
def test_a_portal_that_closes_the_socket_is_not_hammered(link: _Link):
"""A clean close used to return normally and reconnect with no wait."""
waits = link.run([True, True, True])
# One wait per session. Returning normally from a session used to skip the
# sleep entirely, so a portal that accepted and closed span the loop.
assert link.sessions == 3
assert len(waits) == 3, waits
assert all(wait > 0 for wait in waits), waits
def test_a_link_that_stood_up_retries_at_once(link: _Link):
"""A dropped connection is a network event, not a refusal."""
waits = link.run([True, True])
# Jittered around the base delay rather than backed off.
assert len(waits) == 2, waits
assert all(BASE_BACKOFF_S * 0.7 <= w <= BASE_BACKOFF_S * 1.3 for w in waits), waits
def test_a_link_that_never_attaches_backs_off(link: _Link):
waits = link.run([OSError("no route to host")] * 6)
assert waits[0] < waits[1] < waits[2], waits
assert max(waits) <= MAX_BACKOFF_S * 1.3
def test_backing_off_stops_at_the_ceiling(link: _Link):
waits = link.run([OSError("refused")] * 12)
assert max(waits) <= MAX_BACKOFF_S * 1.3, waits
def test_a_failure_after_a_good_session_starts_over_from_the_base(link: _Link):
"""Ten minutes of uptime should not inherit the last outage's delay."""
waits = link.run([OSError("x"), OSError("x"), OSError("x"), True, True])
assert waits[-1] <= BASE_BACKOFF_S * 1.3, waits