feat(hub): публикация версии агента — release.py и jira-hub agent publish (#22)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:09:28 +04:00
parent 4e8b32310e
commit eb66c2144a
5 changed files with 239 additions and 1 deletions

View File

@ -1,4 +1,5 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens."""
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens,
agent publish (версия агента для самообновления)."""
import argparse
import json
import sys
@ -19,8 +20,22 @@ def main(argv=None) -> int:
p.add_argument("folder")
sub.add_parser("mcp", help="MCP-сервер по stdio")
sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления")
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)")
p.add_argument("path")
args = ap.parse_args(argv)
if args.cmd == "agent":
from . import release
try:
m = release.publish(paths.release_dir(), args.path)
except release.PublishError as e:
print(e, file=sys.stderr)
return 1
print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт")
return 0
if args.cmd == "serve":
from . import api
hub = api.Hub(paths.db_path())

View File

@ -18,6 +18,10 @@ def backups_dir() -> Path:
return home() / "backups"
def release_dir() -> Path:
return home() / "release"
def secure_dir(path: Path) -> None:
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
предупреждение, хаб продолжает работу."""

104
src/jira_hub/release.py Normal file
View File

@ -0,0 +1,104 @@
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5."""
import hashlib
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from .paths import secure_dir
log = logging.getLogger(__name__)
MAX_SIZE = 1024 * 1024
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
class PublishError(Exception):
pass
def parse_version(text: str) -> int:
found = VERSION_RE.findall(text)
if len(found) != 1:
raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}")
return int(found[0])
def _write_atomic(path: Path, data: bytes) -> None:
tmp = path.with_name("." + path.name + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, path)
def publish(release_dir, source) -> dict:
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной)."""
src = Path(source)
try:
data = src.read_bytes()
except OSError as e:
raise PublishError(f"файл не прочитан {src}: {e}") from e
if len(data) > MAX_SIZE:
raise PublishError(f"файл больше 1 МБ ({len(data)} байт)")
try:
text = data.decode("utf-8")
except UnicodeDecodeError as e:
raise PublishError("файл не в UTF-8") from e
version = parse_version(text)
rd = Path(release_dir)
prev = current(rd)
if prev and version <= prev["version"]:
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
secure_dir(rd)
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
_write_atomic(rd / "agent.py", data)
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
return manifest
def current(release_dir) -> dict | None:
"""Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять)."""
path = Path(release_dir) / "manifest.json"
if not path.exists():
return None
try:
m = json.loads(path.read_text(encoding="utf-8"))
return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")}
except (OSError, ValueError, KeyError, TypeError) as e:
log.warning("release: манифест %s не читается (%s) — публикации нет", path, e)
return None
def load(release_dir) -> dict | None:
"""Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет."""
m = current(release_dir)
if m is None:
return None
try:
data = (Path(release_dir) / "agent.py").read_bytes()
except OSError as e:
log.error("release: agent.py не прочитан: %s", e)
return None
if hashlib.sha256(data).hexdigest() != m["sha256"]:
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
return None
return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")}
def hint(rel, agent, update_state, launcher_version) -> str | None:
"""Подсказка для agent_status (design решение 5)."""
try:
agent_version = int(agent.get("agent_version") or 0)
except (TypeError, ValueError):
agent_version = 0
if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None:
return "агент запущен без run.py — обновится только вручную"
us = update_state or {}
if us.get("state") == "rolled_back":
return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}"
if us.get("state") == "rejected":
return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}"
return None

View File

@ -23,3 +23,15 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys):
assert cli.main(["tokens"]) == 0
assert settings.tokens(conn)["client"] in capsys.readouterr().out
conn.close()
def test_agent_publish(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 6\n", encoding="utf-8")
assert cli.main(["agent", "publish", str(src)]) == 0
out = capsys.readouterr().out
assert out.startswith("опубликована v6, sha256 ") and "…, 18 байт" in out
assert (paths.release_dir() / "manifest.json").exists()
assert cli.main(["agent", "publish", str(src)]) == 1
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err

103
tests/test_release.py Normal file
View File

@ -0,0 +1,103 @@
import json
import os
import stat
import pytest
from jira_hub import release
def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"):
p = tmp_path / name
p.write_text(f'#!/usr/bin/env python3\n"""агент"""\nAGENT_VERSION = {version}\n{body}', encoding="utf-8")
return p
def test_publish_writes_file_and_manifest(tmp_path):
rd = tmp_path / "release"
src = agent_file(tmp_path, 6)
m = release.publish(rd, src)
assert m["version"] == 6 and m["size"] == src.stat().st_size and len(m["sha256"]) == 64
assert (rd / "agent.py").read_bytes() == src.read_bytes()
assert json.loads((rd / "manifest.json").read_text(encoding="utf-8")) == m
assert stat.S_IMODE(os.stat(rd).st_mode) == 0o700
assert release.current(rd) == {"version": 6, "sha256": m["sha256"], "published_at": m["published_at"]}
loaded = release.load(rd)
assert loaded == {"version": 6, "sha256": m["sha256"], "content": src.read_text(encoding="utf-8")}
assert not [p.name for p in rd.iterdir() if p.name.startswith(".")] # временных файлов не осталось
def test_publish_only_up(tmp_path):
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 6))
before = (rd / "manifest.json").read_bytes()
with pytest.raises(release.PublishError, match="опубликована v6, новая должна быть больше"):
release.publish(rd, agent_file(tmp_path, 6, body="print('y')\n"))
with pytest.raises(release.PublishError, match="опубликована v6"):
release.publish(rd, agent_file(tmp_path, 5))
assert (rd / "manifest.json").read_bytes() == before
assert release.publish(rd, agent_file(tmp_path, 7))["version"] == 7
@pytest.mark.parametrize("text", ["print(1)\n", "AGENT_VERSION = 5\nAGENT_VERSION = 6\n", "AGENT_VERSION = 'v5'\n",
" AGENT_VERSION = 5\n"])
def test_version_line_required_once(tmp_path, text):
p = tmp_path / "a.py"
p.write_text(text, encoding="utf-8")
with pytest.raises(release.PublishError, match="AGENT_VERSION"):
release.publish(tmp_path / "release", p)
assert not (tmp_path / "release" / "manifest.json").exists()
def test_publish_rejects_non_utf8_and_big(tmp_path):
p = tmp_path / "a.py"
p.write_bytes(b"AGENT_VERSION = 5\n\xff\xfe")
with pytest.raises(release.PublishError, match="UTF-8"):
release.publish(tmp_path / "release", p)
p.write_bytes(b"AGENT_VERSION = 5\n" + b"#" * (1024 * 1024))
with pytest.raises(release.PublishError, match="1 МБ"):
release.publish(tmp_path / "release", p)
with pytest.raises(release.PublishError, match="не прочитан"):
release.publish(tmp_path / "release", tmp_path / "нет.py")
def test_current_none_without_release(tmp_path):
assert release.current(tmp_path / "release") is None
assert release.load(tmp_path / "release") is None
def test_current_broken_manifest(tmp_path):
rd = tmp_path / "release"
rd.mkdir()
(rd / "manifest.json").write_text('{"version": 6, "sha', encoding="utf-8")
assert release.current(rd) is None
(rd / "manifest.json").write_text('{"version": "шесть", "sha256": "x"}', encoding="utf-8")
assert release.current(rd) is None
def test_load_rejects_sha_mismatch(tmp_path):
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 6))
(rd / "agent.py").write_text("AGENT_VERSION = 6\nподменён\n", encoding="utf-8")
assert release.current(rd)["version"] == 6
assert release.load(rd) is None
def test_hint():
rel = {"version": 6, "sha256": "s", "published_at": "t"}
online5 = {"agent_version": "5", "online": True}
assert release.hint(rel, online5, None, None) == "агент запущен без run.py — обновится только вручную"
assert release.hint(rel, online5, None, 1) is None
assert release.hint(rel, {"agent_version": "5", "online": False}, None, None) is None
assert release.hint(rel, {"agent_version": "6", "online": True}, None, None) is None
assert release.hint(None, online5, None, None) is None
rb = {"state": "rolled_back", "from_version": 6, "to_version": 7, "error": "код 1 через 3 с"}
assert release.hint(rel, {"agent_version": "6", "online": True}, rb, 1) == "откат с v7 на v6: код 1 через 3 с"
rj = {"state": "rejected", "from_version": 5, "to_version": 6, "error": "sha256 не сходится"}
assert release.hint(rel, online5, rj, 1) == "v6 отвергнута: sha256 не сходится"
assert release.hint(rel, online5, {"state": "ok", "from_version": 5, "to_version": 6}, 1) is None
def test_hint_none_when_no_agent():
rel = {"version": 6, "sha256": "s", "published_at": "t"}
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None