Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,51 @@ tlgr --version # expect 2.0.0

`pip install -U tlgr` works the same way if that is how it was installed.

### A pipx editable install — the checkout *is* the install

If tlgr was installed with `pipx install -e <checkout>`, there is nothing to
download: the pipx venv holds a link to that working tree, so the version
that runs is whatever the checkout is currently on. Upgrading is a `git`
command, and that is exactly why it needs care — **moving the branch swaps
the running code under the daemon**, with no install step in between to warn
you. Section 1 is not optional here, and neither is stopping your own
watchdog: a supervisor that restarts the v1 daemon while the checkout is
mid-upgrade starts it on half-new code.

With the daemon stopped, move the checkout:

```bash
git -C <checkout> fetch
git -C <checkout> checkout main # or, on the deployed branch:
git -C <checkout> merge --ff-only origin/main
tlgr --version # expect 2.0.0
```

`--ff-only` on purpose: a merge commit in a deployment checkout is a local
edit nobody will remember making, and it fails loudly when the branch has
drifted instead of quietly resolving it.

Reinstall **only when the dependencies changed** — the code itself is already
live through the link. 2.0.0 is such a release: it adds `msgspec` and pins
`telethon~=1.44.0`, and neither of those reaches the pipx venv from a `git
checkout`. Install them into it:

```bash
pipx runpip tlgr install -e '<checkout>[fast]'
```

`pipx runpip` runs that venv's own pip, which is the only pip that can see
it; plain `pip install` from your shell installs into whatever else is on
`PATH`. Keep the extras you originally installed with — `[fast]` above, plus
`proxy`, `media` or `qr` if you use them — because the reinstall replaces the
extra set rather than adding to it. Then check the imports resolve before
starting anything:

```bash
tlgr --version # expect 2.0.0
tlgr agent whoami --json # imports msgspec and Telethon; fails loudly if either is missing
```

---

## 3. Clear the production marker
Expand Down
52 changes: 52 additions & 0 deletions tests/test_ops_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ def local(op_id: str, request: dict[str, Any] | None = None, **state: Any) -> An
return asyncio.run(spec.impl(context, payload))


def _human(*args: str) -> dict[str, str]:
"""Run a command through the real CLI and read back its key/value table.

Through click rather than the renderer directly: the bug being pinned was
a model that serialised to `{}`, which no assertion on the *object* can
see and which printed an empty screen.
"""
from click.testing import CliRunner

from tlgr.cli import cli

outcome = CliRunner().invoke(cli, list(args))
assert outcome.exit_code == 0, outcome.output
assert not outcome.output.lstrip().startswith("{"), "human mode printed an envelope"
rows: dict[str, str] = {}
for line in outcome.output.splitlines():
key, _, value = line.partition(" ")
if key:
rows[key.strip()] = value.strip()
return rows


async def alocal(in_thread, op_id: str, request: dict[str, Any] | None = None, **state: Any):
"""`local`, off the event loop.

Expand Down Expand Up @@ -86,6 +108,36 @@ def test_a_stopped_daemon_is_reported_not_guessed(self, tlgr_home, stub_account)
assert status.ready is False
assert status.healthy is False

def test_a_stopped_daemon_serialises_the_false_answers(self, tlgr_home, stub_account):
"""`omit_defaults` dropped every false and every zero, so the whole
result encoded to `{}` — the one shape this operation must never
return, because "no" is the answer it exists to give."""
import msgspec

payload = msgspec.to_builtins(local("daemon.status"))
assert payload["running"] is False
assert payload["ready"] is False
assert payload["healthy"] is False
assert payload["pid"] is None
assert payload["version"] is None
assert payload["socket"].endswith("daemon.sock")

def test_a_stopped_daemon_prints_a_table_not_an_envelope(self, tlgr_home, stub_account):
"""Human mode is the key/value table every other op renders; v1
printed `RUNNING false` and the empty result printed nothing."""
rendered = _human("daemon", "status")
assert rendered["running"] == "no"
assert rendered["ready"] == "no"
assert rendered["healthy"] == "no"
assert rendered["pid"] == "-"

def test_the_status_shortcut_answers_the_same_way(self, tlgr_home, stub_account):
"""`tlgr status` is a different operation asking the same question."""
rendered = _human("status")
assert rendered["daemon_running"] == "no"
assert rendered["connected"] == "no"
assert "the daemon is not running" in rendered["problems"]

def test_check_turns_an_unhealthy_daemon_into_an_exit_code(self, tlgr_home, stub_account):
from tlgr.core.errors import DaemonNotRunningError

Expand Down
10 changes: 10 additions & 0 deletions tests/test_registry_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ def test_lint_is_clean():
assert lint() == []


def test_only_the_schema_document_is_json_only():
"""`json-only` prints an envelope where a person asked for a table.

Right for exactly one operation — a JSON Schema document has no table
shape — and wrong for every other, most of all for the ones an operator
reads when something is broken.
"""
assert {spec.id for spec in SPECS if "json-only" in spec.tags} == {"agent.schema"}


class TestOperationContract:
@pytest.mark.parametrize("spec", SPECS, ids=IDS)
def test_example_validates(self, spec):
Expand Down
26 changes: 21 additions & 5 deletions tlgr/models/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,25 @@ class EventBusStatus(Model):
dropped: int = 0


class DaemonStatus(Model):
"""`tlgr daemon status`. `running` and `healthy` are different questions."""
class DaemonStatus(Model, omit_defaults=False):
"""`tlgr daemon status`. `running` and `healthy` are different questions.

`omit_defaults=False` for the whole struct: the answer this operation
exists to give is "no", and `omit_defaults` drops every false and every
zero — so a stopped daemon serialised to `{}`, which printed nothing at
all in human mode and made `status["running"]` a KeyError for the exact
caller the flag is for. v1 printed `RUNNING false`; a field here is
always present, whichever way the answer went.
"""

running: bool = False
ready: bool = False
healthy: bool = False
pid: int | None = None
uptime_seconds: int = 0
version: str = ""
#: `None` rather than `""` when the daemon is not answering: nobody read
#: a version off a process that is not there.
version: str | None = None
protocol: int = 0
layer: int = 0
socket: str = ""
Expand All @@ -102,8 +112,14 @@ class DaemonStatus(Model):
disconnected: list[str] = []


class HealthSummary(Model):
"""`tlgr status`: one screen, the states where everything else fails."""
class HealthSummary(Model, omit_defaults=False):
"""`tlgr status`: one screen, the states where everything else fails.

`omit_defaults=False` for the same reason as `DaemonStatus`: this screen
is read when nothing works, which is precisely when every answer on it is
false — and a screen that prints only the fields that happened to be true
is at its least useful exactly then.
"""

account: str = ""
user_id: int | None = None
Expand Down
15 changes: 13 additions & 2 deletions tlgr/ops/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,13 +730,24 @@ async def daemon_status(ctx: OpContext, req: DaemonStatusReq) -> DaemonStatus:
are the questions people were actually asking, and v1 could not tell them
apart: an account whose connection had died was still counted (COR-37).
"""
from tlgr.core.paths import TlgrPaths
from tlgr.core.process import read_pid

base = _base()
pid = read_pid(base)
status = _probe()
if status is None:
result = DaemonStatus(running=pid is not None, ready=False, healthy=False, pid=pid)
# The socket it *would* have asked, so "not running" says where it
# looked rather than only that it found nothing.
result = DaemonStatus(
running=pid is not None,
ready=False,
healthy=False,
pid=pid,
layer=_telethon_layer(),
socket=str(TlgrPaths(base).socket),
socket_owner=os.getuid(),
)
if req.check:
raise DaemonNotRunningError("the daemon is not answering on its socket")
return result
Expand All @@ -752,7 +763,7 @@ async def daemon_status(ctx: OpContext, req: DaemonStatusReq) -> DaemonStatus:
healthy=bool(info.get("ready")) and not unhealthy,
pid=info.get("pid") or pid,
uptime_seconds=int(info.get("uptime_s") or 0),
version=str(info.get("version", "")),
version=str(info["version"]) if info.get("version") else None,
protocol=int(info.get("protocol") or 0),
layer=_telethon_layer(),
socket=str(info.get("socket", "")),
Expand Down
Loading