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

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

View File

@ -11,13 +11,13 @@
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — | | Настройки | `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` | | Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` |
| HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` | | 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` | | Ссылки | `_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` | | Дерево | `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` | | Папка | `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` | файловая система | | Журнал | `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` | | Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | | Цикл | `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` | | 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/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 (после merge hub#92).
- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой - Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой
живой синхронизации (M-5, design 42). живой синхронизации (M-5, design 42).
- Раскрытие `cmf_author` по имени поля у `CmfDocumentHistory.list` (проба 28.10.2026 проверяла его у `CmfDocument` и
через `fields: ["**"]`) — проверить при первой живой синхронизации v2; не раскроется — автор `{null, null}` (P-1).

View File

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

View File

@ -1,7 +1,8 @@
# eva-agent # eva-agent
Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц Агент модуля 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`. один файл `src/agent.py` и запускатель `src/run.py`.
Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается. Агент работает на 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 uv run --project ../sdlc-hub sdlc-hub agent publish --module wiki src/agent.py
Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента Агент подтянет её сам (`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 мин). Хаб берётся из Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из
основного checkout `sdlc-hub` workspace (переменная `HUB_SRC`), запускается на свободном порту с временным `SDLC_HUB_HOME`; основного checkout `sdlc-hub` workspace (переменная `HUB_SRC`), запускается на свободном порту с временным `SDLC_HUB_HOME`;
живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой
`attachment:<id>`, `wiki_fetch` вложения и его содержимое. `attachment:<id>`, `wiki_fetch` вложения и его содержимое; если тестовый хаб знает историю правок (`history_at` в
`GET /wiki/status`), — ещё первую загрузку версий и догрузку новой версии `DOCH-14`.

View File

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

View File

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

View File

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

View File

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

View File

@ -50,7 +50,8 @@ AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')"
# --- фейковая Eva # --- фейковая Eva
EVA_TOKEN="eva-e2e-secret" EVA_TOKEN="eva-e2e-secret"
/usr/bin/python3 "$HERE/scripts/fake_eva_serve.py" "$EVA_TOKEN" >"$TMP/eva.out" 2>"$TMP/eva.err" & /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 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")" EVA_PORT="$(sed -n 's/^PORT //p' "$TMP/eva.out")"
[ -n "$EVA_PORT" ] || fail "фейковая Eva не поднялась: $(cat "$TMP/eva.err")" [ -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)" GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)"
[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало" [ "$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" echo "e2e: ok"

View File

@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/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 — первым аргументом. Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом.
""" """
import os import os
import signal
import sys import sys
import threading 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(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_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_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: def main() -> int:
@ -29,6 +41,7 @@ def main() -> int:
if len(sys.argv) > 1: if len(sys.argv) > 1:
eva.token = sys.argv[1] eva.token = sys.argv[1]
build(eva) build(eva)
signal.signal(signal.SIGUSR1, lambda *_: publish_more(eva))
print("PORT %d" % eva.server.server_port, flush=True) print("PORT %d" % eva.server.server_port, flush=True)
threading.Event().wait() threading.Event().wait()
return 0 return 0

View File

@ -34,7 +34,7 @@ from html.parser import HTMLParser
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 1 AGENT_VERSION = 2
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) 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) return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
def list_docs(self, flt: list, fields: List[str]) -> List[dict]: 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 (ответ без новых записей), обход """Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
останавливается на собранном, а не крутится бесконечно (M-4).""" останавливается на собранном, а не крутится бесконечно (M-4)."""
out, offset = [], 0 # type: List[dict], int out, offset = [], 0 # type: List[dict], int
seen = set() # type: set seen = set() # type: set
while True: 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 [] slice=[offset, offset + self.PAGE_SIZE]) or []
if isinstance(chunk, dict): # обёрнутый результат if isinstance(chunk, dict): # обёрнутый результат
chunk = chunk.get("items") or chunk.get("result") or [] chunk = chunk.get("items") or chunk.get("result") or []
@ -346,7 +356,7 @@ class EvaClient(Http):
if len(chunk) < self.PAGE_SIZE: if len(chunk) < self.PAGE_SIZE:
return out return out
if not fresh: if not fresh:
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out)) log(" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out)))
return out return out
offset += self.PAGE_SIZE offset += self.PAGE_SIZE
@ -569,6 +579,10 @@ class HubClient(Http):
"""Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ).""" """Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ)."""
return self.call("POST", "/agent/wiki/pages", {"command_id": command_id, "pages": pages}) 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 команды # Журнал: только дозапись; started → finished → acked по id команды
@ -729,8 +743,11 @@ def _parse_at(value) -> float:
# Дерево страниц Eva # Дерево страниц Eva
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES) MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
MAX_VERSIONS = 50 # версий в пачке (предел хаба)
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"] 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]: 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) 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: class Executor:
def __init__(self, eva: EvaClient, hub: Optional[HubClient]): def __init__(self, eva: EvaClient, hub: Optional[HubClient]):
self.eva, self.hub = eva, hub self.eva, self.hub = eva, hub
@ -857,25 +916,29 @@ class Executor:
except (ConnectionError, HttpError) as e: except (ConnectionError, HttpError) as e:
raise HubError(e) from None raise HubError(e) from None
def _send_pages(self, command_id, batch: list) -> None: def _send_batch(self, send: Callable[[Any, list], dict], command_id, batch: list, one: str, many: str) -> None:
"""Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3).""" """Пачка в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом элемента или границами пачки (M-3)."""
try: try:
self.hub.wiki_pages(command_id, batch) send(command_id, batch)
except HttpError as e: except HttpError as e:
if e.code == 413: if e.code == 413:
what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else what = ("%s %s" % (one, batch[0]["code"]) if len(batch) == 1 else
"пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch))) "%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) e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers)
raise HubError(e) from None raise HubError(e) from None
except ConnectionError as e: except ConnectionError as e:
raise HubError(e) from None raise HubError(e) from None
def do_wiki_sync(self, p: dict, cmd: dict) -> dict: def do_wiki_sync(self, p: dict, cmd: dict) -> dict:
"""Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается, """Дерево страниц Eva от корня → хаб пачками; затем, если хаб прислал known_versions, — опубликованные версии
поэтому после перезапуска команда выполняется заново целиком (design 9).""" страниц дерева, которых хаб не знает (POST /agent/wiki/versions). Отчёт — коды в порядке обхода (корень первым) и
число версий. Eva только читается, поэтому после перезапуска команда выполняется заново целиком (design 9)."""
root_code = str(p.get("root") or "").strip() root_code = str(p.get("root") or "").strip()
if not root_code: if not root_code:
return self._out("failed", error="wiki_sync: не задан root") 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"]) root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"])
if not root: if not root:
return self._out("failed", error="страница %s не найдена или нет прав" % root_code) return self._out("failed", error="страница %s не найдена или нет прав" % root_code)
@ -883,8 +946,9 @@ class Executor:
root_code = root.get("code") or root_code root_code = root.get("code") or root_code
base = self.eva.base base = self.eva.base
codes = [] # type: List[str] codes = [] # type: List[str]
batch = [] # type: list tree = [] # type: List[Tuple[str, Optional[str]]]
size = 0 pages = _Batcher(lambda b: self._send_batch(self.hub.wiki_pages, cmd["id"], b, "страница", "пачка страниц"),
MAX_PAGES)
for doc, parent_code in walk(root, children): for doc, parent_code in walk(root, children):
code = doc["code"] code = doc["code"]
full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"]) 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")), "parent_code": parent_code, "orderno": _orderno(doc.get("orderno")),
"url": urllib.parse.urljoin(base + "/", "project/Document/" + code), "url": urllib.parse.urljoin(base + "/", "project/Document/" + code),
"html": html, "attachments": attachments, "links": links} "html": html, "attachments": attachments, "links": links}
psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2 pages.add(page)
if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES):
self._send_pages(cmd["id"], batch)
batch, size = [], 0
batch.append(page)
size += psize
codes.append(code) codes.append(code)
if batch: tree.append((code, page["eva_id"]))
self._send_pages(cmd["id"], batch) pages.flush()
title = (root.get("name") or "").strip() or root_code 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: def do_wiki_fetch(self, p: dict, cmd: dict) -> dict:
"""Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24); """Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24);

View File

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

View File

@ -6,6 +6,7 @@ import re
import socket import socket
import threading import threading
import time import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -59,6 +60,49 @@ def check_pages(command, command_id, pages):
return None 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: class FakeHub:
token = "agent-token" token = "agent-token"
@ -78,6 +122,10 @@ class FakeHub:
self.file_requests = 0 self.file_requests = 0
self.pages = [] # тела POST /agent/wiki/pages self.pages = [] # тела POST /agent/wiki/pages
self.reject_pages = None # (код, тело) — ответ на /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.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.uploaded = [] # содержимое, присланное агентом POST /agent/files self.uploaded = [] # содержимое, присланное агентом POST /agent/files
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа 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"}) return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings": if path == "/agent/settings":
return 200, self.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": if path == "/agent/wiki/pages" and method == "POST":
self.wiki_posts.append(path)
if self.reject_pages: if self.reject_pages:
return self.reject_pages return self.reject_pages
cid = body.get("command_id") cid = body.get("command_id")

View File

@ -195,6 +195,8 @@ class ClientsTest(unittest.TestCase):
hub.claim(1, 2, None) hub.claim(1, 2, None)
hub.wiki_pages(7, [{"code": "DOC-1"}]) hub.wiki_pages(7, [{"code": "DOC-1"}])
hub.upload_file(b"abc") 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], ("POST", "/agent/claim"))
self.assertEqual(calls[0][2]["last_call_at"], None) self.assertEqual(calls[0][2]["last_call_at"], None)
self.assertGreaterEqual(calls[0][3], 45) self.assertGreaterEqual(calls[0][3], 45)

View File

@ -3,6 +3,7 @@ import unittest
from contextlib import redirect_stderr, redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from tests import _common as c from tests import _common as c
from tests import fake_eva
from tests.fake_eva import FakeEva from tests.fake_eva import FakeEva
agent = c.agent agent = c.agent
@ -86,6 +87,7 @@ class EvaClientTest(unittest.TestCase):
self.assertEqual(len(docs), 450) self.assertEqual(len(docs), 450)
self.assertEqual(len(self.eva.api_calls), 2) self.assertEqual(len(self.eva.api_calls), 2)
self.assertIn("slice не соблюдается", out.getvalue()) self.assertIn("slice не соблюдается", out.getvalue())
self.assertIn("CmfDocument.list повторяет записи", out.getvalue())
def test_list_slice_ignored_same_page(self): def test_list_slice_ignored_same_page(self):
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен.""" """M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
@ -184,6 +186,57 @@ class EvaClientTest(unittest.TestCase):
self.assertNotIn("eva-secret", t) self.assertNotIn("eva-secret", t)
self.assertNotIn("eva-secret", repr(self.cli)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -201,7 +201,7 @@ class FolderIntegrationTest(unittest.TestCase):
return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token) return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 1) self.assertEqual(agent.AGENT_VERSION, 2)
def test_runner_through_folder(self): def test_runner_through_folder(self):
cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}) cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"})

View File

@ -1,12 +1,30 @@
import io
import json import json
import os import os
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stdout
from _common import SETTINGS, agent, eva_cfg, no_sleep from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva from fake_eva import FakeEva
import fake_hub
from fake_hub import FakeHub 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): class SyncBase(unittest.TestCase):
def setUp(self): def setUp(self):
@ -19,12 +37,19 @@ class SyncBase(unittest.TestCase):
self.client.sleep = no_sleep self.client.sleep = no_sleep
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg)) self.ex = agent.Executor(self.client, agent.HubClient(self.cfg))
def sync(self, root="DOC-1", label="tz"): def sync(self, root="DOC-1", label="tz", **extra):
cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root}) params = dict({"label": label, "root": root}, **extra)
cid = self.hub.enqueue("wiki_sync", params)
self.hub.queue.remove(cid) self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "claimed" self.hub.commands[cid]["status"] = "claimed"
self.cid = cid 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): def sent(self):
return [p for body in self.hub.pages for p in body["pages"]] 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(), []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()