21 KiB
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, CLIrun|check|selftest|prune,launcher_version.
- HTTP-слой:
- Клиент 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.
- JSON-RPC 2.2:
- Хаб принимает:
- пачки
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.
-
Скопировать общие части jira-agent v14, а не выделять библиотеку. Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное поведение. Цена ошибки: правки общих частей придётся повторять в двух агентах.
-
run.py— дословная копия запускателя jira-agent (LAUNCHER_VERSION = 2), вместе с переменной окруженияJIRA_AGENT_LAUNCHER. Обоснование: в протоколе самообновления важны именаagent.py,update.jsonиAGENT_VERSION; переменная — внутреннее дело пары запускатель–агент. Цена ошибки: переименование переменной в обоих файлах. -
Конфиг
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. - обязательны
-
Пауза между вызовами 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 режет запросы; правится настройкой без выпуска агента.
-
wiki_sync— порядок работы:- Корень берётся через
CmfDocument.getпо коду изparams.root. Нет корня —failed«страница <код> не найдена или нет прав». - Дерево строится как в
collect_subtree. - Обход в глубину от корня с защитой от циклов (
seenпоid). Соседи — по(orderno is None, orderno, name). - По каждой странице отдельный
CmfDocument.get(fields=["code","name","text"]). - Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше.
- Отчёт
codes— коды в порядке обхода, корень первым.
- Поля страницы:
eva_id = str(id),parent_code— код родителя по обходу (у корняnull),orderno— целое илиnull,url = urljoin(base_url + "/", "project/Document/<code>").
Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление.
- Корень берётся через
-
Ссылки разбирает
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). Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом.
- Берутся
-
Без
?version=1вurlвложения. Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение. Цена ошибки: если Eva безversionне отдаёт файл,wiki_fetchпадает; правка одной строки. -
wiki_fetch:- адрес не своего хоста —
failed«чужой хост», запроса нет; - файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт;
Content-Lengthбольше 10 МБ или прочитано больше —failed«файл больше 10 МБ — откройте в Eva»;- иначе
POST /agent/filesиdone {file, size}; - HTTP 404 —
failed«файл не найден в Eva».
Цена ошибки: большие файлы только ссылкой.
- адрес не своего хоста —
-
Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново. Журнал нужен только для досылки отчёта;
wiki_syncне продолжается с середины. Хаб очищает стадию при повторной выдаче. Цена ошибки: лишняя полная перекачка после перезапуска. -
check:- хаб:
GET /agent/settingsсвоим токеном; - Eva:
CmfDocument.list(filter=[], fields=["code"], slice=[0, 1]); - вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent;
selftest— версия плюсcheck.
- хаб:
-
Тесты:
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 и адаптируются: журнал, транспорт, самообновление, запускатель.
-
refвложений и ссылок — значение атрибута после unescape (так отдаётhtml.parser). Обоснование: хаб (convert.py, BeautifulSoup.get("src"/"href")) ищет запись по тому же unescaped-значению. Цена ошибки: адреса с&не сопоставятся, вложение останется неразрешённым; правка одной строки. -
При расхождении плана с кодом хаба по протоколу прав код хаба. Поручение владельца; расхождения фиксируются находками
O-n, хаб не правится. Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e. -
Каркас
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. Цена ошибки: перенос полей в другой класс. -
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остаются как есть: хаб читает только их. Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус). -
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. -
Тесты цикла работают на заглушке
wiki_fetch(failed«не реализовано»); паузу и время вызова Eva тесты задают вручную (eva.last_call_at,eva._touch()). Результатdoneпроверяется через подменуExecutor.run/do_wiki_fetch. Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают. -
test_run_py_is_copyищетjira-agent/src/run.pyна 1–3 уровня выше корня репозитория; нет файла — skip с причиной. Путь из плана (../../jira-agent) верен для основного checkout, из worktree.wt/<срез>— на уровень глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов. -
.gitignoreдополненagent.py.new,agent.py.prev,__pycache__/,*.pyc(рабочие файлы самообновления и кэш). Цена ошибки: нет. -
EvaClient(cfg)остаётся с сигнатурой каркаса (по всемуcfg), а не(cfg_eva, timeout, verify_ssl, ca_bundle)из брифа. Обоснование:Runner/mainуже создают клиента какEvaClient(cfg)(решение 14). Цена ошибки: нет, внутренняя сигнатура. -
EvaClient.call(method, **kwargs)переопределяетHttp.call(method, path, ...). Транспортный вызов — черезHttp.call(self, ...); тесты общегоHttpвtest_clients.pyпереведены наagent.Http.call(client, ...). Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом). Цена ошибки: путаница сигнатур; видна при чтении класса. -
Пауза между вызовами — перед вызовом (и перед повтором) по
last_call_at; первый вызов после старта не задерживается.Runner.load_settingsставитeva.pacingизwiki_pacing(по умолчанию 0.2). Обоснование:last_call_atобщий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой. -
Токен вырезается (
***) из текстовHttpError/ConnectionError/EvaError, выходящих изEvaClient. Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. -
downloadпринимает только адрес подbase(иначеValueError("чужой хост")) и повторяет те же коды, чтоcall. Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. -
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 хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.
- Миниатюра определяется по хвосту пути
-
Обход дерева
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 неуникальны (не бывает), страницы склеятся. -
Страница без названия получает
title = code; корень без названия —root_title = code. Обоснование:stage._checkхаба отклоняет пустойtitle(422<код>: title), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени. -
Страница, исчезнувшая между списком и чтением (
get_doc→None), —failed«страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается. Обоснование: отчётcodesобязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация. -
Размер пачки считается тем же кодированием, что у
Http.call(ensure_ascii=False, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ). Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ. -
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.