2026-10-24 05:08:10 +04:00

22 KiB
Raw Blame History

eva-agent-mvp — решения агента

Спека и дизайн — в jira-hub/openspec/changes/wiki-module/ (issue aep/jira-hub#91, PR aep/jira-hub#92):

  • решения 10, 11, 14 и Context (форматы ссылок Eva);
  • требования wiki «Синхронизация вики» и «Вложения вики по запросу»;
  • modules «Агенты модулей»;
  • hub-api «Маршруты агента» (общие /agent/*).

Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль wiki: имя агента в хабе wiki, токен — jira-hub tokens → «агент wiki».

Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion».

Context

  • Источник для копирования — jira-agent v14 (../jira-agent/src/agent.py, run.py). Его части, не связанные с Jira, отлажены с v2 по v14:
    • HTTP-слой: Http, HttpError, RedirectAway, TooBig, _SameOriginRedirect;
    • транспорт FolderTransport и клиент хаба HubClient;
    • журнал Journal, цикл Runner, самообновление Updater;
    • acquire_lock, CLI run|check|selftest|prune, launcher_version.
  • Клиент Eva и обход дерева — по образцу ../eva_wiki_export.py:
    • JSON-RPC 2.2: POST <base>/api/?m=<метод>, Authorization: Bearer, тело {jsonrpc: "2.2", method, callid, kwargs};
    • повторы на 429/502/503/504 и сетевых сбоях;
    • CmfDocument.get/list, страницы списка по 200;
    • дерево — по parent/tree_parent из списка документов проекта (project.code), запасной путь — обход через tree_parent.code.
  • Хаб принимает:
    • пачки POST /agent/wiki/pages {command_id, pages} — до 50 страниц, тело до 16 МБ (MAX_BODY хаба);
    • форму страницы проверяет modules/wiki/stage.py;
    • отчёт wiki_sync done {root_title, codes, eva_calls}: codes[0] должен быть кодом корня;
    • отчёт wiki_fetch done {file, size}: файл должен лежать в хранилище хаба с этим размером.

Decisions made without discussion

Дата всех решений — 2026-10-24.

  1. Скопировать общие части jira-agent v14, а не выделять библиотеку. Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное поведение. Цена ошибки: правки общих частей придётся повторять в двух агентах.

  2. run.py — дословная копия запускателя jira-agent (LAUNCHER_VERSION = 2), вместе с переменной окружения JIRA_AGENT_LAUNCHER. Обоснование: в протоколе самообновления важны имена agent.py, update.json и AGENT_VERSION; переменная — внутреннее дело пары запускатель–агент. Цена ошибки: переименование переменной в обоих файлах.

  3. Конфиг agent.json:

    • обязательны hub_url, hub_token, eva.base_url, eva.token;
    • необязательны verify_ssl, ca_bundle, timeout (60), poll_seconds (5), auto_update (true).

    Обоснование: формат jira-agent, где ключ jira заменён на eva. Цена ошибки: правка шаблона и load_config.

  4. Пауза между вызовами Eva — wiki_pacing из GET /agent/settings (по умолчанию 0.2 с).

    • 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с.
    • При 429 учитывается Retry-After (не больше 60 с).
    • Число 429 уходит в rate_limited отчёта, число вызовов Eva — в jira_calls: поле протокола хаба, имя общее для всех агентов.

    Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента.

  5. wiki_sync — порядок работы:

    1. Корень берётся через CmfDocument.get по коду из params.root. Нет корня — failed «страница <код> не найдена или нет прав».
    2. Дерево строится как в collect_subtree.
    3. Обход в глубину от корня с защитой от циклов (seen по id). Соседи — по (orderno is None, orderno, name).
    4. По каждой странице отдельный CmfDocument.get(fields=["code","name","text"]).
    5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше.
    6. Отчёт codes — коды в порядке обхода, корень первым.
    • Поля страницы: eva_id = str(id), parent_code — код родителя по обходу (у корня null), orderno — целое или null, url = urljoin(base_url + "/", "project/Document/<code>").

    Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление.

  6. Ссылки разбирает html.parser.HTMLParser из stdlib.

    • Берутся img[src] и a[href].
    • Адрес разрешается urljoin(base_url + "/", ref). «Свой» — тот же scheme и netloc, что у eva.base_url.
    • Вложение — свой адрес с путём, начинающимся на /files/. Из url убираются query и fragment. Миниатюра <путь>.meta/thumbnail.jpg означает файл <путь>.
    • Ссылка на страницу — свой адрес с DOC-<цифры> в пути (код в верхнем регистре).
    • static/… и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте.
    • Имя вложения — alt или title, затем текст ссылки, затем последний сегмент пути после unquote.
    • Каждая ссылка передаётся отдельной записью {ref, url, name} или {ref, code}, ref — ровно значение атрибута. Повторы по url склеивает хаб.

    Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context). Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом.

  7. Без ?version=1 в url вложения. Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение. Цена ошибки: если Eva без version не отдаёт файл, wiki_fetch падает; правка одной строки.

  8. wiki_fetch:

    • адрес не своего хоста — failed «чужой хост», запроса нет;
    • файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт;
    • Content-Length больше 10 МБ или прочитано больше — failed «файл больше 10 МБ — откройте в Eva»;
    • иначе POST /agent/files и done {file, size};
    • HTTP 404 — failed «файл не найден в Eva».

    Цена ошибки: большие файлы только ссылкой.

  9. Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново. Журнал нужен только для досылки отчёта; wiki_sync не продолжается с середины. Хаб очищает стадию при повторной выдаче. Цена ошибки: лишняя полная перекачка после перезапуска.

  10. check:

    • хаб: GET /agent/settings своим токеном;
    • Eva: CmfDocument.list(filter=[], fields=["code"], slice=[0, 1]);
    • вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent;
    • selftest — версия плюс check.
  11. Тесты:

    • unittest на /usr/bin/python3 (3.9);
    • фейки tests/fake_hub.py (как у jira-agent, плюс POST /agent/wiki/pages), tests/fake_eva.py (JSON-RPC на http.server), tests/fake_relay.py;
    • tests/test_py39.py — копия.
    • Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель.
  12. ref вложений и ссылок — значение атрибута после unescape (так отдаёт html.parser). Обоснование: хаб (convert.py, BeautifulSoup .get("src"/"href")) ищет запись по тому же unescaped-значению. Цена ошибки: адреса с &amp; не сопоставятся, вложение останется неразрешённым; правка одной строки.

  13. При расхождении плана с кодом хаба по протоколу прав код хаба. Поручение владельца; расхождения фиксируются находками O-n, хаб не правится. Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e.

  14. Каркас EvaClient создан в задаче 1; Runner(cfg, hub, eva, journal). В EvaClient — только Bearer-заголовок, TLS-настройки, счётчики calls/rate_limited и время последнего вызова (last_call_at, last_call_iso). Обоснование: Runner и Executor из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3. Цена ошибки: перенос полей в другой класс.

  15. claim шлёт last_call_at, а не last_jira_call_at. Обоснование: хаб (core/api.py) читает body.get("last_call_at", body.get("last_jira_call_at")); новое имя не привязано к Jira. Поля отчёта jira_calls/rate_limited остаются как есть: хаб читает только их. Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус).

  16. Jira-специфичные тесты jira-agent не переносились; test_clients.py написан заново на MiniServer. Исключены: JiraClient (multipart, 429/503), Executor, refresh, история, вложения Jira. Сценарии Http (перенаправления, обрывы, не-JSON, предел fetch_bytes) сохранены на маленьком локальном сервере, фейка Eva в задаче 1 нет (появляется в задаче 2). Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет. Цена ошибки: пропущенный сценарий Http; виден по сравнению со списком тестов jira-agent.

  17. Тесты цикла работают на заглушке wiki_fetch (failed «не реализовано»); паузу и время вызова Eva тесты задают вручную (eva.last_call_at, eva._touch()). Результат done проверяется через подмену Executor.run/do_wiki_fetch. Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают.

  18. test_run_py_is_copy ищет jira-agent/src/run.py на 1–3 уровня выше корня репозитория; нет файла — skip с причиной. Путь из плана (../../jira-agent) верен для основного checkout, из worktree .wt/<срез> — на уровень глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов.

  19. .gitignore дополнен agent.py.new, agent.py.prev, __pycache__/, *.pyc (рабочие файлы самообновления и кэш). Цена ошибки: нет.

  20. EvaClient(cfg) остаётся с сигнатурой каркаса (по всему cfg), а не (cfg_eva, timeout, verify_ssl, ca_bundle) из брифа. Обоснование: Runner/main уже создают клиента как EvaClient(cfg) (решение 14). Цена ошибки: нет, внутренняя сигнатура.

  21. EvaClient.call(method, **kwargs) переопределяет Http.call(method, path, ...). Транспортный вызов — через Http.call(self, ...); тесты общего Http в test_clients.py переведены на agent.Http.call(client, ...). Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом). Цена ошибки: путаница сигнатур; видна при чтении класса.

  22. Пауза между вызовами — перед вызовом (и перед повтором) по last_call_at; первый вызов после старта не задерживается. Runner.load_settings ставит eva.pacing из wiki_pacing (по умолчанию 0.2). Обоснование: last_call_at общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой.

  23. Токен вырезается (***) из текстов HttpError/ConnectionError/EvaError, выходящих из EvaClient. Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе.

  24. download принимает только адрес под base (иначе ValueError("чужой хост")) и повторяет те же коды, что call. Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет.

  25. classify_links: уточнения разбора (задача 3).

    • Миниатюра определяется по хвосту пути .meta/thumbnail.jpg (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется.
    • Имя: alt/title → текст <a> (пробелы схлопнуты) → unquote последнего сегмента пути.
    • Порядок записей — порядок открывающих тегов (для <a><img></a> сначала ссылка, потом картинка); повторы не склеиваются.
    • Код страницы — первое DOC-<цифры> в пути (регистр не важен, результат в верхнем регистре); /files/… приоритетнее кода.
    • ref — значение после unescape; сверено с хабом: convert.py ищет по el.get("src"/"href") BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет).
    • В tests/test_py39.py в белый список stdlib добавлен html (html.parser).

    Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.

  26. Обход дерева wiki_sync ведётся по коду страницы, а не по id (уточнение решения 5).

    • children, seen и parent_code ключуются кодом (ref.code): код уникален и есть в каждой ссылке parent/tree_parent; id берётся только для eva_id.
    • Соседи сортируются по (orderno is None, orderno, name без регистра, code) — как collect_subtree экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма.
    • Дерево с проектом — один список документов проекта; без проекта — обход в ширину через tree_parent.code (защита от циклов тоже по коду).

    Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт code; поле id в ссылке может не совпасть с id документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся.

  27. Страница без названия получает title = code; корень без названия — root_title = code. Обоснование: stage._check хаба отклоняет пустой title (422 <код>: title), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени.

  28. Страница, исчезнувшая между списком и чтением (get_doc → None), — failed «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается. Обоснование: отчёт codes обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация.

  29. Размер пачки считается тем же кодированием, что у Http.call (ensure_ascii=False, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ). Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ.

  30. fake_hub.POST /agent/wiki/pages проверяет пачку как stage.accept/_check хаба (≤ 50 страниц, command_id целое, команда wiki_sync в статусе claimed, типы и пределы полей, форма attachments/links) и отвечает {error, detail} с 422/404/409. Тесты test_folder.py приведены к валидным страницам и выданной команде. Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e.

  31. Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку parent/tree_parent в код разрешает build_children.

    • Ссылка Eva приходит то объектом {id, code, ...}, то строкой-id, то объектом без code (так говорит ref_id эталонного экспорта).
    • build_children строит карту str(id) → code по документам проекта и корню. Ссылка с code даёт код сразу; иначе берётся id (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается.
    • Без проекта (фильтр tree_parent.code) разрешение не нужно: родитель известен по запросу.
    • FakeEva: id документов отличны от кодов; ссылки по умолчанию {"id": "<id документа>", "code"}, формы ref_form: full, id_only, string, code_id.

    Обоснование: прежний _ref_code на голой строке-id и dict без code молча давал codes == [root] при done. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).