feat(hub): agent_release в claim, GET /agent/release, состояние обновления в GET /agent (#22)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:11:10 +04:00
parent eb66c2144a
commit 87a0d3d36c
2 changed files with 102 additions and 5 deletions

View File

@ -13,7 +13,7 @@ from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings
from . import backup, db, mirror, queue, relay, release, settings
from .commands import CommandError
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
@ -39,9 +39,11 @@ class ApiError(Exception):
class Hub:
def __init__(self, db_path, backups_dir=None):
def __init__(self, db_path, backups_dir=None, release_dir=None):
self.db_path = Path(db_path)
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3)
self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
conn = self.conn()
@ -63,6 +65,18 @@ class Hub:
if conn:
conn.close()
def note_agent_update(self, body):
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
Значение неверного типа игнорируется — claim из-за него не отвергается."""
us = body.get("update_state")
if us is None or isinstance(us, dict):
if us is not None and isinstance(us.get("error"), str):
us = {**us, "error": us["error"][:500]}
self.agent_update["update_state"] = us
lv = body.get("launcher_version")
if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)):
self.agent_update["launcher_version"] = lv
def make_backup(self, label):
conn = self.conn()
try:
@ -91,6 +105,7 @@ def _not_found(obj, what):
def h_claim(hub, conn, m, q, body):
hub.note_agent_update(body)
deadline = time.monotonic() + hub.claim_wait
while True:
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"),
@ -98,7 +113,16 @@ def h_claim(hub, conn, m, q, body):
if cmd or time.monotonic() >= deadline:
break
time.sleep(1)
return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")}
rel = release.current(hub.release_dir)
return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"),
"agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None}
def h_agent_release(hub, conn, m, q, body):
rel = release.load(hub.release_dir)
if rel is None:
raise ApiError(404, "no_release", "версия агента не опубликована")
return 200, rel
def h_agent_settings(hub, conn, m, q, body):
@ -231,7 +255,11 @@ def h_refresh(hub, conn, m, q, body):
def h_agent_get(hub, conn, m, q, body):
return 200, queue.agent_view(conn)
view = queue.agent_view(conn)
rel = release.current(hub.release_dir)
us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"]
view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv))
return 200, view
def h_sync_runs(hub, conn, m, q, body):
@ -269,6 +297,7 @@ ROUTES = [
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
("POST", r"^/agent/state$", h_agent_state, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),

View File

@ -5,7 +5,7 @@ import urllib.request
import pytest
from jira_hub import api, db, settings
from jira_hub import api, db, release, settings
from jira_hub.client import HubClient, HubError
@ -394,3 +394,71 @@ def test_page_reads_hub_and_has_no_embedded_export():
assert "Нет в зеркале" in html
assert 'author: "viewer"' in html and "/batches/" in html
assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек
def _publish(hub, tmp_path, version):
src = tmp_path / f"agent{version}.py"
src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8")
return release.publish(hub["hub"].release_dir, src)
def test_claim_release_field(hub, tmp_path):
a = hub["agent"]
assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None
m = _publish(hub, tmp_path, 6)
r = a.post("/agent/claim", {"agent_version": 5})
assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]}
def test_agent_release_route(hub, tmp_path):
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
m = _publish(hub, tmp_path, 6)
r = hub["agent"].get("/agent/release")
assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"}
with pytest.raises(HubError) as e:
hub["client"].get("/agent/release")
assert e.value.status == 401
def test_claim_update_state_shown_in_agent(hub, tmp_path):
_publish(hub, tmp_path, 7)
us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600,
"at": "2026-10-14T20:00:00+00:00"}
hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1})
v = hub["client"].get("/agent")
assert v["release"]["version"] == 7 and v["release"]["published_at"]
assert v["launcher_version"] == 1
assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500
assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с")
def test_claim_without_update_fields(hub, tmp_path):
hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5,
"to_version": 6}, "launcher_version": 1})
hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет
v = hub["client"].get("/agent")
assert v["launcher_version"] is None and v["update_state"] is None
def test_claim_bad_update_types_ignored(hub):
a = hub["agent"]
a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1})
r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
assert "command" in r
a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True})
v = hub["client"].get("/agent")
assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1
def test_hint_without_launcher(hub, tmp_path):
_publish(hub, tmp_path, 6)
hub["agent"].post("/agent/claim", {"agent_version": 5})
v = hub["client"].get("/agent")
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
def test_agent_view_without_release(hub):
v = hub["client"].get("/agent")
assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None