eva-agent MVP: wiki_sync и wiki_fetch (#1) #2

Merged
aep merged 15 commits from feat/1-mvp into main 2026-10-24 09:05:15 +03:00
36 changed files with 6005 additions and 19 deletions

8
.gitignore vendored
View File

@ -1,3 +1,11 @@
.superpowers/ .superpowers/
.bounded .bounded
.DS_Store .DS_Store
agent.json
*.jsonl
update.json
*.lock
__pycache__/
*.pyc
agent.py.new
agent.py.prev

View File

@ -1,15 +1,48 @@
# Архитектура # Архитектура
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}} Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в
`tests/` работают на фейках хаба и Eva. Спека: `../jira-hub/openspec/changes/wiki-module/`; решения агента:
`openspec/changes/eva-agent-mvp/design.md`. Общие части — копия jira-agent v14 (`../jira-agent/ARCHITECTURE.md`).
## Модули ## Разделы файла `src/agent.py`
| Модуль | Ответственность | Зависит от | | Раздел | Классы и функции | Ответственность | Что использует |
|---|---|---| |---|---|---|---|
| | | | | Настройки | `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`, `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`: корень, `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` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` |
## Запускатель `src/run.py`
Дословная копия запускателя jira-agent (`LAUNCHER_VERSION = 2`, переменная окружения `JIRA_AGENT_LAUNCHER`). Не импортирует
`agent.py`. Запускает `agent.py run` дочерним процессом; код 75 — подмена на `agent.py.new` с пробным периодом и
откатом; иначе перезапуск с паузой. Проверка идентичности — `test_run_py_is_copy`.
## Границы ## Границы
- Что не должно импортировать что. - Код новой версии приходит только через API хаба (`GET /agent/release`); общая папка для кода не используется.
- Токены хаба и Eva — только в `agent.json`; в журнал, хаб, логи и тексты ошибок не попадают.
- Темп вызовов Eva задаёт хаб (`wiki_pacing` в `GET /agent/settings`), агент его не хранит.
- Одна команда в полёте: следующая берётся после выполнения и подтверждения отчёта.
- Отчёт до подтверждения: `send_result` повторяет каждые 10 с; 404/409/422 от хаба считаются закрытием.
- Команды только читают Eva, поэтому после перезапуска выполняются заново (design 9).
- Поля протокола `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 (после merge hub#92).
- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой
живой синхронизации (M-5, design 42).

View File

@ -1,28 +1,57 @@
# eva-agent # eva-agent
## Что это ## Что это
{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}} Клиент модуля wiki хаба (sdlc-hub, `../jira-hub`) для виртуалки с доступом к Eva банка. Опрашивает хаб, по командам
`wiki_sync` (выгрузить поддерево страниц с корня) и `wiki_fetch` (скачать вложение) читает Eva и отдаёт данные хабу;
в Eva ничего не пишет. Главный сценарий: хаб ставит `wiki_sync` с кодом корня, агент обходит дерево страниц, шлёт
страницы пачками в стадию хаба и отчитывается списком кодов.
Спека и дизайн: `../jira-hub/openspec/changes/wiki-module/` (hub#91, PR hub#92); решения агента —
`openspec/changes/eva-agent-mvp/design.md`. Issue агента — aep/eva-agent#1.
## Стек и ограничения ## Стек и ограничения
- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}} - Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей.
- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}} - Запрещено: синтаксис 3.10+ и сторонние импорты. `tests/test_py39.py` ловит синтаксис 3.10+ и сторонние импорты;
`X | Y` в аннотациях функций ловит прогон на /usr/bin/python3 3.9.6.
- `agent.json` содержит токены хаба и Eva — не коммитить, не отправлять в хаб, не выносить с виртуалки.
Шаблон без секретов — `src/agent.example.json`.
## Архитектура ## Архитектура
- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью. - `src/agent.py` — весь агент, один файл. Общие части скопированы из jira-agent v14 без изменения смысла (решение 1):
- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно. `Http`/`HttpError`/`RedirectAway`/`TooBig`, `FolderTransport` (хаб через общую папку), `HubClient`, `Journal`,
- {{Ключевые интерфейсы/порты между ними, если есть.}} `Updater` (самообновление), `Runner` (claim → пауза → журнал → выполнение → отчёт), `acquire_lock`, CLI
`run|check|selftest|prune`. Своё: `EvaClient` (клиент Eva), `Executor` (`do_wiki_sync`, `do_wiki_fetch`).
- `src/run.py` — запускатель, дословная копия `../jira-agent/src/run.py` (решение 2); `agent.py` не импортирует.
- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, файл `upload_file`); `Runner` знает хаб, Eva и
журнал; журнал не знает об Eva. Имена полей протокола хаба `jira_calls`/`rate_limited` общие для всех агентов:
в `jira_calls` идёт число вызовов Eva.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. Подробнее: `ARCHITECTURE.md`.
## Команды ## Команды
- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор. - `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
- `{{npm run build / ...}}` — сборка. - `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые
- `{{npm run dev / ...}}` — локальный запуск. ресурсы (так проверяем перед коммитом).
- `/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.
- `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление).
## Тесты ## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются. - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`,
- {{Где лежит тестовое окружение (vault, БД, песочница).}} `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`).
- Фикстуры страниц — `tests/fixtures/pages/`. Golden-сценариев нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический.
## Первая подсистема ## Первая подсистема
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}} MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub
jira-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira);
порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki».
## OpenSpec и superpowers ## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

84
README.md Normal file
View File

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

View File

@ -0,0 +1,202 @@
# eva-agent MVP — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент на виртуалке банка выполняет команды модуля `wiki` хаба: `wiki_sync` (дерево EVA Wiki под корнем → пачки страниц хабу) и `wiki_fetch` (вложение до 10 МБ → хранилище хаба), с журналом, транспортом через общую папку и самообновлением, как jira-agent.
**Architecture:** один файл `src/agent.py` (Python 3.9, stdlib) — копия общих частей jira-agent v14 (HTTP, `FolderTransport`, `HubClient`, `Journal`, `Runner`, `Updater`, CLI) плюс `EvaClient` (JSON-RPC 2.2), `LinkParser` (классификация ссылок HTML) и `Executor` с `do_wiki_sync`/`do_wiki_fetch`; `src/run.py` — дословная копия запускателя.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
**Spec:** контракт — `../../jira-hub/.wt/wiki/openspec/changes/wiki-module/` (worktree хаба, ветка `feat/91-wiki`, PR jira-hub#92): `specs/wiki/spec.md` («Синхронизация вики», «Вложения вики по запросу»), `design.md` решения 10, 11, 14 и Context; решения агента — `openspec/changes/eva-agent-mvp/design.md` этого репозитория (1–11). Код хаба, принимающий агента: `src/jira_hub/modules/wiki/{stage.py,sync.py,attachments.py,commands.py}` в том же worktree хаба.
## Global Constraints
- Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ (`match`, `X | Y` в аннотациях функций) и сторонние импорты запрещены — ловит `tests/test_py39.py`.
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный весь набор после каждой задачи.
- `AGENT_VERSION = 1` — ровно одна строка `AGENT_VERSION = <целое>` в `src/agent.py`.
- `src/run.py` — байт в байт `../../jira-agent/src/run.py` (сверка `cmp` в тесте `test_run_py_is_copy`, путь к jira-agent — `../../jira-agent/src/run.py` от корня worktree; если файла нет — тест пропускается с причиной).
- Токен Eva — только в `agent.json` (не в git: `.gitignore` получает `agent.json`, `*.jsonl`, `update.json`, `*.lock`); в логах и отчётах его нет (тест `test_token_not_logged`).
- Протокол хаба: `POST /agent/claim`, `GET /agent/settings`, `GET /agent/commands/{id}`, `POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited}`, `GET /agent/release`, `POST /agent/files {content}` → `{file, size}`, `POST /agent/wiki/pages {command_id, pages}`. Форма страницы — `stage._check` хаба.
- Не трогать репозитории `jira-agent` (только читать), `jira-hub` (только читать и запускать тестовый хаб во временном `JIRA_HUB_HOME`), живой хаб и `~/.local/share/jira-hub`.
- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Дерево Eva с циклом (страница — родитель сама себе или через `tree_parent`) и страница с двумя родителями — обход конечен, страница в отчёте один раз: `test_tree_cycle_and_two_parents` (Task 4).
2. HTML с относительными и абсолютными адресами, миниатюрами `.meta/thumbnail.jpg`, `static/`, чужим хостом, `DOC-` в адресе другого хоста — классификация как в design 6: `tests/test_links.py` (Task 3).
3. Eva отвечает 429 с `Retry-After`, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговый `failed` с текстом без токена: `tests/test_eva_client.py` (Task 2).
4. Вложение без `Content-Length` и больше 10 МБ — чтение обрывается на 10 МБ + 1 байт, `failed` с текстом спеки: `test_fetch_too_big_streaming` (Task 5).
5. Перезапуск агента посреди `wiki_sync` (журнал: started без finished) — команда выполняется заново, хаб получает полный набор пачек и один отчёт: `test_recover_resyncs` (Task 4).
---
### Task 1: Каркас — общие части jira-agent, конфиг, запускатель
**Files:**
- Create: `src/agent.py`, `src/run.py` (копия), `src/agent.example.json`, `tests/{__init__.py,_common.py,fake_hub.py,fake_relay.py,test_py39.py,test_clients.py,test_folder.py,test_journal.py,test_runner.py,test_update.py,test_launcher.py,test_self_update_e2e.py}`, `.gitignore` (дописать)
- Modify: `CLAUDE.md`, `ARCHITECTURE.md` (заполнить шаблоны по образцу `../../jira-agent/CLAUDE.md` и `ARCHITECTURE.md`, для eva-agent), `README.md` (создать: назначение, установка — Task 6 допишет)
**Interfaces:**
- Produces:
- `load_config(path) -> dict` (обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; `folder:` — абсолютный путь; умолчания как у jira-agent).
- Копия из jira-agent без изменений смысла: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `FolderTransport`, `Journal`, `Updater`, `acquire_lock`, `launcher_version`, `cmd_run`, `cmd_prune`, `main`.
- `HubClient` — без jira-маршрутов (`state`, `known_keys`, `history`), плюс `wiki_pages(command_id, pages) -> dict`.
- `Runner` — вызывает `Executor.run(cmd) -> dict` (`{status, result, error, jira_calls, rate_limited}`), как в jira-agent.
- `Executor(eva, hub, settings)` с `do_wiki_sync`/`do_wiki_fetch`, в этой задаче — `failed` «не реализовано».
- `cmd_check(cfg) -> int` проверяет только хаб; Eva добавит Task 6.
- Тесты общих частей переносятся из `../../jira-agent/tests/` с заменой: Jira → нет, `ensure_*` → команда-заглушка `wiki_fetch` с `failed`. Сценарии журнала, папки, 401/403-паузы, самообновления и запускателя сохраняются.
- [ ] **Step 1:** перенести тесты общих частей, они упадут — `agent.py` нет.
- [ ] **Step 2:** собрать `src/agent.py` из jira-agent v14:
- вырезать `JiraClient`, jira-методы `Executor`, `ConfigGap` и проверки Jira;
- `AGENT_VERSION = 1`;
- `run.py` скопировать.
- [ ] **Step 3:** весь набор — PASS; `test_run_py_is_copy` и `test_py39` зелёные.
- [ ] **Step 4: Commit** «Каркас eva-agent: общие части jira-agent v14 (#1)».
---
### Task 2: Клиент Eva
**Files:**
- Modify: `src/agent.py` (класс `EvaClient(Http)` после `HubClient`)
- Create: `tests/fake_eva.py`, `tests/test_eva_client.py`
**Interfaces:**
- Produces:
- `EvaClient(cfg_eva: dict, timeout, verify_ssl, ca_bundle)`, его методы:
- `call(method, **kwargs) -> Any` — `POST <base>/api/?m=<method>`, `Bearer`; повторы по design 4; `error` в ответе — `EvaError("<method>: <error>")`;
- `get_doc(code, fields) -> dict | None`;
- `list_docs(flt, fields) -> list[dict]` — страницы по 200 через `slice`; обёрнутый результат `{items|result}` разворачивается;
- `download(url, limit) -> bytes` — поток с обрывом на `limit + 1`; больше лимита — `TooBig`.
- Свойства: `calls` (число вызовов), `rate_limited` (число 429), `pacing` (пауза, с; ставит Runner из `wiki_pacing`).
- `tests/fake_eva.py: FakeEva` — `http.server` на порту 0:
- хранит документы `{id, code, name, parent, tree_parent, project, orderno, text}`;
- поддерживает фильтры `code ==`, `project.code ==`, `tree_parent.code ==`, `slice`;
- сценарии сбоев: `fail_next(status, times, retry_after=None)`, `rpc_error_next(text)`;
- файлы `/files/...` с `Content-Length` или без него;
- проверяет `Authorization`.
- [ ] **Step 1: Падающие тесты:**
- `test_call_ok`;
- `test_retry_429_retry_after` — ждал `Retry-After` через подменённый `sleep`;
- `test_retry_503_then_ok`;
- `test_gives_up_after_4`;
- `test_rpc_error`;
- `test_list_pages_200` — 450 документов за три вызова;
- `test_pacing_between_calls`;
- `test_download_limit` — без `Content-Length`, 10 МБ + 1;
- `test_token_not_logged` — токен не встречается ни в логе, ни в тексте ошибок.
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Клиент Eva JSON-RPC (#1)».
---
### Task 3: Разбор ссылок страницы
**Files:**
- Modify: `src/agent.py` (функция `classify_links(html, base_url) -> tuple[list, list]` и `class _LinkParser(HTMLParser)`)
- Create: `tests/test_links.py`, `tests/fixtures/pages/*.html` (синтетические, форматы адресов из design хаба, Context; текст ТЗ не копировать)
**Interfaces:**
- Produces: `classify_links(html: str, base_url: str) -> (attachments: [{ref, url, name}], links: [{ref, code}])` по design 6–7, в порядке появления, без склейки повторов.
- [ ] **Step 1: Падающие тесты:**
```python
def test_image_relative_files() # src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/img.png?version=1" →
# url "https://eva.test/files/obj/…/img.png" (без query), name "img.png"
def test_thumbnail_maps_to_file() # ".../model.xlsx.meta/thumbnail.jpg?version=1" → url ".../model.xlsx", name из alt или "model.xlsx"
def test_file_link_same_url() # <a href=".../model.xlsx">model.xlsx</a> → тот же url, что у миниатюры
def test_static_ignored() # src="static/attach-file.png" → ни в одном списке
def test_page_link_relative() # href="project/Document/DOC-019834" → links [{ref, code: "DOC-019834"}]
def test_foreign_host() # href="https://other.test/files/x.png" и "https://other.test/project/Document/DOC-1" → пусто
def test_name_unquoted() # ".../%D0%9F%D0%9C%D0%9F.xlsx" без alt и текста → name "ПМП.xlsx"
def test_ref_exact() # ref равен атрибуту байт в байт (с &amp; → как отдаёт HTMLParser: unescape; проверить, что хаб ищет так же — см. convert.py хаба)
```
Для `test_ref_exact` надо сверить, с чем хаб сравнивает `ref`: с сырым значением атрибута или с unescape. Это видно в `../../jira-hub/.wt/wiki/src/jira_hub/modules/wiki/convert.py`. Записать, как сделано, в отчёт и в design (решение 12).
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Разбор ссылок страниц Eva (#1)».
---
### Task 4: Команда `wiki_sync`
**Files:**
- Modify: `src/agent.py` (`Executor.do_wiki_sync`, функции `build_children(docs)`, `walk(root, children)`), `tests/fake_hub.py` (`POST /agent/wiki/pages` — записывает пачки; проверка формы по образцу `stage._check` хаба: `code`, `title`, `html` строки; ≤ 50 страниц)
- Create: `tests/test_wiki_sync.py`
**Interfaces:**
- Consumes: `EvaClient` (Task 2), `classify_links` (Task 3), `HubClient.wiki_pages` (Task 1).
- Produces: `Executor.do_wiki_sync(params {label, root}) -> {status, result {root_title, codes, eva_calls}, error, jira_calls, rate_limited}` по design 5; пачки — `MAX_PAGES = 50`, `MAX_BATCH_BYTES = 4 * 1024 * 1024`.
- [ ] **Step 1: Падающие тесты:**
```python
def test_sync_tree_order() # корень DOC-1 (project P), дети DOC-3 (orderno 1), DOC-2 (orderno 2), внук DOC-4 под DOC-3 →
# codes [DOC-1, DOC-3, DOC-4, DOC-2]; parent_code; url ".../project/Document/DOC-3"
def test_sync_batches_50() # 120 страниц → пачки 50, 50, 20; codes — 120
def test_sync_batch_bytes() # страницы по 1,5 МБ → пачка не больше 4 МБ
def test_tree_cycle_and_two_parents() # A.parent=B, B.tree_parent=A; C с parent=корень и tree_parent=DOC-3 → каждый код один раз
def test_root_without_project() # у корня нет project → обход через tree_parent.code
def test_root_missing() # get_doc → None → failed «страница DOC-9 не найдена или нет прав»
def test_eva_failure_reported() # Eva 503 четыре раза подряд → failed, текст содержит "503", токена в тексте нет
def test_hub_rejects_batch() # fake_hub 422 на пачку → failed с detail хаба
def test_recover_resyncs() # журнал started без finished, команда claimed в хабе → выполняется заново: полные пачки, один done
def test_counts() # jira_calls == eva.calls, rate_limited == числу 429
```
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Команда wiki_sync (#1)».
---
### Task 5: Команда `wiki_fetch`
**Files:**
- Modify: `src/agent.py` (`Executor.do_wiki_fetch`)
- Create: `tests/test_wiki_fetch.py`
**Interfaces:**
- Produces: `Executor.do_wiki_fetch(params {attachment_id, url}) -> {status, result {file, size}, …}` по design 8; использует `HubClient.upload_file` (как у jira-agent `fetch_attachment`).
- [ ] **Step 1: Падающие тесты:**
- `test_fetch_ok` — 300 КБ; `done`, `size` 307200; файл у fake_hub совпадает побайтно;
- `test_fetch_foreign_host` — `failed` «чужой хост», у fake_eva нет запросов;
- `test_fetch_too_big_header` — `Content-Length` 11 МБ, тело не читается;
- `test_fetch_too_big_streaming` — без `Content-Length`;
- `test_fetch_404` — «файл не найден в Eva»;
- `test_fetch_empty_file` — 0 байт; `done`, `size` 0: хаб принимает пустой файл от агента.
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Команда wiki_fetch (#1)».
---
### Task 6: `check`, документация, сквозной прогон с хабом
**Files:**
- Modify: `src/agent.py` (`cmd_check` — хаб и Eva по design 10), `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/eva-agent-mvp/design.md` (решения исполнителей), `src/agent.example.json`
- Create: `scripts/e2e-hub.sh`, `tests/test_check.py`
**Interfaces:**
- `scripts/e2e-hub.sh`:
- поднимает тестовый хаб из `../../jira-hub/.wt/wiki`: `uv run --project <путь> jira-hub serve --port 0`, неудобно — тогда фиксированный свободный порт, например 8795, с `JIRA_HUB_HOME=$(mktemp -d)`;
- поднимает `FakeEva` с деревом из 5 страниц, картинкой и файлом;
- заводит проект `E2E` с `wiki_root` через `POST /projects` клиентским токеном из `jira-hub tokens`;
- запускает агент `src/agent.py run` с токеном агента `wiki` до первого `done`; нужен флаг `--once` или тайм-аут — решает исполнитель, без изменения протокола;
- проверяет `GET /wiki/tree?project=E2E` (5 страниц), `GET /wiki/pages/<код>` (Markdown со ссылкой `attachment:`), затем `POST /wiki/attachments/<id>/fetch` и `GET …/content`;
- печатает «e2e: ok»; хаб и фейк гасятся по `trap`.
- README — установка на виртуалку:
- каталог рядом с jira-agent (например `C:\…\eva-agent\` или `~/eva-agent/`);
- файлы `agent.py`, `run.py`, `agent.json` (из примера: `hub_url` = тот же `folder:<путь>`, что у jira-agent, `hub_token` = «агент wiki» из `jira-hub tokens`, `eva.base_url`, `eva.token` — профиль Eva → Безопасность);
- `python3 agent.py check`, затем `python3 run.py`;
- автозапуск — как у jira-agent;
- выкатка новых версий — `uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py`.
- [ ] **Step 1:** тесты `test_check` (хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS.
- [ ] **Step 2:** `bash scripts/e2e-hub.sh` → «e2e: ok». Хаб — только из worktree `feat/91-wiki` и только во временном `JIRA_HUB_HOME`.
- [ ] **Step 3:** документация.
- [ ] **Step 4:** весь набор — PASS.
- [ ] **Step 5: Commit** «check, README, сквозной прогон с хабом (#1)».

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@ -0,0 +1,279 @@
# eva-agent-mvp — решения агента
Спека и дизайн — в `jira-hub/openspec/changes/wiki-module/` (issue aep/jira-hub#91, PR aep/jira-hub#92):
- решения 10, 11, 14 и Context (форматы ссылок Eva);
- требования `wiki` «Синхронизация вики» и «Вложения вики по запросу»;
- `modules` «Агенты модулей»;
- `hub-api` «Маршруты агента» (общие `/agent/*`).
Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль `wiki`: имя агента в хабе `wiki`, токен —
`jira-hub tokens` → «агент wiki».
Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все
решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion».
## Context
- **Источник для копирования — jira-agent v14** (`../jira-agent/src/agent.py`, `run.py`). Его части, не связанные с
Jira, отлажены с v2 по v14:
- HTTP-слой: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect`;
- транспорт `FolderTransport` и клиент хаба `HubClient`;
- журнал `Journal`, цикл `Runner`, самообновление `Updater`;
- `acquire_lock`, CLI `run|check|selftest|prune`, `launcher_version`.
- **Клиент Eva и обход дерева — по образцу** `../eva_wiki_export.py`:
- JSON-RPC 2.2: `POST <base>/api/?m=<метод>`, `Authorization: Bearer`, тело `{jsonrpc: "2.2", method, callid,
kwargs}`;
- повторы на 429/502/503/504 и сетевых сбоях;
- `CmfDocument.get`/`list`, страницы списка по 200;
- дерево — по `parent`/`tree_parent` из списка документов проекта (`project.code`), запасной путь — обход через
`tree_parent.code`.
- **Хаб принимает:**
- пачки `POST /agent/wiki/pages {command_id, pages}` — до 50 страниц, тело до 16 МБ (`MAX_BODY` хаба);
- форму страницы проверяет `modules/wiki/stage.py`;
- отчёт `wiki_sync done {root_title, codes, eva_calls}`: `codes[0]` должен быть кодом корня;
- отчёт `wiki_fetch done {file, size}`: файл должен лежать в хранилище хаба с этим размером.
## Decisions made without discussion
Дата всех решений — 2026-10-24.
1. **Скопировать общие части jira-agent v14, а не выделять библиотеку.**
Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное
поведение.
Цена ошибки: правки общих частей придётся повторять в двух агентах.
2. **`run.py` — дословная копия запускателя jira-agent** (`LAUNCHER_VERSION = 2`), вместе с переменной окружения
`JIRA_AGENT_LAUNCHER`.
Обоснование: в протоколе самообновления важны имена `agent.py`, `update.json` и `AGENT_VERSION`; переменная —
внутреннее дело пары запускатель–агент.
Цена ошибки: переименование переменной в обоих файлах.
3. **Конфиг `agent.json`:**
- обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`;
- необязательны `verify_ssl`, `ca_bundle`, `timeout` (60), `poll_seconds` (5), `auto_update` (true).
Обоснование: формат jira-agent, где ключ `jira` заменён на `eva`.
Цена ошибки: правка шаблона и `load_config`.
4. **Пауза между вызовами Eva — `wiki_pacing` из `GET /agent/settings`** (по умолчанию 0.2 с).
- 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с.
- При 429 учитывается `Retry-After` (не больше 60 с).
- Число 429 уходит в `rate_limited` отчёта, число вызовов Eva — в `jira_calls`: поле протокола хаба, имя общее
для всех агентов.
Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента.
5. **`wiki_sync` — порядок работы:**
1. Корень берётся через `CmfDocument.get` по коду из `params.root`. Нет корня — `failed` «страница <код> не найдена
или нет прав».
2. Дерево строится как в `collect_subtree`.
3. Обход в глубину от корня с защитой от циклов (`seen` по `id`). Соседи — по `(orderno is None, orderno, name)`.
4. По каждой странице отдельный `CmfDocument.get(fields=["code","name","text"])`.
5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше.
6. Отчёт `codes` — коды в порядке обхода, корень первым.
- Поля страницы: `eva_id = str(id)`, `parent_code` — код родителя по обходу (у корня `null`), `orderno` — целое или
`null`, `url = urljoin(base_url + "/", "project/Document/<code>")`.
Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление.
6. **Ссылки разбирает `html.parser.HTMLParser` из stdlib.**
- Берутся `img[src]` и `a[href]`.
- Адрес разрешается `urljoin(base_url + "/", ref)`. «Свой» — тот же `scheme` и `netloc`, что у `eva.base_url`.
- **Вложение** — свой адрес с путём, начинающимся на `/files/`. Из `url` убираются query и fragment. Миниатюра
`<путь>.meta/thumbnail.jpg` означает файл `<путь>`.
- **Ссылка на страницу** — свой адрес с `DOC-<цифры>` в пути (код в верхнем регистре).
- **`static/…`** и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте.
- **Имя вложения** — `alt` или `title`, затем текст ссылки, затем последний сегмент пути после `unquote`.
- Каждая ссылка передаётся отдельной записью `{ref, url, name}` или `{ref, code}`, `ref` — ровно значение
атрибута. Повторы по `url` склеивает хаб.
Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context).
Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом.
7. **Без `?version=1` в `url` вложения.**
Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение.
Цена ошибки: если Eva без `version` не отдаёт файл, `wiki_fetch` падает; правка одной строки.
8. **`wiki_fetch`:**
- адрес не своего хоста — `failed` «чужой хост», запроса нет;
- файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт;
- `Content-Length` больше 10 МБ или прочитано больше — `failed` «файл больше 10 МБ — откройте в Eva»;
- иначе `POST /agent/files` и `done {file, size}`;
- HTTP 404 — `failed` «файл не найден в Eva».
Цена ошибки: большие файлы только ссылкой.
9. **Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново.** Журнал нужен
только для досылки отчёта; `wiki_sync` не продолжается с середины. Хаб очищает стадию при повторной выдаче.
Цена ошибки: лишняя полная перекачка после перезапуска.
10. **`check`:**
- хаб: `GET /agent/settings` своим токеном;
- Eva: `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`;
- вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent;
- `selftest` — версия плюс `check`.
11. **Тесты:**
- `unittest` на `/usr/bin/python3` (3.9);
- фейки `tests/fake_hub.py` (как у jira-agent, плюс `POST /agent/wiki/pages`), `tests/fake_eva.py` (JSON-RPC на
`http.server`), `tests/fake_relay.py`;
- `tests/test_py39.py` — копия.
- Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель.
12. **`ref` вложений и ссылок — значение атрибута после unescape** (так отдаёт `html.parser`).
Обоснование: хаб (`convert.py`, BeautifulSoup `.get("src"/"href")`) ищет запись по тому же unescaped-значению.
Цена ошибки: адреса с `&amp;` не сопоставятся, вложение останется неразрешённым; правка одной строки.
13. **При расхождении плана с кодом хаба по протоколу прав код хаба.** Поручение владельца; расхождения фиксируются
находками `O-n`, хаб не правится.
Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e.
14. **Каркас `EvaClient` создан в задаче 1; `Runner(cfg, hub, eva, journal)`.** В `EvaClient` — только Bearer-заголовок,
TLS-настройки, счётчики `calls`/`rate_limited` и время последнего вызова (`last_call_at`, `last_call_iso`).
Обоснование: `Runner` и `Executor` из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без
каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3.
Цена ошибки: перенос полей в другой класс.
15. **`claim` шлёт `last_call_at`, а не `last_jira_call_at`.**
Обоснование: хаб (`core/api.py`) читает `body.get("last_call_at", body.get("last_jira_call_at"))`; новое имя не
привязано к Jira. Поля отчёта `jira_calls`/`rate_limited` остаются как есть: хаб читает только их.
Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус).
16. **Jira-специфичные тесты jira-agent не переносились; `test_clients.py` написан заново на `MiniServer`.**
Исключены: `JiraClient` (multipart, 429/503), `Executor`, `refresh`, история, вложения Jira. Сценарии `Http`
(перенаправления, обрывы, не-JSON, предел `fetch_bytes`) сохранены на маленьком локальном сервере, фейка Eva в
задаче 1 нет (появляется в задаче 2).
Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет.
Цена ошибки: пропущенный сценарий `Http`; виден по сравнению со списком тестов jira-agent.
17. **Тесты цикла работают на заглушке `wiki_fetch` (`failed` «не реализовано»); паузу и время вызова Eva тесты
задают вручную (`eva.last_call_at`, `eva._touch()`).** Результат `done` проверяется через подмену
`Executor.run`/`do_wiki_fetch`.
Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают.
18. **`test_run_py_is_copy` ищет `jira-agent/src/run.py` на 1–3 уровня выше корня репозитория; нет файла — skip с
причиной.** Путь из плана (`../../jira-agent`) верен для основного checkout, из worktree `.wt/<срез>` — на уровень
глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов.
19. **`.gitignore` дополнен `agent.py.new`, `agent.py.prev`, `__pycache__/`, `*.pyc`** (рабочие файлы самообновления
и кэш). Цена ошибки: нет.
20. **`EvaClient(cfg)` остаётся с сигнатурой каркаса (по всему `cfg`), а не `(cfg_eva, timeout, verify_ssl, ca_bundle)` из брифа.**
Обоснование: `Runner`/`main` уже создают клиента как `EvaClient(cfg)` (решение 14).
Цена ошибки: нет, внутренняя сигнатура.
21. **`EvaClient.call(method, **kwargs)` переопределяет `Http.call(method, path, ...)`.** Транспортный вызов — через
`Http.call(self, ...)`; тесты общего `Http` в `test_clients.py` переведены на `agent.Http.call(client, ...)`.
Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом).
Цена ошибки: путаница сигнатур; видна при чтении класса.
22. **Пауза между вызовами — перед вызовом (и перед повтором) по `last_call_at`; первый вызов после старта не
задерживается. `Runner.load_settings` ставит `eva.pacing` из `wiki_pacing` (по умолчанию 0.2).**
Обоснование: `last_call_at` общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой.
23. **Токен вырезается (`***`) из текстов `HttpError`/`ConnectionError`/`EvaError`, выходящих из `EvaClient`.**
Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе.
24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.**
Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет.
25. **`classify_links`: уточнения разбора (задача 3).**
- Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется.
- Имя: `alt`/`title` → текст `<a>` (пробелы схлопнуты) → `unquote` последнего сегмента пути.
- Порядок записей — порядок открывающих тегов (для `<a><img></a>` сначала ссылка, потом картинка); повторы не склеиваются.
- Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода.
- `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет).
- В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`).
Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.
26. **Обход дерева `wiki_sync` ведётся по коду страницы, а не по `id` (уточнение решения 5).**
- `children`, `seen` и `parent_code` ключуются кодом (`ref.code`): код уникален и есть в каждой ссылке `parent`/`tree_parent`; `id` берётся только для `eva_id`.
- Соседи сортируются по `(orderno is None, orderno, name без регистра, code)` — как `collect_subtree` экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма.
- Дерево с проектом — один список документов проекта; без проекта — обход в ширину через `tree_parent.code` (защита от циклов тоже по коду).
Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт `code`; поле `id` в ссылке может не совпасть с `id` документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся.
27. **Страница без названия получает `title = code`; корень без названия — `root_title = code`.**
Обоснование: `stage._check` хаба отклоняет пустой `title` (422 `<код>: title`), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени.
28. **Страница, исчезнувшая между списком и чтением (`get_doc` → `None`), — `failed` «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается.**
Обоснование: отчёт `codes` обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация.
29. **Размер пачки считается тем же кодированием, что у `Http.call` (`ensure_ascii=False`, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ).**
Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ.
30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде.
Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e.
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`.
Цена ошибки: нет (оба подтверждены ревьюером по коду).

View File

@ -0,0 +1,17 @@
## Why
Модулю `wiki` хаба (jira-hub#91, PR #92) нужен агент на виртуалке банка: Eva доступна только из сети банка, токен Eva
не должен покидать виртуалку. Решение владельца 23.10.2026: отдельный агент, не jira-agent.
## What Changes
- Новый агент `eva-agent`: `src/agent.py` (Python 3.9, stdlib) и `src/run.py`. Транспорт, журнал и самообновление
такие же, как у jira-agent v14.
- Команды хаба `wiki_sync` и `wiki_fetch`. Их контракт описан в `jira-hub/openspec/changes/wiki-module/specs/wiki/spec.md`.
- README по установке на виртуалку и сквозной прогон с тестовым хабом.
## Non-Goals
- Запись в Eva и история изменений.
- Частичная перекачка дерева (нужна дата изменения документа в Eva).
- Общая библиотека с jira-agent.

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

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

38
scripts/fake_eva_serve.py Normal file
View File

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

13
src/agent.example.json Normal file
View File

@ -0,0 +1,13 @@
{
"hub_url": "folder:/путь/к/общей/папке/jira-bridge",
"hub_token": "токен агента wiki из `jira-hub tokens`",
"eva": {
"base_url": "https://eva.example.ru",
"token": "токен Eva (Bearer)"
},
"verify_ssl": true,
"ca_bundle": null,
"timeout": 60,
"poll_seconds": 5,
"auto_update": true
}

1444
src/agent.py Normal file

File diff suppressed because it is too large Load Diff

319
src/run.py Normal file
View File

@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления
агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения.
Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет.
Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py».
python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run
Обновляется вручную (редко): остановить, заменить файл, запустить.
"""
import fcntl
import hashlib
import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import List, Optional
LAUNCHER_VERSION = 2
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6)
TRIAL_SECONDS = 600 # новая версия должна записать ok за это время
KILL_GRACE = 60 # после SIGTERM до SIGKILL
BACKOFF_START, BACKOFF_MAX = 5, 300
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков)
def log(msg: str) -> None:
print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
def read_state(path: str) -> Optional[dict]:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool:
"""Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5)."""
base = base or {}
data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"),
"sha256": base.get("sha256"), "error": error,
"at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
except OSError as e:
log("не удалось записать update.json: %s" % e)
return False
return True
def sha256_file(path: str) -> Optional[str]:
try:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
except OSError:
return None
def remove(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def acquire_lock(base_dir: str):
"""Один запускатель на каталог. None — уже занято."""
f = open(os.path.join(base_dir, "run.py.lock"), "a")
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
f.close()
return None
return f
class Launcher:
def __init__(self, base_dir: str, args: List[str]):
self.dir, self.args = base_dir, list(args)
self.agent = os.path.join(base_dir, "agent.py")
self.new = os.path.join(base_dir, "agent.py.new")
self.prev = os.path.join(base_dir, "agent.py.prev")
self.state_path = os.path.join(base_dir, "update.json")
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
self.backoff = BACKOFF_START
self.stopping = False
self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии
self.child = None
# --- сигналы ---------------------------------------------------------------
def on_sigint(self, signum, frame):
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает,
что человек остановил (I-1). Без агента (пауза) — выход."""
self.interrupted = True
if self.child is None:
self.stopping = True
def on_sigterm(self, signum, frame):
self.stopping = True
child = self.child
if child is not None and hasattr(child, "send_signal"):
child.send_signal(signal.SIGTERM)
# --- файлы версии ----------------------------------------------------------
def reconcile(self) -> None:
"""Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката."""
for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"):
remove(tmp)
if not os.path.exists(self.agent) and os.path.exists(self.prev):
os.replace(self.prev, self.agent)
log("agent.py не найден — восстановлен из agent.py.prev")
st = read_state(self.state_path)
state, sha = (st or {}).get("state"), (st or {}).get("sha256")
if state == "staged":
if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5)
remove(self.new)
write_state(self.state_path, "trial", st)
log("v%s уже на месте — пробный запуск" % st.get("to_version"))
elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha:
log("найдена подготовленная версия — ставлю")
self.apply_update()
else:
remove(self.new)
write_state(self.state_path, "rejected", st, "подготовленный файл потерян")
log("подготовленная v%s потеряна — отвергнута" % st.get("to_version"))
return
remove(self.new)
if state == "trial" and (not sha or sha256_file(self.agent) != sha):
self.drop_stale_trial(st)
def in_trial(self) -> bool:
"""Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2)."""
st = read_state(self.state_path)
if not (st and st.get("state") == "trial"):
return False
if not st.get("sha256") or sha256_file(self.agent) != st.get("sha256"):
self.drop_stale_trial(st)
return False
return True
def drop_stale_trial(self, st: dict) -> None:
"""agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3)."""
reason = "пробный период прерван: agent.py не v%s" % st.get("to_version")
write_state(self.state_path, "rolled_back", st, reason)
log(reason)
def apply_update(self) -> None:
st = read_state(self.state_path)
if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")):
remove(self.new)
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
log("обновление отвергнуто: файл изменился после проверки")
return
tmp = self.prev + ".tmp"
try:
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
dst.write(src.read())
os.replace(tmp, self.prev)
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
except OSError as e:
self.fail_update(st, e)
return
if not write_state(self.state_path, "trial", st):
try:
os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода
except OSError as e:
log("не удалось вернуть agent.py.prev: %s" % e)
self.fail_update(st, "update.json не записан")
return
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
def fail_update(self, st: dict, err) -> None:
"""Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4)."""
remove(self.prev + ".tmp")
remove(self.new)
write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err)
log("подмена v%s не удалась: %s" % (st.get("to_version"), err))
def rollback(self, st: Optional[dict], reason: str) -> bool:
if not os.path.exists(self.prev):
log("откат невозможен: нет agent.py.prev")
return False
os.replace(self.prev, self.agent)
write_state(self.state_path, "rolled_back", st, reason)
log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason))
return True
# --- процесс агента --------------------------------------------------------
def spawn(self):
env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION))
return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir)
def terminate(self, child) -> None:
child.send_signal(signal.SIGTERM)
try:
child.wait(self.kill_grace)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
def wait_child(self, child, trial: bool):
"""(код выхода, «пробный период истёк», «версия подтверждена»). В пробный период ждём ok не дольше trial_seconds.
Подтверждение — любое состояние update.json, кроме trial этой же версии, пока агент жив: запускатель сам
update.json при живом агенте не пишет, значит это сделал подтвердивший агент (ok, затем rejected/staged для
следующей версии — I-1)."""
start = time.monotonic()
st0 = read_state(self.state_path) if trial else None
trial_sha = (st0 or {}).get("sha256")
confirmed = False
while True:
try:
return child.wait(1), False, confirmed
except subprocess.TimeoutExpired:
pass
if trial and not confirmed:
st = read_state(self.state_path)
if st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha):
confirmed = True
elif time.monotonic() - start >= self.trial_seconds:
self.terminate(child)
return child.returncode, True, False
def pause(self, seconds: float) -> bool:
"""Пауза перед перезапуском; False — остановлены во время паузы."""
end = time.monotonic() + seconds
while not self.stopping:
left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1)
if left <= 0:
break
time.sleep(min(1.0, left))
return not self.stopping
def next_backoff(self, uptime: float) -> int:
if uptime >= HEALTHY_UPTIME:
self.backoff = BACKOFF_START
wait = self.backoff
self.backoff = min(self.backoff * 2, BACKOFF_MAX)
return wait
def run(self) -> int:
self.reconcile()
code = 0
while True:
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
return code
trial = self.in_trial()
trial_sha = (read_state(self.state_path) or {}).get("sha256") if trial else None
started = time.monotonic()
self.interrupted = False # Ctrl-C относится к этому запуску агента
self.child = self.spawn()
if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл
self.child.send_signal(signal.SIGTERM)
code, timed_out, confirmed = self.wait_child(self.child, trial)
self.child = None
uptime = time.monotonic() - started
st = read_state(self.state_path)
ok = bool(st and st.get("state") == "ok")
if trial and not (ok or confirmed) and st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha) \
and sha256_file(self.agent) == trial_sha:
confirmed = True # ok и следующая запись агента случились между опросами (I-1)
ok = ok or confirmed
reason = "агент упал (код %s)" % code
if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3)
log("подтверждение пришло в последний момент — отката нет")
self.backoff = BACKOFF_START
continue
if timed_out:
if self.rollback(st, "не вышла на связь за 10 мин"):
continue
elif self.stopping or (self.interrupted and code in STOP_CODES):
return code
elif code == EXIT_UPDATE:
self.apply_update()
self.backoff = BACKOFF_START
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
return 0
continue
elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6)
reason = "журнал занят другим процессом (агент уже запущен)"
elif trial and not ok:
if self.rollback(st, "код %s через %d с" % (code, uptime)):
continue
if self.stopping:
return code
wait = self.next_backoff(uptime)
log("%s, перезапуск через %d с" % (reason, wait))
if not self.pause(wait):
return code
def main(argv=None) -> int:
base = os.path.dirname(os.path.abspath(__file__))
lock = acquire_lock(base)
if lock is None:
log("запускатель уже работает в %s — второй не стартует" % base)
return 1
try:
launcher = Launcher(base, sys.argv[1:] if argv is None else argv)
signal.signal(signal.SIGINT, launcher.on_sigint)
signal.signal(signal.SIGTERM, launcher.on_sigterm)
log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent))
return launcher.run()
finally:
lock.close()
if __name__ == "__main__":
sys.exit(main())

7
tests/__init__.py Normal file
View File

@ -0,0 +1,7 @@
# Тесты импортируют соседние модули (_common, fake_hub) по простому имени — добавляем каталог в путь.
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)

29
tests/_common.py Normal file
View File

@ -0,0 +1,29 @@
"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейки."""
import os
import sys
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
if SRC not in sys.path:
sys.path.insert(0, SRC)
import agent # noqa: E402
SETTINGS = {
"wiki_pacing": 0,
"min_agent_version": 1,
"settings_version": 1,
}
EVA_URL = "http://127.0.0.1:1" # заглушка Executor в Eva не ходит; фейк Eva появится вместе с её клиентом
def eva_cfg(eva_url=EVA_URL, **over):
cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token",
"eva": {"base_url": eva_url, "token": "eva-secret"},
"verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0}
cfg.update(over)
return cfg
def no_sleep(_seconds):
pass

158
tests/fake_eva.py Normal file
View File

@ -0,0 +1,158 @@
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server."""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
def _field(doc, path):
"""Значение поля по пути `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):
return None
cur = cur.get(part)
return cur
class FakeEva:
token = "eva-secret"
def __init__(self):
self.docs = []
# форма ссылок 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 = []
self.lock = threading.Lock()
eva = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
eva.handle(self, "GET")
def do_POST(self):
eva.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
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": 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)
def fail_next(self, status, times=1, retry_after=None):
self.failures.extend([[status, retry_after]] * times)
def rpc_error_next(self, text):
self.rpc_errors.append(text)
@property
def api_calls(self):
return [r for r in self.requests if r["path"].startswith("/api/")]
def handle(self, h, verb):
u = urlsplit(h.path)
n = int(h.headers.get("Content-Length") or 0)
raw = h.rfile.read(n) if n else b""
auth = h.headers.get("Authorization")
with self.lock:
self.requests.append({"method": verb, "path": h.path,
"body": json.loads(raw) if raw else None, "auth": auth})
failure = self.failures.pop(0) if self.failures else None
rpc_error = self.rpc_errors.pop(0) if self.rpc_errors else None
if auth != "Bearer " + self.token:
return self._reply(h, 401, b'{"error": "unauthorized"}')
if failure:
extra = {"Retry-After": str(failure[1])} if failure[1] is not None else {}
return self._reply(h, failure[0], b'{"error": "busy"}', extra)
try:
if u.path == "/api/" and verb == "POST":
body = json.loads(raw)
if rpc_error is not None:
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
else:
out = {"jsonrpc": "2.2", "callid": body.get("callid"),
"result": self.rpc(body["method"], body.get("kwargs") or {})}
return self._reply(h, 200, json.dumps(out).encode("utf-8"))
if u.path.startswith("/files/") and verb == "GET":
if u.path not in self.files:
return self._reply(h, 404, b'{"error": "not found"}')
data, with_length = self.files[u.path]
h.send_response(200)
h.send_header("Content-Type", "application/octet-stream")
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
self._reply(h, 404, b"{}")
except (BrokenPipeError, ConnectionResetError):
pass
def _reply(self, h, status, body, headers=None):
h.send_response(status)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(body)))
for k, v in (headers or {}).items():
h.send_header(k, v)
h.end_headers()
h.wfile.write(body)
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: self._out(d, fields) # noqa: E731
if method == "CmfDocument.get":
return pick(found[0]) if found else None
if method == "CmfDocument.list":
lo, hi = kw.get("slice") or [0, len(found)]
return [pick(d) for d in found[lo:hi]]
raise ValueError("метод %s не поддержан" % method)
@staticmethod
def _match(doc, flt):
for field, op, value in flt:
if op != "==" or _field(doc, field) != value:
return False
return True

214
tests/fake_hub.py Normal file
View File

@ -0,0 +1,214 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import base64
import hashlib
import json
import re
import socket
import threading
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"
def __init__(self, settings):
self.settings = dict(settings)
self.queue = []
self.commands = {}
self.results = {}
self.claims = 0
self.claim_bodies = []
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.files = {} # sha256 → содержимое (хранилище файлов хаба)
self.file_requests = 0
self.pages = [] # тела POST /agent/wiki/pages
self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
self.lock = threading.Lock()
hub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
hub.handle(self, "GET")
def do_POST(self):
hub.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def enqueue(self, ctype, params, pause=0):
cid = len(self.commands) + 1
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
self.queue.append(cid)
return cid
def put_file(self, data):
sha = hashlib.sha256(data).hexdigest()
self.files[sha] = data
return sha
def _issued(self, c):
return dict(c["params"])
def _release_ref(self):
r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None
def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"})
path = h.path.split("?")[0]
if path == "/agent/files" and method == "POST" and self.reject_files == "drop":
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR)
return
if path == "/agent/claim" and self.claim_wait:
end = time.monotonic() + self.claim_wait
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
with self.lock:
if self.queue:
break
time.sleep(0.05)
with self.lock:
code, payload = self.route(method, path, body)
self._send(h, code, payload)
def _send(self, h, code, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
h.send_response(code)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(data)))
h.end_headers()
h.wfile.write(data)
def route(self, method, path, body):
if path == "/agent/claim":
self.claim_bodies.append(body or {})
self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release":
self.release_requests += 1
return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings":
return 200, self.settings
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)
if m:
c = self.commands.get(int(m.group(1)))
return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m:
cid = int(m.group(1))
if self.fail_results > 0:
self.fail_results -= 1
return 503, {"error": "busy"}
if cid in self.reject_results:
return self.reject_results[cid], {"error": "rejected"}
if cid not in self.commands:
return 404, {"error": "not_found"}
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
self.results[cid] = body
self.commands[cid]["status"] = body.get("status")
if self.in_flight == cid:
self.in_flight = None
return 200, self.commands[cid]
return 404, {"error": "not_found", "detail": path}

74
tests/fake_relay.py Normal file
View File

@ -0,0 +1,74 @@
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
import json
import os
import threading
import time
import urllib.error
import urllib.request
def write_atomic(directory, name, payload):
tmp = os.path.join(directory, "." + name + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
os.replace(tmp, os.path.join(directory, name))
class FakeRelay:
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
self.requests = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True)
for sub in ("req", "resp"):
os.makedirs(os.path.join(root, sub), exist_ok=True)
def start(self):
self._thread.start()
return self
def stop(self):
self._stop.set()
self._thread.join(5)
def _loop(self):
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
while not self._stop.is_set():
for name in sorted(os.listdir(req_dir)):
if name.startswith(".") or not name.endswith(".json"):
continue
path = os.path.join(req_dir, name)
try:
with open(path, encoding="utf-8") as f:
req = json.load(f)
os.remove(path)
except (OSError, ValueError):
continue
self.requests.append(req)
if self.mode == "silent":
continue
if self.mode == "garbage":
write_atomic(resp_dir, name, "{не json")
continue
if self.mode == "late":
time.sleep(self.delay)
status, body = self._forward(req)
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
time.sleep(0.02)
def _forward(self, req):
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
r.add_header("Authorization", req["authorization"])
if data is not None:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=10) as resp:
return resp.status, resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
try:
return e.code, e.read().decode("utf-8")
finally:
e.close()

1
tests/fixtures/pages/amp.html vendored Normal file
View File

@ -0,0 +1 @@
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/a.png?version=1&amp;x=2"><a href="project/Document/DOC-7?a=1&amp;b=2">к</a>

1
tests/fixtures/pages/image.html vendored Normal file
View File

@ -0,0 +1 @@
<p>Схема:</p><img alt="Схема" src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/img.png?version=1">

3
tests/fixtures/pages/links.html vendored Normal file
View File

@ -0,0 +1,3 @@
<p><a href="project/Document/DOC-019834">см. раздел</a> <a href="https://eva.test/project/Document/doc-5">абс.</a>
<a href="https://other.test/files/x.png">чужой файл</a> <a href="https://other.test/project/Document/DOC-1">чужая</a>
<a href="https://eva.test/other/page">прочее</a></p>

1
tests/fixtures/pages/name_quoted.html vendored Normal file
View File

@ -0,0 +1 @@
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/%D0%9F%D0%9C%D0%9F.xlsx?version=1">

View File

@ -0,0 +1,2 @@
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx.meta/thumbnail.jpg?version=1" alt="model.xlsx"> <img src="static/attach-file.png"> <a href="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx?version=1">model.xlsx</a></p>
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/data.dbml.meta/thumbnail.jpg?version=1"></p>

77
tests/test_check.py Normal file
View File

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

254
tests/test_clients.py Normal file
View File

@ -0,0 +1,254 @@
"""HTTP-слой (Http, EvaClient, HubClient) и конфиг. Перенос из jira-agent: сценарии Http без Jira-специфики."""
import contextlib
import io
import json
import os
import socket
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest import mock
from _common import agent, eva_cfg
class MiniServer:
"""Крошечный сервер: routes — путь → (код, заголовки, тело-байты); requests — что пришло."""
def __init__(self):
self.routes = {}
self.requests = []
self.last_headers = {}
srv = self
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
srv.requests.append(self.path)
srv.last_headers = {k.lower(): v for k, v in self.headers.items()}
code, headers, body = srv.routes.get(self.path, (404, {}, b"{}"))
self.send_response(code)
for k, v in headers.items():
self.send_header(k, v)
if "Content-Length" not in headers:
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
class ClientsTest(unittest.TestCase):
def setUp(self):
self.srv = MiniServer()
self.eva = agent.EvaClient(eva_cfg(self.srv.base_url))
def tearDown(self):
self.srv.stop()
def test_get_and_counters(self):
self.srv.routes["/x"] = (200, {"Content-Type": "application/json"}, b'{"a": 1}')
self.assertEqual(agent.Http.call(self.eva, "GET", "/x"), {"a": 1})
self.assertEqual(self.eva.calls, 1)
self.assertIsNotNone(self.eva.last_call_at)
self.assertIsNotNone(self.eva.last_call_iso)
self.eva.reset_counters()
self.assertEqual((self.eva.calls, self.eva.rate_limited), (0, 0))
def test_bearer_header_and_no_secret_in_repr(self):
self.assertEqual(self.eva.headers["Authorization"], "Bearer eva-secret")
self.assertNotIn("eva-secret", repr(self.eva))
self.srv.routes["/x"] = (200, {}, b"{}")
agent.Http.call(self.eva, "GET", "/x")
self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret")
def test_secret_not_in_log_output(self):
buf = io.StringIO()
self.srv.routes["/x"] = (200, {}, b"{}")
with contextlib.redirect_stdout(buf):
agent.Http.call(self.eva, "GET", "/x")
agent.log("проверка")
self.assertNotIn("eva-secret", buf.getvalue())
def test_http_error_carries_code_text_headers(self):
self.srv.routes["/x"] = (429, {"Retry-After": "7"}, b"slow down")
with self.assertRaises(agent.HttpError) as cm:
agent.Http.call(self.eva, "GET", "/x")
self.assertEqual(cm.exception.code, 429)
self.assertEqual(cm.exception.headers.get("retry-after"), "7")
self.assertIn("slow down", cm.exception.text)
self.assertEqual(self.eva.calls, 1)
def test_fetch_bytes(self):
self.srv.routes["/f"] = (200, {}, b"\x89PNG\x00\x01")
self.assertEqual(self.eva.fetch_bytes("/f", 100), b"\x89PNG\x00\x01")
self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret")
self.assertEqual(self.eva.calls, 1)
def test_fetch_bytes_limit(self):
self.srv.routes["/f"] = (200, {}, b"x" * 20)
with self.assertRaises(agent.TooBig):
self.eva.fetch_bytes("/f", 10)
self.assertEqual(self.eva.fetch_bytes("/f", 20), b"x" * 20)
def test_fetch_bytes_redirect_same_host(self):
self.srv.routes["/f"] = (302, {"Location": self.srv.base_url + "/moved"}, b"")
self.srv.routes["/moved"] = (200, {}, b"abc")
self.assertEqual(self.eva.fetch_bytes("/f", 100), b"abc")
def test_fetch_bytes_redirect_other_host(self):
port = self.srv.server.server_port
self.srv.routes["/f"] = (302, {"Location": "http://localhost:%d/moved" % port}, b"")
self.srv.routes["/moved"] = (200, {}, b"abc")
with self.assertRaises(agent.RedirectAway) as e:
self.eva.fetch_bytes("/f", 100)
self.assertEqual(e.exception.host, "localhost")
self.assertNotIn("/moved", self.srv.requests)
def test_fetch_bytes_cut_body_is_connection_error(self):
self.srv.routes["/f"] = (200, {"Content-Length": "40"}, b"x" * 10) # обещано 40, отдано 10
with self.assertRaises(ConnectionError):
self.eva.fetch_bytes("/f", 1000)
def test_json_call_redirect_other_host_refused(self):
port = self.srv.server.server_port
self.srv.routes["/a"] = (302, {"Location": "http://localhost:%d/b" % port}, b"")
self.srv.routes["/b"] = (200, {}, b"{}")
with self.assertRaises(agent.HttpError) as e:
agent.Http.call(self.eva, "GET", "/a")
self.assertIn("localhost", e.exception.text)
self.assertNotIn("/b", self.srv.requests)
def test_json_call_redirect_same_host_followed(self):
self.srv.routes["/a"] = (302, {"Location": self.srv.base_url + "/b"}, b"")
self.srv.routes["/b"] = (200, {}, b'{"ok": true}')
self.assertEqual(agent.Http.call(self.eva, "GET", "/a"), {"ok": True})
def test_error_body_read_failure_is_connection_error(self):
import urllib.error
class Broken(io.BytesIO):
def read(self, *a):
raise TimeoutError("timed out")
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
with self.assertRaises(ConnectionError):
agent.Http.call(self.eva, "GET", "/x")
self.assertEqual(self.eva.calls, 1)
def test_incomplete_read_is_connection_error(self):
import http.client
with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
with self.assertRaises(ConnectionError):
agent.Http.call(self.eva, "GET", "/x")
def test_incomplete_read_inside_response_body(self):
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
def serve():
c, _ = srv.accept()
c.recv(4096)
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":")
c.close()
t = threading.Thread(target=serve, daemon=True)
t.start()
client = agent.EvaClient(eva_cfg("http://127.0.0.1:%d" % srv.getsockname()[1]))
try:
with self.assertRaises(ConnectionError):
agent.Http.call(client, "GET", "/x")
finally:
t.join(2)
srv.close()
def test_unreachable_raises_connection_error(self):
with self.assertRaises(ConnectionError):
agent.Http.call(agent.EvaClient(eva_cfg("http://127.0.0.1:1")), "GET", "/x")
def test_non_json_200_becomes_http_error(self):
self.srv.routes["/x"] = (200, {}, b"<html>\xff\xfe not json")
with self.assertRaises(agent.HttpError) as cm:
agent.Http.call(self.eva, "GET", "/x")
self.assertEqual(cm.exception.code, 200)
self.assertEqual(self.eva.calls, 1)
def test_hub_client_claim_and_wiki_pages_routes(self):
calls = []
hub = agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t"))
hub.call = lambda method, path, body=None, params=None, timeout=None: (calls.append((method, path, body, timeout)), {})[1]
hub.claim(1, 2, None)
hub.wiki_pages(7, [{"code": "DOC-1"}])
hub.upload_file(b"abc")
self.assertEqual(calls[0][:2], ("POST", "/agent/claim"))
self.assertEqual(calls[0][2]["last_call_at"], None)
self.assertGreaterEqual(calls[0][3], 45)
self.assertEqual(calls[1], ("POST", "/agent/wiki/pages", {"command_id": 7, "pages": [{"code": "DOC-1"}]}, None))
self.assertEqual(calls[2][:2], ("POST", "/agent/files"))
self.assertEqual(calls[2][2], {"content": "YWJj"})
def test_load_config_requires_fields(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
def write(cfg):
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f)
for bad in ({"hub_url": "http://x", "hub_token": "t"},
{"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e"}},
{"hub_url": "http://x", "hub_token": "t", "eva": {"token": "z"}},
{"hub_url": "http://x", "hub_token": "t", "eva": "https://e"}):
write(bad)
with self.assertRaises(SystemExit):
agent.load_config(path)
write({"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e", "token": "z"}})
cfg = agent.load_config(path)
self.assertEqual((cfg["timeout"], cfg["poll_seconds"], cfg["verify_ssl"]), (60, 5, True))
self.assertNotIn("auto_update", cfg) # умолчание true применяет _run: `is not False`
with self.assertRaises(SystemExit):
agent.load_config(os.path.join(d, "нет.json"))
def test_example_config_loads(self):
cfg = agent.load_config(os.path.join(agent.HERE, "agent.example.json"))
self.assertEqual(cfg["auto_update"], True)
self.assertIn("base_url", cfg["eva"])
def test_check_command(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(eva_cfg(), f)
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(agent.main(["--config", path, "check"]), 3) # хаб и Eva недоступны (порт 1)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
def test_selftest_command(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(eva_cfg(), f)
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(agent.main(["--config", path, "selftest"]), 3) # недоступность — 3, а не 1
self.assertIn("eva-agent v%d" % agent.AGENT_VERSION, out.getvalue())
if __name__ == "__main__":
unittest.main()

189
tests/test_eva_client.py Normal file
View File

@ -0,0 +1,189 @@
import io
import unittest
from contextlib import redirect_stderr, redirect_stdout
from tests import _common as c
from tests.fake_eva import FakeEva
agent = c.agent
class EvaClientTest(unittest.TestCase):
def setUp(self):
self.eva = FakeEva()
self.addCleanup(self.eva.stop)
self.cli = agent.EvaClient(c.eva_cfg(self.eva.base_url, timeout=10))
self.sleeps = []
self.cli.sleep = self.sleeps.append
def test_call_ok(self):
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
doc = self.cli.get_doc("DOC-1", ["code", "name"])
self.assertEqual(doc, {"code": "DOC-1", "name": "Корень"})
self.assertIsNone(self.cli.get_doc("DOC-404", ["code"]))
req = self.eva.api_calls[0]
self.assertEqual(req["path"], "/api/?m=CmfDocument.get")
self.assertEqual(req["auth"], "Bearer eva-secret")
self.assertEqual(req["body"]["jsonrpc"], "2.2")
self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOC-1"]])
self.assertEqual(self.cli.calls, 2)
def test_retry_429_retry_after(self):
self.eva.fail_next(429, 1, retry_after=7)
self.assertEqual(self.cli.call("CmfDocument.list", filter=[], fields=["code"]), [])
self.assertEqual(self.sleeps, [7])
self.assertEqual(self.cli.rate_limited, 1)
def test_retry_after_capped(self):
self.eva.fail_next(429, 1, retry_after=600)
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(self.sleeps, [60])
def test_retry_503_then_ok(self):
self.eva.fail_next(503, 2)
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(self.sleeps, [1, 2])
self.assertEqual(self.cli.rate_limited, 0)
def test_gives_up_after_4(self):
self.eva.fail_next(503, 10)
with self.assertRaises(agent.HttpError) as cm:
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(cm.exception.code, 503)
self.assertEqual(len(self.eva.api_calls), 4)
self.assertEqual(self.sleeps, [1, 2, 4])
def test_no_retry_on_401(self):
self.cli.headers["Authorization"] = "Bearer wrong"
with self.assertRaises(agent.HttpError):
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(len(self.eva.api_calls), 1)
def test_rpc_error(self):
self.eva.rpc_error_next("нет прав")
with self.assertRaises(agent.EvaError) as cm:
self.cli.call("CmfDocument.get", filter=[], fields=["code"])
self.assertEqual(str(cm.exception), "CmfDocument.get: нет прав")
def test_list_pages_200(self):
for i in range(450):
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
docs = self.cli.list_docs([["project.code", "==", "P"]], ["code"])
self.assertEqual(len(docs), 450)
self.assertEqual(len(self.eva.api_calls), 3)
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)}
self.eva.add_doc(1, "DOC-1")
self.assertEqual(self.cli.list_docs([], ["code"]), [{"code": "DOC-1"}])
def test_tree_parent_filter(self):
self.eva.add_doc(1, "DOC-1")
self.eva.add_doc(2, "DOC-2", tree_parent="DOC-1")
self.eva.add_doc(3, "DOC-3", tree_parent="DOC-2")
got = self.cli.list_docs([["tree_parent.code", "==", "DOC-1"]], ["code"])
self.assertEqual(got, [{"code": "DOC-2"}])
def test_pacing_between_calls(self):
self.cli.pacing = 0.5
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(self.sleeps, []) # перед первым вызовом паузы нет
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
self.assertEqual(len(self.sleeps), 1)
self.assertTrue(0 < self.sleeps[0] <= 0.5)
def test_download_ok(self):
self.eva.add_file("/files/a.bin", b"abc")
self.assertEqual(self.cli.download(self.eva.base_url + "/files/a.bin", 10), b"abc")
self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret")
def test_download_foreign_host(self):
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)
self.eva.add_file("/files/ok.bin", b"x" * limit, length=False)
with self.assertRaises(agent.TooBig):
self.cli.download(self.eva.base_url + "/files/big.bin", limit)
self.assertEqual(len(self.cli.download(self.eva.base_url + "/files/ok.bin", limit)), limit)
def test_download_404(self):
with self.assertRaises(agent.HttpError) as cm:
self.cli.download(self.eva.base_url + "/files/none", 10)
self.assertEqual(cm.exception.code, 404)
def test_token_not_logged(self):
out, err = io.StringIO(), io.StringIO()
texts = []
with redirect_stdout(out), redirect_stderr(err):
self.eva.fail_next(503, 8)
for call in (lambda: self.cli.call("CmfDocument.list", filter=[], fields=["code"]),
lambda: self.cli.download(self.eva.base_url + "/files/none", 10)):
try:
call()
except Exception as e: # noqa: BLE001
texts.append(str(e))
self.eva.rpc_error_next("сбой eva-secret")
try:
self.cli.call("CmfDocument.get", filter=[], fields=["code"])
except agent.EvaError as e:
texts.append(str(e))
dead = agent.EvaClient(c.eva_cfg("http://127.0.0.1:1"))
dead.sleep = lambda s: None
try:
dead.call("CmfDocument.get", filter=[], fields=["code"])
except ConnectionError as e:
texts.append(str(e))
self.assertEqual(len(texts), 4)
for t in texts + [out.getvalue(), err.getvalue()]:
self.assertNotIn("eva-secret", t)
self.assertNotIn("eva-secret", repr(self.cli))
if __name__ == "__main__":
unittest.main()

251
tests/test_folder.py Normal file
View File

@ -0,0 +1,251 @@
import base64
import hashlib
import io
import json
import os
import tempfile
import time
import unittest
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
class FolderTransportTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути
os.makedirs(self.root)
self.hub = FakeHub(SETTINGS)
self.relay = None
def tearDown(self):
if self.relay:
self.relay.stop()
self.hub.stop()
self.tmp.cleanup()
def client(self, timeout=5):
cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
return agent.HubClient(cfg)
def run_relay(self, mode="forward", delay=0.0):
self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
return self.relay
def listing(self, sub):
return sorted(os.listdir(os.path.join(self.root, sub)))
def test_settings_through_folder(self):
self.run_relay()
hub = self.client()
self.assertIsNotNone(hub.folder)
self.assertEqual(hub.settings()["settings_version"], 1)
self.assertEqual(self.listing("resp"), [])
self.assertEqual(self.listing("req"), [])
req = self.relay.requests[0]
self.assertEqual(req["method"], "GET")
self.assertEqual(req["path"], "/agent/settings")
self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
self.assertEqual(len(req["id"]), 32)
def test_error_status_raises_http_error(self):
self.run_relay()
with self.assertRaises(agent.HttpError) as e:
self.client().result(999, {"status": "done"})
self.assertEqual(e.exception.code, 404)
def test_claim_deadline_at_least_45_seconds(self):
self.run_relay()
before = time.time()
self.client(timeout=5).claim(2, 1, None)
deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
self.assertIsNotNone(deadline.tzinfo)
self.assertGreaterEqual(deadline.timestamp() - before, 44.5)
def test_no_answer_gives_connection_error_and_removes_request(self):
hub = self.client(timeout=0.5)
t0 = time.monotonic()
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertLess(time.monotonic() - t0, 2)
self.assertIn("не ответил через папку", str(e.exception))
self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])
def test_missing_folder_gives_connection_error(self):
cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
with self.assertRaises(ConnectionError) as e:
agent.HubClient(cfg).settings()
self.assertIn("нет папки", str(e.exception))
def test_bad_json_response_gives_connection_error(self):
self.run_relay("garbage")
hub = self.client()
hub.folder.BAD_JSON_WAIT = 0.3
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertIn("нечитаемый ответ", str(e.exception))
def _bad_response(self, payload):
path = os.path.join(self.root, "resp-bad.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
ft = agent.FolderTransport(self.root, "Bearer x")
ft.BAD_JSON_WAIT = 0.3
return ft, path
def test_wrong_status_type_is_unreadable(self):
for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"},
{"id": "x", "status": 200, "body": 5}):
ft, path = self._bad_response(payload)
with self.assertRaises(ConnectionError) as e:
ft._take(path)
self.assertIn("нечитаемый ответ", str(e.exception))
def test_no_answer_message_has_no_hub_prefix(self):
hub = self.client(timeout=0.3)
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8)
def test_late_response_is_not_reused(self):
self.run_relay("late", delay=1.0)
with self.assertRaises(ConnectionError):
self.client(timeout=0.3).settings()
time.sleep(1.3) # опоздавший ответ лёг в resp/
late = self.listing("resp")
self.assertEqual(len(late), 1)
self.relay.mode = "forward"
self.assertEqual(self.client().settings()["settings_version"], 1)
self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут
def test_foreign_files_untouched(self):
resp_dir = os.path.join(self.root, "resp")
os.makedirs(resp_dir, exist_ok=True)
foreign = os.path.join(resp_dir, "0" * 32 + ".json")
with open(foreign, "w", encoding="utf-8") as f:
json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
self.run_relay()
self.client().settings()
self.assertTrue(os.path.exists(foreign))
def test_http_url_keeps_http_transport(self):
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
hub = agent.HubClient(cfg)
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()
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": 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})
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"})
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
with self.assertRaises(agent.HttpError) as e:
hub.wiki_pages(5, [{}])
self.assertEqual(e.exception.code, 422)
def test_upload_file_through_folder(self):
self.run_relay()
data = os.urandom(300 * 1024)
got = self.client().upload_file(data)
self.assertEqual(got, {"file": hashlib.sha256(data).hexdigest(), "size": len(data)})
self.assertEqual(self.hub.uploaded, [data])
self.assertEqual(self.relay.requests[0]["path"], "/agent/files")
def test_upload_file_over_http(self):
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
self.assertEqual(hub.upload_file(b""), {"file": hashlib.sha256(b"").hexdigest(), "size": 0})
self.assertEqual(self.hub.uploaded, [b""])
class FolderIntegrationTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
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(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 1)
def test_runner_through_folder(self):
cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"})
cfg = self.cfg()
journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
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(self.hub.results[cid]["status"], "failed") # заглушка Executor
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()])
def test_check_reports_folder(self):
out = io.StringIO()
with redirect_stdout(out):
code = agent.cmd_check(self.cfg())
self.assertEqual(code, 0)
self.assertIn("хаб: доступен через папку %s, настройки v1" % self.root, out.getvalue())
def test_check_folder_silent_relay(self):
self.relay.stop()
self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
out = io.StringIO()
t0 = time.monotonic()
with redirect_stdout(out):
code = agent.cmd_check(self.cfg())
self.assertEqual(code, 3)
self.assertLess(time.monotonic() - t0, 13)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
self.assertIn("не ответил через папку", out.getvalue())
def test_relative_folder_url_rejected(self):
path = os.path.join(self.tmp.name, "agent.json")
for url in ("folder:relative/path", "folder:"):
cfg = self.cfg()
cfg["hub_url"] = url
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f)
with self.assertRaises(SystemExit) as e:
agent.load_config(path)
self.assertIn("абсолютный путь", str(e.exception.code))
if __name__ == "__main__":
unittest.main()

154
tests/test_journal.py Normal file
View File

@ -0,0 +1,154 @@
import json
import os
from datetime import datetime, timezone
import tempfile
import unittest
from unittest import mock
from _common import agent
class JournalTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "journal.jsonl")
self.j = agent.Journal(self.path)
patcher = mock.patch.object(agent, "log")
self.log = patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
self.dir.cleanup()
def test_empty(self):
self.assertEqual(self.j.records(), [])
self.assertEqual(self.j.unacked_finished(), [])
self.assertEqual(self.j.unfinished_started(), [])
def test_lifecycle(self):
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [1])
self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
self.assertEqual(self.j.unfinished_started(), [])
pending = self.j.unacked_finished()
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]["command_id"], 1)
self.assertEqual(pending[0]["result"], {"key": "A-1"})
self.assertEqual(pending[0]["jira_calls"], 2)
self.j.acked(1)
self.assertEqual(self.j.unacked_finished(), [])
self.j.started(2)
self.j.started(3)
self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
self.assertEqual(self.j.unfinished_started(), [2])
self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])
def test_broken_line_is_skipped(self):
self.j.started(1)
with open(self.path, "a", encoding="utf-8") as f:
f.write("{не json\n")
self.j.finished(1, "done", None, None, 1, 0)
self.assertEqual(len(self.j.records()), 2)
self.log.assert_called_with("журнал: строка 2 не читается, пропускаю")
def test_prune_keeps_unacked_and_recent(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
self.j.started(2)
self.j.finished(2, "done", None, None, 1, 0)
self.j.acked(2)
self.j.started(3)
removed = self.j.prune(days=30)
self.assertEqual(removed, 3)
ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
self.assertEqual(self.j.prune(days=30), 0)
def test_no_secrets_in_records(self):
self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
with open(self.path, encoding="utf-8") as f:
text = f.read()
self.assertNotIn("password", text)
self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")
def test_torn_tail_is_not_glued(self):
with open(self.path, "wb") as f:
f.write('{"kind": "star'.encode("utf-8"))
self.j.started(1)
recs = self.j.records()
self.assertEqual([(r["kind"], r["command_id"]) for r in recs], [("started", 1)])
self.assertEqual(self.j.unfinished_started(), [1])
def test_restart_after_acked_reopens(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.acked(1)
self.assertEqual(self.j.unfinished_started(), [])
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [1])
def test_duplicate_started_is_deduplicated_in_first_open_order(self):
self.j.started(2)
self.j.started(1)
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [2, 1])
self.j.finished(2, "done", None, None, 1, 0)
self.assertEqual(self.j.unfinished_started(), [1])
def test_prune_keeps_id_with_newer_records(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
before = self.j.records()
self.assertEqual(self.j.prune(days=30), 0)
self.assertEqual(self.j.records(), before)
def test_non_dict_and_no_command_id_lines_are_skipped(self):
with open(self.path, "wb") as f:
f.write(b'[1, 2]\n5\n{"kind": "started"}\n')
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.acked(1)
self.assertEqual(len(self.j.records()), 3)
self.assertEqual(self.j.unacked_finished(), [])
self.assertEqual(self.j.unfinished_started(), [])
self.assertEqual(self.j.prune(days=30), 0)
self.log.assert_any_call("журнал: удаляю битых строк 3")
self.assertEqual(len(self.j.records()), 3)
with open(self.path, encoding="utf-8") as f:
self.assertNotIn("[1, 2]", f.read())
def test_naive_and_invalid_at_in_prune(self):
self.j.started(1)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00"})
self.j.started(2)
self.j.append({"kind": "acked", "command_id": 2, "at": "не дата"})
self.assertEqual(self.j.prune(days=30), 2)
self.assertEqual([(r["kind"], r["command_id"]) for r in self.j.records()], [("started", 2), ("acked", 2)])
def test_journal_file_is_private(self):
self.j.started(1)
mode = os.stat(self.path).st_mode & 0o777
self.assertEqual(mode & 0o077, 0)
def test_last_at_is_latest_record_time(self):
self.assertIsNone(self.j.last_at())
self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
self.j.append({"kind": "finished", "command_id": 1, "at": "2026-10-13T10:05:00+00:00"})
self.j.append({"kind": "acked", "command_id": 1, "at": "2026-10-13T10:04:00+00:00"})
self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 5, tzinfo=timezone.utc))
def test_last_at_skips_bad_and_missing_times(self):
self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
self.j.append({"kind": "started", "command_id": 1, "at": "не время"})
self.j.append({"kind": "started", "command_id": 1, "at": "2026-10-13T11:00:00"}) # без зоны — пропуск
with open(self.path, "a", encoding="utf-8") as f:
f.write("битая строка\n")
self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc))
if __name__ == "__main__":
unittest.main()

503
tests/test_launcher.py Normal file
View File

@ -0,0 +1,503 @@
import hashlib
import json
import os
import signal
import subprocess
import sys
import tempfile
import textwrap
import time
import unittest
from _common import SRC
if SRC not in sys.path:
sys.path.insert(0, SRC)
import run # noqa: E402
# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json).
STATE_HELPERS = textwrap.dedent('''
import json, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
STATE = os.path.join(HERE, "update.json")
def state():
try:
return json.load(open(STATE, encoding="utf-8"))
except Exception:
return None
def write(**kw):
json.dump(kw, open(STATE, "w", encoding="utf-8"))
def mark(name):
open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n")
def human_stop(code=0):
# Ctrl-C в терминале получает вся группа: запускатель тоже видит SIGINT, затем агент выходит
import signal
os.kill(os.getppid(), signal.SIGINT)
sys.exit(code)
''')
def fake(name, body):
return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body)
# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0.
V5_UPDATES = fake("v5", '''
import hashlib
st = state()
if st is None:
data = open(os.path.join(HERE, "next.py"), "rb").read()
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
sys.exit(75)
human_stop()
''')
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
V5_BAD_STAGE = fake("v5", '''
st = state()
if st is None:
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
write(state="staged", from_version=5, to_version=6, sha256="чужой")
sys.exit(75)
human_stop()
''')
V6_OK_THEN_STOP = fake("v6", '''
st = state()
write(**dict(st, state="ok"))
human_stop()
''')
V6_CRASH = fake("v6", 'sys.exit(1)\n')
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n')
V6_SEES_STATE = fake("v6", '''
mark("seen:" + (state() or {}).get("state", "-"))
write(**dict(state(), state="ok"))
human_stop()
''')
V6_OK_ON_TERM = fake("v6", '''
import signal, time
if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2:
human_stop()
def term(*a):
write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода
sys.exit(0)
signal.signal(signal.SIGTERM, term)
time.sleep(60)
''')
# v6 подтвердился (ok), через delay с записал rejected для v10 с другим sha (как Updater._reject) и упал с кодом 1.
V6_OK_THEN_REJECT_NEXT = fake("v6", '''
import time
n = len(open(os.path.join(HERE, "runs.log")).read().split())
if n > 2:
human_stop()
st = state()
write(**dict(st, state="ok"))
time.sleep(DELAY)
write(state="rejected", from_version=6, to_version=10, sha256="чужой", error="отвергнута")
sys.exit(1)
''')
class LauncherTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"])
self.pauses = []
self.l.pause = lambda s: (self.pauses.append(s), True)[1]
self.old_sigint = signal.signal(signal.SIGINT, self.l.on_sigint) # как в run.main: Ctrl-C видит и запускатель
def tearDown(self):
signal.signal(signal.SIGINT, self.old_sigint)
self.tmp.cleanup()
def put(self, name, text):
with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f:
f.write(text)
def read(self, name):
with open(os.path.join(self.dir, name), encoding="utf-8") as f:
return f.read()
def state(self):
return json.loads(self.read("update.json"))
def runs(self):
return self.read("runs.log").split()
def next_version(self, text):
self.put("next.py", text)
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def stage(self, new_text, to_version=6):
self.put("agent.py.new", new_text)
sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest()
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha}))
return sha
def sha(self, text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def path(self, name):
return os.path.join(self.dir, name)
def test_update_applied_and_confirmed(self):
self.put("agent.py", V5_UPDATES)
sha = self.next_version(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP)
self.assertEqual(self.read("agent.py.prev"), V5_UPDATES)
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
st = self.state()
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha))
self.assertEqual(self.runs(), ["v5", "v6"])
def test_crash_in_trial_rolls_back(self):
self.put("agent.py", V5_UPDATES)
sha = self.next_version(V6_CRASH)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha))
self.assertRegex(st["error"], r"^код 1 через \d+ с$")
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
self.assertEqual(self.pauses, []) # откат без паузы
def test_hang_in_trial_rolls_back(self):
self.l.trial_seconds = 1
self.l.kill_grace = 5
self.put("agent.py", V5_UPDATES)
self.next_version(V6_HANG)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин")
def test_crash_after_ok_restarts_with_backoff(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 2 else sys.exit(1)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5, 10])
self.assertEqual(self.state()["state"], "ok") # отката нет
def test_backoff_caps_and_resets(self):
self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300])
self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала
def test_75_without_staged_rejected(self):
self.put("agent.py", V5_BAD_STAGE)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_BAD_STAGE)
self.assertEqual(self.runs(), ["v5", "v5"])
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
def test_staged_at_start_is_applied(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.stage(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6"])
self.assertEqual(self.state()["state"], "ok")
def test_stray_new_removed_at_start(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.put("agent.py.new", "остаток")
self.assertEqual(self.l.run(), 0)
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
def test_missing_agent_restored_from_prev(self):
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v5"])
def test_garbage_update_json_is_no_state(self):
self.put("update.json", '{"state": "tri')
self.put("agent.py", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
def test_env_and_args_passed(self):
self.put("agent.py", fake("v5", '''
open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:]))
human_stop(130)
'''))
self.assertEqual(self.l.run(), 130)
self.assertEqual(self.read("env.txt"), "2 run --journal j.jsonl")
def test_sigint_without_child_stops(self):
self.l.on_sigint(signal.SIGINT, None)
self.assertTrue(self.l.stopping)
def test_sigint_with_child_ignored(self):
self.l.child = object()
self.l.on_sigint(signal.SIGINT, None)
self.assertFalse(self.l.stopping)
self.assertTrue(self.l.interrupted) # выход агента с 0 — остановка человеком
def test_exit_0_without_signal_in_trial_rolls_back(self):
self.put("agent.py", V5_UPDATES)
self.next_version(fake("v6", "sys.exit(0)\n")) # сама вышла, никто не нажимал Ctrl-C (I-1)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual(st["state"], "rolled_back")
self.assertRegex(st["error"], r"^код 0 через \d+ с$")
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
def test_exit_0_without_signal_after_ok_restarts(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 1 else sys.exit(0)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5]) # выход 0 без сигнала — как падение
self.assertEqual(self.runs(), ["v6", "v6"])
def test_ctrl_c_with_75_applies_and_stops(self):
self.put("next.py", V6_OK_THEN_STOP)
self.put("agent.py", fake("v5", '''
import hashlib, signal
data = open(os.path.join(HERE, "next.py"), "rb").read()
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C пришёл, когда версия уже подготовлена
sys.exit(75)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) # подмена сделана, новая не запущена
self.assertEqual(self.state()["state"], "trial") # пробный период начнётся при старте
self.assertEqual(self.runs(), ["v5"])
def test_stale_interrupt_does_not_hide_exit_0(self):
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
self.put("agent.py", fake("v6", '''
import signal
n = len(open(os.path.join(HERE, "runs.log")).read().split())
if n == 1:
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C до установки обработчиков агента
sys.exit(1)
human_stop() if n > 2 else sys.exit(0)
'''))
self.l.stopping = False
orig = self.l.on_sigint
def sigint_child_alive(signum, frame): # агент жив — запускатель только запоминает Ctrl-C
self.l.child = self.l.child or object()
orig(signum, frame)
signal.signal(signal.SIGINT, sigint_child_alive)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v6", "v6"]) # второй выход 0 без сигнала — перезапуск
self.assertEqual(len(self.pauses), 2)
def test_stop_before_spawn(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.l.stopping = True # SIGTERM пришёл между запусками (M-2)
self.assertEqual(self.l.run(), 0)
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
def test_pause_never_sleeps_negative(self):
clock = iter([0.0, 0.5, 1.5, 1.6]) # остаток перешёл через ноль между вызовами (M-1)
slept = []
orig_m, orig_s = run.time.monotonic, run.time.sleep
run.time.monotonic, run.time.sleep = (lambda: next(clock)), slept.append
try:
self.assertTrue(run.Launcher.pause(self.l, 1.0))
finally:
run.time.monotonic, run.time.sleep = orig_m, orig_s
self.assertTrue(all(x >= 0 for x in slept), slept)
def test_second_launcher_refused(self):
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
self.put("run.py", f.read())
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
lock = run.acquire_lock(self.dir)
try:
p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=30)
finally:
lock.close()
self.assertEqual(p.returncode, 1)
self.assertIn("запускатель уже работает", p.stdout.decode("utf-8"))
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
def test_staged_already_replaced_resumes_trial(self):
"""M-5: запускатель упал между os.replace(new, agent) и записью trial."""
self.stage(V6_SEES_STATE)
os.replace(self.path("agent.py.new"), self.path("agent.py"))
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда
self.assertEqual(self.state()["state"], "ok")
def test_staged_without_file_rejected(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64}))
self.assertEqual(self.l.run(), 0)
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян"))
self.assertEqual(self.runs(), ["v5"])
def test_stale_trial_marked_rolled_back(self):
"""M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial."""
self.l.trial_seconds = 1
self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64}))
self.assertEqual(self.l.run(), 0)
st = self.state()
self.assertEqual((st["state"], st["to_version"], st["error"]),
("rolled_back", 9, "пробный период прерван: agent.py не v9"))
self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период
def test_trial_without_sha_is_stale(self):
self.put("agent.py", fake("v8", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.state()["state"], "rolled_back")
def test_trial_survives_launcher_restart(self):
"""M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат."""
self.put("agent.py", V6_CRASH)
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v5"])
self.assertEqual(self.state()["state"], "rolled_back")
def test_ok_at_last_moment_no_rollback(self):
"""M-4: пробный период истёк, но агент записал ok, получив SIGTERM."""
self.l.trial_seconds = 1
self.l.kill_grace = 5
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_ON_TERM)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM)
self.assertEqual(self.state()["state"], "ok")
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
self.assertEqual(self.pauses, []) # перезапуск без паузы
def _confirmed_then_rejected_next(self, delay):
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
st = self.state()
self.assertEqual((st["state"], st["to_version"]), ("rejected", 10)) # не rolled_back: v10 не заблокирована
def test_confirmed_then_next_rejected_no_rollback(self):
"""I-1: агент записал ok, потом сам перезаписал update.json (rejected для v10) и упал — v6 здоровая, откат не нужен."""
self._confirmed_then_rejected_next(2.5)
def test_confirmed_and_rejected_within_poll_no_rollback(self):
"""I-1: ok и rejected записаны быстрее, чем запускатель опрашивает файл раз в секунду."""
self._confirmed_then_rejected_next(0)
def test_locked_in_trial_no_rollback(self):
"""M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию."""
v6 = fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 1 else sys.exit(73)
''')
self.put("agent.py", v6)
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)}))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.runs(), ["v6", "v6"])
self.assertEqual(self.pauses, [5])
self.assertEqual(self.read("agent.py"), v6)
self.assertEqual(self.state()["state"], "trial")
def test_locked_outside_trial_backs_off(self):
self.put("agent.py", fake("v6", '''
n = len(open(os.path.join(HERE, "runs.log")).read().split())
human_stop() if n > 3 else sys.exit(73)
'''))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.pauses, [5, 10, 20])
def test_os_error_before_replace_rejected(self):
"""M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог)."""
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_STOP)
os.mkdir(self.path("agent.py.prev.tmp"))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES)
st = self.state()
self.assertEqual(st["state"], "rejected")
self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"])
self.assertFalse(os.path.exists(self.path("agent.py.new")))
self.assertEqual(self.runs(), ["v5", "v5"])
def test_trial_not_written_reverts(self):
orig = run.write_state
def flaky(path, state, base, error=None):
return False if state == "trial" else orig(path, state, base, error)
run.write_state = flaky
self.addCleanup(setattr, run, "write_state", orig)
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_STOP)
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место
st = self.state()
self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан"))
self.assertEqual(self.runs(), ["v5", "v5"])
def test_write_state_error_does_not_raise(self):
self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {}))
def test_sha256_file_unreadable_is_none(self):
self.assertIsNone(run.sha256_file(self.path("нет-файла")))
def test_tmp_files_removed_at_start(self):
self.put("agent.py", fake("v5", "human_stop()\n"))
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
self.put(name, "остаток")
self.assertEqual(self.l.run(), 0)
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
self.assertFalse(os.path.exists(self.path(name)), name)
def test_sigterm_forwarded_and_no_restart(self):
"""M-10 #22: on_sigterm — пересылка агенту, нового запуска нет."""
import threading
self.put("agent.py", fake("v5", '''
import signal, time
def term(*a):
mark("term")
sys.exit(0)
signal.signal(signal.SIGTERM, term)
open(os.path.join(HERE, "ready"), "w").close()
time.sleep(30)
'''))
def send():
for _ in range(300):
if os.path.exists(self.path("ready")):
break
time.sleep(0.05)
self.l.on_sigterm(signal.SIGTERM, None)
t = threading.Thread(target=send)
t.start()
self.assertEqual(self.l.run(), 0)
t.join()
self.assertEqual(self.runs(), ["v5", "term"])
class RunPyIsCopyTest(unittest.TestCase):
"""run.py — дословная копия запускателя jira-agent (design 2): правки общих частей делаются в обоих репозиториях."""
def test_run_py_is_copy(self):
# от worktree (<корень>/eva-agent/.wt/<срез>) и от основного checkout (<корень>/eva-agent) — разная глубина
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidates = [os.path.normpath(os.path.join(here, up, "jira-agent", "src", "run.py"))
for up in ("..", os.path.join("..", ".."), os.path.join("..", "..", ".."))]
origin = next((c for c in candidates if os.path.exists(c)), None)
if origin is None:
self.skipTest("рядом нет jira-agent/src/run.py (искал: %s)" % ", ".join(candidates))
with open(origin, "rb") as a, open(os.path.join(SRC, "run.py"), "rb") as b:
self.assertEqual(a.read(), b.read(), "src/run.py разошёлся с %s" % origin)
if __name__ == "__main__":
unittest.main()

106
tests/test_links.py Normal file
View File

@ -0,0 +1,106 @@
"""Разбор ссылок страницы Eva: вложения и ссылки на страницы (design 6–7, 12)."""
import os
import unittest
from tests import _common # noqa: F401
import agent
BASE = "https://eva.test"
OBJ = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001"
HERE = os.path.dirname(os.path.abspath(__file__))
def page(name):
with open(os.path.join(HERE, "fixtures", "pages", name), encoding="utf-8") as fh:
return fh.read()
class ClassifyLinksTest(unittest.TestCase):
def test_image_relative_files(self):
att, links = agent.classify_links(page("image.html"), BASE)
self.assertEqual(att, [{"ref": OBJ + "/img.png?version=1", "url": BASE + OBJ + "/img.png", "name": "Схема"}])
self.assertEqual(links, [])
def test_thumbnail_maps_to_file(self):
att, _ = agent.classify_links(page("thumb_and_link.html"), BASE)
self.assertEqual(att[0]["url"], BASE + OBJ + "/model.xlsx")
self.assertEqual(att[0]["name"], "model.xlsx")
self.assertEqual(att[0]["ref"], OBJ + "/model.xlsx.meta/thumbnail.jpg?version=1")
self.assertEqual(att[2]["url"], BASE + OBJ + "/data.dbml")
self.assertEqual(att[2]["name"], "data.dbml")
def test_file_link_same_url(self):
att, _ = agent.classify_links(page("thumb_and_link.html"), BASE)
self.assertEqual(len(att), 3) # повторы не склеиваются
self.assertEqual(att[1]["url"], att[0]["url"])
self.assertEqual(att[1]["ref"], OBJ + "/model.xlsx?version=1")
self.assertEqual(att[1]["name"], "model.xlsx")
def test_static_ignored(self):
att, links = agent.classify_links('<img src="static/attach-file.png">', BASE)
self.assertEqual((att, links), ([], []))
def test_page_link_relative(self):
_, links = agent.classify_links(page("links.html"), BASE)
self.assertEqual(links[0], {"ref": "project/Document/DOC-019834", "code": "DOC-019834"})
self.assertEqual(links[1], {"ref": "https://eva.test/project/Document/doc-5", "code": "DOC-5"})
def test_foreign_host(self):
att, links = agent.classify_links(page("links.html"), BASE)
self.assertEqual(att, [])
self.assertEqual([l["code"] for l in links], ["DOC-019834", "DOC-5"])
att, links = agent.classify_links(
'<a href="https://other.test/files/x.png">x</a><a href="https://other.test/project/Document/DOC-1">y</a>',
BASE)
self.assertEqual((att, links), ([], []))
def test_name_unquoted(self):
att, _ = agent.classify_links(page("name_quoted.html"), BASE)
self.assertEqual(att[0]["name"], "ПМП.xlsx")
self.assertEqual(att[0]["url"], BASE + OBJ + "/%D0%9F%D0%9C%D0%9F.xlsx")
def test_ref_exact(self):
att, links = agent.classify_links(page("amp.html"), BASE)
self.assertEqual(att[0]["ref"], OBJ + "/a.png?version=1&x=2") # после unescape, как ищет хаб
self.assertEqual(links[0]["ref"], "project/Document/DOC-7?a=1&b=2")
self.assertEqual(att[0]["url"], BASE + OBJ + "/a.png")
def test_base_with_trailing_slash_and_title(self):
att, _ = agent.classify_links('<a href="%s/f.bin" title="Файл">текст</a>' % OBJ, BASE + "/")
self.assertEqual(att[0]["name"], "Файл")
def test_name_from_link_text(self):
att, _ = agent.classify_links('<a href="%s/f.bin"> мой <b>файл</b> </a>' % OBJ, BASE)
self.assertEqual(att[0]["name"], "мой файл")
def test_order_nested_img_in_link(self):
att, _ = agent.classify_links(
'<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()

33
tests/test_py39.py Normal file
View File

@ -0,0 +1,33 @@
import ast
import os
import unittest
from _common import SRC
# Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние
# импорты; API и конструкции новее 3.9 (например, `X | Y` — синтаксис 3.10: аннотации функций
# вычисляются при импорте и их ловит прогон на /usr/bin/python3 3.9.6, аннотации локальных переменных не вычисляются), не проверяет.
class Py39Test(unittest.TestCase):
FILES = ("agent.py", "run.py")
def test_parses_as_python_39(self):
for name in self.FILES:
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
ast.parse(f.read(), feature_version=(3, 9))
def test_no_third_party_imports(self):
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "html", "subprocess"}
for name in self.FILES:
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertIn(a.name.split(".")[0], allowed, "%s: %s" % (name, a.name))
elif isinstance(node, ast.ImportFrom):
self.assertIn((node.module or "").split(".")[0], allowed, "%s: %s" % (name, node.module))
if __name__ == "__main__":
unittest.main()

564
tests/test_runner.py Normal file
View File

@ -0,0 +1,564 @@
import json
import os
import tempfile
import threading
import time
import unittest
from unittest import mock
from datetime import datetime, timedelta, timezone
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_hub import FakeHub
FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}
STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva
class RunnerTest(unittest.TestCase):
def setUp(self):
self.hub = FakeHub(SETTINGS)
self.dir = tempfile.TemporaryDirectory()
self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl"))
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal)
self.sleeps = []
self.runner.sleep = self.sleeps.append
self.runner.eva.sleep = no_sleep
self.runner.idle_limit = 1
def tearDown(self):
self.hub.stop()
self.dir.cleanup()
def kinds(self):
return [(r["kind"], r["command_id"]) for r in self.journal.records()]
def claim_in_flight(self, ctype, params, pause=0):
cid = self.hub.enqueue(ctype, params, pause=pause)
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "claimed"
self.hub.in_flight = cid
return cid
def test_processes_queue(self):
self.hub.enqueue("wiki_fetch", FETCH)
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertEqual(self.hub.results[1]["error"], STUB_ERROR)
self.assertEqual(self.hub.results[1]["jira_calls"], 0) # поле протокола хаба: число вызовов Eva
self.assertEqual(self.hub.results[2]["status"], "failed")
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)])
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
def test_claim_sends_update_state_and_launcher(self):
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s")
self.runner.loop()
body = self.hub.claim_bodies[0]
self.assertEqual(body["launcher_version"], 1)
self.assertEqual(body["update_state"]["state"], "trial")
self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim
self.assertEqual(self.hub.claim_bodies[0]["agent_version"], agent.AGENT_VERSION)
def test_claim_without_updater_sends_nulls(self):
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0]["update_state"])
self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"])
def test_idle_with_release_exits_for_update(self):
rel = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
self.hub.release = rel
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
self.runner.updater.check = lambda r: r == {"version": agent.AGENT_VERSION + 1, "sha256": "s"}
self.runner.idle_limit = 5
self.runner.loop()
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
self.assertEqual(self.hub.claims, 1)
def test_stop_during_check_exits_0(self):
self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
def check(r):
self.runner.stop = True # Ctrl-C пришёл, когда версия уже staged
return True
self.runner.updater.check = check
self.runner.idle_limit = 5
self.runner.loop()
self.assertEqual(self.runner.exit_code, 0) # остановка человеком; staged доделает run.py при старте
def test_command_first_then_update(self):
self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
seen = []
self.runner.updater.check = lambda r: (seen.append(r), True)[1]
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed") # команда выполнена и подтверждена
self.assertEqual(len(seen), 1) # проверка — только после claim без команды
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
def test_restart_pace_from_old_finished(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None,
"jira_calls": 1, "rate_limited": 0, "at": at})
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
self.runner.loop()
self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5)
def test_future_journal_time_means_full_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
self.runner.loop()
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12)
self.assertEqual(self.hub.results[1]["status"], "failed")
def test_claim_after_restart_sends_no_journal_estimate(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("wiki_fetch", FETCH)
eva = self.runner.eva
with mock.patch.object(agent.Executor, "do_wiki_fetch",
lambda ex, p, cmd: (eva._touch(), ex._out("done", {"file": "abc", "size": 3}))[1]):
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0].get("last_call_at")) # оценка журнала — только для паузы (M-7 #12)
self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_call_at")) # после вызова Eva — настоящее время
self.assertIsNotNone(self.runner.eva.last_call_at)
def test_restore_pace_keeps_existing_last_call(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
mark = time.monotonic()
self.runner.eva.last_call_at = mark
self.runner.restore_pace()
self.assertEqual(self.runner.eva.last_call_at, mark)
def test_pause_waits_from_last_eva_call(self):
self.runner.eva.last_call_at = time.monotonic()
self.hub.enqueue("wiki_fetch", FETCH, pause=3)
self.runner.loop()
self.assertEqual(len(self.sleeps), 3)
self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1)
self.assertEqual(self.hub.results[1]["status"], "failed")
def test_result_retried_when_hub_down(self):
self.hub.fail_results = 2
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertEqual(self.sleeps, [10, 10])
self.assertEqual(self.journal.unacked_finished(), [])
def test_result_retried_on_connection_error(self):
calls = []
real = self.runner.hub.result
def flaky(cid, payload):
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return real(cid, payload)
self.runner.hub.result = flaky
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.sleeps, [10, 10])
self.assertEqual(self.hub.results[1]["status"], "failed")
def test_result_rejected_is_acked(self):
for cid, code in ((1, 404), (2, 409), (3, 422)):
self.hub.reject_results = {cid: code}
self.journal.finished(cid, "done", {}, None, 0, 0)
self.assertTrue(self.runner.send_result(self.journal.unacked_finished()[0]))
self.assertEqual(self.journal.unacked_finished(), [])
def test_result_rejected_in_loop(self):
self.hub.reject_results = {1: 404}
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertNotIn(1, self.hub.results)
self.assertEqual(self.journal.unacked_finished(), [])
self.assertEqual(self.kinds()[-1], ("acked", 1))
def test_recover_unfinished_started(self):
cid = self.claim_in_flight("wiki_fetch", FETCH, pause=99)
self.journal.started(cid)
self.runner.loop()
self.assertEqual(self.hub.results[cid]["error"], STUB_ERROR) # команда выполнена заново, как решено в design 9
self.assertEqual(self.journal.unfinished_started(), [])
self.assertAlmostEqual(sum(self.sleeps), 99, delta=1) # пауза из хаба, от записи started (M-9)
def test_recover_does_not_journal_claimed_twice(self):
cid = self.claim_in_flight("wiki_fetch", FETCH, pause=0)
self.journal.claimed(cid)
self.runner.loop()
self.assertEqual(self.kinds(), [("claimed", cid), ("started", cid), ("finished", cid), ("acked", cid)])
def test_recover_skips_command_not_claimed_anymore(self):
cid = self.hub.enqueue("wiki_fetch", FETCH)
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "cancelled"
self.journal.started(cid)
self.runner.loop()
self.assertNotIn(cid, self.hub.results)
self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)])
def test_recover_unacked_finished(self):
cid = self.claim_in_flight("wiki_fetch", FETCH)
self.journal.started(cid)
self.journal.finished(cid, "done", {"file": "abc", "size": 3}, None, 2, 0)
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.hub.results[cid]["result"], {"file": "abc", "size": 3})
self.assertEqual(self.hub.results[2]["status"], "failed")
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
def test_stop_during_long_claim(self):
self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30)
self.runner.idle_limit = None
threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start()
start = time.monotonic()
self.runner.loop()
self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim
self.assertEqual(self.hub.claims, 1)
self.assertEqual(self.journal.records(), [])
def test_command_arrives_during_long_claim(self):
self.hub.claim_wait = 5.0
def arrive():
self.hub.enqueue("wiki_fetch", FETCH)
self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания
threading.Timer(0.3, arrive).start()
start = time.monotonic()
self.runner.loop()
self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту
self.assertEqual(self.hub.results[1]["status"], "failed")
def test_stop_during_pause(self):
self.runner.eva.last_call_at = time.monotonic()
self.hub.enqueue("wiki_fetch", FETCH, pause=5)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) # started нет
self.assertEqual(self.hub.results, {})
self.assertEqual(self.hub.commands[1]["status"], "claimed")
def test_stop_during_pause_then_restart_executes_once(self):
self.runner.eva.last_call_at = time.monotonic()
self.hub.enqueue("wiki_fetch", FETCH, pause=5)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.runner.loop()
self.assertEqual(self.journal.unfinished_started(), [1])
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
second = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), agent.Journal(self.journal.path))
second.sleep = self.sleeps.append
second.idle_limit = 1
second.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertEqual(len([r for r in self.journal.records() if r["kind"] == "finished"]), 1)
self.assertEqual(self.journal.unfinished_started(), [])
self.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9)
def test_stop_between_claim_and_execute_is_recorded(self):
self.hub.enqueue("wiki_fetch", FETCH)
real = self.runner.hub.claim
def claim_then_stop(*a):
resp = real(*a)
self.runner.stop = True
return resp
self.runner.hub.claim = claim_then_stop
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"])
self.assertEqual(self.journal.unfinished_started(), [1])
def test_stop_during_execution_finishes_and_reports(self):
self.hub.enqueue("wiki_fetch", FETCH)
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.load_settings()
run = self.runner.executor.run
def run_and_signal(cmd):
self.runner.stop = True # сигнал пришёл во время выполнения
return run(cmd)
self.runner.executor.run = run_and_signal
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertNotIn(2, self.hub.results) # вторая команда не начата
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1)])
def test_stop_during_execution_hub_down_single_attempt(self):
self.hub.fail_results = 5
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.load_settings()
run = self.runner.executor.run
self.runner.executor.run = lambda cmd: (setattr(self.runner, "stop", True), run(cmd))[1]
self.runner.loop()
self.assertEqual(self.sleeps, [])
self.assertEqual([r["command_id"] for r in self.journal.unacked_finished()], [1])
def test_unexpected_exception_becomes_failed(self):
self.runner.load_settings()
def boom(cmd):
raise KeyError("summary")
self.runner.executor.run = boom
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertIn("KeyError", self.hub.results[1]["error"])
self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.journal.unacked_finished(), [])
def test_recover_waits_when_hub_down(self):
cid = self.claim_in_flight("wiki_fetch", FETCH)
self.journal.started(cid)
real = self.runner.hub.command
calls = []
def flaky(i):
calls.append(1)
if len(calls) == 1:
raise ConnectionError("нет связи")
return real(i)
self.runner.hub.command = flaky
self.runner.loop()
self.assertEqual(self.sleeps, [10])
self.assertEqual(self.hub.results[cid]["status"], "failed")
def test_settings_reload_on_version_change(self):
self.runner.load_settings()
self.runner.settings["settings_version"] = 0
self.runner.loop()
self.assertEqual(self.runner.settings["settings_version"], 1)
def test_settings_reloaded_before_command(self):
self.runner.load_settings()
self.hub.settings["settings_version"] = 2
self.hub.settings["wiki_pacing"] = 0.5
self.hub.enqueue("wiki_fetch", FETCH)
seen = []
real = self.runner.execute
self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd))
self.runner.loop()
self.assertEqual(seen, [2])
self.assertEqual(self.runner.eva.pacing, 0.5)
def test_hub_down_at_start_keeps_polling(self):
calls = []
def hub_settings():
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return dict(SETTINGS)
self.runner.hub.settings = hub_settings
self.runner.loop()
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [1, 1]) # пол паузы на ошибках хаба — 1 с
def test_retry_waits_from_failure_even_if_last_call_is_old(self):
# F-1: Eva недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) —
# ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова.
self.runner.load_settings()
self.runner.eva.last_call_at = time.monotonic() - 600
runs = []
def flaky(cmd):
runs.append(1)
if len(runs) == 1:
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0,
"rate_limited": 0, "retryable": True}
return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0}
self.runner.executor.run = flaky
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(len(runs), 2)
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа
def test_retry_exhausted_gives_failed_unknown(self):
self.runner.load_settings()
runs = []
def down(cmd):
runs.append(1)
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 1, "rate_limited": 0, "retryable": True}
self.runner.executor.run = down
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(len(runs), 4) # первый запуск + 3 повтора
r = self.hub.results[1]
self.assertEqual(r["status"], "failed")
self.assertIn("результат неизвестен", r["error"])
self.assertIn("(обрыв связи с Eva)", r["error"])
self.assertEqual(r["jira_calls"], 4)
def test_stop_during_retry_wait_leaves_command_for_recovery(self):
self.runner.load_settings()
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "x", "jira_calls": 1,
"rate_limited": 0, "retryable": True}
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.journal.unfinished_started(), [1])
self.assertEqual(self.hub.results, {})
def test_agent_version_in_result(self):
self.hub.enqueue("wiki_fetch", FETCH)
self.runner.loop()
self.assertEqual(self.hub.results[1]["agent_version"], agent.AGENT_VERSION)
def test_hub_auth_error_waits_60(self):
def denied(*a):
raise agent.HttpError(401, "unauthorized")
self.runner.hub.claim = denied
self.runner.sleep = lambda s: (self.sleeps.append(s), setattr(self.runner, "stop", True))
self.runner.loop()
self.assertEqual(self.sleeps, [60])
def test_wrong_hub_token_401_real_hub(self):
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token="bad")
r = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal)
r.sleep = lambda s: (self.sleeps.append(s), setattr(r, "stop", True))
r.loop()
self.assertEqual(self.sleeps, [60])
def test_settings_reload_failure_retried_before_command(self):
self.runner.load_settings()
self.hub.settings["settings_version"] = 2
self.hub.enqueue("wiki_fetch", FETCH)
real = self.runner.hub.settings
calls = []
def flaky():
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return real()
self.runner.hub.settings = flaky
self.runner.loop()
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [1, 1])
self.assertEqual(self.runner.settings["settings_version"], 2)
self.assertEqual(self.hub.results[1]["status"], "failed")
def test_claim_timeout_at_least_45(self):
seen = []
real = agent.Http.call
agent.Http.call = lambda self_, m, p, body=None, params=None, timeout=None: (seen.append(timeout), {"command": None, "settings_version": 1})[1]
try:
agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t", timeout=5)).claim(1, 1, None)
finally:
agent.Http.call = real
self.assertEqual(seen, [45])
def test_signal_handler(self):
import signal
from unittest import mock
handlers = {}
seen = {}
def fake_loop(runner):
handlers[signal.SIGINT](signal.SIGINT, None)
seen["stop_after_first"] = runner.stop
with self.assertRaises(SystemExit) as cm:
handlers[signal.SIGINT](signal.SIGINT, None)
seen["code"] = cm.exception.code
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
with mock.patch.object(agent.signal, "signal", lambda n, h: handlers.__setitem__(n, h)), \
mock.patch.object(agent.Runner, "loop", fake_loop):
agent.cmd_run(cfg, self.journal.path)
self.assertTrue(seen["stop_after_first"])
self.assertEqual(seen["code"], 130)
def test_prune_refused_while_agent_runs(self):
old = "2020-01-01T00:00:00+00:00"
self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None,
"jira_calls": 1, "rate_limited": 0, "at": old})
self.journal.append({"kind": "acked", "command_id": 7, "at": old})
held = agent.acquire_lock(self.journal.path)
try:
self.assertEqual(agent.cmd_prune(self.journal.path), 1)
self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4)
finally:
held.close()
self.assertEqual(agent.cmd_prune(self.journal.path), 0)
self.assertEqual(self.journal.records(), [])
def test_second_process_refused(self):
"""I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду."""
from unittest import mock
held = agent.acquire_lock(self.journal.path)
self.assertIsNotNone(held)
try:
self.assertIsNone(agent.acquire_lock(self.journal.path))
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
with mock.patch.object(agent.Runner, "loop", lambda r: self.fail("цикл не должен стартовать")):
self.assertEqual(agent.cmd_run(cfg, self.journal.path), agent.EXIT_LOCKED)
self.assertEqual(agent.EXIT_LOCKED, 73)
finally:
held.close()
again = agent.acquire_lock(self.journal.path)
self.assertIsNotNone(again) # после выхода первого — снова можно
again.close()
def test_future_journal_time_single_log_line(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
logs = []
orig = agent.log
agent.log = logs.append
try:
self.runner.restore_pace()
finally:
agent.log = orig
self.assertEqual(len(logs), 1, logs) # M-2 #18: без «не позже <будущее>»
self.assertIn("в будущем", logs[0])
self.assertIsNotNone(self.runner.eva.last_call_at)
def test_future_journal_time_then_stop_in_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True) # Ctrl-C в первую секунду паузы
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records() if r["command_id"] == 1], ["claimed"])
self.assertEqual(self.runner.eva.calls, 0) # к Eva ни одного вызова (M-3 #18)
self.assertEqual(self.hub.commands[1]["status"], "claimed")
def test_retry_exhausted_on_hub_names_hub(self):
self.runner.load_settings()
logs = []
orig = agent.log
agent.log = logs.append
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "хаб: не ответил",
"jira_calls": 0, "rate_limited": 0, "retryable": True, "peer": "хабом"}
self.hub.enqueue("wiki_fetch", FETCH)
try:
self.runner.loop()
finally:
agent.log = orig
r = self.hub.results[1]
self.assertEqual(r["error"], "результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил")
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
self.assertNotIn("peer", r)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,81 @@
import hashlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import unittest
from _common import SETTINGS, SRC
from fake_eva import FakeEva
from fake_hub import FakeHub
def wait_for(pred, timeout=30):
end = time.monotonic() + timeout
while time.monotonic() < end:
if pred():
return True
time.sleep(0.1)
return False
class SelfUpdateE2E(unittest.TestCase):
"""run.py + настоящий agent.py: v<N> видит публикацию v<N+1>, ставит её, v<N+1> подтверждает; Ctrl-C группе — выход 0."""
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": 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))
cur = "AGENT_VERSION = %d\n" % self.cur
content = src.replace(cur, "AGENT_VERSION = %d\n" % (self.cur + 1), 1)
self.assertNotEqual(content, src)
self.hub.release = {"version": self.cur + 1, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"content": content}
self.content = content
self.log = open(os.path.join(d, "out.log"), "wb")
self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log,
stderr=subprocess.STDOUT, start_new_session=True)
def tearDown(self):
if self.proc.poll() is None:
os.killpg(self.proc.pid, signal.SIGKILL)
self.proc.wait()
self.log.close()
self.hub.stop()
self.eva.stop()
self.tmp.cleanup()
def state(self):
try:
with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def test_update_to_next_and_stop_by_ctrl_c(self):
ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60)
with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f:
out = f.read()
self.assertTrue(ok, out)
with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f:
self.assertEqual(f.read(), self.content)
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == self.cur + 1 for b in self.hub.claim_bodies)))
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == self.cur + 1][-1]
self.assertEqual(last["launcher_version"], 2)
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
self.assertEqual(self.proc.wait(30), 0)

303
tests/test_update.py Normal file
View File

@ -0,0 +1,303 @@
import hashlib
import json
import os
import tempfile
import unittest
from _common import SETTINGS, agent
from fake_hub import FakeHub
CUR = agent.AGENT_VERSION
NEXT = CUR + 1
class UpdateStateTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "update.json")
def tearDown(self):
self.dir.cleanup()
def test_write_and_read(self):
d = agent.write_update_state(self.path, "staged", 5, 6, "abc")
self.assertEqual(agent.read_update_state(self.path), d)
self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None))
self.assertTrue(d["at"])
self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался
def test_read_missing(self):
self.assertIsNone(agent.read_update_state(self.path))
def test_read_update_state_garbage(self):
for text in ('{"state": "tri', "[1, 2]", ""):
with open(self.path, "w", encoding="utf-8") as f:
f.write(text)
self.assertIsNone(agent.read_update_state(self.path))
def test_confirm_trial_of_this_version(self):
u = agent.Updater(None, self.dir.name, 1)
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
u.confirm()
st = agent.read_update_state(u.state_path)
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s"))
def test_confirm_write_error_only_logged(self):
"""M-3 финального ревью: диск полон в момент подтверждения — агент не падает, подтвердит на следующем claim."""
u = agent.Updater(None, self.dir.name, 1)
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
logs = []
def full(*a, **kw):
raise OSError(28, "No space left on device")
orig_log, orig_write = agent.log, agent.write_update_state
agent.log, agent.write_update_state = logs.append, full
try:
u.confirm()
finally:
agent.log, agent.write_update_state = orig_log, orig_write
self.assertEqual(u.state()["state"], "trial")
self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs)
u.confirm()
self.assertEqual(u.state()["state"], "ok")
def test_confirm_ignores_other_states(self):
u = agent.Updater(None, self.dir.name, 1)
u.confirm()
self.assertIsNone(u.state()) # нет файла — не создаётся
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s")
u.confirm()
self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша
agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с")
u.confirm()
self.assertEqual(u.state()["state"], "rolled_back")
def make_release(version, body="print('ok')\n"):
content = "AGENT_VERSION = %d\n%s" % (version, body)
return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
class UpdaterCheckTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.hub = FakeHub(SETTINGS)
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1)
self.selftests = []
self.code = 0
self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1]
self.now = [1000.0]
self.u.clock = lambda: self.now[0]
def tearDown(self):
self.hub.stop()
self.dir.cleanup()
def ref(self, rel):
return {"version": rel["version"], "sha256": rel["sha256"]}
def capture_logs(self):
logs = []
orig = agent.log
agent.log = logs.append
self.addCleanup(setattr, agent, "log", orig)
return logs
def test_incomplete_release_ignored_with_hourly_hint(self):
logs = self.capture_logs()
for ref in ({"version": NEXT, "sha256": None}, {"version": str(NEXT), "sha256": "a" * 64},
{"version": True, "sha256": "a" * 64}, {"version": NEXT, "sha256": "A" * 64},
{"version": NEXT, "sha256": "a" * 63}, {"version": NEXT, "sha256": "a" * 64 + "\n"}):
self.assertFalse(self.u.check(ref))
self.now[0] += 3600
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 2)
self.assertEqual(self.hub.release_requests, 0)
self.assertIsNone(self.u.state())
def test_incomplete_release_hint_is_bounded(self):
"""M-4: подсказка не печатает произвольно длинный мусор от хаба целиком."""
logs = self.capture_logs()
self.assertFalse(self.u.check({"version": NEXT, "sha256": "x" * 10000}))
hints = [l for l in logs if l.startswith("хаб прислал неполную публикацию")]
self.assertEqual(len(hints), 1)
self.assertLess(len(hints[0]), 300)
def test_hints_have_separate_timers(self):
logs = self.capture_logs()
self.u.launcher_version = None
self.hub.release = make_release(NEXT)
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 1)
self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 1)
def test_write_error_is_transient(self):
self.hub.release = make_release(NEXT)
ref = self.ref(self.hub.release)
self.u.new_path = os.path.join(self.dir.name, "нет-каталога", "agent.py.new") # open() → FileNotFoundError
self.assertFalse(self.u.check(ref))
self.assertIsNone(self.u.state())
self.assertEqual(self.selftests, [])
self.now[0] += 299
self.assertFalse(self.u.check(ref))
self.assertEqual(self.hub.release_requests, 1) # раньше 5 минут не повторяет
def test_selftest_os_error_is_transient(self):
self.hub.release = make_release(NEXT)
def boom(path):
raise OSError(28, "No space left on device")
self.u.run_selftest = boom
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
self.assertFalse(os.path.exists(self.u.new_path))
def test_staged_write_error_is_transient(self):
self.hub.release = make_release(NEXT)
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
self.assertFalse(self.u.check(self.ref(self.hub.release))) # без staged выходить с 75 нельзя
self.assertFalse(os.path.exists(self.u.new_path))
def test_reject_write_error_only_logged(self):
rel = make_release(NEXT)
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
logs = self.capture_logs()
self.assertFalse(self.u.check(self.ref(rel))) # не бросает
self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs)
def test_good_version_is_staged(self):
rel = make_release(NEXT)
self.hub.release = rel
self.assertTrue(self.u.check(self.ref(rel)))
with open(self.u.new_path, encoding="utf-8") as f:
self.assertEqual(f.read(), rel["content"])
st = self.u.state()
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", CUR, NEXT, rel["sha256"]))
self.assertEqual(self.selftests, [self.u.new_path])
def test_not_newer_or_disabled_or_no_release(self):
self.hub.release = make_release(CUR)
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertFalse(self.u.check(None))
self.hub.release = make_release(NEXT)
self.u.enabled = False
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertEqual(self.hub.release_requests, 0)
def test_sha_mismatch_rejected_once(self):
rel = make_release(NEXT)
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
ref = self.ref(rel)
self.hub.release["sha256"] = rel["sha256"]
self.assertFalse(self.u.check(ref))
st = self.u.state()
self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", NEXT, "sha256 не сходится"))
self.assertFalse(os.path.exists(self.u.new_path))
self.assertFalse(self.u.check(ref))
self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает
def test_syntax_error_and_wrong_version_rejected(self):
bad = make_release(NEXT, body="def (:\n")
self.hub.release = bad
self.assertFalse(self.u.check(self.ref(bad)))
self.assertIn("не компилируется", self.u.state()["error"])
wrong = make_release(NEXT + 1)
wrong = dict(wrong, version=NEXT + 2)
self.hub.release = wrong
self.assertFalse(self.u.check(self.ref(wrong)))
self.assertIn("AGENT_VERSION", self.u.state()["error"])
self.assertEqual(self.selftests, [])
def test_selftest_bad_code_rejected(self):
self.hub.release = make_release(NEXT)
self.code = 1
self.assertFalse(self.u.check(self.ref(self.hub.release)))
st = self.u.state()
self.assertEqual(st["state"], "rejected")
self.assertTrue(st["error"].startswith("selftest: код 1"))
self.assertIn("вывод selftest", st["error"])
self.assertFalse(os.path.exists(self.u.new_path))
def test_selftest_unavailable_is_transient(self):
self.hub.release = make_release(NEXT)
self.code = 3
ref = self.ref(self.hub.release)
self.assertFalse(self.u.check(ref))
self.assertIsNone(self.u.state()) # update.json не тронут
self.assertFalse(os.path.exists(self.u.new_path))
self.now[0] += 299
self.assertFalse(self.u.check(ref))
self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет
self.now[0] += 2
self.code = 0
self.assertTrue(self.u.check(ref))
def test_selftest_timeout_is_transient(self):
self.hub.release = make_release(NEXT)
self.code = None
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
def test_download_failure_is_transient(self):
rel = make_release(NEXT) # в claim есть, а /agent/release — 404
self.assertFalse(self.u.check(self.ref(rel)))
self.assertIsNone(self.u.state())
def test_release_changed_between_requests_is_transient(self):
old, new = make_release(NEXT), make_release(NEXT + 1)
self.hub.release = new
self.assertFalse(self.u.check(self.ref(old)))
self.assertIsNone(self.u.state())
self.assertEqual(self.selftests, [])
def test_rolled_back_same_sha_not_retried(self):
rel = make_release(NEXT)
self.hub.release = rel
agent.write_update_state(self.u.state_path, "rolled_back", CUR, NEXT, rel["sha256"], "код 1 через 3 с")
self.assertFalse(self.u.check(self.ref(rel)))
fixed = make_release(NEXT, body="print('fixed')\n")
self.hub.release = fixed
self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем
def test_without_launcher_only_hint(self):
self.u.launcher_version = None
self.hub.release = make_release(NEXT)
logs = []
orig = agent.log
agent.log = logs.append
try:
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.now[0] += 3600
self.assertFalse(self.u.check(self.ref(self.hub.release)))
finally:
agent.log = orig
self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 2)
self.assertEqual(self.hub.release_requests, 0)
def test_stop_during_selftest_is_not_reject(self):
self.hub.release = make_release(NEXT)
stop = [False]
self.u.should_stop = lambda: stop[0]
def selftest(path):
stop[0] = True # Ctrl-C пришёл и selftest упал с 1
return 1, "KeyboardInterrupt"
self.u.run_selftest = selftest
self.assertFalse(self.u.check(self.ref(self.hub.release)))
self.assertIsNone(self.u.state())
self.assertFalse(os.path.exists(self.u.new_path))
def test_real_selftest_runs_new_file(self):
u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json"))
path = os.path.join(self.dir.name, "agent.py.new")
with open(path, "w", encoding="utf-8") as f:
f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n")
code, out = u._run_selftest(path)
self.assertEqual(code, 3)
self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out)
if __name__ == "__main__":
unittest.main()

139
tests/test_wiki_fetch.py Normal file
View File

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

266
tests/test_wiki_sync.py Normal file
View File

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