Compare commits
10 Commits
243b4b8f8d
...
9320de3adf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9320de3adf | ||
|
|
cf7c7bde8c | ||
|
|
343c49fc2e | ||
|
|
c9fd3d6290 | ||
|
|
4a410d4b08 | ||
|
|
a51c509a87 | ||
|
|
acdd52742e | ||
|
|
3597495a6a | ||
|
|
50878d5965 | ||
|
|
9793fdd40b |
@ -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).
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@ -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 сами по себе не запускать.
|
||||
|
||||
80
README.md
80
README.md
@ -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` вложения и его содержимое.
|
||||
|
||||
@ -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
108
scripts/e2e-hub.sh
Executable 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
38
scripts/fake_eva_serve.py
Normal 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())
|
||||
@ -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",
|
||||
|
||||
278
src/agent.py
278
src/agent.py
@ -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__":
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
77
tests/test_check.py
Normal 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()
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
139
tests/test_wiki_fetch.py
Normal 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
266
tests/test_wiki_sync.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user