План: версии страниц в wiki_sync (#7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 11:41:52 +04:00
parent 5c487d0ae9
commit 11e39b348d

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