From 11e39b348d72a4acb4042d16a14731ff0881f9b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:41:52 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=20=D0=B2=20wiki=5Fsync=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-28-wiki-versions.md | 498 ++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-wiki-versions.md diff --git a/docs/superpowers/plans/2026-10-28-wiki-versions.md b/docs/superpowers/plans/2026-10-28-wiki-versions.md new file mode 100644 index 0000000..c162033 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-wiki-versions.md @@ -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", "==", ]]`, `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 `. + +## 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=`). + +--- + +### 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="

v1

") + self.assertEqual(self.cli.get_version("DOCH-1"), {"code": "DOCH-1", "text": "

v1

"}) + 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`-строки) или `": <поле>"` для `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": , "name": }` (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", "

v1

") + e.add_version("CmfDocument:1", "DOCH-12", "2026-10-27T18:02:00+03:00", "

v2

") + e.add_version("CmfDocument:1", "DOCH-13", "2026-10-27T18:05:00+03:00", "

v3

") + e.add_version("CmfDocument:1", "DOCH-19", "2026-10-27T18:10:00+03:00", "

черновик

", has_published=False) + e.add_version("CmfDocument:2", "DOCH-21", "2026-08-01T10:00:00+03:00", "

a

") + e.add_version("CmfDocument:2", "DOCH-22", "2026-08-02T10:00:00+03:00", "

b

") + 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": "

v1

", "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='xСхема') + 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 = "

" + "x" * (1500 * 1024) + "

" + 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` `

корень v1

`, `DOCH-12` `2026-10-27T11:00:00+03:00` `

корень

`, `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` `

корень v3

` и печатает `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)" +```