eva-agent v2: версии страниц в wiki_sync (#7) #8
@ -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).
|
||||
|
||||
@ -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».
|
||||
|
||||
|
||||
11
README.md
11
README.md
@ -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`.
|
||||
|
||||
498
docs/superpowers/plans/2026-10-28-wiki-versions.md
Normal file
498
docs/superpowers/plans/2026-10-28-wiki-versions.md
Normal 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)"
|
||||
```
|
||||
2
openspec/changes/wiki-versions/.openspec.yaml
Normal file
2
openspec/changes/wiki-versions/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-28
|
||||
82
openspec/changes/wiki-versions/design.md
Normal file
82
openspec/changes/wiki-versions/design.md
Normal 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`
|
||||
синхронизация стоит до выдачи прав.
|
||||
23
openspec/changes/wiki-versions/proposal.md
Normal file
23
openspec/changes/wiki-versions/proposal.md
Normal 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 хаба.
|
||||
- Частичная перекачка дерева страниц: тексты страниц по-прежнему читаются все.
|
||||
- Новые настройки агента.
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
149
src/agent.py
149
src/agent.py
@ -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);
|
||||
|
||||
@ -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")
|
||||
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 == "CmfDocument.get":
|
||||
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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user