eva-agent v2: версии страниц в wiki_sync (#7) #8

Merged
aep merged 8 commits from feat/7-wiki-history into main 2026-10-28 13:01:52 +03:00
16 changed files with 1187 additions and 42 deletions

View File

@ -11,13 +11,13 @@
| Настройки | `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` |
| 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_all` (общий постраничный список по классу: страницы по 200, стоп, если Eva не соблюдает `slice`), `list_docs`, `list_versions` (`CmfDocumentHistory.list` по `parent_id`, без текста), `get_version` (`CmfDocumentHistory.get` по коду, с текстом), `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` |
| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`), `wiki_versions` (`POST /agent/wiki/versions`); при `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` |
| Исполнитель | `Executor`, `HubError`, `_Batcher`, `known_versions_ok`, `version_author`, `_VersionMissing` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (`_Batcher`: 50 страниц или 4 МБ; `_send_batch`: 413 — ошибка с кодом страницы/границами пачки); затем, только если в параметрах есть `known_versions`, — шаг версий `_sync_versions`: по странице дерева `list_versions`, отбор `has_published is True`, текст (`get_version`) только версий, которых нет в `known_versions` страницы, автор `{login, name}` (поле — строка или `null`), пачки по 50 версий или 4 МБ (413 — с кодом версии/границами пачки); отчёт `{root_title, codes, eva_calls[, versions]}`. `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` |
@ -40,9 +40,13 @@
## Сквозной прогон
`scripts/e2e-hub.sh`: настоящий хаб (`sdlc-hub serve` из основного checkout `sdlc-hub`, временный `SDLC_HUB_HOME`, свободный
порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются.
порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются. Блок истории
(первая загрузка версий, догрузка `DOCH-14` по SIGUSR1 фейковой Eva) выполняется, только если `GET /wiki/status` хаба
отдаёт `history_at` (хаб с change `wiki-history`); иначе печатается пропуск.
## Открытые вопросы
- Живая проверка на виртуалке с настоящей Eva (после merge hub#92).
- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой
живой синхронизации (M-5, design 42).
- Раскрытие `cmf_author` по имени поля у `CmfDocumentHistory.list` (проба 28.10.2026 проверяла его у `CmfDocument` и
через `fields: ["**"]`) — проверить при первой живой синхронизации v2; не раскроется — автор `{null, null}` (P-1).

View File

@ -4,7 +4,8 @@
Клиент модуля wiki хаба (sdlc-hub, `../sdlc-hub`) для виртуалки с доступом к Eva банка. Опрашивает хаб, по командам
`wiki_sync` (выгрузить поддерево страниц с корня) и `wiki_fetch` (скачать вложение) читает Eva и отдаёт данные хабу;
в Eva ничего не пишет. Главный сценарий: хаб ставит `wiki_sync` с кодом корня, агент обходит дерево страниц, шлёт
страницы пачками в стадию хаба и отчитывается списком кодов.
страницы пачками в стадию хаба, затем — опубликованные версии страниц, которых хаб не знает (если хаб прислал
`known_versions`; спека — `../sdlc-hub/openspec/changes/wiki-history/`), и отчитывается списком кодов и числом версий.
Спека и дизайн: `../sdlc-hub/openspec/changes/wiki-module/` (hub#91, PR hub#92); решения агента —
`openspec/changes/eva-agent-mvp/design.md`. Issue агента — aep/eva-agent#1.
@ -21,7 +22,7 @@
`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 и
- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, версий `wiki_versions`, файл `upload_file`); `Runner` знает хаб, Eva и
журнал; журнал не знает об Eva. Имена полей протокола хаба `jira_calls`/`rate_limited` общие для всех агентов:
в `jira_calls` идёт число вызовов Eva.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. Подробнее: `ARCHITECTURE.md`.
@ -49,7 +50,7 @@
## Первая подсистема
MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../sdlc-hub
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 2 (версии страниц в `wiki_sync`, change `wiki-versions`, aep/eva-agent#7). Выкатка: `uv run --project ../sdlc-hub
sdlc-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira);
порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki».

View File

@ -1,7 +1,8 @@
# eva-agent
Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц
Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
Eva (`wiki_sync`, вместе с опубликованными версиями страниц для истории правок хаба) и скачивает вложения
(`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
один файл `src/agent.py` и запускатель `src/run.py`.
Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается.
@ -72,7 +73,10 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist
uv run --project ../sdlc-hub sdlc-hub agent publish --module wiki src/agent.py
Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента
jira) поднимать после того, как `agent_status` покажет новую версию агента wiki.
jira) поднимать после того, как `agent_status` покажет новую версию агента wiki. Для v2 (версии страниц): после выкатки
хаба с change `wiki-history` и v2 поднять `wiki_min_agent_version` до 2 (Migration Plan хаба, п. 3). До этого — проверить
в логе агента первой синхронизации v2 строку «N из M версий без автора»: если она есть, `cmf_author` у версий Eva не
раскрывает, и авторы в истории будут пустыми (их не исправит повторная синхронизация).
## Разработка
@ -81,4 +85,5 @@ jira) поднимать после того, как `agent_status` покаже
Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из
основного checkout `sdlc-hub` workspace (переменная `HUB_SRC`), запускается на свободном порту с временным `SDLC_HUB_HOME`;
живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой
`attachment:<id>`, `wiki_fetch` вложения и его содержимое.
`attachment:<id>`, `wiki_fetch` вложения и его содержимое; если тестовый хаб знает историю правок (`history_at` в
`GET /wiki/status`), — ещё первую загрузку версий и догрузку новой версии `DOCH-14`.

View File

@ -0,0 +1,498 @@
# Версии страниц в wiki_sync — план реализации
> **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_sync` после страниц шлёт хабу опубликованные версии страниц дерева, которых нет в `known_versions`, и отчитывается `result.versions`; без `known_versions` агент v2 ведёт себя как v1.
**Architecture:** всё в `src/agent.py`. `EvaClient` получает общий постраничный список по классу (`list_all`) и методы `CmfDocumentHistory` (`list_versions`, `get_version`); `HubClient` — маршрут `wiki_versions`; `Executor.do_wiki_sync` после отправки страниц выполняет шаг версий (`_sync_versions`), пачки страниц и версий режет общий `_Batcher`, отправку с разбором 413 делает общий `_send_batch`. Фейки Eva и хаба получают версии и маршрут; e2e проверяет историю, только если тестовый хаб её знает.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`; bash для e2e.
**Spec:** решения агента — `openspec/changes/wiki-versions/design.md` (решения 1–9) и `proposal.md` этого worktree; контракт с хабом (единственный источник истины по протоколу) — `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/wiki-history/openspec/changes/wiki-history/specs/wiki/spec.md`, требование «История правок страниц», и `design.md` рядом (раздел «Что известно об API Eva», решения 1–5, 10).
## Global Constraints
- Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ и сторонние импорты запрещены (`tests/test_py39.py`; `X | Y` в аннотациях ловит прогон на `/usr/bin/python3`).
- Тесты из корня worktree: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный весь набор после каждой задачи.
- `AGENT_VERSION = 2` — ровно одна строка `AGENT_VERSION = <целое>` в `src/agent.py` (её читает `Updater.VERSION_RE`).
- Протокол: параметр `known_versions` — объект `{<код страницы>: [<код версии>…]}`; маршрут `POST /agent/wiki/versions {command_id, versions}`; версия — `{page_code, code, created_at, author: {login, name}, html, attachments, links}`; `created_at` — `cmf_created_at` Eva как есть (ISO-8601 со смещением); `login`, `name` — строка или `null`; в пачке ≤ 50 версий; отчёт `done` — `{root_title, codes, eva_calls, versions}`, `versions` — число присланных версий.
- Eva: `CmfDocumentHistory.list` с `filter=[["parent_id", "==", <id страницы из обхода>]]`, `fields=["code", "cmf_created_at", "has_published", "cmf_author"]`, `slice` по `PAGE_SIZE` (200); `CmfDocumentHistory.get` с `filter=[["code", "==", <код версии>]]`, `fields=["code", "text"]`. Черновики (`has_published` не `True`) не отправляются и их текст не запрашивается.
- Пачка версий — ≤ 50 версий и ≤ 4 МБ JSON (`MAX_BATCH_BYTES`), как у страниц.
- Eva только читается; токены — только в `agent.json`, в тексты ошибок не попадают.
- В основной checkout `eva-agent` и `sdlc-hub` не писать; хаб для e2e — из основного checkout `sdlc-hub` (только запуск во временном `SDLC_HUB_HOME`).
- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Тестовый и живой хаб без истории (основной checkout `sdlc-hub` до merge hub#140) — `known_versions` в параметрах нет; агент не зовёт `CmfDocumentHistory` и не шлёт `/agent/wiki/versions` (там 404): `test_versions_skipped_without_known` (Task 3) и e2e (Task 4).
2. 5xx хаба на пачке версий — `Runner` повторяет команду целиком: страницы уходят второй раз, версии — заново все новые, отчёт один с числом последнего прогона: `test_versions_hub_503_retried_by_runner` (Task 3).
3. Eva не соблюдает `slice` у `CmfDocumentHistory.list` — список не крутится бесконечно, в логе имя класса: `test_list_versions_slice_ignored` (Task 1).
4. Автор закрыт ACL (поля нет, `_acl_fields: {"login": "deny"}`), не раскрыт (строка-id) или отсутствует — версия уходит с `null` в закрытых полях, команда не падает: `test_versions_author_missing` (Task 3).
5. Битый `known_versions` (не объект, значение не список строк) — `failed` до первого вызова Eva, а не полная перекачка истории: `test_known_versions_malformed` (Task 3).
## Решения плана (дописать в design.md, Task 4)
- **P-1. Автор — по полям.** `login`/`name` берутся из `cmf_author`, каждое — строка или `null` отдельно: закрытое ACL поле в ответе Eva отсутствует (проба 28.10: `_acl_fields` со значением `deny`), открытое приходит строкой. `cmf_author` не объект (нет, строка-id) — `{null, null}`. Уточняет решение 4 («поля закрыты — `{null, null}`»).
- **P-2. Страница обхода без `id`** (Eva не отдала) — версии этой страницы не запрашиваются, строка в лог; команда продолжается.
- **P-3. Битый `known_versions`** — `failed` «wiki_sync: known_versions — не объект {код страницы: [коды версий]}» до вызовов Eva. Пустой `{}` — первый прогон (решение 6).
- **P-4. Версия исчезла между `list` и `get`** — `failed` «версия <код> страницы <код> не найдена или нет прав», как пропавшая страница.
- **P-5. Порядок версий в отчёте** — порядок обхода страниц, внутри страницы — порядок ответа Eva; хаб сортирует сам (по времени, затем по коду).
- **P-6. e2e с историей — по признаку хаба.** Блок истории в `scripts/e2e-hub.sh` выполняется, только если `GET /wiki/status` отдаёт ключ `history_at`; иначе печатается пропуск. Блок проверяется только с хабом, где реализован change `wiki-history` (после merge hub#140 или `HUB_SRC=<worktree хаба>`).
---
### Task 1: Клиент Eva — общий список по классу и версии страниц
**Model:** haiku
**Files:**
- Modify: `src/agent.py` (класс `EvaClient`, ~строки 255–351; константы рядом с `TREE_FIELDS`, ~строка 733)
- Modify: `tests/fake_eva.py`
- Test: `tests/test_eva_client.py`
**Interfaces:**
- Produces:
- `EvaClient.list_all(self, cls: str, flt: list, fields: List[str]) -> List[dict]` — нынешнее тело `list_docs` с методом `"%s.list" % cls`; лог несоблюдения `slice`: `" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out))`.
- `EvaClient.list_docs(self, flt, fields) -> List[dict]` — `list_all("CmfDocument", flt, fields)` (поведение прежнее).
- `VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"]`, `VERSION_TEXT_FIELDS = ["code", "text"]` — модульные константы.
- `EvaClient.list_versions(self, doc_id: str) -> List[dict]` — `list_all("CmfDocumentHistory", [["parent_id", "==", doc_id]], VERSION_FIELDS)`.
- `EvaClient.get_version(self, code: str) -> Optional[dict]` — `call("CmfDocumentHistory.get", filter=[["code", "==", code]], fields=VERSION_TEXT_FIELDS)`.
- Фейк: `FakeEva.versions: list`; `FakeEva.add_version(self, parent_id, code, created_at="2026-10-27T17:59:00.123456+03:00", text="", has_published=True, author=AUTHOR)` хранит `{"id": "CmfDocumentHistory:" + str(code), "parent_id": str(parent_id), "code", "cmf_created_at", "has_published", "cmf_author": author, "text"}` (author — как есть, сырое значение поля Eva); модульная `AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}`; `rpc` отвечает на `CmfDocumentHistory.list` (фильтр, `slice`, поля — `{k: v.get(k) for k in fields}`) и `CmfDocumentHistory.get` по `self.versions`; `FakeEva.rpc_error_on(self, method: str, text: str)` — разовая ошибка JSON-RPC на первом вызове этого метода (не трогает `rpc_errors`).
- [ ] **Step 1: Написать падающие тесты в `tests/test_eva_client.py`**
```python
def test_list_versions_filter_and_fields(self):
self.eva.add_version("CmfDocument:1", "DOCH-1")
self.eva.add_version("CmfDocument:1", "DOCH-2", has_published=False)
self.eva.add_version("CmfDocument:2", "DOCH-9")
got = self.cli.list_versions("CmfDocument:1")
self.assertEqual([v["code"] for v in got], ["DOCH-1", "DOCH-2"])
self.assertEqual(got[0], {"code": "DOCH-1", "cmf_created_at": "2026-10-27T17:59:00.123456+03:00",
"has_published": True, "cmf_author": fake_eva.AUTHOR})
req = self.eva.api_calls[0]
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.list")
self.assertEqual(req["body"]["kwargs"]["filter"], [["parent_id", "==", "CmfDocument:1"]])
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "cmf_created_at", "has_published", "cmf_author"])
self.assertEqual(req["body"]["kwargs"]["slice"], [0, 200])
def test_list_versions_pages_200(self):
for i in range(450):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
self.assertEqual(len(self.cli.list_versions("CmfDocument:1")), 450)
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]])
def test_list_versions_slice_ignored(self):
orig = self.eva.rpc
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
for i in range(450):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
out = io.StringIO()
with redirect_stdout(out):
got = self.cli.list_versions("CmfDocument:1")
self.assertEqual(len(got), 450)
self.assertEqual(len(self.eva.api_calls), 2)
self.assertIn("CmfDocumentHistory.list повторяет записи", out.getvalue())
def test_get_version(self):
self.eva.add_version("CmfDocument:1", "DOCH-1", text="<p>v1</p>")
self.assertEqual(self.cli.get_version("DOCH-1"), {"code": "DOCH-1", "text": "<p>v1</p>"})
self.assertIsNone(self.cli.get_version("DOCH-404"))
req = self.eva.api_calls[0]
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.get")
self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOCH-1"]])
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "text"])
self.assertEqual(self.cli.calls, 2)
def test_rpc_error_on_method(self):
self.eva.add_doc(1, "DOC-1", project="P")
self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
self.assertIsNotNone(self.cli.get_doc("DOC-1", ["code"]))
with self.assertRaises(agent.EvaError) as cm:
self.cli.list_versions("1")
self.assertEqual(str(cm.exception), "CmfDocumentHistory.list: нет прав")
self.assertEqual(self.cli.list_versions("1"), []) # ошибка разовая
```
В `test_list_slice_ignored_returns_all` добавить `self.assertIn("CmfDocument.list повторяет записи", out.getvalue())`. Импорт: `from tests import fake_eva` рядом с `from tests.fake_eva import FakeEva`.
- [ ] **Step 2: Запустить — падают**
Run: `/usr/bin/python3 -m unittest tests.test_eva_client -v`
Expected: FAIL/ERROR — `AttributeError: 'FakeEva' object has no attribute 'add_version'` / `'EvaClient' object has no attribute 'list_versions'`.
- [ ] **Step 3: Фейк — `add_version`, `AUTHOR`, `rpc_error_on`, ветки `CmfDocumentHistory.*` в `FakeEva.rpc` (`tests/fake_eva.py`)**
`rpc` выбирает источник по классу метода (`CmfDocument.*` — `self.docs` через `_out`, `CmfDocumentHistory.*` — `self.versions` без преобразования ссылок); фильтр — тот же `_match`. Разовая ошибка по методу проверяется в `handle` под `self.lock` по `body["method"]`. Докстринг модуля — упомянуть `CmfDocumentHistory.get/list`.
- [ ] **Step 4: `list_all`, `list_docs` через него, `list_versions`, `get_version`, `VERSION_FIELDS`, `VERSION_TEXT_FIELDS` в `src/agent.py`**
- [ ] **Step 5: Весь набор — зелёный**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/fake_eva.py tests/test_eva_client.py
git commit -m "EvaClient: общий список по классу и версии страниц CmfDocumentHistory (#7)"
```
---
### Task 2: Маршрут версий хаба, фейк хаба и общая отправка пачек
**Model:** haiku
**Files:**
- Modify: `src/agent.py` (`HubClient` ~строки 533–573; раздел «Дерево» — константы; `Executor._send_pages` ~строки 860–871 и цикл пачек в `do_wiki_sync` ~873–911)
- Modify: `tests/fake_hub.py`
- Test: `tests/test_clients.py`, `tests/test_wiki_sync.py`
**Interfaces:**
- Consumes: ничего из Task 1.
- Produces:
- `HubClient.wiki_versions(self, command_id, versions: list) -> dict` — `POST /agent/wiki/versions {"command_id": command_id, "versions": versions}`.
- `MAX_VERSIONS = 50` — модульная константа (рядом с `MAX_PAGES`).
- `class _Batcher` (модуль, раздел «Исполнитель»): `__init__(self, send: Callable[[list], None], max_items: int, max_bytes: int = MAX_BATCH_BYTES)`; `add(self, item: dict) -> None` — размер элемента `len(json.dumps(item, ensure_ascii=False).encode("utf-8")) + 2`, перед добавлением отправляет накопленное, если пачка непуста и `len >= max_items` или `size + isize > max_bytes`; `flush(self) -> None` — отправляет непустой остаток; `sent: int` — число отправленных элементов. Логика — та, что сейчас в цикле `do_wiki_sync`.
- `Executor._send_batch(self, send: Callable[[int, list], dict], command_id, batch: list, one: str, many: str) -> None` — заменяет `_send_pages`; 413 → `HubError(HttpError(413, "<что> больше 16 МБ — хаб не принял: <текст[:300]>"))`, где `<что>` = `"%s %s" % (one, batch[0]["code"])` для одного элемента, иначе `"%s %s…%s (%d шт.)" % (many, batch[0]["code"], batch[-1]["code"], len(batch))`; прочие `HttpError`/`ConnectionError` → `HubError`. Для страниц: `one="страница"`, `many="пачка страниц"`; для версий (Task 3): `one="версия"`, `many="пачка версий"`.
- Фейк хаба: `check_versions(command, command_id, versions)` — как `check_pages`: `command_id` не целое — 422 `bad_request`; не список 1..50 — 422 `bad_versions` «versions — список от 1 до 50 версий»; нет команды — 404; не `wiki_sync` или не `claimed` — 409 `not_claimed`; по версии — 422 `bad_versions` с `detail` `"версия %d: code"` (нет `code`-строки) или `"<code>: <поле>"` для `page_code` (непустая строка), `created_at` (строка, `datetime.fromisoformat` с `tzinfo`), `author` (объект, `login`/`name` — строка или `None`), `html` (строка), `attachments`, `links` (как у страниц). `FakeHub.versions: list` — тела принятых пачек; `FakeHub.reject_versions = None` — `(код, тело)` на каждый запрос; `FakeHub.fail_versions = 0` — сколько ближайших запросов ответить 503 `{"error": "busy"}`; `FakeHub.wiki_posts: list` — пути принятых и отклонённых `POST /agent/wiki/pages|versions` по порядку.
- [ ] **Step 1: Написать падающие тесты**
В `tests/test_clients.py`, в `test_hub_client_claim_and_wiki_pages_routes` после `upload_file`:
```python
hub.wiki_versions(7, [{"code": "DOCH-1"}])
self.assertEqual(calls[3], ("POST", "/agent/wiki/versions", {"command_id": 7, "versions": [{"code": "DOCH-1"}]}, None))
```
В `tests/test_wiki_sync.py`, новый класс:
```python
GOOD = {"page_code": "DOC-1", "code": "DOCH-1", "created_at": "2026-10-27T17:59:00+03:00",
"author": {"login": None, "name": None}, "html": "", "attachments": [], "links": []}
class FakeHubVersionsTest(unittest.TestCase):
def test_check_versions(self):
claimed = {"type": "wiki_sync", "status": "claimed"}
self.assertIsNone(fake_hub.check_versions(claimed, 1, [GOOD]))
self.assertEqual(fake_hub.check_versions(claimed, 1, [GOOD] * 51)[0], 422)
self.assertEqual(fake_hub.check_versions({"type": "wiki_sync", "status": "done"}, 1, [GOOD])[0], 409)
self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, created_at="2026-10-27T17:59:00")]),
(422, {"error": "bad_versions", "detail": "DOCH-1: created_at"}))
self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, author={"login": 1, "name": None})]),
(422, {"error": "bad_versions", "detail": "DOCH-1: author"}))
```
(Импорт `import fake_hub` рядом с `from fake_hub import FakeHub`.) Существующие `test_hub_413_single_page`, `test_hub_413_batch`, `test_sync_batches_50`, `test_sync_batch_bytes` не меняются — они страхуют перенос в `_Batcher`/`_send_batch`.
- [ ] **Step 2: Запустить — падают**
Run: `/usr/bin/python3 -m unittest tests.test_clients tests.test_wiki_sync -v`
Expected: `AttributeError: 'HubClient' object has no attribute 'wiki_versions'`, `module 'fake_hub' has no attribute 'check_versions'`.
- [ ] **Step 3: `check_versions`, маршрут `/agent/wiki/versions` и поля `versions`, `reject_versions`, `fail_versions`, `wiki_posts` в `tests/fake_hub.py`**
Порядок проверок в маршруте: `fail_versions` → `reject_versions` → `check_versions` → запись в `versions`; путь в `wiki_posts` — до проверок. У маршрута страниц путь в `wiki_posts` — так же.
- [ ] **Step 4: `HubClient.wiki_versions`, `MAX_VERSIONS`, `_Batcher`, `Executor._send_batch` в `src/agent.py`; `do_wiki_sync` режет страницы через `_Batcher(lambda b: self._send_batch(self.hub.wiki_pages, cmd["id"], b, "страница", "пачка страниц"), MAX_PAGES)`; `_send_pages` удалить**
- [ ] **Step 5: Весь набор — зелёный**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/fake_hub.py tests/test_clients.py tests/test_wiki_sync.py
git commit -m "HubClient.wiki_versions, фейк маршрута версий, общая отправка пачек (#7)"
```
---
### Task 3: Шаг версий в `do_wiki_sync`
**Model:** sonnet
**Files:**
- Modify: `src/agent.py` (раздел «Исполнитель»: `Executor.do_wiki_sync`, новые `_sync_versions`, `version_author`, `known_versions_ok`)
- Test: `tests/test_wiki_sync.py`
**Interfaces:**
- Consumes: Task 1 — `EvaClient.list_versions(doc_id: str) -> List[dict]`, `EvaClient.get_version(code: str) -> Optional[dict]`, `FakeEva.add_version(...)`, `fake_eva.AUTHOR`, `FakeEva.rpc_error_on(method, text)`; Task 2 — `HubClient.wiki_versions`, `MAX_VERSIONS`, `_Batcher`, `Executor._send_batch`, `FakeHub.versions|reject_versions|fail_versions|wiki_posts`.
- Produces:
- `known_versions_ok(value) -> bool` — `True`, если `value` — `dict`, у которого каждое значение — `list` строк.
- `version_author(ref) -> dict` — `{"login": <str|None>, "name": <str|None>}` (P-1): `ref` не `dict` — оба `None`; иначе каждое поле — значение, если это непустая строка, иначе `None`.
- `Executor._sync_versions(self, command_id: int, tree: List[Tuple[str, Optional[str]]], known: Dict[str, List[str]]) -> int` — `tree` — `(код страницы, id страницы или None)` в порядке обхода; возвращает число отправленных версий (`_Batcher.sent`). По странице: `id is None` — лог `" у страницы %s нет id — версии не запрошены" % code`, дальше; иначе `list_versions(id)`; по версии: `has_published is not True` — пропуск молча; `code` не непустая строка — лог `" версия без code у %s — пропущена" % page_code`; `cmf_created_at` не непустая строка — лог `" версия %s у %s без cmf_created_at — пропущена" % (vcode, page_code)`; код в `known.get(page_code, [])` — пропуск без `get`; иначе `get_version(vcode)`; ответ `None` — `raise _VersionMissing("версия %s страницы %s не найдена или нет прав" % (vcode, page_code))` (P-4).
- `class _VersionMissing(Exception)` — раздел «Исполнитель»; `do_wiki_sync` ловит его вокруг `_sync_versions` и отвечает `self._out("failed", error=str(e))`. `html` = `text`, если строка, иначе `""`; `attachments, links = classify_links(html, self.eva.base)`. Пачки — `_Batcher(lambda b: self._send_batch(self.hub.wiki_versions, command_id, b, "версия", "пачка версий"), MAX_VERSIONS)`.
- `do_wiki_sync`: в начале, если `"known_versions" in p` и `not known_versions_ok(p["known_versions"])` — `failed` `"wiki_sync: known_versions — не объект {код страницы: [коды версий]}"` до вызовов Eva (P-3); в цикле страниц копит `tree` (`(code, str(doc["id"]) if doc.get("id") is not None else None)`); после `flush` страниц — `_sync_versions`, только если ключ `known_versions` есть (решение 6); результат `{"root_title", "codes", "eva_calls", "versions"}` (ключ `versions` — только при шаге версий); `eva_calls = self.eva.calls` считается после шага версий (решение 8).
Общий помощник теста (в `SyncBase`): `sync(self, root="DOC-1", label="tz", **extra)` — `params = dict({"label": label, "root": root}, **extra)` и для `enqueue`, и для `run`; `sent_versions(self)` — `[v for body in self.hub.versions for v in body["versions"]]`; `history_calls(self, method)` — `[r for r in self.eva.api_calls if r["path"] == "/api/?m=" + method]`. Метод `tree2(self)` в `WikiVersionsTest` строит дерево по умолчанию: `add_doc("CmfDocument:1", "DOC-1", "Корень", project="P")`, `add_doc("CmfDocument:2", "DOC-2", "Б", parent="DOC-1", project="P", orderno=1)`.
- [ ] **Step 1: Написать падающие тесты (`tests/test_wiki_sync.py`, класс `WikiVersionsTest(SyncBase)`)**
```python
def test_versions_skipped_without_known(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-11")
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertNotIn("versions", out["result"])
self.assertEqual(self.hub.versions, [])
self.assertFalse([r for r in self.eva.api_calls if "CmfDocumentHistory" in r["path"]])
def test_versions_first_load(self): # сценарий «Первая загрузка истории»
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", "DOCH-11", "2026-10-27T17:59:00.123456+03:00", "<p>v1</p>")
e.add_version("CmfDocument:1", "DOCH-12", "2026-10-27T18:02:00+03:00", "<p>v2</p>")
e.add_version("CmfDocument:1", "DOCH-13", "2026-10-27T18:05:00+03:00", "<p>v3</p>")
e.add_version("CmfDocument:1", "DOCH-19", "2026-10-27T18:10:00+03:00", "<p>черновик</p>", has_published=False)
e.add_version("CmfDocument:2", "DOCH-21", "2026-08-01T10:00:00+03:00", "<p>a</p>")
e.add_version("CmfDocument:2", "DOCH-22", "2026-08-02T10:00:00+03:00", "<p>b</p>")
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["versions"], 5)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-11", "DOCH-12", "DOCH-13", "DOCH-21", "DOCH-22"])
self.assertEqual(self.sent_versions()[0], {
"page_code": "DOC-1", "code": "DOCH-11", "created_at": "2026-10-27T17:59:00.123456+03:00",
"author": {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"},
"html": "<p>v1</p>", "attachments": [], "links": []})
self.assertEqual(self.hub.versions[0]["command_id"], self.cid)
self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages", "/agent/wiki/versions"])
self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.list")],
[[["parent_id", "==", "CmfDocument:1"]], [["parent_id", "==", "CmfDocument:2"]]])
self.assertEqual(len(self.history_calls("CmfDocumentHistory.get")), 5) # черновик не читается
self.assertEqual(out["result"]["eva_calls"], self.client.calls)
self.assertEqual(out["result"]["eva_calls"], len(self.eva.api_calls))
def test_versions_incremental(self): # сценарий «Догрузка новых версий»
self.tree2()
for code in ("DOCH-10", "DOCH-11", "DOCH-12"):
self.eva.add_version("CmfDocument:1", code)
out = self.sync(known_versions={"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-77": ["DOCH-1"]})
self.assertEqual(out["result"]["versions"], 1)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-12"])
self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.get")],
[[["code", "==", "DOCH-12"]]])
def test_versions_none_in_eva(self):
self.tree2()
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 0)
self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages"])
def test_versions_author_missing(self):
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", "DOCH-1", author=None)
e.add_version("CmfDocument:1", "DOCH-2", author="CmfPerson:6c2b")
e.add_version("CmfDocument:1", "DOCH-3", author={"id": "CmfPerson:1", "_acl_fields": {"login": "deny", "name": "deny"}, "_acl_obj": "deny"})
e.add_version("CmfDocument:1", "DOCH-4", author={"id": "CmfPerson:2", "login": "x@y.ru", "_acl_fields": {"name": "deny"}})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual([v["author"] for v in self.sent_versions()],
[{"login": None, "name": None}] * 3 + [{"login": "x@y.ru", "name": None}])
def test_versions_without_code_or_time_skipped(self):
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", None)
e.add_version("CmfDocument:1", "DOCH-31", created_at=None)
e.add_version("CmfDocument:1", "DOCH-32")
buf = io.StringIO()
with redirect_stdout(buf):
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 1)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-32"])
self.assertIn("версия без code у DOC-1 — пропущена", buf.getvalue())
self.assertIn("версия DOCH-31 у DOC-1 без cmf_created_at — пропущена", buf.getvalue())
def test_has_published_strict(self):
self.tree2()
for i, flag in enumerate(("true", 1, None, False)):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, has_published=flag)
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 0)
self.assertEqual(self.history_calls("CmfDocumentHistory.get"), [])
def test_version_html_and_links(self):
self.tree2()
self.eva.add_version("CmfDocument:1", "DOCH-1", text='<a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема">')
self.eva.add_version("CmfDocument:1", "DOCH-2", text=None)
self.sync(known_versions={})
v1, v2 = self.sent_versions()
self.assertEqual(v1["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
self.assertEqual(v1["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png", "name": "Схема"}])
self.assertEqual((v2["html"], v2["attachments"], v2["links"]), ("", [], []))
def test_versions_batches_50(self):
self.tree2()
for i in range(120):
self.eva.add_version("CmfDocument:1", "DOCH-%03d" % i)
out = self.sync(known_versions={})
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [50, 50, 20])
self.assertEqual(out["result"]["versions"], 120)
def test_versions_batch_bytes(self):
self.tree2()
big = "<p>" + "x" * (1500 * 1024) + "</p>"
for i in (1, 2, 3):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, text=big)
self.sync(known_versions={})
sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.versions]
self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2, 1])
def test_versions_413(self):
for n, expect in ((1, "версия DOCH-11 больше 16 МБ"), (3, "пачка версий DOCH-11…DOCH-13 (3 шт.) больше 16 МБ")):
with self.subTest(n=n):
self.setUp(); self.tree2()
for i in range(n):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % (11 + i))
self.hub.reject_versions = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertIn(expect, out["error"])
self.assertFalse(out.get("retryable"))
def test_versions_hub_409(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
self.hub.reject_versions = (409, {"error": "not_claimed", "detail": "команда 1 не выдана агенту как wiki_sync"})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertIn("409", out["error"])
self.assertIn("не выдана агенту", out["error"])
def test_versions_eva_rpc_error(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "Eva: CmfDocumentHistory.list: нет прав")
self.assertEqual(len(self.hub.pages), 1) # страницы ушли, версии — нет
self.assertEqual(self.hub.versions, [])
def test_version_vanished(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-12")
self.client.get_version = lambda code: None
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "версия DOCH-12 страницы DOC-1 не найдена или нет прав")
def test_page_without_id(self):
self.eva.add_doc("CmfDocument:1", "DOC-1", "Корень", project="P")
self.eva.add_doc(None, "DOC-2", "Б", parent="DOC-1", project="P")
buf = io.StringIO()
with redirect_stdout(buf):
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual(len(self.history_calls("CmfDocumentHistory.list")), 1)
self.assertIn("у страницы DOC-2 нет id — версии не запрошены", buf.getvalue())
def test_known_versions_malformed(self):
for bad in (None, [], {"DOC-1": "DOCH-1"}, {"DOC-1": [1]}):
with self.subTest(bad=bad):
self.setUp(); self.tree2()
out = self.sync(known_versions=bad)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "wiki_sync: known_versions — не объект {код страницы: [коды версий]}")
self.assertEqual(self.eva.api_calls, [])
self.assertEqual(self.hub.pages, [])
def test_versions_hub_503_retried_by_runner(self):
self.tree2()
self.eva.add_version("CmfDocument:1", "DOCH-1"); self.eva.add_version("CmfDocument:2", "DOCH-2")
self.hub.fail_versions = 1
cid = self.hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1", "known_versions": {}})
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.path.join(tmp.name, "journal.jsonl")))
runner.sleep = lambda s: None # и пауза RETRY_MIN_WAIT перед повтором (wait_seconds)
runner.idle_limit = 1
runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "done", self.hub.results[cid])
self.assertEqual(self.hub.results[cid]["result"]["versions"], 2)
self.assertEqual(len(self.hub.pages), 2) # команда выполнена заново целиком
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2])
```
Импорт `io`, `from contextlib import redirect_stdout` в начало файла (`tempfile`, `os`, `json` уже импортированы).
- [ ] **Step 2: Запустить — падают**
Run: `/usr/bin/python3 -m unittest tests.test_wiki_sync -v`
Expected: FAIL новых тестов (`KeyError: 'versions'`, лишние/нет вызовов `CmfDocumentHistory`); прежние тесты класса `WikiSyncTest` — PASS.
- [ ] **Step 3: `known_versions_ok`, `version_author`, `_VersionMissing`, `Executor._sync_versions` и правка `do_wiki_sync` в `src/agent.py`** — по Interfaces этой задачи; докстринг `do_wiki_sync` дополнить шагом версий.
- [ ] **Step 4: Весь набор — зелёный**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/test_wiki_sync.py
git commit -m "wiki_sync: опубликованные версии страниц хабу, result.versions (#7)"
```
---
### Task 4: Версия 2, сквозной прогон и документация
**Model:** sonnet
**Files:**
- Modify: `src/agent.py:37` (`AGENT_VERSION`)
- Modify: `scripts/fake_eva_serve.py`, `scripts/e2e-hub.sh`
- Modify: `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`, `openspec/changes/wiki-versions/design.md`
**Interfaces:**
- Consumes: Task 1 — `FakeEva.add_version(parent_id, code, created_at, text, has_published, author)`; Task 3 — поведение шага версий.
- Produces:
- `fake_eva_serve.build(eva)` добавляет версии: DOC-1 (id `1`) — `DOCH-11` `2026-10-27T10:00:00+03:00` `<p>корень v1</p>`, `DOCH-12` `2026-10-27T11:00:00+03:00` `<p>корень</p>`, `DOCH-13` черновик (`has_published=False`); DOC-2 (id `2`) — `DOCH-21` `2026-10-26T10:00:00+03:00`.
- `fake_eva_serve.publish_more(eva)` — добавляет DOC-1 `DOCH-14` `2026-10-28T09:00:00+03:00` `<p>корень v3</p>` и печатает `VERSION DOCH-14` (flush); вызывается обработчиком `SIGUSR1`, который `main` ставит до печати `PORT`.
- [ ] **Step 1: `AGENT_VERSION = 2`**
Run: `grep -c '^AGENT_VERSION = 2$' src/agent.py`
Expected: `1`.
- [ ] **Step 2: `scripts/fake_eva_serve.py` — версии в `build`, `publish_more`, `signal.signal(signal.SIGUSR1, …)`; докстринг — «дерево из 5 страниц, версии DOC-1/DOC-2, картинка и файл; SIGUSR1 — опубликовать DOCH-14»**
- [ ] **Step 3: `scripts/e2e-hub.sh` — блок истории перед `echo "e2e: ok"` (P-6)**
- сохранить pid фейковой Eva (`EVA_PID=$!`) при запуске;
- признак: `printf '%s' "$STATUS" | /usr/bin/python3 -c 'import json,sys; print("yes" if "history_at" in json.load(sys.stdin) else "no")'`;
- `no` → `echo "e2e: хаб без истории правок — шаг версий не проверялся"`;
- `yes` →
- `GET /wiki/status?project=E2E`: `history_at` не `None`;
- `GET /wiki/pages/DOC-1/versions?project=E2E`: длина `versions` = 2;
- `GET /wiki/pages/DOC-2/versions?project=E2E`: длина `versions` = 1;
- `kill -USR1 "$EVA_PID"`, ждать строку `VERSION DOCH-14` в `$TMP/eva.out` (до 5 с);
- `api POST /wiki/sync '{"project":"E2E"}'`, затем `agent run --once`;
- DOC-1: длина `versions` = 3, `versions.0.version` = `DOCH-14`;
- каждая неудача — `fail "<что>: <ответ>"`.
Run: `bash scripts/e2e-hub.sh`
Expected: строка `e2e: хаб без истории правок — шаг версий не проверялся`, затем `e2e: ok` (хаб основного checkout `sdlc-hub` истории не знает: до merge hub#140 ветка `yes` не исполняется; её проверка — `HUB_SRC=/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/wiki-history bash scripts/e2e-hub.sh`, когда в том worktree будет код change `wiki-history`; если его ещё нет — записать в отчёт задачи как невыполненную проверку).
- [ ] **Step 4: Документация**
- `CLAUDE.md`: «Версия агента — 1» → «Версия агента — 2 (версии страниц в `wiki_sync`, change `wiki-versions`, aep/eva-agent#7)»; в «Что это» — шаг версий после страниц и ссылка на спеку хаба `../sdlc-hub/openspec/changes/wiki-history/`; в «Архитектура» — `Executor` шлёт и версии (`wiki_versions`).
- `ARCHITECTURE.md`: строка «Eva» — `list_all` (общий постраничный список по классу), `list_versions`, `get_version`; «Хаб» — `wiki_versions` (`POST /agent/wiki/versions`); «Исполнитель» — шаг версий: только при `known_versions`, `has_published is True`, текст только новых, автор `{login, name}` или `null`, пачки 50 версий или 4 МБ, 413 с кодом версии/границами пачки, `result.versions`; `_Batcher`, `_send_batch`; «Сквозной прогон» — блок истории по признаку `history_at`; «Открытые вопросы» — раскрытие `cmf_author` по имени поля у `CmfDocumentHistory.list` проверить при первой живой синхронизации v2.
- `README.md`: в абзаце о `wiki_sync` — версии страниц; в «Разработка» — e2e проверяет историю (первая загрузка, догрузка `DOCH-14`), только если тестовый хаб её знает; в «Выкатка» — после выкатки v2 поднять `wiki_min_agent_version` до 2 (Migration Plan хаба, п. 3).
- `openspec/changes/wiki-versions/design.md`, раздел `## Decisions made without discussion`: решения P-1…P-6 из раздела «Решения плана» этого плана — дата 28.10.2026, решение, обоснование, цена ошибки.
- [ ] **Step 5: Весь набор и e2e — зелёные**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` и `bash scripts/e2e-hub.sh`
Expected: `OK`; `e2e: ok`.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py scripts/fake_eva_serve.py scripts/e2e-hub.sh README.md ARCHITECTURE.md CLAUDE.md openspec/changes/wiki-versions/design.md
git commit -m "eva-agent v2: версии в e2e, документация, решения плана (#7)"
```

View File

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

View File

@ -0,0 +1,82 @@
## Context
`do_wiki_sync` обходит дерево от корня (`build_children` + `walk`), читает текст каждой страницы
(`CmfDocument.get … ["code", "name", "text"]`) и шлёт пачки страниц `POST /agent/wiki/pages` (≤50 страниц, ≤4 МБ JSON),
затем отчитывается `done {root_title, codes, eva_calls}`. Хаб с change `wiki-history` дописывает в параметры выдачи
`known_versions` и принимает версии отдельным маршрутом.
Проверено на живой Eva 28.10.2026:
- `CmfDocumentHistory.list` с `filter=[["parent_id", "==", "CmfDocument:<uuid>"]]` работает; `parent.code` — ошибка
`-32001` (вложенный фильтр).
- Поля версии: `code` (`DOCH-…`), `cmf_created_at` (ISO со смещением `+03:00`), `cmf_author_id`, `text` (полный HTML,
до ~56 тыс. символов), `has_published`, `cur_workflow`. У `CmfDocument` поле `cmf_author` по имени раскрывается в объект с `name` и
`login`; у `CmfDocumentHistory` проба проверяла только `fields: ["**"]` — раскрытие по имени поля не проверено (I-1).
- Черновик (`has_published = false`) Eva меняет на месте.
## Goals / Non-Goals
**Goals:** опубликованные версии страниц дерева — хабу, без повторной перекачки известных; работа с хабом без истории.
**Non-Goals:** см. proposal.md.
## Decisions
1. **Шаг версий — после страниц, в той же команде.** Страницы отправлены — затем версии; отчёт один. Порядок
страниц — порядок обхода.
2. **Выборка по странице дерева.** Для каждой страницы обхода — `CmfDocumentHistory.list` с фильтром `parent_id` = id
страницы из обхода (`doc["id"]`), полями `["code", "cmf_created_at", "has_published", "cmf_author"]`, страницами по
`PAGE_SIZE` с той же защитой от несоблюдения `slice`, что у `list_docs` (общий помощник для класса). Опубликованные
отбираются у себя (`has_published is True`): фильтр Eva по этому полю не проверялся.
3. **Текст — только новых.** Версия, чей код есть в `known_versions[<код страницы>]`, пропускается без запроса
текста. Для остальных — `CmfDocumentHistory.get` по коду с полями `["code", "text"]`; `html` = `text` (не строка —
пустая строка); `attachments`, `links` — `classify_links(html, base)`, как у страниц.
4. **Автор.** `{login, name}` из `cmf_author`; объекта нет или поля закрыты (`_acl_fields`) — `{login: null, name: null}`.
5. **Пачки.** Те же пределы, что у страниц: ≤50 версий и ≤4 МБ JSON на пачку; 413 от хаба — ошибка с кодом версии или
границами пачки (как `_send_pages`).
6. **Совместимость с прежним хабом.** Ключа `known_versions` в параметрах нет — шаг версий не выполняется, в отчёте
нет `versions`. Пустой объект `{}` — первый прогон, вся история.
7. **Ошибки.** Ошибка Eva на шаге версий (`EvaError`, сеть после повторов) — команда `failed` с текстом, как на шаге
страниц; хаб оставит копию и историю прежними (решение 10 change хаба). Версия без `code` или без
`cmf_created_at` пропускается с записью в лог.
8. **Счётчики.** `result.versions` — число отправленных версий; `eva_calls` включает вызовы шага версий.
9. **Тесты.** `tests/fake_eva.py` получает `CmfDocumentHistory.list` (фильтр `parent_id`) и `.get` (по `code`); сквозной
прогон с тестовым хабом — первая загрузка и догрузка одной новой версии.
## Risks / Trade-offs
- Один вызов Eva на страницу при каждой синхронизации; при пачке новых версий — ещё по вызову на версию. Первый прогон
AWPCA (28 страниц, десятки версий) — порядка сотни вызовов с паузой `wiki_pacing`.
- Если Eva однажды поменяет текст опубликованной версии, агент её не перечитает (код уже известен хабу).
## Decisions made without discussion
- 28.10.2026 — **отбор опубликованных у себя, не фильтром Eva (решение 2).** Обоснование: фильтр по `has_published` не
проверялся, а лишние метаданные дёшевы. Цена ошибки: чуть больше данных в ответе на страницу.
- 28.10.2026 — **пустой автор `{null, null}` вместо отказа (решение 4).** Цена ошибки: в ленте правка без имени.
- 28.10.2026 — **без `known_versions` шаг пропускается (решение 6).** Цена ошибки: нет — прежний хаб маршрута версий
не знает.
- 28.10.2026 — **P-1. Автор — по полям.** `login`/`name` из `cmf_author` берутся по отдельности: строка или `null`
(закрытое ACL поле Eva не отдаёт — `_acl_fields: deny`); `cmf_author` не объект — `{null, null}`. Уточняет решение 4.
Цена ошибки: в ленте правка без имени или без логина.
- 28.10.2026 — **P-2. Страница обхода без `id`** — версии не запрашиваются, строка в лог, команда продолжается. Цена
ошибки: у такой страницы нет истории.
- 28.10.2026 — **P-3. Битый `known_versions`** (не объект или значение не список строк) — `failed` «wiki_sync:
known_versions — не объект {код страницы: [коды версий]}» до вызовов Eva. Обоснование: иначе хаб получил бы всю
историю заново. Цена ошибки: синхронизация стоит до исправления хаба.
- 28.10.2026 — **P-4. Версия исчезла между `list` и `get`** — `failed` «версия <код> страницы <код> не найдена или нет
прав», как пропавшая страница. Цена ошибки: редкая гонка с правкой в Eva останавливает синхронизацию до повтора.
- 28.10.2026 — **P-5. Порядок версий** — порядок обхода страниц, внутри страницы — порядок ответа Eva; хаб сортирует
сам. Цена ошибки: нет.
- 28.10.2026 — **P-6. e2e с историей — по признаку хаба** (`history_at` в `GET /wiki/status`); с хабом без истории
блок пропускается с сообщением. Цена ошибки: до merge hub#140 шаг версий проверяется только юнит-тестами.
- 28.10.2026 — **I-1 (финальное ревью). Версии без автора — строка в лог** «N из M версий без автора (cmf_author не
раскрыт или закрыт ACL)» на синхронизацию. Обоснование: автор известной версии не перечитывается, потеря необратима.
Перед подъёмом `wiki_min_agent_version` — проба `CmfDocumentHistory.list` с `fields=["cmf_author"]` на виртуалке.
Цена ошибки: нет.
- 28.10.2026 — **M-1 (финальное ревью, поднято до Important). Код версии уходит один раз за прогон**, даже если Eva
при сдвиге `slice` отдала запись повторно. Обоснование: хаб считает уникальные коды, повтор давал `failed`
«нет версий: пришло N−1 из N». Цена ошибки: нет.
- 28.10.2026 — **M-3 (финальное ревью, поднято до Important). Нет ключа `text` в ответе `CmfDocumentHistory.get` —
`failed`** «версия X страницы Y: Eva не отдала text (нет прав?)». Уточняет решение 3: пустая версия в хабе не
исправилась бы (код уже известен). `text: null` по-прежнему — пустая строка. Цена ошибки: при закрытом ACL `text`
синхронизация стоит до выдачи прав.

View File

@ -0,0 +1,23 @@
## Why
Хаб получает историю правок страниц вики (aep/sdlc-hub#140, change `wiki-history`): ленту «кто, когда, что поменял» и
дифф каждой правки. Историю знает только Eva, а ходит в неё только агент. Проверка API 28.10.2026 (скрипты
`eva_probe.py`, `eva_probe2.py`) нашла версии страниц — `CmfDocumentHistory` — с полным текстом, автором и признаком
публикации (aep/eva-agent#7).
## What Changes
- Команда `wiki_sync` после отправки страниц забирает опубликованные версии страниц дерева, которых нет в
`known_versions` из параметров команды, и шлёт их хабу пачками `POST /agent/wiki/versions`; отчёт `done` получает
`result.versions`.
- Без `known_versions` в параметрах (хаб без истории) шаг версий пропускается: агент v2 работает и с прежним хабом.
- `AGENT_VERSION` 2.
Контракт команды, маршрута и отчёта — в спеке хаба: `sdlc-hub/openspec/changes/wiki-history/specs/wiki/spec.md`,
требование «История правок страниц». Здесь — только решения агента (design.md).
## Non-Goals
- Черновики, версии вложений, журнал аудита — вне объёма change хаба.
- Частичная перекачка дерева страниц: тексты страниц по-прежнему читаются все.
- Новые настройки агента.

View File

@ -50,7 +50,8 @@ AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')"
# --- фейковая 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+=($!)
EVA_PID=$!
PIDS+=($EVA_PID)
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")"
@ -107,4 +108,26 @@ EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); impo
GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)"
[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало"
# --- история правок: только если тестовый хаб её знает (change wiki-history хаба; P-6)
nversions() { /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["versions"]))'; }
HAS_HISTORY="$(printf '%s' "$STATUS" | /usr/bin/python3 -c 'import json,sys; print("yes" if "history_at" in json.load(sys.stdin) else "no")')"
if [ "$HAS_HISTORY" = "no" ]; then
echo "e2e: хаб без истории правок — шаг версий не проверялся"
else
STATUS="$(api GET "/wiki/status?project=E2E")"
[ "$(printf '%s' "$STATUS" | jget history_at)" != "None" ] || fail "wiki/status: history_at пуст: $STATUS"
V1="$(api GET "/wiki/pages/DOC-1/versions?project=E2E")"
[ "$(printf '%s' "$V1" | nversions)" = "2" ] || fail "версии DOC-1: ожидалось 2: $V1"
V2="$(api GET "/wiki/pages/DOC-2/versions?project=E2E")"
[ "$(printf '%s' "$V2" | nversions)" = "1" ] || fail "версии DOC-2: ожидалась 1: $V2"
kill -USR1 "$EVA_PID"
for _ in $(seq 1 50); do grep -q '^VERSION DOCH-14' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done
grep -q '^VERSION DOCH-14' "$TMP/eva.out" || fail "фейковая Eva не опубликовала DOCH-14"
api POST /wiki/sync '{"project":"E2E"}' >/dev/null || fail "POST /wiki/sync"
agent run --once || fail "agent.py run --once (догрузка версий)"
V1="$(api GET "/wiki/pages/DOC-1/versions?project=E2E")"
[ "$(printf '%s' "$V1" | nversions)" = "3" ] || fail "версии DOC-1 после догрузки: ожидалось 3: $V1"
[ "$(printf '%s' "$V1" | jget versions.0.version)" = "DOCH-14" ] || fail "новейшая версия DOC-1 не DOCH-14: $V1"
fi
echo "e2e: ok"

View File

@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл.
"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, версии DOC-1/DOC-2, картинка и файл;
SIGUSR1 — опубликовать DOCH-14.
Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом.
"""
import os
import signal
import sys
import threading
@ -22,6 +24,16 @@ def build(eva: FakeEva) -> None:
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)
eva.add_version(1, "DOCH-11", "2026-10-27T10:00:00+03:00", "<p>корень v1</p>")
eva.add_version(1, "DOCH-12", "2026-10-27T11:00:00+03:00", "<p>корень</p>")
eva.add_version(1, "DOCH-13", "2026-10-27T12:00:00+03:00", "<p>черновик</p>", has_published=False)
eva.add_version(2, "DOCH-21", "2026-10-26T10:00:00+03:00", img)
def publish_more(eva: FakeEva) -> None:
"""Новая опубликованная версия DOC-1 — для проверки догрузки истории."""
eva.add_version(1, "DOCH-14", "2026-10-28T09:00:00+03:00", "<p>корень v3</p>")
print("VERSION DOCH-14", flush=True)
def main() -> int:
@ -29,6 +41,7 @@ def main() -> int:
if len(sys.argv) > 1:
eva.token = sys.argv[1]
build(eva)
signal.signal(signal.SIGUSR1, lambda *_: publish_more(eva))
print("PORT %d" % eva.server.server_port, flush=True)
threading.Event().wait()
return 0

View File

@ -34,7 +34,7 @@ from html.parser import HTMLParser
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 1
AGENT_VERSION = 2
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__))
@ -327,12 +327,22 @@ class EvaClient(Http):
return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
def list_docs(self, flt: list, fields: List[str]) -> List[dict]:
return self.list_all("CmfDocument", flt, fields)
def list_versions(self, doc_id: str) -> List[dict]:
"""Версии страницы (CmfDocumentHistory) по id документа; фильтр parent.code Eva не принимает (-32001)."""
return self.list_all("CmfDocumentHistory", [["parent_id", "==", doc_id]], VERSION_FIELDS)
def get_version(self, code: str) -> Optional[dict]:
return self.call("CmfDocumentHistory.get", filter=[["code", "==", code]], fields=VERSION_TEXT_FIELDS)
def list_all(self, cls: str, flt: list, fields: List[str]) -> List[dict]:
"""Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
останавливается на собранном, а не крутится бесконечно (M-4)."""
out, offset = [], 0 # type: List[dict], int
seen = set() # type: set
while True:
chunk = self.call("CmfDocument.list", filter=flt, fields=fields,
chunk = self.call("%s.list" % cls, filter=flt, fields=fields,
slice=[offset, offset + self.PAGE_SIZE]) or []
if isinstance(chunk, dict): # обёрнутый результат
chunk = chunk.get("items") or chunk.get("result") or []
@ -346,7 +356,7 @@ class EvaClient(Http):
if len(chunk) < self.PAGE_SIZE:
return out
if not fresh:
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out))
log(" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out)))
return out
offset += self.PAGE_SIZE
@ -569,6 +579,10 @@ class HubClient(Http):
"""Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ)."""
return self.call("POST", "/agent/wiki/pages", {"command_id": command_id, "pages": pages})
def wiki_versions(self, command_id, versions: list) -> dict:
"""Пачка версий страниц wiki_sync в стадию хаба (до 50 версий, тело до 16 МБ)."""
return self.call("POST", "/agent/wiki/versions", {"command_id": command_id, "versions": versions})
# ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды
@ -729,8 +743,11 @@ def _parse_at(value) -> float:
# Дерево страниц Eva
# ---------------------------------------------------------------------------
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
MAX_VERSIONS = 50 # версий в пачке (предел хаба)
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"]
VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"] # список версий — без текста
VERSION_TEXT_FIELDS = ["code", "text"]
def _ref_code(ref) -> Optional[str]:
@ -819,6 +836,48 @@ class HubError(Exception):
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
def known_versions_ok(value) -> bool:
"""known_versions от хаба: {код страницы: [коды версий]}."""
return isinstance(value, dict) and all(isinstance(v, list) and all(isinstance(x, str) for x in v)
for v in value.values())
def version_author(ref) -> dict:
"""Автор версии из раскрытого cmf_author: каждое поле — непустая строка или None (закрытое ACL поле Eva не
отдаёт); не объект (нет поля, строка-id) — оба None (P-1)."""
if not isinstance(ref, dict):
return {"login": None, "name": None}
pick = lambda k: ref.get(k) if isinstance(ref.get(k), str) and ref.get(k) else None # noqa: E731
return {"login": pick("login"), "name": pick("name")}
class _VersionMissing(Exception):
"""Версия из списка Eva не читается по коду (P-4)."""
class _Batcher:
"""Пачки для стадии хаба: не больше max_items элементов и max_bytes JSON; send получает накопленный список."""
def __init__(self, send: Callable[[list], None], max_items: int, max_bytes: int = MAX_BATCH_BYTES):
self.send, self.max_items, self.max_bytes = send, max_items, max_bytes
self.batch = [] # type: list
self.size = 0
self.sent = 0
def add(self, item: dict) -> None:
isize = len(json.dumps(item, ensure_ascii=False).encode("utf-8")) + 2
if self.batch and (len(self.batch) >= self.max_items or self.size + isize > self.max_bytes):
self.flush()
self.batch.append(item)
self.size += isize
def flush(self) -> None:
if self.batch:
self.send(self.batch)
self.sent += len(self.batch)
self.batch, self.size = [], 0
class Executor:
def __init__(self, eva: EvaClient, hub: Optional[HubClient]):
self.eva, self.hub = eva, hub
@ -857,25 +916,29 @@ class Executor:
except (ConnectionError, HttpError) as e:
raise HubError(e) from None
def _send_pages(self, command_id, batch: list) -> None:
"""Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3)."""
def _send_batch(self, send: Callable[[Any, list], dict], command_id, batch: list, one: str, many: str) -> None:
"""Пачка в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом элемента или границами пачки (M-3)."""
try:
self.hub.wiki_pages(command_id, batch)
send(command_id, batch)
except HttpError as e:
if e.code == 413:
what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else
"пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch)))
what = ("%s %s" % (one, batch[0]["code"]) if len(batch) == 1 else
"%s %s…%s (%d шт.)" % (many, batch[0]["code"], batch[-1]["code"], len(batch)))
e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers)
raise HubError(e) from None
except ConnectionError as e:
raise HubError(e) from None
def do_wiki_sync(self, p: dict, cmd: dict) -> dict:
"""Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается,
поэтому после перезапуска команда выполняется заново целиком (design 9)."""
"""Дерево страниц Eva от корня → хаб пачками; затем, если хаб прислал known_versions, — опубликованные версии
страниц дерева, которых хаб не знает (POST /agent/wiki/versions). Отчёт — коды в порядке обхода (корень первым) и
число версий. Eva только читается, поэтому после перезапуска команда выполняется заново целиком (design 9)."""
root_code = str(p.get("root") or "").strip()
if not root_code:
return self._out("failed", error="wiki_sync: не задан root")
with_versions = "known_versions" in p # хаб без истории ключа не шлёт — шаг версий не выполняется
if with_versions and not known_versions_ok(p["known_versions"]):
return self._out("failed", error="wiki_sync: known_versions — не объект {код страницы: [коды версий]}")
root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"])
if not root:
return self._out("failed", error="страница %s не найдена или нет прав" % root_code)
@ -883,8 +946,9 @@ class Executor:
root_code = root.get("code") or root_code
base = self.eva.base
codes = [] # type: List[str]
batch = [] # type: list
size = 0
tree = [] # type: List[Tuple[str, Optional[str]]]
pages = _Batcher(lambda b: self._send_batch(self.hub.wiki_pages, cmd["id"], b, "страница", "пачка страниц"),
MAX_PAGES)
for doc, parent_code in walk(root, children):
code = doc["code"]
full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"])
@ -898,17 +962,60 @@ class Executor:
"parent_code": parent_code, "orderno": _orderno(doc.get("orderno")),
"url": urllib.parse.urljoin(base + "/", "project/Document/" + code),
"html": html, "attachments": attachments, "links": links}
psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2
if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES):
self._send_pages(cmd["id"], batch)
batch, size = [], 0
batch.append(page)
size += psize
pages.add(page)
codes.append(code)
if batch:
self._send_pages(cmd["id"], batch)
tree.append((code, page["eva_id"]))
pages.flush()
title = (root.get("name") or "").strip() or root_code
return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls})
result = {"root_title": title, "codes": codes}
if with_versions:
try:
result["versions"] = self._sync_versions(cmd["id"], tree, p["known_versions"])
except _VersionMissing as e:
return self._out("failed", error=str(e))
result["eva_calls"] = self.eva.calls
return self._out("done", result)
def _sync_versions(self, command_id: int, tree: List[Tuple[str, Optional[str]]], known: Dict[str, List[str]]) -> int:
"""Опубликованные версии страниц дерева, которых нет в known, — хабу пачками; число отправленных (design 2–5)."""
base = self.eva.base
out = _Batcher(lambda b: self._send_batch(self.hub.wiki_versions, command_id, b, "версия", "пачка версий"),
MAX_VERSIONS)
no_author = 0
for page_code, doc_id in tree:
if doc_id is None:
log(" у страницы %s нет id — версии не запрошены" % page_code)
continue
seen = set(known.get(page_code, []))
for v in self.eva.list_versions(doc_id):
if v.get("has_published") is not True:
continue
vcode = v.get("code")
if not isinstance(vcode, str) or not vcode:
log(" версия без code у %s — пропущена" % page_code)
continue
created = v.get("cmf_created_at")
if not isinstance(created, str) or not created:
log(" версия %s у %s без cmf_created_at — пропущена" % (vcode, page_code))
continue
if vcode in seen:
continue
full = self.eva.get_version(vcode)
if not full:
raise _VersionMissing("версия %s страницы %s не найдена или нет прав" % (vcode, page_code))
if "text" not in full: # закрытое поле Eva не отдаёт; пустая версия в хабе не исправилась бы (M-3)
raise _VersionMissing("версия %s страницы %s: Eva не отдала text (нет прав?)" % (vcode, page_code))
html = full["text"] if isinstance(full["text"], str) else ""
attachments, links = classify_links(html, base)
author = version_author(v.get("cmf_author"))
no_author += author == {"login": None, "name": None}
out.add({"page_code": page_code, "code": vcode, "created_at": created, "author": author, "html": html,
"attachments": attachments, "links": links})
seen.add(vcode) # повтор кода в списке Eva (сдвиг slice) не уходит дважды (M-1)
out.flush()
if no_author: # автор известной версии не перечитывается — потерю видно сразу (I-1)
log(" %d из %d версий без автора (cmf_author не раскрыт или закрыт ACL)" % (no_author, out.sent))
return out.sent
def do_wiki_fetch(self, p: dict, cmd: dict) -> dict:
"""Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24);

View File

@ -1,4 +1,5 @@
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server."""
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list, CmfDocumentHistory.get/list) и /files/... на
http.server."""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -17,6 +18,10 @@ def _field(doc, path):
return cur
# автор версии, как его отдаёт Eva в раскрытом поле cmf_author (проба 28.10.2026)
AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
class FakeEva:
token = "eva-secret"
@ -29,6 +34,8 @@ class FakeEva:
self.requests = [] # {"method", "path", "body", "auth"}
self.failures = [] # [status, retry_after] — по одному на ответ
self.rpc_errors = []
self.method_errors = {} # метод → текст разовой ошибки JSON-RPC
self.versions = []
self.lock = threading.Lock()
eva = self
@ -87,6 +94,16 @@ class FakeEva:
def rpc_error_next(self, text):
self.rpc_errors.append(text)
def rpc_error_on(self, method: str, text: str):
"""Разовая ошибка JSON-RPC на первом вызове метода method (очередь rpc_errors не трогает)."""
self.method_errors[method] = text
def add_version(self, parent_id, code, created_at="2026-10-27T17:59:00.123456+03:00", text="", has_published=True,
author=AUTHOR):
self.versions.append({"id": "CmfDocumentHistory:" + str(code), "parent_id": str(parent_id), "code": code,
"cmf_created_at": created_at, "has_published": has_published, "cmf_author": author,
"text": text})
@property
def api_calls(self):
return [r for r in self.requests if r["path"].startswith("/api/")]
@ -109,6 +126,9 @@ class FakeEva:
try:
if u.path == "/api/" and verb == "POST":
body = json.loads(raw)
if rpc_error is None:
with self.lock:
rpc_error = self.method_errors.pop(body.get("method"), None)
if rpc_error is not None:
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
else:
@ -140,12 +160,16 @@ class FakeEva:
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":
if method.startswith("CmfDocumentHistory."):
found = [v for v in self.versions if self._match(v, kw.get("filter") or [])]
pick = lambda v: {k: v.get(k) for k in fields} if fields else dict(v) # noqa: E731
else:
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
pick = lambda d: self._out(d, fields) # noqa: E731
if method in ("CmfDocument.get", "CmfDocumentHistory.get"):
return pick(found[0]) if found else None
if method == "CmfDocument.list":
if method in ("CmfDocument.list", "CmfDocumentHistory.list"):
lo, hi = kw.get("slice") or [0, len(found)]
return [pick(d) for d in found[lo:hi]]
raise ValueError("метод %s не поддержан" % method)

View File

@ -6,6 +6,7 @@ import re
import socket
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -59,6 +60,49 @@ def check_pages(command, command_id, pages):
return None
def check_versions(command, command_id, versions):
"""Форма пачки версий как в приёме версий хаба (change wiki-history): (код, тело ошибки) или None."""
def bad(detail, status=422, error="bad_versions"):
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(versions, list) or not versions or len(versions) > MAX_PAGES:
return bad("versions — список от 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")
for i, v in enumerate(versions, 1):
code = v.get("code") if isinstance(v, dict) else None
if not isinstance(code, str) or not code.strip():
return bad("версия %d: code" % i)
page = v.get("page_code")
if not isinstance(page, str) or not page.strip():
return bad("%s: page_code" % code)
try:
ok = isinstance(v.get("created_at"), str) and datetime.fromisoformat(v["created_at"]).tzinfo is not None
except ValueError:
ok = False
if not ok:
return bad("%s: created_at" % code)
a = v.get("author")
if not isinstance(a, dict) or not all(a.get(k) is None or isinstance(a.get(k), str) for k in ("login", "name")):
return bad("%s: author" % code)
if not isinstance(v.get("html"), str):
return bad("%s: html" % code)
att, links = v.get("attachments"), v.get("links")
if not isinstance(att, list) or not all(
isinstance(x, dict) and isinstance(x.get("ref"), str) and isinstance(x.get("url"), str)
and isinstance(x.get("name") or "", str) for x in att):
return bad("%s: attachments" % code)
if not isinstance(links, list) or not all(
isinstance(x, dict) and isinstance(x.get("ref"), str) and isinstance(x.get("code"), str)
for x in links):
return bad("%s: links" % code)
return None
class FakeHub:
token = "agent-token"
@ -78,6 +122,10 @@ class FakeHub:
self.file_requests = 0
self.pages = [] # тела POST /agent/wiki/pages
self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой
self.versions = [] # тела принятых POST /agent/wiki/versions
self.reject_versions = None # (код, тело) — ответ на /agent/wiki/versions ошибкой
self.fail_versions = 0 # сколько ближайших /agent/wiki/versions ответить 503
self.wiki_posts = [] # пути POST /agent/wiki/pages|versions по порядку (и отклонённых)
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
@ -173,7 +221,21 @@ class FakeHub:
return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings":
return 200, self.settings
if path == "/agent/wiki/versions" and method == "POST":
self.wiki_posts.append(path)
if self.fail_versions > 0:
self.fail_versions -= 1
return 503, {"error": "busy"}
if self.reject_versions:
return self.reject_versions
cid = body.get("command_id")
bad = check_versions(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("versions"))
if bad:
return bad
self.versions.append(body)
return 200, {"accepted": len(body.get("versions") or [])}
if path == "/agent/wiki/pages" and method == "POST":
self.wiki_posts.append(path)
if self.reject_pages:
return self.reject_pages
cid = body.get("command_id")

View File

@ -195,6 +195,8 @@ class ClientsTest(unittest.TestCase):
hub.claim(1, 2, None)
hub.wiki_pages(7, [{"code": "DOC-1"}])
hub.upload_file(b"abc")
hub.wiki_versions(7, [{"code": "DOCH-1"}])
self.assertEqual(calls[3], ("POST", "/agent/wiki/versions", {"command_id": 7, "versions": [{"code": "DOCH-1"}]}, None))
self.assertEqual(calls[0][:2], ("POST", "/agent/claim"))
self.assertEqual(calls[0][2]["last_call_at"], None)
self.assertGreaterEqual(calls[0][3], 45)

View File

@ -3,6 +3,7 @@ import unittest
from contextlib import redirect_stderr, redirect_stdout
from tests import _common as c
from tests import fake_eva
from tests.fake_eva import FakeEva
agent = c.agent
@ -86,6 +87,7 @@ class EvaClientTest(unittest.TestCase):
self.assertEqual(len(docs), 450)
self.assertEqual(len(self.eva.api_calls), 2)
self.assertIn("slice не соблюдается", out.getvalue())
self.assertIn("CmfDocument.list повторяет записи", out.getvalue())
def test_list_slice_ignored_same_page(self):
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
@ -184,6 +186,57 @@ class EvaClientTest(unittest.TestCase):
self.assertNotIn("eva-secret", t)
self.assertNotIn("eva-secret", repr(self.cli))
def test_list_versions_filter_and_fields(self):
self.eva.add_version("CmfDocument:1", "DOCH-1")
self.eva.add_version("CmfDocument:1", "DOCH-2", has_published=False)
self.eva.add_version("CmfDocument:2", "DOCH-9")
got = self.cli.list_versions("CmfDocument:1")
self.assertEqual([v["code"] for v in got], ["DOCH-1", "DOCH-2"])
self.assertEqual(got[0], {"code": "DOCH-1", "cmf_created_at": "2026-10-27T17:59:00.123456+03:00",
"has_published": True, "cmf_author": fake_eva.AUTHOR})
req = self.eva.api_calls[0]
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.list")
self.assertEqual(req["body"]["kwargs"]["filter"], [["parent_id", "==", "CmfDocument:1"]])
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "cmf_created_at", "has_published", "cmf_author"])
self.assertEqual(req["body"]["kwargs"]["slice"], [0, 200])
def test_list_versions_pages_200(self):
for i in range(450):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
self.assertEqual(len(self.cli.list_versions("CmfDocument:1")), 450)
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]])
def test_list_versions_slice_ignored(self):
orig = self.eva.rpc
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
for i in range(450):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
out = io.StringIO()
with redirect_stdout(out):
got = self.cli.list_versions("CmfDocument:1")
self.assertEqual(len(got), 450)
self.assertEqual(len(self.eva.api_calls), 2)
self.assertIn("CmfDocumentHistory.list повторяет записи", out.getvalue())
def test_get_version(self):
self.eva.add_version("CmfDocument:1", "DOCH-1", text="<p>v1</p>")
self.assertEqual(self.cli.get_version("DOCH-1"), {"code": "DOCH-1", "text": "<p>v1</p>"})
self.assertIsNone(self.cli.get_version("DOCH-404"))
req = self.eva.api_calls[0]
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.get")
self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOCH-1"]])
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "text"])
self.assertEqual(self.cli.calls, 2)
def test_rpc_error_on_method(self):
self.eva.add_doc(1, "DOC-1", project="P")
self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
self.assertIsNotNone(self.cli.get_doc("DOC-1", ["code"]))
with self.assertRaises(agent.EvaError) as cm:
self.cli.list_versions("1")
self.assertEqual(str(cm.exception), "CmfDocumentHistory.list: нет прав")
self.assertEqual(self.cli.list_versions("1"), []) # ошибка разовая
if __name__ == "__main__":
unittest.main()

View File

@ -201,7 +201,7 @@ class FolderIntegrationTest(unittest.TestCase):
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)
self.assertEqual(agent.AGENT_VERSION, 2)
def test_runner_through_folder(self):
cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"})

View File

@ -1,12 +1,30 @@
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
import fake_hub
from fake_hub import FakeHub
GOOD = {"page_code": "DOC-1", "code": "DOCH-1", "created_at": "2026-10-27T17:59:00+03:00",
"author": {"login": None, "name": None}, "html": "", "attachments": [], "links": []}
class FakeHubVersionsTest(unittest.TestCase):
def test_check_versions(self):
claimed = {"type": "wiki_sync", "status": "claimed"}
self.assertIsNone(fake_hub.check_versions(claimed, 1, [GOOD]))
self.assertEqual(fake_hub.check_versions(claimed, 1, [GOOD] * 51)[0], 422)
self.assertEqual(fake_hub.check_versions({"type": "wiki_sync", "status": "done"}, 1, [GOOD])[0], 409)
self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, created_at="2026-10-27T17:59:00")]),
(422, {"error": "bad_versions", "detail": "DOCH-1: created_at"}))
self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, author={"login": 1, "name": None})]),
(422, {"error": "bad_versions", "detail": "DOCH-1: author"}))
class SyncBase(unittest.TestCase):
def setUp(self):
@ -19,12 +37,19 @@ class SyncBase(unittest.TestCase):
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})
def sync(self, root="DOC-1", label="tz", **extra):
params = dict({"label": label, "root": root}, **extra)
cid = self.hub.enqueue("wiki_sync", params)
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}})
return self.ex.run({"id": cid, "type": "wiki_sync", "params": params})
def sent_versions(self):
return [v for body in self.hub.versions for v in body["versions"]]
def history_calls(self, method):
return [r for r in self.eva.api_calls if r["path"] == "/api/?m=" + method]
def sent(self):
return [p for body in self.hub.pages for p in body["pages"]]
@ -262,5 +287,226 @@ class WikiSyncRecoverTest(unittest.TestCase):
self.assertEqual(journal.unfinished_started(), [])
class WikiVersionsTest(SyncBase):
def tree2(self):
self.eva.add_doc("CmfDocument:1", "DOC-1", "Корень", project="P")
self.eva.add_doc("CmfDocument:2", "DOC-2", "Б", parent="DOC-1", project="P", orderno=1)
def test_versions_skipped_without_known(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-11")
out = self.sync()
self.assertEqual(out["status"], "done", out)
self.assertNotIn("versions", out["result"])
self.assertEqual(self.hub.versions, [])
self.assertFalse([r for r in self.eva.api_calls if "CmfDocumentHistory" in r["path"]])
def test_versions_first_load(self): # сценарий «Первая загрузка истории»
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", "DOCH-11", "2026-10-27T17:59:00.123456+03:00", "<p>v1</p>")
e.add_version("CmfDocument:1", "DOCH-12", "2026-10-27T18:02:00+03:00", "<p>v2</p>")
e.add_version("CmfDocument:1", "DOCH-13", "2026-10-27T18:05:00+03:00", "<p>v3</p>")
e.add_version("CmfDocument:1", "DOCH-19", "2026-10-27T18:10:00+03:00", "<p>черновик</p>", has_published=False)
e.add_version("CmfDocument:2", "DOCH-21", "2026-08-01T10:00:00+03:00", "<p>a</p>")
e.add_version("CmfDocument:2", "DOCH-22", "2026-08-02T10:00:00+03:00", "<p>b</p>")
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["versions"], 5)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-11", "DOCH-12", "DOCH-13", "DOCH-21", "DOCH-22"])
self.assertEqual(self.sent_versions()[0], {
"page_code": "DOC-1", "code": "DOCH-11", "created_at": "2026-10-27T17:59:00.123456+03:00",
"author": {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"},
"html": "<p>v1</p>", "attachments": [], "links": []})
self.assertEqual(self.hub.versions[0]["command_id"], self.cid)
self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages", "/agent/wiki/versions"])
self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.list")],
[[["parent_id", "==", "CmfDocument:1"]], [["parent_id", "==", "CmfDocument:2"]]])
self.assertEqual(len(self.history_calls("CmfDocumentHistory.get")), 5) # черновик не читается
self.assertEqual(out["result"]["eva_calls"], self.client.calls)
self.assertEqual(out["result"]["eva_calls"], len(self.eva.api_calls))
def test_versions_incremental(self): # сценарий «Догрузка новых версий»
self.tree2()
for code in ("DOCH-10", "DOCH-11", "DOCH-12"):
self.eva.add_version("CmfDocument:1", code)
out = self.sync(known_versions={"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-77": ["DOCH-1"]})
self.assertEqual(out["result"]["versions"], 1)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-12"])
self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.get")],
[[["code", "==", "DOCH-12"]]])
def test_versions_none_in_eva(self):
self.tree2()
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 0)
self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages"])
def test_versions_author_missing(self):
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", "DOCH-1", author=None)
e.add_version("CmfDocument:1", "DOCH-2", author="CmfPerson:6c2b")
e.add_version("CmfDocument:1", "DOCH-3", author={"id": "CmfPerson:1", "_acl_fields": {"login": "deny", "name": "deny"}, "_acl_obj": "deny"})
e.add_version("CmfDocument:1", "DOCH-4", author={"id": "CmfPerson:2", "login": "x@y.ru", "_acl_fields": {"name": "deny"}})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual([v["author"] for v in self.sent_versions()],
[{"login": None, "name": None}] * 3 + [{"login": "x@y.ru", "name": None}])
def test_versions_without_code_or_time_skipped(self):
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", None)
e.add_version("CmfDocument:1", "DOCH-31", created_at=None)
e.add_version("CmfDocument:1", "DOCH-32")
buf = io.StringIO()
with redirect_stdout(buf):
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 1)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-32"])
self.assertIn("версия без code у DOC-1 — пропущена", buf.getvalue())
self.assertIn("версия DOCH-31 у DOC-1 без cmf_created_at — пропущена", buf.getvalue())
def test_has_published_strict(self):
self.tree2()
for i, flag in enumerate(("true", 1, None, False)):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, has_published=flag)
out = self.sync(known_versions={})
self.assertEqual(out["result"]["versions"], 0)
self.assertEqual(self.history_calls("CmfDocumentHistory.get"), [])
def test_version_html_and_links(self):
self.tree2()
self.eva.add_version("CmfDocument:1", "DOCH-1", text='<a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема">')
self.eva.add_version("CmfDocument:1", "DOCH-2", text=None)
self.sync(known_versions={})
v1, v2 = self.sent_versions()
self.assertEqual(v1["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
self.assertEqual(v1["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png", "name": "Схема"}])
self.assertEqual((v2["html"], v2["attachments"], v2["links"]), ("", [], []))
def test_versions_without_author_logged(self): # I-1: потеря автора видна в логе
self.tree2(); e = self.eva
e.add_version("CmfDocument:1", "DOCH-1", author=None)
e.add_version("CmfDocument:1", "DOCH-2", author="CmfPerson:6c2b")
e.add_version("CmfDocument:1", "DOCH-3")
buf = io.StringIO()
with redirect_stdout(buf):
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertIn("2 из 3 версий без автора (cmf_author не раскрыт или закрыт ACL)", buf.getvalue())
def test_versions_all_with_author_not_logged(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
buf = io.StringIO()
with redirect_stdout(buf):
self.sync(known_versions={})
self.assertNotIn("без автора", buf.getvalue())
def test_versions_duplicate_code_sent_once(self): # M-1: повтор кода в списке Eva
self.tree2()
self.eva.add_version("CmfDocument:1", "DOCH-1", created_at="2026-10-27T10:00:00+03:00")
self.eva.add_version("CmfDocument:1", "DOCH-1", created_at="2026-10-27T10:00:01+03:00")
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["versions"], 1)
self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-1"])
def test_version_text_missing_fails(self): # M-3: Eva не отдала text — не сохранять пустую версию
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
self.client.get_version = lambda code: {"code": code}
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "версия DOCH-1 страницы DOC-1: Eva не отдала text (нет прав?)")
self.assertEqual(self.hub.versions, [])
def test_versions_batches_50(self):
self.tree2()
for i in range(120):
self.eva.add_version("CmfDocument:1", "DOCH-%03d" % i)
out = self.sync(known_versions={})
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [50, 50, 20])
self.assertEqual(out["result"]["versions"], 120)
def test_versions_batch_bytes(self):
self.tree2()
big = "<p>" + "x" * (1500 * 1024) + "</p>"
for i in (1, 2, 3):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, text=big)
self.sync(known_versions={})
sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.versions]
self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2, 1])
def test_versions_413(self):
for n, expect in ((1, "версия DOCH-11 больше 16 МБ"), (3, "пачка версий DOCH-11…DOCH-13 (3 шт.) больше 16 МБ")):
with self.subTest(n=n):
self.setUp(); self.tree2()
for i in range(n):
self.eva.add_version("CmfDocument:1", "DOCH-%d" % (11 + i))
self.hub.reject_versions = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertIn(expect, out["error"])
self.assertFalse(out.get("retryable"))
def test_versions_hub_409(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
self.hub.reject_versions = (409, {"error": "not_claimed", "detail": "команда 1 не выдана агенту как wiki_sync"})
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertIn("409", out["error"])
self.assertIn("не выдана агенту", out["error"])
def test_versions_eva_rpc_error(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "Eva: CmfDocumentHistory.list: нет прав")
self.assertEqual(len(self.hub.pages), 1) # страницы ушли, версии — нет
self.assertEqual(self.hub.versions, [])
def test_version_vanished(self):
self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-12")
self.client.get_version = lambda code: None
out = self.sync(known_versions={})
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "версия DOCH-12 страницы DOC-1 не найдена или нет прав")
def test_page_without_id(self):
self.eva.add_doc("CmfDocument:1", "DOC-1", "Корень", project="P")
self.eva.add_doc(None, "DOC-2", "Б", parent="DOC-1", project="P")
buf = io.StringIO()
with redirect_stdout(buf):
out = self.sync(known_versions={})
self.assertEqual(out["status"], "done", out)
self.assertEqual(len(self.history_calls("CmfDocumentHistory.list")), 1)
self.assertIn("у страницы DOC-2 нет id — версии не запрошены", buf.getvalue())
def test_known_versions_malformed(self):
for bad in (None, [], {"DOC-1": "DOCH-1"}, {"DOC-1": [1]}):
with self.subTest(bad=bad):
self.setUp(); self.tree2()
out = self.sync(known_versions=bad)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "wiki_sync: known_versions — не объект {код страницы: [коды версий]}")
self.assertEqual(self.eva.api_calls, [])
self.assertEqual(self.hub.pages, [])
def test_versions_hub_503_retried_by_runner(self):
self.tree2()
self.eva.add_version("CmfDocument:1", "DOCH-1"); self.eva.add_version("CmfDocument:2", "DOCH-2")
self.hub.fail_versions = 1
cid = self.hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1", "known_versions": {}})
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.path.join(tmp.name, "journal.jsonl")))
runner.sleep = lambda s: None # и пауза RETRY_MIN_WAIT перед повтором (wait_seconds)
runner.idle_limit = 1
runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "done", self.hub.results[cid])
self.assertEqual(self.hub.results[cid]["result"]["versions"], 2)
self.assertEqual(len(self.hub.pages), 2) # команда выполнена заново целиком
self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2])
if __name__ == "__main__":
unittest.main()