Operator-and-analyst quality bundle: a security fix for the optional
Telegram bot, two CLI gaps closed, and three rounds of UX polish on
`agnes diagnose` and `agnes pull` so non-TTY consumers (CI runners,
Claude Code SessionStart hooks, sub-agent watchdogs) get readable,
actionable signal.
- Pairing-code RNG: random.choices -> secrets.choice (CSPRNG).
- Telegram script runner: refuse out-of-shape usernames before sudo -u.
CLAUDE.md.bak.<ISO-timestamp> before regenerating.
- agnes admin unregister-table <id> -> DELETE /api/admin/registry/{id}
- agnes admin update-table <id> --field=value ... -> PUT /api/admin/registry/{id}
response but never promotes the headline. BQ billing-equals-data check
downgraded warning -> info.
default (5 s / 1 MiB vs 30 s / 10%) so sub-agent watchdogs don't kill
the pull as a hung process. New env knobs:
AGNES_PULL_PROGRESS_INTERVAL_{SECONDS,BYTES}.
--include-schema (or ?include=schema) to opt back in.
Tests: 120 passed across the touched modules, including new tests for
each fix. Pre-existing failures on main (DB migration v1->v9, binary
rename) are unrelated and not introduced here.
185 lines
7.3 KiB
Python
185 lines
7.3 KiB
Python
"""Tests for `agnes pull` progress UX (Change 3).
|
|
|
|
The Rich progress bar handles the TTY case fine, but Claude Code's
|
|
SessionStart context — and any hook running `agnes pull` non-interactively —
|
|
has stderr connected to a pipe, not a TTY. In that case Rich either
|
|
suppresses output entirely or emits raw ANSI noise into the consumer's
|
|
log. Goal: when the caller asks for progress and stderr is not a TTY,
|
|
emit a plain-text per-10%-or-30s update so the operator gets *some*
|
|
signal instead of multi-minute silence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_config_dir(tmp_path, monkeypatch):
|
|
cfg = tmp_path / "_cfg"
|
|
cfg.mkdir()
|
|
monkeypatch.setenv("AGNES_CONFIG_DIR", str(cfg))
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_pull_io(monkeypatch):
|
|
"""Stub the manifest + memory + download endpoints so run_pull can
|
|
execute end-to-end with a fake parquet write per table."""
|
|
canned_manifest = {
|
|
"tables": {
|
|
"tbl_big": {"hash": "h1", "rows": 0, "size_bytes": 1_000_000},
|
|
},
|
|
}
|
|
canned_memory = {"mandatory": [], "approved": []}
|
|
|
|
def _api_get(path, *args, **kwargs):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
if path == "/api/sync/manifest":
|
|
resp.json.return_value = canned_manifest
|
|
elif path == "/api/memory/bundle":
|
|
resp.json.return_value = canned_memory
|
|
resp.raise_for_status = lambda: None
|
|
return resp
|
|
|
|
def _stream_download(path, target_path, progress_callback=None):
|
|
# Simulate a chunked download: emit progress in 4 increments
|
|
# totaling the announced size.
|
|
total = 1_000_000
|
|
slices = [total // 4] * 3 + [total - 3 * (total // 4)]
|
|
Path(target_path).write_bytes(b"PAR1" + b"\x00" * 1000 + b"PAR1")
|
|
if progress_callback:
|
|
for s in slices:
|
|
progress_callback(s)
|
|
return total
|
|
|
|
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
|
|
monkeypatch.setattr("cli.lib.pull.stream_download", _stream_download,
|
|
raising=False)
|
|
monkeypatch.setattr("cli.lib.pull._is_valid_parquet", lambda p: True,
|
|
raising=False)
|
|
monkeypatch.setattr("cli.lib.pull._file_md5", lambda p: "h1", raising=False)
|
|
|
|
|
|
def test_textual_progress_when_stderr_is_not_tty(
|
|
tmp_path, fake_pull_io, monkeypatch, capsys,
|
|
):
|
|
"""Non-TTY stderr → emit a plain-text progress line per file."""
|
|
# Force the non-TTY branch even if pytest's fake stderr is a tty.
|
|
monkeypatch.setattr("sys.stderr.isatty", lambda: False, raising=False)
|
|
|
|
from cli.lib.pull import run_pull
|
|
result = run_pull(
|
|
server_url="http://x", token="t", workspace=tmp_path,
|
|
show_progress=True,
|
|
)
|
|
captured = capsys.readouterr()
|
|
# Some indication of the file + bytes ran; we don't pin exact format.
|
|
assert "tbl_big" in captured.err
|
|
assert result.tables_updated == 1
|
|
# No raw ANSI escape sequences in the textual fallback.
|
|
assert "\x1b[" not in captured.err.split("tbl_big")[0]
|
|
|
|
|
|
def test_no_progress_output_when_show_progress_is_false(
|
|
tmp_path, fake_pull_io, monkeypatch, capsys,
|
|
):
|
|
"""`show_progress=False` (the SessionStart hook path) emits no
|
|
progress text on stderr in either TTY or non-TTY mode."""
|
|
monkeypatch.setattr("sys.stderr.isatty", lambda: False, raising=False)
|
|
|
|
from cli.lib.pull import run_pull
|
|
run_pull(
|
|
server_url="http://x", token="t", workspace=tmp_path,
|
|
show_progress=False,
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "tbl_big" not in captured.err
|
|
|
|
|
|
def test_textual_progress_emits_at_completion(
|
|
tmp_path, fake_pull_io, monkeypatch, capsys,
|
|
):
|
|
"""At least one final completion line gets emitted per file even if
|
|
the throttle window doesn't trigger mid-file."""
|
|
monkeypatch.setattr("sys.stderr.isatty", lambda: False, raising=False)
|
|
from cli.lib.pull import run_pull
|
|
run_pull(
|
|
server_url="http://x", token="t", workspace=tmp_path,
|
|
show_progress=True,
|
|
)
|
|
captured = capsys.readouterr()
|
|
# Final line marks the file as done — either "100%" or a "✓ tbl_big" /
|
|
# "tbl_big done" indicator. We accept any final-completion form.
|
|
assert (
|
|
"100%" in captured.err
|
|
or "done" in captured.err.lower()
|
|
or "complete" in captured.err.lower()
|
|
)
|
|
|
|
|
|
class TestProgressIntervalKnobs:
|
|
"""Issue #203: cadence is configurable via env vars so non-TTY
|
|
consumers (CI runners, sub-agent watchdogs) can tighten the floor
|
|
when the default is too quiet for their dead-process detector."""
|
|
|
|
def _stream(self):
|
|
return io.StringIO()
|
|
|
|
def test_default_seconds_floor_is_5s(self, monkeypatch):
|
|
"""Default cadence is 5 s (was 30 s pre-#203)."""
|
|
monkeypatch.delenv("AGNES_PULL_PROGRESS_INTERVAL_SECONDS", raising=False)
|
|
from cli.lib.pull import _read_progress_interval_seconds
|
|
assert _read_progress_interval_seconds() == 5.0
|
|
|
|
def test_default_bytes_floor_is_1mib(self, monkeypatch):
|
|
"""Default cadence is 1 MiB; complements the time-based floor."""
|
|
monkeypatch.delenv("AGNES_PULL_PROGRESS_INTERVAL_BYTES", raising=False)
|
|
from cli.lib.pull import _read_progress_interval_bytes
|
|
assert _read_progress_interval_bytes() == 1024 * 1024
|
|
|
|
def test_seconds_env_override(self, monkeypatch):
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_SECONDS", "0.5")
|
|
from cli.lib.pull import _read_progress_interval_seconds
|
|
assert _read_progress_interval_seconds() == 0.5
|
|
|
|
def test_bytes_env_override(self, monkeypatch):
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_BYTES", "131072")
|
|
from cli.lib.pull import _read_progress_interval_bytes
|
|
assert _read_progress_interval_bytes() == 131072
|
|
|
|
def test_invalid_envs_fall_back_to_default(self, monkeypatch):
|
|
"""Garbage input doesn't break the pull — fall back to defaults."""
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_SECONDS", "nope")
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_BYTES", "-1")
|
|
from cli.lib.pull import (
|
|
_read_progress_interval_bytes,
|
|
_read_progress_interval_seconds,
|
|
)
|
|
assert _read_progress_interval_seconds() == 5.0
|
|
assert _read_progress_interval_bytes() == 1024 * 1024
|
|
|
|
def test_byte_floor_emits_more_often_than_pct_threshold(self, monkeypatch):
|
|
"""A 100 MB file with 1 MiB byte cadence should emit far more
|
|
than 10 progress lines (the 10%-of-total cadence alone would).
|
|
This was the operator complaint in #203: on multi-GB parquets
|
|
the 30 s / 10 % policy produced one line every ~3 minutes."""
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_SECONDS", "9999")
|
|
monkeypatch.setenv("AGNES_PULL_PROGRESS_INTERVAL_BYTES", "1048576")
|
|
from cli.lib.pull import _TextualProgress
|
|
sink = self._stream()
|
|
total = 100 * 1024 * 1024 # 100 MiB
|
|
prog = _TextualProgress(
|
|
stream=sink, total_files=1, file_sizes={"tbl": total}
|
|
)
|
|
chunk = 64 * 1024 # 64 KiB chunks → 1600 advances
|
|
for _ in range(total // chunk):
|
|
prog.advance("tbl", chunk)
|
|
prog.finish()
|
|
emitted = sink.getvalue().count("\n")
|
|
assert emitted >= 50, f"only {emitted} lines emitted; cadence too coarse"
|