Compare commits

..

10 Commits

Author SHA1 Message Date
Andrey Epifancev
9320de3adf Решения 44–45: объём финальной волны, отклонённые T2.3 и T5.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 06:06:29 +04:00
Andrey Epifancev
cf7c7bde8c Документация и решения 36–43: выкатка --module wiki, base_url, launchd (I-3, T6.1, T6.4, M-2, M-6, #1)
- CLAUDE.md: `jira-hub agent publish --module wiki src/agent.py`, порог `wiki_min_agent_version`.
- README: `wiki_min_agent_version` вместо `min_agent_version`; `eva.base_url` — только origin; агент только для macOS
  (fcntl), пример launchd `ru.aepif.eva-agent`; строка про проверку SSO на первой живой синхронизации (M-5).
- ARCHITECTURE: строки Eva и Executor по реализованному, строки ссылок (`_LinkParser`, `classify_links`) и дерева
  (`build_children`, `walk`).
- design.md: решения 36–43, в том числе почему M-5 отложен до живой проверки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:56:57 +04:00
Andrey Epifancev
343c49fc2e Тесты I-2 и M-4 без вывода лога в прогон (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:56:57 +04:00
Andrey Epifancev
c9fd3d6290 Ошибки Eva и 413 хаба в тексте отказа, защита list_docs (M-1, M-3, M-4, M-7, #1)
- EvaError (Eva вернула `error`) — failed «Eva: <текст>» без повтора, а не «внутренняя ошибка агента».
- 413 хаба на пачку страниц — в ошибке названа страница («страница DOC-x больше 16 МБ») или границы пачки.
- list_docs останавливается, если Eva не соблюдает slice и ответ не приносит новых записей.
- Удалены неиспользуемые Executor.s и Executor.sleep; Executor(eva, hub).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:51:45 +04:00
Andrey Epifancev
4a410d4b08 wiki_fetch: кодирование адреса и отдельное исключение ForeignHost (I-2, T5.2, #1)
Адрес вложения из HTML бывает с пробелами и кириллицей: путь и запрос теперь кодируются перед запросом, готовые %XX
не кодируются повторно, одиночный % становится %25. Отказ по хосту — ForeignHost («чужой хост» или «некорректный
адрес»), а не голый ValueError: UnicodeEncodeError и прочие ValueError больше не маскируются под «чужой хост».
Добавлены тесты на похожий хост, userinfo, другой порт и схему.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:48:14 +04:00
Andrey Epifancev
a51c509a87 classify_links не падает на битом HTML и адресах (I-1, #1)
На Python 3.9 `<![foo]>` вызывал NotImplementedError из ParserBase.error(), а адрес вида `http://[::1` — ValueError в
urljoin/urlsplit; одна такая конструкция роняла wiki_sync целиком. Теперь нестандартная секция пропускается до `>`,
битая ссылка — пропускается, остальные ссылки страницы сохраняются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:46:28 +04:00
Andrey Epifancev
acdd52742e check, README, сквозной прогон с хабом (#1)
cmd_check проверяет хаб и Eva (код 0 или 3), флаг run --once,
scripts/e2e-hub.sh поднимает тестовый хаб во временном JIRA_HUB_HOME.
Решения 33–35 в design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:32:32 +04:00
Andrey Epifancev
3597495a6a Команда wiki_fetch (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:17:49 +04:00
Andrey Epifancev
50878d5965 wiki_sync: разрешение ссылок parent/tree_parent через карту id -> код (T4.1, #1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:08:10 +04:00
Andrey Epifancev
9793fdd40b Команда wiki_sync (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:03:20 +04:00
19 changed files with 1266 additions and 58 deletions

View File

@ -11,14 +11,16 @@
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — |
| Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` |
| HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` |
| Eva | `EvaClient` | клиент Eva (Bearer-токен): счётчики вызовов и 429, время последнего вызова. Каркас; JSON-RPC и повторы — в задачах 2–3 | `Http` |
| Eva | `EvaClient`, `EvaError`, `ForeignHost`, `_quote_url_part` | клиент Eva (Bearer-токен): JSON-RPC 2.2 `call` (`POST /api/?m=<метод>`, `error` в ответе — `EvaError`), повторы 429/502/503/504 и сетевых сбоев (4 попытки, `Retry-After`), пауза `pacing` между вызовами, токен вырезается из текстов ошибок; `get_doc`, `list_docs` (страницы по 200, стоп, если Eva не соблюдает `slice`), `download` (только адрес своего хоста, иначе `ForeignHost`; путь кодируется перед запросом; предел 10 МБ); счётчики вызовов и 429, время последнего вызова | `Http` |
| Ссылки | `_LinkParser`, `_ParseError`, `classify_links` | `_LinkParser` (`html.parser`) собирает `img[src]` и `a[href]` в порядке появления, нестандартные `<![…]>` пропускает; `classify_links` делит их на вложения `{ref, url, name}` (свой хост, `/files/…`, миниатюра → файл) и ссылки на страницы `{ref, code}` (`DOC-<цифры>`); чужой хост, `static/` и битые адреса пропускает | `html.parser`, `urllib.parse` |
| Дерево | `build_children`, `walk`, `_ref_code`, `_ref_id`, `_orderno` | `build_children` — `{код родителя: [дети]}`: с проектом — один список документов проекта и связи `parent`/`tree_parent` (ссылка разрешается в код через карту id → код), без проекта — обход в ширину по `tree_parent.code`; соседи по `(orderno, name, code)`. `walk` — обход в глубину, корень первым, каждый код один раз (циклы, два родителя) | `EvaClient` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http` | `os`, `uuid` |
| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `claimed` → `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` |
| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (50 страниц или 4 МБ; 413 — ошибка с кодом страницы/границами пачки), отчёт `{root_title, codes, eva_calls}`. `do_wiki_fetch`: `download` → `POST /agent/files`, отчёт `{attachment_id, file, size}`; «чужой хост», «файл больше 10 МБ», «файл не найден в Eva». Ошибки: сбой хаба — `HubError` («хаб: …»), `EvaError` — «Eva: …», 5xx и сеть — повтор через `Runner` | `EvaClient`, `HubClient`, `classify_links`, `build_children`, `walk` |
| Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` |
## Запускатель `src/run.py`
@ -36,5 +38,11 @@
- Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429.
- Тесты не ходят во внешние сервисы.
## Сквозной прогон
`scripts/e2e-hub.sh`: настоящий хаб (`jira-hub serve` из worktree `jira-hub/.wt/wiki`, временный `JIRA_HUB_HOME`, свободный
порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются.
## Открытые вопросы
- Проверка Eva в `check` и клиент JSON-RPC — задачи 2 и 6 плана.
- Живая проверка на виртуалке с настоящей Eva (после merge hub#92).
- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой
живой синхронизации (M-5, design 42).

View File

@ -30,7 +30,9 @@
- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые
ресурсы (так проверяем перед коммитом).
- `/usr/bin/python3 src/agent.py check` — доступность хаба (и Eva — после задачи 6 плана), без записи.
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Eva (`CmfDocument.list`, 1 запись), без записи; код 0 или 3.
- `/usr/bin/python3 src/agent.py run --once` — выполнить выданное и выйти при первом пустом claim.
- `bash scripts/e2e-hub.sh` — сквозной прогон с тестовым хабом из `../../../jira-hub/.wt/wiki` (временный `JIRA_HUB_HOME`, свободный порт, фейковая Eva); «e2e: ok». Живой хаб не трогать.
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`).
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет).
- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3.
@ -38,17 +40,18 @@
## Тесты
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`,
`tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py` (появится вместе с клиентом Eva).
`tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py`; `scripts/fake_eva_serve.py` поднимает тот же фейк отдельным процессом для e2e.
- `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`.
- Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`,
`test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.py` (самообновление),
`test_launcher.py` (запускатель + `test_run_py_is_copy`), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py`).
- Golden-фикстур пока нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический.
- Фикстуры страниц — `tests/fixtures/pages/`. Golden-сценариев нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический.
## Первая подсистема
MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub
jira-hub agent publish src/agent.py` (из основного checkout), как у jira-agent; токен — «агент wiki».
jira-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira);
порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki».
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

View File

@ -4,7 +4,81 @@
Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
один файл `src/agent.py` и запускатель `src/run.py`.
Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`).
Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается.
Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить).
Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`.
## Установка на виртуалку
1. Локальный каталог рядом с jira-agent, например `~/eva-agent/` (запускать из него, не из общей папки).
2. Положить в него `agent.py` и `run.py` (из `src/`) и `agent.json` (из `agent.example.json`):
- `hub_url` — тот же `folder:<путь>`, что у jira-agent (общая папка с хабом; при VPN виртуалка не видит Mac mini по
сети), либо `http://<хост>:8790`;
- `hub_token` — строка «агент wiki» из `jira-hub tokens` (токен именно агента wiki, не jira и не клиентский);
- `eva.base_url` — адрес Eva **только origin**: схема, хост и порт, без пути и без `/` в конце, например
`https://eva.example.ru`. Агент сам добавляет к нему `/api/` (JSON-RPC), `/files/…` (вложения) и
`/project/Document/<код>` (ссылка на страницу); «свой» адрес вложения сравнивается с ним же, поэтому путь в
`base_url` (`https://eva.example.ru/eva`) ломает и вызовы, и проверку хоста;
- `eva.token` — токен из профиля Eva (Безопасность);
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате.
`agent.json` содержит токены: не коммитить, не выносить с виртуалки.
3. Проверка: `python3 agent.py check` — печатает «хаб: доступен…» и «eva: ok»; код 0, при недоступности хаба или Eva — 3.
4. Запуск: `python3 run.py` (запускатель следит за версиями и самообновлением). `python3 agent.py run` — без запускателя.
5. Автозапуск — launchd, как у jira-agent, со своей меткой (см. ниже); запускатель сам перезапускает агента.
Другие команды: `python3 agent.py run --once` (выполнить выданное и выйти при первом пустом claim), `selftest`
(версия + `check`, код 0 или 3), `prune` (чистка журнала).
Проверить при первой живой синхронизации: как Eva отвечает на протухший токен — перенаправлением на SSO (агент
не идёт на чужой адрес и пишет «перенаправление … отклонено»), страницей входа с кодом 200 («ответ не JSON») или 401.
## Автозапуск через launchd
Пример `~/Library/LaunchAgents/ru.aepif.eva-agent.plist` (метка своя, не `ru.aepif.jira-agent`: агенты запускаются
и останавливаются независимо):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ru.aepif.eva-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/Users/USER/eva-agent/run.py</string>
</array>
<key>WorkingDirectory</key><string>/Users/USER/eva-agent</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/USER/eva-agent/agent.log</string>
<key>StandardErrorPath</key><string>/Users/USER/eva-agent/agent.log</string>
</dict>
</plist>
```
Подставить свой путь вместо `USER`. Запуск и остановка:
```
launchctl load ~/Library/LaunchAgents/ru.aepif.eva-agent.plist
launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist
```
`WorkingDirectory` нужен, чтобы `agent.json` и `journal.jsonl` лежали рядом с `agent.py`.
## Выкатка новых версий
Из основного checkout репозитория (не из worktree), при поднятой версии `AGENT_VERSION` в `src/agent.py`:
uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py
Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента
jira) поднимать после того, как `agent_status` покажет новую версию агента wiki.
## Разработка
Настройки — `src/agent.example.json`. Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из
`../../../jira-hub/.wt/wiki` (переменная `HUB_SRC`), запускается на свободном порту с временным `JIRA_HUB_HOME`;
живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой
`attachment:<id>`, `wiki_fetch` вложения и его содержимое.

View File

@ -162,3 +162,118 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
- В `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`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).
32. **`wiki_fetch` (задача 5): уточнения.**
- Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается.
- Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются).
- Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`.
- Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки.
- Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva).
Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой.
33. **`check` (задача 6): код возврата 0 или 3, оба узла проверяются всегда.**
- Хаб — `GET /agent/settings`, Eva — `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva.
- Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; `selftest` по-прежнему 0 или 3. Тесты каркаса приведены: `test_check_command` и `test_check_folder_silent_relay` ждут 3; `FolderIntegrationTest` и `SelfUpdateE2E` поднимают `FakeEva` (selftest = check, без доступной Eva самообновление откладывается).
- У Eva в `check` одна попытка (`ATTEMPTS = 1`): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на `***` (решение 23).
Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там `selftest`).
34. **Флаг `agent.py run --once` для сквозного прогона.** Ставит `runner.idle_limit = 1`: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, `Runner.idle_limit` уже был в каркасе (используют тесты).
Обоснование: прогон делает две фазы (`wiki_sync`, затем `wiki_fetch`, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с.
35. **Сквозной прогон: `scripts/e2e-hub.sh` + `scripts/fake_eva_serve.py`.**
- Хаб — `uv run --project <jira-hub/.wt/wiki> --no-sync jira-hub serve --host 127.0.0.1 --port <свободный>` с `JIRA_HUB_HOME=$(mktemp -d)/hub-home`; порт 8790 отвергается явно. `--no-sync`, чтобы прогон не менял `.venv` соседнего worktree.
- Токены берутся из `jira-hub tokens` (клиентский и «агент wiki»); транспорт агента — прямой HTTP, `auto_update=false`.
- Фейковая Eva (`tests/fake_eva.py`) запускается отдельным процессом, 5 страниц в проекте `E2E`, картинка `/files/obj/e2e/scheme.png`; проект создаётся `POST /projects {label, name, wiki_root}`, затем `wiki_sync`, `GET /wiki/status|tree|pages/DOC-2`, `POST /wiki/attachments/<id>/fetch`, `wiki_fetch`, `GET …/content` и сверка sha256.
- Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с.
- Хаб принял всё, что требует его спека (расхождений с хабом не найдено).
Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных.
36. **`classify_links` не падает на нестандартном HTML и битых адресах (I-1).**
- На Python 3.9 `_markupbase` зовёт `error()` на `<![foo]>`, `<![ ]>` и подобном, а базовый `error()` бросает
`NotImplementedError`. `_LinkParser.error()` бросает своё `_ParseError`, а `parse_marked_section` ловит его и
пропускает конструкцию до `>` (как «мусорный комментарий»; нет `>` — ждёт данных, при `close()` её пропускает
`goahead`). Известные секции (`<![if …]>`, `<![endif]>`, `<![CDATA[…]]>`) разбираются как раньше.
- Простой no-op `error()` не годится: после него `parse_marked_section` падает на несвязанной переменной `match`, а
`_scan_name` — на распаковке `None`.
- Запасной путь: `feed`/`close` обёрнуты в `try` (`_ParseError`, `NotImplementedError`, `AssertionError`), собранное до
сбоя сохраняется, в лог — строка.
- Ссылка, на которой `urljoin`/`urlsplit` бросают `ValueError` (`http://[::1`), пропускается, остальные остаются.
Обоснование: одна битая конструкция в HTML страницы роняла весь `wiki_sync` «внутренней ошибкой агента».
Цена ошибки: ссылка внутри нераспознанной секции не попадёт в `attachments`/`links` (останется в HTML как есть).
37. **`wiki_fetch`: адрес кодируется перед запросом, отказ по хосту — `ForeignHost` (I-2).**
- Путь и запрос после `base_url` кодируются `quote(…, safe="/%:@!$&'()*+,;=-._~")` (у запроса ещё `?`); готовые
`%XX` не кодируются повторно, одиночный `%` без двух шестнадцатеричных цифр становится `%25`; фрагмент `#…`
отбрасывается.
- `download` бросает `ForeignHost("чужой хост")` (другие схема, хост, порт, userinfo, адрес не под `base_url`) или
`ForeignHost("некорректный адрес")` (`urlsplit` не разбирает: `http://[::1`, порт не числом). `do_wiki_fetch`
ловит только `ForeignHost`; прочие `ValueError` (в том числе `UnicodeEncodeError`) уходят в «внутреннюю ошибку
агента», а не маскируются под «чужой хост».
- `url` вложения в `classify_links` не перекодируется: это ключ склейки вложений в хабе, менять его форму — вне MVP.
Обоснование: имена файлов в Eva с пробелами и кириллицей; без кодирования `http.client` отказывал (`InvalidURL`
повторялся как обрыв связи, `UnicodeEncodeError` выдавался за «чужой хост»).
Цена ошибки: если Eva ждёт путь в другой кодировке (не UTF-8), файл не найдётся (404 «файл не найден в Eva»).
38. **`EvaError` в команде — `failed` «Eva: <текст>» без повтора (M-1).** Текст уже очищен от токена (решение 23).
Обоснование: ошибка JSON-RPC (нет прав, неверный фильтр) — ответ Eva, а не сбой агента; повтор не поможет.
Цена ошибки: временная ошибка Eva, пришедшая в `error`, не повторится автоматически — повторяет пользователь.
39. **413 хаба на пачку страниц — ошибка с кодом страницы или границами пачки (M-3).** «хаб: HTTP 413: страница DOC-x
больше 16 МБ — хаб не принял: <ответ хаба>» для пачки из одной страницы; «пачка страниц DOC-a…DOC-b (N шт.) больше
16 МБ …» иначе. Не повторяется. Пачка из нескольких страниц больше 16 МБ невозможна (пачка закрывается на 4 МБ), но
текст на этот случай есть.
Обоснование: по «HTTP 413» без кода не понять, какую страницу Eva чистить. Цена ошибки: нет.
40. **`list_docs` останавливается, если ответ страницы не приносит новых записей (M-4).** Записи склеиваются по
JSON-представлению (`sort_keys`); полная страница без новых записей — останов с записью в лог, собранное
возвращается. Так ловятся оба случая «Eva игнорирует `slice`»: весь список каждый раз и одна и та же первая страница.
Обоснование: иначе бесконечный цикл вызовов Eva. Цена ошибки: если Eva игнорирует `slice` и отдаёт только первые
N записей, дерево будет неполным без явного отказа (видно в логе).
41. **`Executor(eva, hub)`: удалены неиспользуемые `Executor.s` (настройки) и `Executor.sleep` (M-7).** Настройки,
нужные исполнителю (`wiki_pacing`), ставит `Runner.load_settings` прямо в `EvaClient.pacing`.
Цена ошибки: нет.
42. **Перенаправление Eva на SSO (M-5) не чинится до живой проверки.** Сейчас перенаправление на другой адрес
отклоняется (`RedirectAway` → «перенаправление … отклонено»), страница входа с кодом 200 даёт «ответ не JSON».
Как именно Eva банка отвечает на протухший токен, неизвестно; чинить вслепую — угадывать формат. В README и
ARCHITECTURE строка «проверить при первой живой синхронизации».
Цена ошибки: при протухшем токене текст ошибки менее понятен («ответ не JSON» вместо «токен Eva недействителен»).
43. **Документация (I-3, T6.1, T6.4, M-2, M-6).**
- Выкатка — `jira-hub agent publish --module wiki src/agent.py` (без `--module` хаб публикует версию агенту `jira`,
`registry.DEFAULT_AGENT`); порог — `wiki_min_agent_version` (ключ модуля wiki хаба, `agent_min_version_key`).
- `eva.base_url` — только origin: к нему добавляются `/api/`, `/files/…`, `/project/Document/<код>`, и с ним же
сравнивается адрес вложения. `agent.example.json` уже содержит origin без пути (`https://eva.example.ru`); JSON
без комментариев, поэтому пояснение — в README.
- Агент — только macOS (`fcntl` в `acquire_lock`): из README убраны `C:\…` и «планировщик задач», добавлен пример
launchd с меткой `ru.aepif.eva-agent` по образцу jira-agent.
Цена ошибки: нет.
44. **Финальная волна правок — один fixer, объём: I-1, I-2, I-3, M-1–M-4, M-6, M-7, T6.1, T6.4, T5.2; M-5 не чинится (R-14).**
Обоснование: M-5 (отказ на `text/html`) мог бы ложно отвергнуть настоящие `.html`-вложения; поведение Eva при
просроченном токене проверяется на первой живой синхронизации.
Цена ошибки: при SSO-редиректе страница входа сохранится как вложение; правится одной проверкой Content-Type.
45. **T2.3 и T5.1 отклонены по итогам финального ревью (R-15).** `Http.__init__` делает `base.rstrip("/")`; размер
из ответа хаба равен `len(decoded)`, и хаб сверяет его с `files.size`.
Цена ошибки: нет (оба подтверждены ревьюером по коду).

108
scripts/e2e-hub.sh Executable file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Сквозной прогон eva-agent с настоящим хабом (модуль wiki) и фейковой Eva. Печатает «e2e: ok».
# Хаб — ТОЛЬКО из worktree jira-hub/.wt/wiki, ТОЛЬКО с временным JIRA_HUB_HOME и портом не 8790; живой хаб не трогаем.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
HUB_SRC="${HUB_SRC:-$HERE/../../../jira-hub/.wt/wiki}"
HUB_SRC="$(cd "$HUB_SRC" && pwd)"
PORT="${E2E_PORT:-$(/usr/bin/python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')}"
[ "$PORT" != "8790" ] || { echo "e2e: порт 8790 занят живым хабом" >&2; exit 2; }
TMP="$(mktemp -d)"
export JIRA_HUB_HOME="$TMP/hub-home"
mkdir -p "$JIRA_HUB_HOME"
PIDS=()
stop_tree() { # остановить процесс и его детей (uv run запускает хаб дочерним процессом)
local p="$1" c
for c in $(pgrep -P "$p" 2>/dev/null || true); do stop_tree "$c"; done
kill "$p" 2>/dev/null || true
}
cleanup() {
trap - EXIT INT TERM
for p in ${PIDS[@]+"${PIDS[@]}"}; do stop_tree "$p"; done
for _ in 1 2 3 4 5 6 7 8 9 10; do
alive=0
for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -0 "$p" 2>/dev/null && alive=1; done
[ "$alive" = 0 ] && break
sleep 0.2
done
for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -9 "$p" 2>/dev/null || true; done
rm -rf "$TMP"
}
trap cleanup EXIT INT TERM
hub() { uv run --project "$HUB_SRC" --no-sync jira-hub "$@"; }
fail() { echo "e2e: ОШИБКА — $*" >&2; echo "--- лог хаба ---" >&2; tail -n 30 "$TMP/hub.log" >&2 || true; exit 1; }
jget() { /usr/bin/python3 -c 'import json,sys; d=json.load(sys.stdin)
for k in sys.argv[1].split("."):
d = d[int(k)] if isinstance(d, list) else d[k]
print(d)' "$1"; }
# --- токены (создают базу во временном каталоге)
TOKENS="$(hub tokens)"
CLIENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^клиентский токен: //p')"
AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')"
[ -n "$CLIENT_TOKEN" ] && [ -n "$AGENT_TOKEN" ] || fail "токены не выданы: $TOKENS"
# --- фейковая Eva
EVA_TOKEN="eva-e2e-secret"
/usr/bin/python3 "$HERE/scripts/fake_eva_serve.py" "$EVA_TOKEN" >"$TMP/eva.out" 2>"$TMP/eva.err" &
PIDS+=($!)
for _ in $(seq 1 50); do grep -q '^PORT ' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done
EVA_PORT="$(sed -n 's/^PORT //p' "$TMP/eva.out")"
[ -n "$EVA_PORT" ] || fail "фейковая Eva не поднялась: $(cat "$TMP/eva.err")"
# --- хаб
hub serve --host 127.0.0.1 --port "$PORT" >"$TMP/hub.log" 2>&1 &
PIDS+=($!)
HUB="http://127.0.0.1:$PORT"
for _ in $(seq 1 100); do
nc -z 127.0.0.1 "$PORT" 2>/dev/null && break
kill -0 "${PIDS[1]}" 2>/dev/null || fail "хаб завершился при старте"
sleep 0.1
done
nc -z 127.0.0.1 "$PORT" 2>/dev/null || fail "хаб не открыл порт $PORT"
api() { # api METHOD PATH [json]
local m="$1" p="$2"; shift 2
if [ $# -gt 0 ]; then
curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" -H 'Content-Type: application/json' -d "$1" "$HUB$p"
else
curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" "$HUB$p"
fi
}
# --- конфиг агента (прямой HTTP к тестовому хабу, без самообновления)
cat >"$TMP/agent.json" <<JSON
{"hub_url": "$HUB", "hub_token": "$AGENT_TOKEN",
"eva": {"base_url": "http://127.0.0.1:$EVA_PORT", "token": "$EVA_TOKEN"},
"verify_ssl": true, "ca_bundle": null, "timeout": 30, "poll_seconds": 0, "auto_update": false}
JSON
agent() { /usr/bin/python3 "$HERE/src/agent.py" --config "$TMP/agent.json" --journal "$TMP/journal.jsonl" "$@"; }
agent check || fail "agent.py check"
# --- проект с корнем вики -> пакет wiki_sync
api POST /projects '{"label":"E2E","name":"Сквозной прогон","wiki_root":"DOC-1"}' >"$TMP/project.json" || fail "POST /projects"
BATCH="$(jget batch_id <"$TMP/project.json")"
agent run --once || fail "agent.py run --once (wiki_sync)"
STATUS="$(api GET "/wiki/status?project=E2E")"
[ "$(printf '%s' "$STATUS" | jget pages)" = "5" ] || fail "wiki/status: ожидалось 5 страниц: $STATUS"
TREE="$(api GET "/wiki/tree?project=E2E")"
[ "$(printf '%s' "$TREE" | /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["pages"]))')" = "5" ] || fail "wiki/tree: $TREE"
PAGE="$(api GET "/wiki/pages/DOC-2?project=E2E")"
printf '%s' "$PAGE" | jget markdown | grep -q 'attachment:[0-9]' || fail "в Markdown DOC-2 нет ссылки attachment:<id>: $PAGE"
ATT_ID="$(printf '%s' "$PAGE" | jget attachments.0.id)"
# --- вложение по запросу -> wiki_fetch
api POST "/wiki/attachments/$ATT_ID/fetch" '{}' >/dev/null || fail "POST attachments/$ATT_ID/fetch"
agent run --once || fail "agent.py run --once (wiki_fetch)"
api GET "/wiki/attachments/$ATT_ID/content" >"$TMP/content.bin" || fail "GET attachments/$ATT_ID/content"
EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import fake_eva_serve as f; sys.stdout.buffer.write(f.PNG)' "$HERE/scripts" | shasum -a 256 | cut -d' ' -f1)"
GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)"
[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало"
echo "e2e: ok"

38
scripts/fake_eva_serve.py Normal file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл.
Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом.
"""
import os
import sys
import threading
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests"))
from fake_eva import FakeEva # noqa: E402
PNG = bytes.fromhex("89504e470d0a1a0a") + b"e2e-attachment-content"
def build(eva: FakeEva) -> None:
img = '<p>Схема:</p><img alt="Схема" src="/files/obj/e2e/scheme.png">'
eva.add_doc(1, "DOC-1", "Корень e2e", project="E2E", text="<p>корень</p>")
eva.add_doc(2, "DOC-2", "Требования", parent="DOC-1", project="E2E", orderno=1, text=img)
eva.add_doc(3, "DOC-3", "Процессы", parent="DOC-1", project="E2E", orderno=2,
text='<p>см. <a href="/project/Document/DOC-2">требования</a></p>')
eva.add_doc(4, "DOC-4", "Подпроцесс", parent="DOC-3", project="E2E", text="<p>подпроцесс</p>")
eva.add_doc(5, "DOC-5", "Приложение", parent="DOC-1", project="E2E", orderno=3, text="<p>приложение</p>")
eva.add_file("/files/obj/e2e/scheme.png", PNG)
def main() -> int:
eva = FakeEva()
if len(sys.argv) > 1:
eva.token = sys.argv[1]
build(eva)
print("PORT %d" % eva.server.server_port, flush=True)
threading.Event().wait()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,5 +1,5 @@
{
"hub_url": "http://192.168.0.106:8790",
"hub_url": "folder:/путь/к/общей/папке/jira-bridge",
"hub_token": "токен агента wiki из `jira-hub tokens`",
"eva": {
"base_url": "https://eva.example.ru",

View File

@ -6,8 +6,8 @@
jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/.
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи
python3 agent.py run # основной цикл (по умолчанию)
python3 agent.py check # доступ к хабу и Eva, без записи
python3 agent.py run # основной цикл (по умолчанию); run --once — выполнить выданное и выйти (сквозной прогон)
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
@ -113,6 +113,9 @@ class RedirectAway(Exception):
self.host = host
MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт)
class TooBig(Exception):
"""Ответ длиннее предела: чтение оборвано."""
@ -201,11 +204,13 @@ class Http:
req.add_header("Accept", "*/*")
try:
with self._opener().open(req, timeout=timeout or self.timeout) as resp:
if resp.length is not None and resp.length > limit:
raise TooBig("ответ длиннее %d байт (Content-Length %d)" % (limit, resp.length)) # тело не читаем
data = resp.read(limit + 1)
rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше
if rest and len(data) <= limit:
raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest))
except RedirectAway:
except (RedirectAway, TooBig):
self._touch()
raise
except urllib.error.HTTPError as e:
@ -233,6 +238,20 @@ class EvaError(Exception):
"""JSON-RPC Eva вернула `error`: текст «<метод>: <error>»."""
class ForeignHost(Exception):
"""Адрес вложения не на хосте Eva (или не разбирается): токен туда не отправляется, запроса нет."""
_URL_SAFE = "/%:@!$&'()*+,;=-._~"
_STRAY_PERCENT = re.compile(r"%(?![0-9A-Fa-f]{2})")
def _quote_url_part(part: str, extra_safe: str = "") -> str:
"""Процентная кодировка пути/запроса для HTTP: пробелы и не-ASCII кодируются, готовые %XX остаются как есть,
одиночный `%` без двух шестнадцатеричных цифр становится %25 (I-2)."""
return urllib.parse.quote(_STRAY_PERCENT.sub("%25", part), safe=_URL_SAFE + extra_safe)
class EvaClient(Http):
"""Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова."""
PAGE_SIZE = 200
@ -308,32 +327,69 @@ class EvaClient(Http):
return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
def list_docs(self, flt: list, fields: List[str]) -> List[dict]:
"""Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
останавливается на собранном, а не крутится бесконечно (M-4)."""
out, offset = [], 0 # type: List[dict], int
seen = set() # type: set
while True:
chunk = self.call("CmfDocument.list", filter=flt, fields=fields,
slice=[offset, offset + self.PAGE_SIZE]) or []
if isinstance(chunk, dict): # обёрнутый результат
chunk = chunk.get("items") or chunk.get("result") or []
out.extend(chunk)
fresh = 0
for d in chunk:
key = json.dumps(d, sort_keys=True, ensure_ascii=False)
if key not in seen:
seen.add(key)
out.append(d)
fresh += 1
if len(chunk) < self.PAGE_SIZE:
return out
if not fresh:
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out))
return out
offset += self.PAGE_SIZE
def download(self, url: str, limit: int) -> bytes:
"""Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста."""
if _origin(url) != _origin(self.base) or not url.startswith(self.base):
raise ValueError("чужой хост")
return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit))
"""Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста
(иначе ForeignHost); путь и запрос перед отправкой кодируются (адрес из HTML бывает с пробелами и кириллицей)."""
try:
same = _origin(url) == _origin(self.base)
except ValueError: # `http://[::1`, порт не числом
raise ForeignHost("некорректный адрес") from None
rest = url[len(self.base):] if url.startswith(self.base) else None
if not same or rest is None or rest[:1] not in ("", "/", "?"):
raise ForeignHost("чужой хост")
path, _, query = rest.split("#", 1)[0].partition("?")
path = _quote_url_part(path) + ("?" + _quote_url_part(query, "?") if query else "")
return self._retrying(lambda: self.fetch_bytes(path, limit))
class _ParseError(Exception):
"""Разбор HTML споткнулся (ParserBase.error в 3.9): см. _LinkParser.error."""
class _LinkParser(HTMLParser):
"""Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки)."""
"""Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки).
На 3.9 `_markupbase` зовёт error() на `<![foo]>` и подобном, а базовый error() бросает NotImplementedError (I-1).
error() бросает _ParseError, а parse_marked_section пропускает такую конструкцию до `>`, как мусорный комментарий."""
def __init__(self):
HTMLParser.__init__(self, convert_charrefs=True)
self.items = [] # [tag, ref, hint, text]
self._open = None # запись открытой <a>, в неё копится текст
def error(self, message):
raise _ParseError(message)
def parse_marked_section(self, i, report=1):
try:
return HTMLParser.parse_marked_section(self, i, report)
except _ParseError:
j = self.rawdata.find(">", i + 3)
return -1 if j < 0 else j + 1 # -1: ждать данных; при close() goahead сам пропустит до `>`
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "img":
@ -369,12 +425,18 @@ def classify_links(html: str, base_url: str) -> Tuple[list, list]:
ref — значение атрибута после unescape (design 12). Чужой хост и static/ не передаются."""
parser = _LinkParser()
parser.feed(html or "")
parser.close()
try:
parser.feed(html or "")
parser.close()
except (_ParseError, NotImplementedError, AssertionError) as e: # запасной путь: собранное до сбоя сохраняется
log(" разбор HTML прерван (%s: %s), ссылок до сбоя: %d" % (type(e).__name__, e, len(parser.items)))
base = urllib.parse.urlsplit(base_url)
attachments, links = [], []
for _tag, ref, hint, text in parser.items:
parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip()))
try:
parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip()))
except ValueError: # `http://[::1` — ссылка битая, пропускаем (I-1)
continue
if (parts.scheme, parts.netloc) != (base.scheme, base.netloc):
continue
path = parts.path
@ -663,6 +725,88 @@ def _parse_at(value) -> float:
return dt.timestamp()
# ---------------------------------------------------------------------------
# Дерево страниц Eva
# ---------------------------------------------------------------------------
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"]
def _ref_code(ref) -> Optional[str]:
"""Код из ссылки Eva с полем code; страница без родителя — None."""
code = ref.get("code") if isinstance(ref, dict) else None
return code if isinstance(code, str) and code else None
def _ref_id(ref) -> Optional[str]:
"""Ссылочные поля Eva приходят то объектом {id, code, ...}, то строкой-id (как в eva_wiki_export.ref_id)."""
if not ref:
return None
if isinstance(ref, dict):
v = ref.get("id")
return str(v) if v not in (None, "") else None
return str(ref)
def _orderno(value) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, float) and value.is_integer():
return int(value)
return value if isinstance(value, int) else None
def build_children(eva: "EvaClient", root: dict) -> Dict[str, List[dict]]:
"""{код родителя: [дети]} для поддерева корня. С проектом — один список документов проекта и связи parent/tree_parent
(tree_parent в Eva бывает не заполнен); без проекта — обход в ширину через фильтр tree_parent.code."""
children = {} # type: Dict[str, List[dict]]
project = _ref_code(root.get("project"))
if project:
docs = eva.list_docs([["project.code", "==", project]], TREE_FIELDS)
id_to_code = {str(d["id"]): d["code"] for d in docs + [root] if d.get("id") is not None and d.get("code")}
def parent_code(ref) -> Optional[str]:
# dict с code — код как есть; иначе (dict только с id, голая строка-id) — через карту id → код
return _ref_code(ref) or id_to_code.get(_ref_id(ref) or "")
for d in docs:
for pc in {parent_code(d.get("parent")), parent_code(d.get("tree_parent"))} - {None}:
children.setdefault(pc, []).append(d)
else:
queue, seen = [root], {root.get("code")}
while queue:
node = queue.pop(0)
kids = eva.list_docs([["tree_parent.code", "==", node["code"]]], TREE_FIELDS)
children[node["code"]] = kids
for k in kids:
if k.get("code") not in seen:
seen.add(k.get("code"))
queue.append(k)
for pc, kids in children.items():
uniq = {k["code"]: k for k in kids if k.get("code")}
children[pc] = sorted(uniq.values(), key=lambda k: (_orderno(k.get("orderno")) is None,
_orderno(k.get("orderno")) or 0,
(k.get("name") or "").lower(), k["code"]))
return children
def walk(root: dict, children: Dict[str, List[dict]]):
"""Обход в глубину, корень первым: (документ, код родителя по обходу). Каждый код — один раз (циклы, два родителя)."""
seen = set() # type: set
stack = [(root, None)]
while stack:
doc, parent = stack.pop()
code = doc["code"]
if code in seen:
continue
seen.add(code)
yield doc, parent
for kid in reversed(children.get(code, [])):
if kid["code"] not in seen:
stack.append((kid, code))
# ---------------------------------------------------------------------------
# Исполнитель: команда wiki_sync / wiki_fetch (читает Eva, отдаёт хабу)
# ---------------------------------------------------------------------------
@ -676,9 +820,8 @@ class HubError(Exception):
class Executor:
def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict):
self.eva, self.hub, self.s = eva, hub, settings
self.sleep = time.sleep
def __init__(self, eva: EvaClient, hub: Optional[HubClient]):
self.eva, self.hub = eva, hub
def run(self, cmd: dict) -> dict:
self.eva.reset_counters()
@ -692,6 +835,8 @@ class Executor:
out["retryable"] = e.retryable
out["peer"] = "хабом"
return out
except EvaError as e: # Eva ответила `error` (нет прав, неверный фильтр): повтор не поможет (M-1)
return self._out("failed", error="Eva: %s" % e) # текст уже очищен от токена (решение 23)
except HttpError as e:
out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит
@ -712,11 +857,81 @@ class Executor:
except (ConnectionError, HttpError) as e:
raise HubError(e) from None
def _send_pages(self, command_id, batch: list) -> None:
"""Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3)."""
try:
self.hub.wiki_pages(command_id, batch)
except HttpError as e:
if e.code == 413:
what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else
"пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch)))
e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers)
raise HubError(e) from None
except ConnectionError as e:
raise HubError(e) from None
def do_wiki_sync(self, p: dict, cmd: dict) -> dict:
return self._out("failed", error="wiki_sync: не реализовано")
"""Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается,
поэтому после перезапуска команда выполняется заново целиком (design 9)."""
root_code = str(p.get("root") or "").strip()
if not root_code:
return self._out("failed", error="wiki_sync: не задан root")
root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"])
if not root:
return self._out("failed", error="страница %s не найдена или нет прав" % root_code)
children = build_children(self.eva, root)
root_code = root.get("code") or root_code
base = self.eva.base
codes = [] # type: List[str]
batch = [] # type: list
size = 0
for doc, parent_code in walk(root, children):
code = doc["code"]
full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"])
if not full:
return self._out("failed", error="страница %s не найдена или нет прав" % code)
html = full.get("text") if isinstance(full.get("text"), str) else ""
attachments, links = classify_links(html, base)
page = {"code": code, "eva_id": str(doc["id"]) if doc.get("id") is not None else None,
# хаб отклоняет пустой title (stage._check): без названия страница называется своим кодом
"title": (full.get("name") or doc.get("name") or "").strip() or code,
"parent_code": parent_code, "orderno": _orderno(doc.get("orderno")),
"url": urllib.parse.urljoin(base + "/", "project/Document/" + code),
"html": html, "attachments": attachments, "links": links}
psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2
if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES):
self._send_pages(cmd["id"], batch)
batch, size = [], 0
batch.append(page)
size += psize
codes.append(code)
if batch:
self._send_pages(cmd["id"], batch)
title = (root.get("name") or "").strip() or root_code
return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls})
def do_wiki_fetch(self, p: dict, cmd: dict) -> dict:
return self._out("failed", error="wiki_fetch: не реализовано")
"""Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24);
содержимое в лог не попадает, в памяти одна копия (плюс base64 на время отправки)."""
url = str(p.get("url") or "").strip()
if not url:
return self._out("failed", error="wiki_fetch: не задан url")
try:
data = self.eva.download(url, MAX_ATTACHMENT)
except ForeignHost as e:
return self._out("failed", error=str(e))
except TooBig:
return self._out("failed", error="файл больше 10 МБ — откройте в Eva")
except HttpError as e:
if e.code == 404:
return self._out("failed", error="файл не найден в Eva")
raise
except RedirectAway as e:
return self._out("failed", error="Eva перенаправила скачивание на %s" % e.host)
log(" вложение #%s (%d байт) <- Eva" % (p.get("attachment_id"), len(data)))
got = self._hub(self.hub.upload_file, data)
del data
return self._out("done", {"attachment_id": p.get("attachment_id"), "file": got.get("file"), "size": got.get("size")})
# ---------------------------------------------------------------------------
@ -897,8 +1112,7 @@ class Runner:
def load_settings(self) -> None:
self.settings = self.hub.settings()
self.executor = Executor(self.eva, self.hub, self.settings)
self.executor.sleep = self.sleep
self.executor = Executor(self.eva, self.hub)
self.eva.pacing = float(self.settings.get("wiki_pacing", 0.2))
log("настройки хаба v%s" % self.settings.get("settings_version"))
@ -1001,7 +1215,7 @@ class Runner:
return # остановка: finished не пишем, команду подхватит recover
out["jira_calls"], out["rate_limited"] = calls, limited
self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited)
log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False)))
log(" → %s %s" % (out["status"], (out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))[:300]))
rec = dict(out)
rec["command_id"] = cid
self.send_result(rec)
@ -1114,13 +1328,13 @@ def acquire_lock(journal_path: str):
return f
def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int:
lock = acquire_lock(journal_path)
if lock is None:
log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path)
return EXIT_LOCKED
try:
return _run(cfg, journal_path, config_path)
return _run(cfg, journal_path, config_path, once)
finally:
lock.close()
@ -1148,8 +1362,10 @@ def launcher_version() -> Optional[int]:
return None
def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int:
runner = Runner(cfg, HubClient(cfg), EvaClient(cfg), Journal(journal_path))
if once:
runner.idle_limit = 1 # выполнить всё, что выдаст хаб, и выйти при первом пустом claim
runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path)
runner.updater.should_stop = lambda: runner.stop
@ -1189,13 +1405,20 @@ def cmd_check(cfg: dict) -> int:
except (ConnectionError, HttpError) as e:
log("хаб: НЕДОСТУПЕН — %s" % e)
ok = False
# проверка Eva — в задаче 6 плана
return 0 if ok else 1
eva = EvaClient(cfg)
eva.ATTEMPTS = 1 # проверка отвечает сразу, без повторов с паузами
try:
eva.call("CmfDocument.list", filter=[], fields=["code"], slice=[0, 1])
log("eva: ok")
except (ConnectionError, HttpError, EvaError) as e:
log("eva: НЕДОСТУПНА — %s" % e if isinstance(e, ConnectionError) else "eva: ошибка — %s" % e)
ok = False
return 0 if ok else 3
def cmd_selftest(cfg: dict) -> int:
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
0 — хаб и Eva доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
log("eva-agent v%d" % AGENT_VERSION)
return 0 if cmd_check(cfg) == 0 else 3
@ -1204,6 +1427,7 @@ def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
ap.add_argument("--once", action="store_true", help="для run: выполнить выданные команды и выйти при первом пустом claim")
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"])
args = ap.parse_args(argv)
cfg = load_config(args.config)
@ -1213,7 +1437,7 @@ def main(argv=None) -> int:
return cmd_selftest(cfg)
if args.cmd == "prune":
return cmd_prune(args.journal)
return cmd_run(cfg, args.journal, args.config)
return cmd_run(cfg, args.journal, args.config, args.once)
if __name__ == "__main__":

View File

@ -6,7 +6,9 @@ from urllib.parse import parse_qs, urlsplit
def _field(doc, path):
"""Значение поля по пути `project.code`: ссылка хранится как {"id", "code"}."""
"""Значение поля по пути `project.code`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом."""
if path in ("parent.code", "tree_parent.code"):
return doc.get(path.split(".")[0])
cur = doc
for part in path.split("."):
if not isinstance(cur, dict):
@ -20,7 +22,10 @@ class FakeEva:
def __init__(self):
self.docs = []
self.files = {} # путь → (байты, с Content-Length?)
# форма ссылок parent/tree_parent в ответах: "full" — {"id": "<id документа>", "code"} (по умолчанию, как в Eva),
# "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка)
self.ref_form = "full"
self.files = {} # путь → (байты, Content-Length: True/False/заявленное число)
self.requests = [] # {"method", "path", "body", "auth"}
self.failures = [] # [status, retry_after] — по одному на ответ
self.rpc_errors = []
@ -49,11 +54,30 @@ class FakeEva:
self.server.server_close()
def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""):
# parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form
ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731
self.docs.append({"id": id, "code": code, "name": name, "parent": ref(parent),
"tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno,
self.docs.append({"id": id, "code": code, "name": name, "parent": parent,
"tree_parent": tree_parent, "project": ref(project), "orderno": orderno,
"text": text})
def _ref(self, code):
if code is None:
return None
target = next((d for d in self.docs if d["code"] == code), None)
rid = str(target["id"]) if target else code
form = self.ref_form
if form == "id_only":
return {"id": rid}
if form == "string":
return rid
if form == "code_id":
return {"id": code, "code": code}
return {"id": rid, "code": code}
def _out(self, d, fields):
d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"]))
return {k: d.get(k) for k in fields} if fields else d
def add_file(self, path, data, length=True):
self.files[path] = (data, length)
@ -97,8 +121,8 @@ class FakeEva:
data, with_length = self.files[u.path]
h.send_response(200)
h.send_header("Content-Type", "application/octet-stream")
if with_length:
h.send_header("Content-Length", str(len(data)))
if with_length: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка)
h.send_header("Content-Length", str(len(data) if with_length is True else with_length))
h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения
h.wfile.write(data)
return
@ -118,7 +142,7 @@ class FakeEva:
def rpc(self, method, kw):
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
fields = kw.get("fields")
pick = (lambda d: {k: d.get(k) for k in fields}) if fields else dict # noqa: E731
pick = lambda d: self._out(d, fields) # noqa: E731
if method == "CmfDocument.get":
return pick(found[0]) if found else None
if method == "CmfDocument.list":

View File

@ -9,6 +9,56 @@ import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MAX_PAGES = 50
MAX_FILE = 10 * 1024 * 1024
def check_pages(command, command_id, pages):
"""Форма пачки как в stage.accept/_check хаба: (код, тело ошибки) или None. 422 — {"error", "detail"}."""
def bad(detail, status=422, error="bad_pages"):
return status, {"error": error, "detail": detail}
if isinstance(command_id, bool) or not isinstance(command_id, int):
return bad("command_id — целое", error="bad_request")
if not isinstance(pages, list) or not pages or len(pages) > MAX_PAGES:
return bad("pages — список от 1 до %d страниц" % MAX_PAGES)
if command is None:
return bad("command %s" % command_id, 404, "not_found")
if command["type"] != "wiki_sync" or command["status"] != "claimed":
return bad("команда %s не выдана агенту как wiki_sync" % command_id, 409, "not_claimed")
limits = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
for i, page in enumerate(pages, 1):
if not isinstance(page, dict):
return bad("страница %d: не объект" % i)
code = page.get("code")
if not isinstance(code, str) or not code.strip() or len(code) > limits["code"]:
return bad("страница %d: code" % i)
for key, required in (("title", True), ("parent_code", False), ("url", False)):
v = page.get(key)
if v is None and not required:
continue
if not isinstance(v, str) or (required and not v.strip()) or len(v) > limits[key]:
return bad("%s: %s" % (code, key))
if not isinstance(page.get("html"), str):
return bad("%s: html" % code)
o = page.get("orderno")
if o is not None and (not isinstance(o, int) or isinstance(o, bool)):
return bad("%s: orderno" % code)
e = page.get("eva_id")
if e is not None and (not isinstance(e, str) or len(e) > limits["eva_id"]):
return bad("%s: eva_id" % code)
att, links = page.get("attachments") or [], page.get("links") or []
if not isinstance(att, list) or not all(
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
and isinstance(a.get("name") or "", str) for a in att):
return bad("%s: attachments" % code)
if not isinstance(links, list) or not all(
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str)
for l in links):
return bad("%s: links" % code)
return None
class FakeHub:
token = "agent-token"
@ -126,12 +176,18 @@ class FakeHub:
if path == "/agent/wiki/pages" and method == "POST":
if self.reject_pages:
return self.reject_pages
cid = body.get("command_id")
bad = check_pages(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("pages"))
if bad:
return bad
self.pages.append(body)
return 200, {"accepted": len(body.get("pages") or [])}
if path == "/agent/files" and method == "POST":
if self.reject_files:
return self.reject_files
data = base64.b64decode(body.get("content") or "", validate=True)
if len(data) > MAX_FILE: # как files.put хаба; пустой файл от агента принимается (allow_empty)
return 413, {"error": "too_large", "detail": "файл больше %d байт (%d)" % (MAX_FILE, len(data))}
self.uploaded.append(data)
return 200, {"file": self.put_file(data), "size": len(data)}
m = re.match(r"^/agent/commands/(\d+)$", path)

77
tests/test_check.py Normal file
View File

@ -0,0 +1,77 @@
import contextlib
import io
import unittest
from _common import SETTINGS, agent, eva_cfg
from fake_eva import FakeEva
from fake_hub import FakeHub
class CheckTest(unittest.TestCase):
def setUp(self):
self.eva = FakeEva()
self.hub = FakeHub(SETTINGS)
self.addCleanup(self.eva.stop)
self.addCleanup(self.hub.stop)
self.eva.add_doc(1, "DOC-1", "К", project="P")
def check(self, **over):
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, **over)
out = io.StringIO()
with contextlib.redirect_stdout(out):
code = agent.cmd_check(cfg)
return code, out.getvalue()
def test_all_ok(self):
code, out = self.check()
self.assertEqual(code, 0, out)
self.assertIn("хаб: ", out)
self.assertIn("eva: ok", out)
call = self.eva.api_calls[-1]["body"]
self.assertEqual(call["method"], "CmfDocument.list")
self.assertEqual(call["kwargs"], {"filter": [], "fields": ["code"], "slice": [0, 1]})
def test_eva_401(self):
self.eva.token = "другой"
code, out = self.check()
self.assertEqual(code, 3)
self.assertIn("eva: ", out)
self.assertIn("401", out)
self.assertNotIn("eva-secret", out)
def test_eva_unreachable(self):
cfg = eva_cfg("http://127.0.0.1:1", hub_url=self.hub.base_url, hub_token=self.hub.token)
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(agent.cmd_check(cfg), 3)
self.assertIn("eva: НЕДОСТУПНА", out.getvalue())
def test_hub_down_but_eva_checked(self):
cfg = eva_cfg(self.eva.base_url) # хаб: порт 1
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(agent.cmd_check(cfg), 3)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
self.assertIn("eva: ok", out.getvalue())
def test_selftest_zero_when_ok(self):
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(agent.cmd_selftest(cfg), 0)
def test_run_once_exits_on_idle(self):
import json
import os
import tempfile
with tempfile.TemporaryDirectory() as d:
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, auto_update=False)
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f)
with contextlib.redirect_stdout(io.StringIO()):
code = agent.main(["--config", path, "--journal", os.path.join(d, "j.jsonl"), "run", "--once"])
self.assertEqual(code, 0)
if __name__ == "__main__":
unittest.main()

View File

@ -236,7 +236,7 @@ class ClientsTest(unittest.TestCase):
json.dump(eva_cfg(), f)
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(agent.main(["--config", path, "check"]), 1) # хаб недоступен (порт 1)
self.assertEqual(agent.main(["--config", path, "check"]), 3) # хаб и Eva недоступны (порт 1)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
def test_selftest_command(self):

View File

@ -74,6 +74,30 @@ class EvaClientTest(unittest.TestCase):
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls],
[[0, 200], [200, 400], [400, 600]])
def test_list_slice_ignored_returns_all(self):
"""M-4: Eva не поддерживает slice и отдаёт весь список каждый раз — второй ответ без новых записей, стоп."""
orig = self.eva.rpc
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
for i in range(450):
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
out = io.StringIO()
with redirect_stdout(out):
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
self.assertEqual(len(docs), 450)
self.assertEqual(len(self.eva.api_calls), 2)
self.assertIn("slice не соблюдается", out.getvalue())
def test_list_slice_ignored_same_page(self):
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
orig = self.eva.rpc
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200]))
for i in range(450):
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
with redirect_stdout(io.StringIO()):
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)])
self.assertEqual(len(self.eva.api_calls), 2)
def test_list_unwraps_dict(self):
orig = self.eva.rpc
self.eva.rpc = lambda m, kw: {"items": orig(m, kw)}
@ -101,10 +125,25 @@ class EvaClientTest(unittest.TestCase):
self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret")
def test_download_foreign_host(self):
with self.assertRaises(ValueError):
with self.assertRaises(agent.ForeignHost):
self.cli.download("http://example.invalid/files/a.bin", 10)
self.assertEqual(self.eva.requests, [])
def test_download_lookalikes(self):
"""T5.2: похожий хост, userinfo, другой порт и схема — ForeignHost до запроса (адрес Eva — https://eva.test)."""
cli = agent.EvaClient(c.eva_cfg("https://eva.test"))
cli.fetch_bytes = lambda *a, **kw: self.fail("запроса быть не должно")
for url in ("https://eva.test.evil/files/a.bin", "https://eva.test@evil/files/a.bin",
"https://eva.test:8443/files/a.bin", "http://eva.test/files/a.bin",
"https://eva.testx/files/a.bin", "https://user@eva.test/files/a.bin"):
with self.subTest(url=url):
with self.assertRaises(agent.ForeignHost) as cm:
cli.download(url, 10)
self.assertEqual(str(cm.exception), "чужой хост")
with self.assertRaises(agent.ForeignHost) as cm:
cli.download("https://eva.test:abc/files/a.bin", 10)
self.assertEqual(str(cm.exception), "некорректный адрес")
def test_download_limit(self):
limit = 10 * 1024 * 1024
self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False)

View File

@ -10,6 +10,7 @@ from contextlib import redirect_stdout
from datetime import datetime, timezone
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
from fake_hub import FakeHub
from fake_relay import FakeRelay
@ -138,18 +139,26 @@ class FolderTransportTest(unittest.TestCase):
self.assertIsNone(hub.folder)
self.assertEqual(hub.settings()["settings_version"], 1)
def claimed_sync(self):
cid = self.hub.enqueue("wiki_sync", {"label": "x", "root": "DOC-1"})
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "claimed"
return cid
def test_wiki_pages_through_folder(self):
self.run_relay()
pages = [{"code": "DOC-1", "name": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
got = self.client().wiki_pages(5, pages)
cid = self.claimed_sync()
pages = [{"code": "DOC-1", "title": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
got = self.client().wiki_pages(cid, pages)
self.assertEqual(got, {"accepted": 1})
self.assertEqual(self.hub.pages, [{"command_id": 5, "pages": pages}])
self.assertEqual(self.hub.pages, [{"command_id": cid, "pages": pages}])
self.assertEqual(self.relay.requests[0]["path"], "/agent/wiki/pages")
def test_wiki_pages_over_http(self):
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
self.assertEqual(hub.wiki_pages(5, [{"code": "DOC-1"}]), {"accepted": 1})
self.assertEqual(self.hub.pages[0]["command_id"], 5)
cid = self.claimed_sync()
self.assertEqual(hub.wiki_pages(cid, [{"code": "DOC-1", "title": "T", "html": ""}]), {"accepted": 1})
self.assertEqual(self.hub.pages[0]["command_id"], cid)
def test_wiki_pages_rejected_is_http_error(self):
self.hub.reject_pages = (422, {"error": "bad_page"})
@ -179,15 +188,17 @@ class FolderIntegrationTest(unittest.TestCase):
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
os.makedirs(self.root)
self.hub = FakeHub(SETTINGS)
self.eva = FakeEva() # check ходит и в Eva (design 10)
self.relay = FakeRelay(self.root, self.hub.base_url).start()
def tearDown(self):
self.relay.stop()
self.hub.stop()
self.eva.stop()
self.tmp.cleanup()
def cfg(self):
return eva_cfg(hub_url="folder:" + self.root, hub_token=self.hub.token)
return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 1)
@ -202,7 +213,7 @@ class FolderIntegrationTest(unittest.TestCase):
runner.idle_limit = 1
runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor
self.assertEqual(self.hub.results[cid]["error"], "wiki_fetch: не реализовано")
self.assertEqual(self.hub.results[cid]["error"], "чужой хост")
self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION)
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])
@ -220,7 +231,7 @@ class FolderIntegrationTest(unittest.TestCase):
t0 = time.monotonic()
with redirect_stdout(out):
code = agent.cmd_check(self.cfg())
self.assertEqual(code, 1)
self.assertEqual(code, 3)
self.assertLess(time.monotonic() - t0, 13)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
self.assertIn("не ответил через папку", out.getvalue())

View File

@ -78,6 +78,29 @@ class ClassifyLinksTest(unittest.TestCase):
'<a href="%s/a.bin"><img src="%s/b.png"></a><img src="%s/c.png">' % (OBJ, OBJ, OBJ), BASE)
self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"])
def test_unknown_marked_section_does_not_crash(self):
"""I-1: на 3.9 `<![foo]>` уходит в ParserBase.error() → NotImplementedError; ссылки до и после сохраняются."""
html = ('<img src="%s/a.png"><![foo]><p>текст</p><![if !supportLists]>x<![endif]>'
'<a href="/project/Document/DOC-3">c</a><img src="%s/b.png">' % (OBJ, OBJ))
att, links = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
self.assertEqual(links, [{"ref": "/project/Document/DOC-3", "code": "DOC-3"}])
def test_marked_section_bad_name_and_unclosed(self):
for html in ('<img src="%s/a.png"><![ ]><img src="%s/b.png">' % (OBJ, OBJ),
'<img src="%s/a.png"><img src="%s/b.png"><![foo' % (OBJ, OBJ)):
with self.subTest(html=html):
att, _ = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
def test_bad_url_skipped(self):
"""I-1: `http://[::1` и порт не числом дают ValueError в urljoin/urlsplit — ссылка пропускается, остальные целы."""
html = ('<img src="http://[::1"><a href="https://[zz/files/x">x</a><img src="https://eva.test:abc/files/y.png">'
'<img src="%s/b.png"><a href="/project/Document/DOC-4">d</a>' % OBJ)
att, links = agent.classify_links(html, BASE)
self.assertEqual([a["name"] for a in att], ["b.png"])
self.assertEqual([l["code"] for l in links], ["DOC-4"])
if __name__ == "__main__":
unittest.main()

View File

@ -12,7 +12,7 @@ from fake_hub import FakeHub
FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}
STUB_ERROR = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed
STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva
class RunnerTest(unittest.TestCase):

View File

@ -11,6 +11,7 @@ import time
import unittest
from _common import SETTINGS, SRC
from fake_eva import FakeEva
from fake_hub import FakeHub
@ -28,13 +29,14 @@ class SelfUpdateE2E(unittest.TestCase):
def setUp(self):
self.hub = FakeHub(SETTINGS)
self.eva = FakeEva() # selftest = check: нужна доступная Eva
self.tmp = tempfile.TemporaryDirectory()
d = self.tmp.name
shutil.copy(os.path.join(SRC, "run.py"), d)
shutil.copy(os.path.join(SRC, "agent.py"), d)
with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f:
json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2,
"eva": {"base_url": "http://127.0.0.1:1", "token": "eva-secret"}}, f)
"eva": {"base_url": self.eva.base_url, "token": "eva-secret"}}, f)
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
src = f.read()
self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1))
@ -54,6 +56,7 @@ class SelfUpdateE2E(unittest.TestCase):
self.proc.wait()
self.log.close()
self.hub.stop()
self.eva.stop()
self.tmp.cleanup()
def state(self):

139
tests/test_wiki_fetch.py Normal file
View File

@ -0,0 +1,139 @@
import hashlib
import io
import os
import unittest
from contextlib import redirect_stdout
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
from fake_hub import FakeHub
MB = 1024 * 1024
TOO_BIG = "файл больше 10 МБ — откройте в Eva"
class WikiFetchTest(unittest.TestCase):
def setUp(self):
self.eva = FakeEva()
self.hub = FakeHub(SETTINGS)
self.addCleanup(self.eva.stop)
self.addCleanup(self.hub.stop)
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
client = agent.EvaClient(cfg)
client.sleep = no_sleep
self.ex = agent.Executor(client, agent.HubClient(cfg))
def fetch(self, url):
return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}})
def file_url(self, path="/files/a.bin"):
return self.eva.base_url + path
def test_fetch_ok(self):
data = os.urandom(300 * 1024)
self.eva.add_file("/files/a.bin", data)
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 307200)
self.assertEqual(out["result"]["file"], hashlib.sha256(data).hexdigest())
self.assertEqual(self.hub.files[out["result"]["file"]], data)
self.assertEqual(self.eva.requests[0]["auth"], "Bearer " + self.eva.token)
def test_fetch_foreign_host(self):
out = self.fetch("http://127.0.0.1:1/files/a.bin")
self.assertEqual(out["status"], "failed")
self.assertIn("чужой хост", out["error"])
self.assertEqual(self.eva.requests, [])
self.assertEqual(self.hub.uploaded, [])
def test_fetch_foreign_lookalikes(self):
"""T5.2: похожий хост, userinfo, другой порт и схема — «чужой хост», запроса к Eva нет."""
port = self.eva.base_url.rsplit(":", 1)[1] # http://127.0.0.1:<port>
for url in ("http://127.0.0.1.evil:%s/files/a.bin" % port,
"http://127.0.0.1:%s@evil.test/files/a.bin" % port,
"http://127.0.0.1:%d/files/a.bin" % (int(port) + 1),
"https://127.0.0.1:%s/files/a.bin" % port):
with self.subTest(url=url):
out = self.fetch(url)
self.assertEqual((out["status"], out["error"]), ("failed", "чужой хост"))
self.assertEqual(self.eva.requests, [])
self.assertEqual(self.hub.uploaded, [])
def test_fetch_malformed_url(self):
out = self.fetch("http://[::1/files/a.bin")
self.assertEqual(out["status"], "failed")
self.assertIn("некорректный адрес", out["error"])
self.assertEqual(self.eva.requests, [])
def test_fetch_non_ascii_and_spaces(self):
"""I-2: адрес из HTML с пробелами и кириллицей кодируется перед запросом; %XX не кодируется повторно."""
enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png"
self.eva.add_file(enc, b"png")
for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"):
with self.subTest(path=path), redirect_stdout(io.StringIO()):
out = self.fetch(self.file_url(path))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 3)
self.assertEqual([r["path"] for r in self.eva.requests], [enc] * 3)
def test_fetch_stray_percent(self):
self.eva.add_file("/files/100%25.txt", b"ok")
with redirect_stdout(io.StringIO()):
out = self.fetch(self.file_url("/files/100%.txt"))
self.assertEqual(out["status"], "done", out)
def test_value_error_inside_is_not_foreign_host(self):
"""I-2: ValueError внутри скачивания (например, UnicodeEncodeError) не выдаётся за «чужой хост»."""
def boom(*a, **kw):
raise UnicodeEncodeError("ascii", "я", 0, 1, "ordinal not in range")
self.ex.eva.fetch_bytes = boom
with self.assertRaises(UnicodeEncodeError):
self.fetch(self.file_url())
def test_fetch_too_big_header(self):
self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], TOO_BIG)
self.assertEqual(self.hub.uploaded, [])
def test_fetch_too_big_streaming(self):
self.eva.add_file("/files/a.bin", b"x" * (10 * MB + 1), length=False)
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], TOO_BIG)
self.assertEqual(self.hub.uploaded, [])
def test_fetch_exactly_limit(self):
self.eva.add_file("/files/a.bin", b"x" * (10 * MB))
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 10 * MB)
def test_fetch_404(self):
out = self.fetch(self.file_url("/files/none.bin"))
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл не найден в Eva")
def test_fetch_empty_file(self):
self.eva.add_file("/files/a.bin", b"")
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 0)
self.assertEqual(self.hub.files[out["result"]["file"]], b"")
def test_fetch_hub_error_is_hub_error(self):
self.eva.add_file("/files/a.bin", b"abc")
self.hub.reject_files = (500, {"error": "boom"})
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб:"), out)
self.assertTrue(out["retryable"])
def test_fetch_no_url(self):
out = self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7}})
self.assertEqual(out["status"], "failed")
if __name__ == "__main__":
unittest.main()

266
tests/test_wiki_sync.py Normal file
View File

@ -0,0 +1,266 @@
import json
import os
import tempfile
import unittest
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
from fake_hub import FakeHub
class SyncBase(unittest.TestCase):
def setUp(self):
self.eva = FakeEva()
self.hub = FakeHub(SETTINGS)
self.addCleanup(self.eva.stop)
self.addCleanup(self.hub.stop)
self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.client = agent.EvaClient(self.cfg)
self.client.sleep = no_sleep
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg))
def sync(self, root="DOC-1", label="tz"):
cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root})
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "claimed"
self.cid = cid
return self.ex.run({"id": cid, "type": "wiki_sync", "params": {"label": label, "root": root}})
def sent(self):
return [p for body in self.hub.pages for p in body["pages"]]
class WikiSyncTest(SyncBase):
def test_sync_tree_order(self):
e = self.eva
e.add_doc(1, "DOC-1", "Корень", project="P", text="<p>корень</p>")
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P", orderno=2, text="<p>б</p>")
e.add_doc(3, "DOC-3", "А", parent="DOC-1", project="P", orderno=1, text="<p>а</p>")
e.add_doc(4, "DOC-4", "Внук", parent="DOC-3", project="P", text="<p>внук</p>")
e.add_doc(5, "DOC-5", "Чужой", parent="DOC-1", project="OTHER")
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
self.assertEqual(out["result"]["root_title"], "Корень")
self.assertEqual(out["result"]["eva_calls"], self.client.calls)
pages = {p["code"]: p for p in self.sent()}
self.assertEqual([p["code"] for p in self.sent()], out["result"]["codes"])
self.assertIsNone(pages["DOC-1"]["parent_code"])
self.assertEqual(pages["DOC-3"]["parent_code"], "DOC-1")
self.assertEqual(pages["DOC-4"]["parent_code"], "DOC-3")
self.assertEqual(pages["DOC-3"]["orderno"], 1)
self.assertIsNone(pages["DOC-4"]["orderno"])
self.assertEqual(pages["DOC-3"]["url"], self.eva.base_url + "/project/Document/DOC-3")
self.assertEqual(pages["DOC-3"]["eva_id"], "3")
self.assertEqual(pages["DOC-3"]["title"], "А")
self.assertEqual(pages["DOC-3"]["html"], "<p>а</p>")
self.assertEqual(self.hub.pages[0]["command_id"], self.cid)
def test_sync_passes_links_and_attachments(self):
self.eva.add_doc(1, "DOC-1", "К", project="P", text=(
'<p><a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема"></p>'))
out = self.sync()
self.assertEqual(out["status"], "done", out)
page = self.sent()[0]
self.assertEqual(page["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
self.assertEqual(page["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png",
"name": "Схема"}])
def test_sync_batches_50(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
for i in range(2, 121):
self.eva.add_doc(i, "DOC-%d" % i, "С%03d" % i, parent="DOC-1", project="P", orderno=i)
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [50, 50, 20])
self.assertEqual(len(out["result"]["codes"]), 120)
self.assertEqual(out["result"]["codes"][0], "DOC-1")
def test_sync_batch_bytes(self):
big = "<p>" + "x" * (1500 * 1024) + "</p>"
self.eva.add_doc(1, "DOC-1", "Корень", project="P", text=big)
for i in (2, 3):
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i, text=big)
out = self.sync()
self.assertEqual(out["status"], "done", out)
sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.pages]
self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [2, 1])
def test_tree_cycle_and_two_parents(self):
e = self.eva
e.add_doc(1, "DOC-1", "Корень", project="P")
e.add_doc(3, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1)
e.add_doc(10, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2)
e.add_doc(11, "DOC-B", "Б", tree_parent="DOC-A", project="P")
e.add_doc(12, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3)
out = self.sync()
self.assertEqual(out["status"], "done", out)
codes = out["result"]["codes"]
self.assertEqual(len(codes), len(set(codes)))
self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"])
self.assertEqual([p["code"] for p in self.sent()], codes)
def fill_forms_tree(self):
e = self.eva
e.add_doc(101, "DOC-1", "Корень", project="P")
e.add_doc(103, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1) # только parent
e.add_doc(104, "DOC-4", "Внук", tree_parent="DOC-3", project="P") # только tree_parent
e.add_doc(110, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2)
e.add_doc(111, "DOC-B", "Б", tree_parent="DOC-A", project="P") # цикл A <-> B
e.add_doc(112, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3) # два родителя
def test_all_ref_forms(self):
"""Ссылки parent/tree_parent бывают {id, code}, {id} и голой строкой-id; id документа не равен коду."""
for form in ("full", "id_only", "string", "code_id"):
with self.subTest(form=form):
self.setUp()
self.eva.ref_form = form
self.fill_forms_tree()
out = self.sync()
self.assertEqual(out["status"], "done", out)
codes = out["result"]["codes"]
self.assertEqual(codes[:4], ["DOC-1", "DOC-3", "DOC-C", "DOC-4"])
self.assertEqual(len(codes), len(set(codes)))
self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-4", "DOC-A", "DOC-B", "DOC-C"])
parents = {p["code"]: p["parent_code"] for p in self.sent()}
self.assertEqual(parents["DOC-4"], "DOC-3")
self.assertEqual(parents["DOC-A"], "DOC-1")
self.assertEqual(parents["DOC-B"], "DOC-A")
self.assertEqual(parents["DOC-C"], "DOC-3") # первый родитель по обходу в глубину
self.assertEqual(self.sent()[0]["eva_id"], "101")
self.eva.stop()
self.hub.stop()
def test_root_without_project(self):
e = self.eva
e.add_doc(1, "DOC-1", "Корень", text="<p>к</p>")
e.add_doc(2, "DOC-2", "Б", tree_parent="DOC-1", orderno=2)
e.add_doc(3, "DOC-3", "А", tree_parent="DOC-1", orderno=1)
e.add_doc(4, "DOC-4", "Внук", tree_parent="DOC-3")
e.add_doc(5, "DOC-5", "Посторонний", tree_parent="DOC-9")
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
self.assertEqual({p["code"]: p["parent_code"] for p in self.sent()},
{"DOC-1": None, "DOC-3": "DOC-1", "DOC-4": "DOC-3", "DOC-2": "DOC-1"})
def test_root_missing(self):
out = self.sync("DOC-9")
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "страница DOC-9 не найдена или нет прав")
self.assertEqual(self.hub.pages, [])
def test_eva_failure_reported(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
self.eva.fail_next(503, 4)
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertIn("503", out["error"])
self.assertNotIn(FakeEva.token, out["error"])
self.assertEqual(self.hub.pages, [])
def test_hub_rejects_batch(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
self.hub.reject_pages = (422, {"error": "bad_pages", "detail": "DOC-1: html"})
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertIn("422", out["error"])
self.assertIn("DOC-1: html", out["error"])
def test_eva_rpc_error_reported(self):
"""M-1: `error` JSON-RPC Eva — failed «Eva: <текст>», без повтора и без «внутренней ошибки агента»."""
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
self.eva.rpc_error_next("нет прав, токен eva-secret")
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "Eva: CmfDocument.get: нет прав, токен ***")
self.assertFalse(out.get("retryable"))
self.assertEqual(self.hub.pages, [])
def test_eva_rpc_error_in_runner(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
self.eva.rpc_error_next("нет прав")
runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.devnull))
runner.executor = self.ex
self.assertEqual(runner.run_once({"id": 1, "type": "wiki_sync", "params": {"root": "DOC-1"}})["error"],
"Eva: CmfDocument.get: нет прав")
def test_hub_413_single_page(self):
"""M-3: хаб не принял тело (413) — в ошибке названа страница."""
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertIn("страница DOC-1 больше 16 МБ", out["error"])
self.assertFalse(out.get("retryable"))
def test_hub_413_batch(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
for i in (2, 3):
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertIn("пачка страниц DOC-1…DOC-3 (3 шт.) больше 16 МБ", out["error"])
def test_empty_name_falls_back_to_code(self):
self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check)
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.sent()[0]["title"], "DOC-1")
self.assertEqual(out["result"]["root_title"], "DOC-1")
def test_page_vanished(self):
e = self.eva
e.add_doc(1, "DOC-1", "Корень", project="P")
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
real = self.client.get_doc
self.client.get_doc = lambda code, fields: None if code == "DOC-2" else real(code, fields)
out = self.sync()
self.assertEqual(out["status"], "failed")
self.assertIn("DOC-2", out["error"])
def test_counts(self):
e = self.eva
e.add_doc(1, "DOC-1", "Корень", project="P")
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
e.fail_next(429, 2, retry_after=0)
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["jira_calls"], len(e.requests))
self.assertEqual(out["jira_calls"], self.client.calls)
self.assertEqual(out["rate_limited"], 2)
class WikiSyncRecoverTest(unittest.TestCase):
def test_recover_resyncs(self):
eva, hub = FakeEva(), FakeHub(SETTINGS)
tmp = tempfile.TemporaryDirectory()
for x in (eva.stop, hub.stop, tmp.cleanup):
self.addCleanup(x)
eva.add_doc(1, "DOC-1", "Корень", project="P")
for i in range(2, 61):
eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
cid = hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1"})
hub.queue.remove(cid)
hub.commands[cid]["status"] = "claimed"
hub.in_flight = cid
journal = agent.Journal(os.path.join(tmp.name, "journal.jsonl"))
journal.claimed(cid)
journal.started(cid) # перезапуск посреди выполнения: finished нет
cfg = eva_cfg(eva.base_url, hub_url=hub.base_url, hub_token=hub.token)
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal)
runner.sleep = lambda s: None
runner.eva.sleep = no_sleep
runner.idle_limit = 1
runner.loop()
self.assertEqual(hub.results[cid]["status"], "done", hub.results[cid])
self.assertEqual(len(hub.results[cid]["result"]["codes"]), 60)
self.assertEqual([len(b["pages"]) for b in hub.pages], [50, 10])
self.assertEqual(len([r for r in journal.records() if r["kind"] == "finished"]), 1)
self.assertEqual(journal.unfinished_started(), [])
if __name__ == "__main__":
unittest.main()