Andrey Epifancev 9793fdd40b Команда wiki_sync (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:03:20 +04:00

180 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.