GET /agent отдаёт agent_reported; release.hint(reported=); MCP agent_status: «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (решения 7–8, R-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
9.1 KiB
Python
187 lines
9.1 KiB
Python
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
|
||
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7."""
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import threading
|
||
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)
|
||
|
||
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
||
|
||
_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
|
||
_warned_lock = threading.Lock()
|
||
|
||
|
||
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 в том же каталоге, write + flush + fsync, переименование (решение 1).
|
||
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
|
||
tmp = path.with_name("." + path.name + ".tmp")
|
||
try:
|
||
with open(tmp, "wb") as f:
|
||
f.write(data)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp, path)
|
||
except OSError:
|
||
try:
|
||
tmp.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def publish(release_dir, source) -> dict:
|
||
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
|
||
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
|
||
опубликованной остаётся прежняя версия, наружу — только PublishError."""
|
||
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']}, новая должна быть больше")
|
||
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
|
||
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
||
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
|
||
existed, moved = agent.exists(), False
|
||
try:
|
||
secure_dir(rd)
|
||
if agent.exists():
|
||
os.replace(agent, prev_file)
|
||
moved = True
|
||
_write_atomic(agent, data)
|
||
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
||
except OSError as e:
|
||
_undo(agent, prev_file, moved, existed)
|
||
raise PublishError(f"не удалось записать публикацию: {e}") from e
|
||
try:
|
||
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
|
||
except OSError as e:
|
||
log.warning("release: %s не удалён: %s", prev_file, e)
|
||
return manifest
|
||
|
||
|
||
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
|
||
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
||
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
|
||
Сбой возврата — в лог: команда всё равно завершается сообщением."""
|
||
try:
|
||
if moved:
|
||
os.replace(prev_file, agent)
|
||
elif not existed:
|
||
agent.unlink(missing_ok=True)
|
||
except OSError as e:
|
||
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)
|
||
|
||
|
||
def _manifest_problem(m) -> str | None:
|
||
"""Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9)."""
|
||
if not isinstance(m, dict):
|
||
return "не объект"
|
||
version = m.get("version")
|
||
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||
return f"version — не целое ≥ 1: {version!r}"
|
||
sha = m.get("sha256")
|
||
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
|
||
return "sha256 — не 64 символа [0-9a-f]"
|
||
return None
|
||
|
||
|
||
def _note_manifest(problem: str | None, path: Path) -> None:
|
||
"""Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест
|
||
сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков."""
|
||
global _warned
|
||
text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет"
|
||
with _warned_lock:
|
||
if text is not None and text != _warned:
|
||
log.warning("%s", text)
|
||
_warned = text
|
||
|
||
|
||
def current(release_dir) -> dict | None:
|
||
"""Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
|
||
path = Path(release_dir) / "manifest.json"
|
||
try:
|
||
m = json.loads(path.read_text(encoding="utf-8"))
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
_note_manifest(None, path)
|
||
return None
|
||
except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
|
||
_note_manifest(f"не читается ({e})", path)
|
||
return None
|
||
problem = _manifest_problem(m)
|
||
_note_manifest(problem, path)
|
||
if problem:
|
||
return None
|
||
return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")}
|
||
|
||
|
||
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
|
||
try:
|
||
content = data.decode("utf-8")
|
||
except UnicodeDecodeError: # решение 6: 404, а не 500
|
||
log.error("release: agent.py не в UTF-8 — публикации нет")
|
||
return None
|
||
return {"version": m["version"], "sha256": m["sha256"], "content": content}
|
||
|
||
|
||
def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None:
|
||
"""Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim
|
||
ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7)."""
|
||
try:
|
||
agent_version = int(agent.get("agent_version") or 0)
|
||
except (TypeError, ValueError):
|
||
agent_version = 0
|
||
if (reported and 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
|