180 lines
21 KiB
Markdown
180 lines
21 KiB
Markdown
# 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-значению.
|
||
Цена ошибки: адреса с `&` не сопоставятся, вложение останется неразрешённым; правка одной строки.
|
||
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.
|