From 5c487d0ae96d2da48896d3c43a94e641af554932 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:31:32 +0400 Subject: [PATCH 1/8] =?UTF-8?q?OpenSpec:=20design-only=20change=20wiki-ver?= =?UTF-8?q?sions=20=E2=80=94=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=20=D0=B2=20wiki=5Fsy?= =?UTF-8?q?nc=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/wiki-versions/.openspec.yaml | 2 + openspec/changes/wiki-versions/design.md | 57 +++++++++++++++++++ openspec/changes/wiki-versions/proposal.md | 23 ++++++++ 3 files changed, 82 insertions(+) create mode 100644 openspec/changes/wiki-versions/.openspec.yaml create mode 100644 openspec/changes/wiki-versions/design.md create mode 100644 openspec/changes/wiki-versions/proposal.md diff --git a/openspec/changes/wiki-versions/.openspec.yaml b/openspec/changes/wiki-versions/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/wiki-versions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/wiki-versions/design.md b/openspec/changes/wiki-versions/design.md new file mode 100644 index 0000000..4a77fd1 --- /dev/null +++ b/openspec/changes/wiki-versions/design.md @@ -0,0 +1,57 @@ +## 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:"]]` работает; `parent.code` — ошибка + `-32001` (вложенный фильтр). +- Поля версии: `code` (`DOCH-…`), `cmf_created_at` (ISO со смещением `+03:00`), `cmf_author_id`, `text` (полный HTML, + до ~56 тыс. символов), `has_published`, `cur_workflow`. `cmf_author` по имени поля раскрывается в объект с `name` и + `login` (как у `CmfDocument`). +- Черновик (`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).** Цена ошибки: нет — прежний хаб маршрута версий + не знает. diff --git a/openspec/changes/wiki-versions/proposal.md b/openspec/changes/wiki-versions/proposal.md new file mode 100644 index 0000000..86d6eca --- /dev/null +++ b/openspec/changes/wiki-versions/proposal.md @@ -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 хаба. +- Частичная перекачка дерева страниц: тексты страниц по-прежнему читаются все. +- Новые настройки агента. -- 2.47.2 From 11e39b348d72a4acb4042d16a14731ff0881f9b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:41:52 +0400 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=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)" +``` -- 2.47.2 From 4bea83cdaedff82c9a9ef1f6d64a1769a6feb622 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:46:11 +0400 Subject: [PATCH 3/8] =?UTF-8?q?EvaClient:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D1=83=20=D0=B8=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?=20CmfDocumentHistory=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 16 ++++++++++-- tests/fake_eva.py | 34 ++++++++++++++++++++++---- tests/test_eva_client.py | 53 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/agent.py b/src/agent.py index 2b115c1..82b39ec 100644 --- a/src/agent.py +++ b/src/agent.py @@ -327,12 +327,22 @@ class EvaClient(Http): return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields) def list_docs(self, flt: list, fields: List[str]) -> List[dict]: + return self.list_all("CmfDocument", flt, fields) + + def list_versions(self, doc_id: str) -> List[dict]: + """Версии страницы (CmfDocumentHistory) по id документа; фильтр parent.code Eva не принимает (-32001).""" + return self.list_all("CmfDocumentHistory", [["parent_id", "==", doc_id]], VERSION_FIELDS) + + def get_version(self, code: str) -> Optional[dict]: + return self.call("CmfDocumentHistory.get", filter=[["code", "==", code]], fields=VERSION_TEXT_FIELDS) + + def list_all(self, cls: str, flt: list, fields: List[str]) -> List[dict]: """Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход останавливается на собранном, а не крутится бесконечно (M-4).""" out, offset = [], 0 # type: List[dict], int seen = set() # type: set while True: - chunk = self.call("CmfDocument.list", filter=flt, fields=fields, + chunk = self.call("%s.list" % cls, filter=flt, fields=fields, slice=[offset, offset + self.PAGE_SIZE]) or [] if isinstance(chunk, dict): # обёрнутый результат chunk = chunk.get("items") or chunk.get("result") or [] @@ -346,7 +356,7 @@ class EvaClient(Http): if len(chunk) < self.PAGE_SIZE: return out if not fresh: - log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out)) + log(" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out))) return out offset += self.PAGE_SIZE @@ -731,6 +741,8 @@ def _parse_at(value) -> float: MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES) MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"] +VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"] # список версий — без текста +VERSION_TEXT_FIELDS = ["code", "text"] def _ref_code(ref) -> Optional[str]: diff --git a/tests/fake_eva.py b/tests/fake_eva.py index ab2ee26..b84760c 100644 --- a/tests/fake_eva.py +++ b/tests/fake_eva.py @@ -1,4 +1,5 @@ -"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server.""" +"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list, CmfDocumentHistory.get/list) и /files/... на +http.server.""" import json import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -17,6 +18,10 @@ def _field(doc, path): return cur +# автор версии, как его отдаёт Eva в раскрытом поле cmf_author (проба 28.10.2026) +AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"} + + class FakeEva: token = "eva-secret" @@ -29,6 +34,8 @@ class FakeEva: self.requests = [] # {"method", "path", "body", "auth"} self.failures = [] # [status, retry_after] — по одному на ответ self.rpc_errors = [] + self.method_errors = {} # метод → текст разовой ошибки JSON-RPC + self.versions = [] self.lock = threading.Lock() eva = self @@ -87,6 +94,16 @@ class FakeEva: def rpc_error_next(self, text): self.rpc_errors.append(text) + def rpc_error_on(self, method: str, text: str): + """Разовая ошибка JSON-RPC на первом вызове метода method (очередь rpc_errors не трогает).""" + self.method_errors[method] = text + + def add_version(self, parent_id, code, created_at="2026-10-27T17:59:00.123456+03:00", text="", has_published=True, + author=AUTHOR): + self.versions.append({"id": "CmfDocumentHistory:" + str(code), "parent_id": str(parent_id), "code": code, + "cmf_created_at": created_at, "has_published": has_published, "cmf_author": author, + "text": text}) + @property def api_calls(self): return [r for r in self.requests if r["path"].startswith("/api/")] @@ -109,6 +126,9 @@ class FakeEva: try: if u.path == "/api/" and verb == "POST": body = json.loads(raw) + if rpc_error is None: + with self.lock: + rpc_error = self.method_errors.pop(body.get("method"), None) if rpc_error is not None: out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error} else: @@ -140,12 +160,16 @@ class FakeEva: h.wfile.write(body) def rpc(self, method, kw): - found = [d for d in self.docs if self._match(d, kw.get("filter") or [])] fields = kw.get("fields") - pick = lambda d: self._out(d, fields) # noqa: E731 - if method == "CmfDocument.get": + if method.startswith("CmfDocumentHistory."): + found = [v for v in self.versions if self._match(v, kw.get("filter") or [])] + pick = lambda v: {k: v.get(k) for k in fields} if fields else dict(v) # noqa: E731 + else: + found = [d for d in self.docs if self._match(d, kw.get("filter") or [])] + pick = lambda d: self._out(d, fields) # noqa: E731 + if method in ("CmfDocument.get", "CmfDocumentHistory.get"): return pick(found[0]) if found else None - if method == "CmfDocument.list": + if method in ("CmfDocument.list", "CmfDocumentHistory.list"): lo, hi = kw.get("slice") or [0, len(found)] return [pick(d) for d in found[lo:hi]] raise ValueError("метод %s не поддержан" % method) diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index c3a6002..5b99e6a 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -3,6 +3,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from tests import _common as c +from tests import fake_eva from tests.fake_eva import FakeEva agent = c.agent @@ -86,6 +87,7 @@ class EvaClientTest(unittest.TestCase): self.assertEqual(len(docs), 450) self.assertEqual(len(self.eva.api_calls), 2) self.assertIn("slice не соблюдается", out.getvalue()) + self.assertIn("CmfDocument.list повторяет записи", out.getvalue()) def test_list_slice_ignored_same_page(self): """M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен.""" @@ -184,6 +186,57 @@ class EvaClientTest(unittest.TestCase): self.assertNotIn("eva-secret", t) self.assertNotIn("eva-secret", repr(self.cli)) + def test_list_versions_filter_and_fields(self): + self.eva.add_version("CmfDocument:1", "DOCH-1") + self.eva.add_version("CmfDocument:1", "DOCH-2", has_published=False) + self.eva.add_version("CmfDocument:2", "DOCH-9") + got = self.cli.list_versions("CmfDocument:1") + self.assertEqual([v["code"] for v in got], ["DOCH-1", "DOCH-2"]) + self.assertEqual(got[0], {"code": "DOCH-1", "cmf_created_at": "2026-10-27T17:59:00.123456+03:00", + "has_published": True, "cmf_author": fake_eva.AUTHOR}) + req = self.eva.api_calls[0] + self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.list") + self.assertEqual(req["body"]["kwargs"]["filter"], [["parent_id", "==", "CmfDocument:1"]]) + self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "cmf_created_at", "has_published", "cmf_author"]) + self.assertEqual(req["body"]["kwargs"]["slice"], [0, 200]) + + def test_list_versions_pages_200(self): + for i in range(450): + self.eva.add_version("CmfDocument:1", "DOCH-%d" % i) + self.assertEqual(len(self.cli.list_versions("CmfDocument:1")), 450) + self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]]) + + def test_list_versions_slice_ignored(self): + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None)) + for i in range(450): + self.eva.add_version("CmfDocument:1", "DOCH-%d" % i) + out = io.StringIO() + with redirect_stdout(out): + got = self.cli.list_versions("CmfDocument:1") + self.assertEqual(len(got), 450) + self.assertEqual(len(self.eva.api_calls), 2) + self.assertIn("CmfDocumentHistory.list повторяет записи", out.getvalue()) + + def test_get_version(self): + self.eva.add_version("CmfDocument:1", "DOCH-1", text="

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"), []) # ошибка разовая + if __name__ == "__main__": unittest.main() -- 2.47.2 From 37ebe8be5ae5f3fd9a2f0c93840f5aa64cee55ef Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:50:03 +0400 Subject: [PATCH 4/8] =?UTF-8?q?HubClient.wiki=5Fversions,=20=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9,=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=87=D0=B5=D0=BA=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 52 ++++++++++++++++++++++++---------- tests/fake_hub.py | 62 +++++++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 2 ++ tests/test_wiki_sync.py | 16 +++++++++++ 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/src/agent.py b/src/agent.py index 82b39ec..3fe436b 100644 --- a/src/agent.py +++ b/src/agent.py @@ -579,6 +579,10 @@ class HubClient(Http): """Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ).""" return self.call("POST", "/agent/wiki/pages", {"command_id": command_id, "pages": pages}) + def wiki_versions(self, command_id, versions: list) -> dict: + """Пачка версий страниц wiki_sync в стадию хаба (до 50 версий, тело до 16 МБ).""" + return self.call("POST", "/agent/wiki/versions", {"command_id": command_id, "versions": versions}) + # --------------------------------------------------------------------------- # Журнал: только дозапись; started → finished → acked по id команды @@ -739,6 +743,7 @@ def _parse_at(value) -> float: # Дерево страниц Eva # --------------------------------------------------------------------------- MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES) +MAX_VERSIONS = 50 # версий в пачке (предел хаба) MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"] VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"] # список версий — без текста @@ -831,6 +836,29 @@ class HubError(Exception): self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) +class _Batcher: + """Пачки для стадии хаба: не больше max_items элементов и max_bytes JSON; send получает накопленный список.""" + + def __init__(self, send: Callable[[list], None], max_items: int, max_bytes: int = MAX_BATCH_BYTES): + self.send, self.max_items, self.max_bytes = send, max_items, max_bytes + self.batch = [] # type: list + self.size = 0 + self.sent = 0 + + def add(self, item: dict) -> None: + isize = len(json.dumps(item, ensure_ascii=False).encode("utf-8")) + 2 + if self.batch and (len(self.batch) >= self.max_items or self.size + isize > self.max_bytes): + self.flush() + self.batch.append(item) + self.size += isize + + def flush(self) -> None: + if self.batch: + self.send(self.batch) + self.sent += len(self.batch) + self.batch, self.size = [], 0 + + class Executor: def __init__(self, eva: EvaClient, hub: Optional[HubClient]): self.eva, self.hub = eva, hub @@ -869,14 +897,14 @@ class Executor: except (ConnectionError, HttpError) as e: raise HubError(e) from None - def _send_pages(self, command_id, batch: list) -> None: - """Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3).""" + def _send_batch(self, send: Callable[[Any, list], dict], command_id, batch: list, one: str, many: str) -> None: + """Пачка в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом элемента или границами пачки (M-3).""" try: - self.hub.wiki_pages(command_id, batch) + send(command_id, batch) except HttpError as e: if e.code == 413: - what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else - "пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch))) + what = ("%s %s" % (one, batch[0]["code"]) if len(batch) == 1 else + "%s %s…%s (%d шт.)" % (many, batch[0]["code"], batch[-1]["code"], len(batch))) e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers) raise HubError(e) from None except ConnectionError as e: @@ -895,8 +923,8 @@ class Executor: root_code = root.get("code") or root_code base = self.eva.base codes = [] # type: List[str] - batch = [] # type: list - 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): code = doc["code"] full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"]) @@ -910,15 +938,9 @@ class Executor: "parent_code": parent_code, "orderno": _orderno(doc.get("orderno")), "url": urllib.parse.urljoin(base + "/", "project/Document/" + code), "html": html, "attachments": attachments, "links": links} - psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2 - if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES): - self._send_pages(cmd["id"], batch) - batch, size = [], 0 - batch.append(page) - size += psize + pages.add(page) codes.append(code) - if batch: - self._send_pages(cmd["id"], batch) + pages.flush() title = (root.get("name") or "").strip() or root_code return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 5075480..af89107 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -6,6 +6,7 @@ import re import socket import threading import time +from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -59,6 +60,49 @@ def check_pages(command, command_id, pages): return None +def check_versions(command, command_id, versions): + """Форма пачки версий как в приёме версий хаба (change wiki-history): (код, тело ошибки) или None.""" + def bad(detail, status=422, error="bad_versions"): + return status, {"error": error, "detail": detail} + + if isinstance(command_id, bool) or not isinstance(command_id, int): + return bad("command_id — целое", error="bad_request") + if not isinstance(versions, list) or not versions or len(versions) > MAX_PAGES: + return bad("versions — список от 1 до %d версий" % MAX_PAGES) + if command is None: + return bad("command %s" % command_id, 404, "not_found") + if command["type"] != "wiki_sync" or command["status"] != "claimed": + return bad("команда %s не выдана агенту как wiki_sync" % command_id, 409, "not_claimed") + for i, v in enumerate(versions, 1): + code = v.get("code") if isinstance(v, dict) else None + if not isinstance(code, str) or not code.strip(): + return bad("версия %d: code" % i) + page = v.get("page_code") + if not isinstance(page, str) or not page.strip(): + return bad("%s: page_code" % code) + try: + ok = isinstance(v.get("created_at"), str) and datetime.fromisoformat(v["created_at"]).tzinfo is not None + except ValueError: + ok = False + if not ok: + return bad("%s: created_at" % code) + a = v.get("author") + if not isinstance(a, dict) or not all(a.get(k) is None or isinstance(a.get(k), str) for k in ("login", "name")): + return bad("%s: author" % code) + if not isinstance(v.get("html"), str): + return bad("%s: html" % code) + att, links = v.get("attachments"), v.get("links") + if not isinstance(att, list) or not all( + isinstance(x, dict) and isinstance(x.get("ref"), str) and isinstance(x.get("url"), str) + and isinstance(x.get("name") or "", str) for x in att): + return bad("%s: attachments" % code) + if not isinstance(links, list) or not all( + isinstance(x, dict) and isinstance(x.get("ref"), str) and isinstance(x.get("code"), str) + for x in links): + return bad("%s: links" % code) + return None + + class FakeHub: token = "agent-token" @@ -78,6 +122,10 @@ class FakeHub: self.file_requests = 0 self.pages = [] # тела POST /agent/wiki/pages self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой + self.versions = [] # тела принятых POST /agent/wiki/versions + self.reject_versions = None # (код, тело) — ответ на /agent/wiki/versions ошибкой + self.fail_versions = 0 # сколько ближайших /agent/wiki/versions ответить 503 + self.wiki_posts = [] # пути POST /agent/wiki/pages|versions по порядку (и отклонённых) self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб self.uploaded = [] # содержимое, присланное агентом POST /agent/files self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа @@ -173,7 +221,21 @@ class FakeHub: return (200, self.release) if self.release else (404, {"error": "no_release"}) if path == "/agent/settings": return 200, self.settings + if path == "/agent/wiki/versions" and method == "POST": + self.wiki_posts.append(path) + if self.fail_versions > 0: + self.fail_versions -= 1 + return 503, {"error": "busy"} + if self.reject_versions: + return self.reject_versions + cid = body.get("command_id") + bad = check_versions(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("versions")) + if bad: + return bad + self.versions.append(body) + return 200, {"accepted": len(body.get("versions") or [])} if path == "/agent/wiki/pages" and method == "POST": + self.wiki_posts.append(path) if self.reject_pages: return self.reject_pages cid = body.get("command_id") diff --git a/tests/test_clients.py b/tests/test_clients.py index 67dd2ad..c2ae212 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -195,6 +195,8 @@ class ClientsTest(unittest.TestCase): hub.claim(1, 2, None) hub.wiki_pages(7, [{"code": "DOC-1"}]) hub.upload_file(b"abc") + hub.wiki_versions(7, [{"code": "DOCH-1"}]) + self.assertEqual(calls[3], ("POST", "/agent/wiki/versions", {"command_id": 7, "versions": [{"code": "DOCH-1"}]}, None)) self.assertEqual(calls[0][:2], ("POST", "/agent/claim")) self.assertEqual(calls[0][2]["last_call_at"], None) self.assertGreaterEqual(calls[0][3], 45) diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index c051025..ab12bb8 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -5,8 +5,24 @@ import unittest from _common import SETTINGS, agent, eva_cfg, no_sleep from fake_eva import FakeEva +import fake_hub from fake_hub import FakeHub +GOOD = {"page_code": "DOC-1", "code": "DOCH-1", "created_at": "2026-10-27T17:59:00+03:00", + "author": {"login": None, "name": None}, "html": "", "attachments": [], "links": []} + + +class FakeHubVersionsTest(unittest.TestCase): + def test_check_versions(self): + claimed = {"type": "wiki_sync", "status": "claimed"} + self.assertIsNone(fake_hub.check_versions(claimed, 1, [GOOD])) + self.assertEqual(fake_hub.check_versions(claimed, 1, [GOOD] * 51)[0], 422) + self.assertEqual(fake_hub.check_versions({"type": "wiki_sync", "status": "done"}, 1, [GOOD])[0], 409) + self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, created_at="2026-10-27T17:59:00")]), + (422, {"error": "bad_versions", "detail": "DOCH-1: created_at"})) + self.assertEqual(fake_hub.check_versions(claimed, 1, [dict(GOOD, author={"login": 1, "name": None})]), + (422, {"error": "bad_versions", "detail": "DOCH-1: author"})) + class SyncBase(unittest.TestCase): def setUp(self): -- 2.47.2 From b98b963ae100c52d605b58695b9775f04a8ac29d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:55:06 +0400 Subject: [PATCH 5/8] =?UTF-8?q?wiki=5Fsync:=20=D0=BE=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=20=D1=85=D0=B0=D0=B1=D1=83,=20result.version?= =?UTF-8?q?s=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 72 +++++++++++++- tests/test_wiki_sync.py | 201 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/agent.py b/src/agent.py index 3fe436b..b1c2a68 100644 --- a/src/agent.py +++ b/src/agent.py @@ -836,6 +836,25 @@ class HubError(Exception): self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) +def known_versions_ok(value) -> bool: + """known_versions от хаба: {код страницы: [коды версий]}.""" + return isinstance(value, dict) and all(isinstance(v, list) and all(isinstance(x, str) for x in v) + for v in value.values()) + + +def version_author(ref) -> dict: + """Автор версии из раскрытого cmf_author: каждое поле — непустая строка или None (закрытое ACL поле Eva не + отдаёт); не объект (нет поля, строка-id) — оба None (P-1).""" + if not isinstance(ref, dict): + return {"login": None, "name": None} + pick = lambda k: ref.get(k) if isinstance(ref.get(k), str) and ref.get(k) else None # noqa: E731 + return {"login": pick("login"), "name": pick("name")} + + +class _VersionMissing(Exception): + """Версия из списка Eva не читается по коду (P-4).""" + + class _Batcher: """Пачки для стадии хаба: не больше max_items элементов и max_bytes JSON; send получает накопленный список.""" @@ -911,11 +930,15 @@ class Executor: raise HubError(e) from None def do_wiki_sync(self, p: dict, cmd: dict) -> dict: - """Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается, - поэтому после перезапуска команда выполняется заново целиком (design 9).""" + """Дерево страниц Eva от корня → хаб пачками; затем, если хаб прислал known_versions, — опубликованные версии + страниц дерева, которых хаб не знает (POST /agent/wiki/versions). Отчёт — коды в порядке обхода (корень первым) и + число версий. Eva только читается, поэтому после перезапуска команда выполняется заново целиком (design 9).""" root_code = str(p.get("root") or "").strip() if not root_code: return self._out("failed", error="wiki_sync: не задан root") + with_versions = "known_versions" in p # хаб без истории ключа не шлёт — шаг версий не выполняется + if with_versions and not known_versions_ok(p["known_versions"]): + return self._out("failed", error="wiki_sync: known_versions — не объект {код страницы: [коды версий]}") root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"]) if not root: return self._out("failed", error="страница %s не найдена или нет прав" % root_code) @@ -923,6 +946,7 @@ class Executor: root_code = root.get("code") or root_code base = self.eva.base codes = [] # type: List[str] + tree = [] # type: List[Tuple[str, Optional[str]]] pages = _Batcher(lambda b: self._send_batch(self.hub.wiki_pages, cmd["id"], b, "страница", "пачка страниц"), MAX_PAGES) for doc, parent_code in walk(root, children): @@ -940,9 +964,51 @@ class Executor: "html": html, "attachments": attachments, "links": links} pages.add(page) codes.append(code) + tree.append((code, page["eva_id"])) pages.flush() title = (root.get("name") or "").strip() or root_code - return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) + result = {"root_title": title, "codes": codes} + if with_versions: + try: + result["versions"] = self._sync_versions(cmd["id"], tree, p["known_versions"]) + except _VersionMissing as e: + return self._out("failed", error=str(e)) + result["eva_calls"] = self.eva.calls + return self._out("done", result) + + def _sync_versions(self, command_id: int, tree: List[Tuple[str, Optional[str]]], known: Dict[str, List[str]]) -> int: + """Опубликованные версии страниц дерева, которых нет в known, — хабу пачками; число отправленных (design 2–5).""" + base = self.eva.base + out = _Batcher(lambda b: self._send_batch(self.hub.wiki_versions, command_id, b, "версия", "пачка версий"), + MAX_VERSIONS) + 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)) + html = full.get("text") if isinstance(full.get("text"), str) else "" + attachments, links = classify_links(html, base) + out.add({"page_code": page_code, "code": vcode, "created_at": created, + "author": version_author(v.get("cmf_author")), "html": html, + "attachments": attachments, "links": links}) + out.flush() + return out.sent def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: """Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24); diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index ab12bb8..3caff51 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -1,7 +1,9 @@ +import io import json import os import tempfile import unittest +from contextlib import redirect_stdout from _common import SETTINGS, agent, eva_cfg, no_sleep from fake_eva import FakeEva @@ -35,12 +37,19 @@ class SyncBase(unittest.TestCase): self.client.sleep = no_sleep self.ex = agent.Executor(self.client, agent.HubClient(self.cfg)) - def sync(self, root="DOC-1", label="tz"): - cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root}) + def sync(self, root="DOC-1", label="tz", **extra): + params = dict({"label": label, "root": root}, **extra) + cid = self.hub.enqueue("wiki_sync", params) self.hub.queue.remove(cid) self.hub.commands[cid]["status"] = "claimed" self.cid = cid - return self.ex.run({"id": cid, "type": "wiki_sync", "params": {"label": label, "root": root}}) + return self.ex.run({"id": cid, "type": "wiki_sync", "params": params}) + + def sent_versions(self): + return [v for body in self.hub.versions for v in body["versions"]] + + def history_calls(self, method): + return [r for r in self.eva.api_calls if r["path"] == "/api/?m=" + method] def sent(self): return [p for body in self.hub.pages for p in body["pages"]] @@ -278,5 +287,191 @@ class WikiSyncRecoverTest(unittest.TestCase): self.assertEqual(journal.unfinished_started(), []) +class WikiVersionsTest(SyncBase): + def tree2(self): + self.eva.add_doc("CmfDocument:1", "DOC-1", "Корень", project="P") + self.eva.add_doc("CmfDocument:2", "DOC-2", "Б", parent="DOC-1", project="P", orderno=1) + + def test_versions_skipped_without_known(self): + self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-11") + out = self.sync() + self.assertEqual(out["status"], "done", out) + self.assertNotIn("versions", out["result"]) + self.assertEqual(self.hub.versions, []) + self.assertFalse([r for r in self.eva.api_calls if "CmfDocumentHistory" in r["path"]]) + + def test_versions_first_load(self): # сценарий «Первая загрузка истории» + self.tree2(); e = self.eva + e.add_version("CmfDocument:1", "DOCH-11", "2026-10-27T17:59:00.123456+03:00", "

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]) + + if __name__ == "__main__": unittest.main() -- 2.47.2 From c711a119b9356f4b017c7a536d3f2e1399935faa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:59:56 +0400 Subject: [PATCH 6/8] =?UTF-8?q?eva-agent=20v2:=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=20e2e,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F,=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 12 ++++++++---- CLAUDE.md | 7 ++++--- README.md | 9 ++++++--- openspec/changes/wiki-versions/design.md | 14 +++++++++++++ scripts/e2e-hub.sh | 25 +++++++++++++++++++++++- scripts/fake_eva_serve.py | 15 +++++++++++++- src/agent.py | 2 +- 7 files changed, 71 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4e4d45..235ad2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,13 +11,13 @@ | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — | | Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` | | HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` | -| Eva | `EvaClient`, `EvaError`, `ForeignHost`, `_quote_url_part` | клиент Eva (Bearer-токен): JSON-RPC 2.2 `call` (`POST /api/?m=<метод>`, `error` в ответе — `EvaError`), повторы 429/502/503/504 и сетевых сбоев (4 попытки, `Retry-After`), пауза `pacing` между вызовами, токен вырезается из текстов ошибок; `get_doc`, `list_docs` (страницы по 200, стоп, если Eva не соблюдает `slice`), `download` (только адрес своего хоста, иначе `ForeignHost`; путь кодируется перед запросом; предел 10 МБ); счётчики вызовов и 429, время последнего вызова | `Http` | +| Eva | `EvaClient`, `EvaError`, `ForeignHost`, `_quote_url_part` | клиент Eva (Bearer-токен): JSON-RPC 2.2 `call` (`POST /api/?m=<метод>`, `error` в ответе — `EvaError`), повторы 429/502/503/504 и сетевых сбоев (4 попытки, `Retry-After`), пауза `pacing` между вызовами, токен вырезается из текстов ошибок; `get_doc`, `list_all` (общий постраничный список по классу: страницы по 200, стоп, если Eva не соблюдает `slice`), `list_docs`, `list_versions` (`CmfDocumentHistory.list` по `parent_id`, без текста), `get_version` (`CmfDocumentHistory.get` по коду, с текстом), `download` (только адрес своего хоста, иначе `ForeignHost`; путь кодируется перед запросом; предел 10 МБ); счётчики вызовов и 429, время последнего вызова | `Http` | | Ссылки | `_LinkParser`, `_ParseError`, `classify_links` | `_LinkParser` (`html.parser`) собирает `img[src]` и `a[href]` в порядке появления, нестандартные `` пропускает; `classify_links` делит их на вложения `{ref, url, name}` (свой хост, `/files/…`, миниатюра → файл) и ссылки на страницы `{ref, code}` (`DOC-<цифры>`); чужой хост, `static/` и битые адреса пропускает | `html.parser`, `urllib.parse` | | Дерево | `build_children`, `walk`, `_ref_code`, `_ref_id`, `_orderno` | `build_children` — `{код родителя: [дети]}`: с проектом — один список документов проекта и связи `parent`/`tree_parent` (ссылка разрешается в код через карту id → код), без проекта — обход в ширину по `tree_parent.code`; соседи по `(orderno, name, code)`. `walk` — обход в глубину, корень первым, каждый код один раз (циклы, два родителя) | `EvaClient` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http` | `os`, `uuid` | -| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` | +| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`), `wiki_versions` (`POST /agent/wiki/versions`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `claimed` → `started` → `finished` → `acked`; восстановление; `prune` | файловая система | -| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (50 страниц или 4 МБ; 413 — ошибка с кодом страницы/границами пачки), отчёт `{root_title, codes, eva_calls}`. `do_wiki_fetch`: `download` → `POST /agent/files`, отчёт `{attachment_id, file, size}`; «чужой хост», «файл больше 10 МБ», «файл не найден в Eva». Ошибки: сбой хаба — `HubError` («хаб: …»), `EvaError` — «Eva: …», 5xx и сеть — повтор через `Runner` | `EvaClient`, `HubClient`, `classify_links`, `build_children`, `walk` | +| Исполнитель | `Executor`, `HubError`, `_Batcher`, `known_versions_ok`, `version_author`, `_VersionMissing` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (`_Batcher`: 50 страниц или 4 МБ; `_send_batch`: 413 — ошибка с кодом страницы/границами пачки); затем, только если в параметрах есть `known_versions`, — шаг версий `_sync_versions`: по странице дерева `list_versions`, отбор `has_published is True`, текст (`get_version`) только версий, которых нет в `known_versions` страницы, автор `{login, name}` (поле — строка или `null`), пачки по 50 версий или 4 МБ (413 — с кодом версии/границами пачки); отчёт `{root_title, codes, eva_calls[, versions]}`. `do_wiki_fetch`: `download` → `POST /agent/files`, отчёт `{attachment_id, file, size}`; «чужой хост», «файл больше 10 МБ», «файл не найден в Eva». Ошибки: сбой хаба — `HubError` («хаб: …»), `EvaError` — «Eva: …», 5xx и сеть — повтор через `Runner` | `EvaClient`, `HubClient`, `classify_links`, `build_children`, `walk` | | Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | @@ -40,9 +40,13 @@ ## Сквозной прогон `scripts/e2e-hub.sh`: настоящий хаб (`sdlc-hub serve` из основного checkout `sdlc-hub`, временный `SDLC_HUB_HOME`, свободный -порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются. +порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются. Блок истории +(первая загрузка версий, догрузка `DOCH-14` по SIGUSR1 фейковой Eva) выполняется, только если `GET /wiki/status` хаба +отдаёт `history_at` (хаб с change `wiki-history`); иначе печатается пропуск. ## Открытые вопросы - Живая проверка на виртуалке с настоящей Eva (после merge hub#92). - Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой живой синхронизации (M-5, design 42). +- Раскрытие `cmf_author` по имени поля у `CmfDocumentHistory.list` (проба 28.10.2026 проверяла его у `CmfDocument` и + через `fields: ["**"]`) — проверить при первой живой синхронизации v2; не раскроется — автор `{null, null}` (P-1). diff --git a/CLAUDE.md b/CLAUDE.md index 34fb648..c4dd264 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ Клиент модуля wiki хаба (sdlc-hub, `../sdlc-hub`) для виртуалки с доступом к Eva банка. Опрашивает хаб, по командам `wiki_sync` (выгрузить поддерево страниц с корня) и `wiki_fetch` (скачать вложение) читает Eva и отдаёт данные хабу; в Eva ничего не пишет. Главный сценарий: хаб ставит `wiki_sync` с кодом корня, агент обходит дерево страниц, шлёт -страницы пачками в стадию хаба и отчитывается списком кодов. +страницы пачками в стадию хаба, затем — опубликованные версии страниц, которых хаб не знает (если хаб прислал +`known_versions`; спека — `../sdlc-hub/openspec/changes/wiki-history/`), и отчитывается списком кодов и числом версий. Спека и дизайн: `../sdlc-hub/openspec/changes/wiki-module/` (hub#91, PR hub#92); решения агента — `openspec/changes/eva-agent-mvp/design.md`. Issue агента — aep/eva-agent#1. @@ -21,7 +22,7 @@ `Updater` (самообновление), `Runner` (claim → пауза → журнал → выполнение → отчёт), `acquire_lock`, CLI `run|check|selftest|prune`. Своё: `EvaClient` (клиент Eva), `Executor` (`do_wiki_sync`, `do_wiki_fetch`). - `src/run.py` — запускатель, дословная копия `../jira-agent/src/run.py` (решение 2); `agent.py` не импортирует. -- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, файл `upload_file`); `Runner` знает хаб, Eva и +- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, версий `wiki_versions`, файл `upload_file`); `Runner` знает хаб, Eva и журнал; журнал не знает об Eva. Имена полей протокола хаба `jira_calls`/`rate_limited` общие для всех агентов: в `jira_calls` идёт число вызовов Eva. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. Подробнее: `ARCHITECTURE.md`. @@ -49,7 +50,7 @@ ## Первая подсистема MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева, -`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../sdlc-hub +`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 2 (версии страниц в `wiki_sync`, change `wiki-versions`, aep/eva-agent#7). Выкатка: `uv run --project ../sdlc-hub sdlc-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira); порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki». diff --git a/README.md b/README.md index d563e98..e2edde2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # eva-agent Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц -Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, +Eva (`wiki_sync`, вместе с опубликованными версиями страниц для истории правок хаба) и скачивает вложения +(`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, один файл `src/agent.py` и запускатель `src/run.py`. Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается. @@ -72,7 +73,8 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist uv run --project ../sdlc-hub sdlc-hub agent publish --module wiki src/agent.py Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента -jira) поднимать после того, как `agent_status` покажет новую версию агента wiki. +jira) поднимать после того, как `agent_status` покажет новую версию агента wiki. Для v2 (версии страниц): после выкатки +хаба с change `wiki-history` и v2 поднять `wiki_min_agent_version` до 2 (Migration Plan хаба, п. 3). ## Разработка @@ -81,4 +83,5 @@ jira) поднимать после того, как `agent_status` покаже Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из основного checkout `sdlc-hub` workspace (переменная `HUB_SRC`), запускается на свободном порту с временным `SDLC_HUB_HOME`; живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой -`attachment:`, `wiki_fetch` вложения и его содержимое. +`attachment:`, `wiki_fetch` вложения и его содержимое; если тестовый хаб знает историю правок (`history_at` в +`GET /wiki/status`), — ещё первую загрузку версий и догрузку новой версии `DOCH-14`. diff --git a/openspec/changes/wiki-versions/design.md b/openspec/changes/wiki-versions/design.md index 4a77fd1..fbe0f0e 100644 --- a/openspec/changes/wiki-versions/design.md +++ b/openspec/changes/wiki-versions/design.md @@ -55,3 +55,17 @@ - 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 шаг версий проверяется только юнит-тестами. diff --git a/scripts/e2e-hub.sh b/scripts/e2e-hub.sh index bf17c92..7f2afa6 100755 --- a/scripts/e2e-hub.sh +++ b/scripts/e2e-hub.sh @@ -50,7 +50,8 @@ AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')" # --- фейковая Eva EVA_TOKEN="eva-e2e-secret" /usr/bin/python3 "$HERE/scripts/fake_eva_serve.py" "$EVA_TOKEN" >"$TMP/eva.out" 2>"$TMP/eva.err" & -PIDS+=($!) +EVA_PID=$! +PIDS+=($EVA_PID) for _ in $(seq 1 50); do grep -q '^PORT ' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done EVA_PORT="$(sed -n 's/^PORT //p' "$TMP/eva.out")" [ -n "$EVA_PORT" ] || fail "фейковая Eva не поднялась: $(cat "$TMP/eva.err")" @@ -107,4 +108,26 @@ EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); impo GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)" [ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало" +# --- история правок: только если тестовый хаб её знает (change wiki-history хаба; P-6) +nversions() { /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["versions"]))'; } +HAS_HISTORY="$(printf '%s' "$STATUS" | /usr/bin/python3 -c 'import json,sys; print("yes" if "history_at" in json.load(sys.stdin) else "no")')" +if [ "$HAS_HISTORY" = "no" ]; then + echo "e2e: хаб без истории правок — шаг версий не проверялся" +else + STATUS="$(api GET "/wiki/status?project=E2E")" + [ "$(printf '%s' "$STATUS" | jget history_at)" != "None" ] || fail "wiki/status: history_at пуст: $STATUS" + V1="$(api GET "/wiki/pages/DOC-1/versions?project=E2E")" + [ "$(printf '%s' "$V1" | nversions)" = "2" ] || fail "версии DOC-1: ожидалось 2: $V1" + V2="$(api GET "/wiki/pages/DOC-2/versions?project=E2E")" + [ "$(printf '%s' "$V2" | nversions)" = "1" ] || fail "версии DOC-2: ожидалась 1: $V2" + kill -USR1 "$EVA_PID" + for _ in $(seq 1 50); do grep -q '^VERSION DOCH-14' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done + grep -q '^VERSION DOCH-14' "$TMP/eva.out" || fail "фейковая Eva не опубликовала DOCH-14" + api POST /wiki/sync '{"project":"E2E"}' >/dev/null || fail "POST /wiki/sync" + agent run --once || fail "agent.py run --once (догрузка версий)" + V1="$(api GET "/wiki/pages/DOC-1/versions?project=E2E")" + [ "$(printf '%s' "$V1" | nversions)" = "3" ] || fail "версии DOC-1 после догрузки: ожидалось 3: $V1" + [ "$(printf '%s' "$V1" | jget versions.0.version)" = "DOCH-14" ] || fail "новейшая версия DOC-1 не DOCH-14: $V1" +fi + echo "e2e: ok" diff --git a/scripts/fake_eva_serve.py b/scripts/fake_eva_serve.py index c319169..1fa274e 100644 --- a/scripts/fake_eva_serve.py +++ b/scripts/fake_eva_serve.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл. +"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, версии DOC-1/DOC-2, картинка и файл; +SIGUSR1 — опубликовать DOCH-14. Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом. """ import os +import signal import sys import threading @@ -22,6 +24,16 @@ def build(eva: FakeEva) -> None: eva.add_doc(4, "DOC-4", "Подпроцесс", parent="DOC-3", project="E2E", text="

подпроцесс

") eva.add_doc(5, "DOC-5", "Приложение", parent="DOC-1", project="E2E", orderno=3, text="

приложение

") eva.add_file("/files/obj/e2e/scheme.png", PNG) + eva.add_version(1, "DOCH-11", "2026-10-27T10:00:00+03:00", "

корень v1

") + eva.add_version(1, "DOCH-12", "2026-10-27T11:00:00+03:00", "

корень

") + eva.add_version(1, "DOCH-13", "2026-10-27T12:00:00+03:00", "

черновик

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

корень v3

") + print("VERSION DOCH-14", flush=True) def main() -> int: @@ -29,6 +41,7 @@ def main() -> int: if len(sys.argv) > 1: eva.token = sys.argv[1] build(eva) + signal.signal(signal.SIGUSR1, lambda *_: publish_more(eva)) print("PORT %d" % eva.server.server_port, flush=True) threading.Event().wait() return 0 diff --git a/src/agent.py b/src/agent.py index b1c2a68..2bf994b 100644 --- a/src/agent.py +++ b/src/agent.py @@ -34,7 +34,7 @@ from html.parser import HTMLParser from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 1 +AGENT_VERSION = 2 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new HERE = os.path.dirname(os.path.abspath(__file__)) -- 2.47.2 From 15ff6529128fc804207872c7a9ab7a34b95ed199 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:02:53 +0400 Subject: [PATCH 7/8] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?:=20AGENT=5FVERSION=20=3D=202=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 32fe055..8ea81e4 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -201,7 +201,7 @@ class FolderIntegrationTest(unittest.TestCase): return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 1) + self.assertEqual(agent.AGENT_VERSION, 2) def test_runner_through_folder(self): cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}) -- 2.47.2 From 060a2c6a1f7d7dc3af0a3124d6378ca62176527d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:15:33 +0400 Subject: [PATCH 8/8] =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20=D0=B2=20=D0=BB=D0=BE=D0=B3,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20=D0=BA=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8,=20=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=20text=20=E2=80=94=20failed=20(#7,=20I-1,=20M-1,=20M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- openspec/changes/wiki-versions/design.md | 15 ++++++++-- src/agent.py | 13 +++++++-- tests/test_wiki_sync.py | 35 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e2edde2..b05525f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента jira) поднимать после того, как `agent_status` покажет новую версию агента wiki. Для v2 (версии страниц): после выкатки -хаба с change `wiki-history` и v2 поднять `wiki_min_agent_version` до 2 (Migration Plan хаба, п. 3). +хаба с change `wiki-history` и v2 поднять `wiki_min_agent_version` до 2 (Migration Plan хаба, п. 3). До этого — проверить +в логе агента первой синхронизации v2 строку «N из M версий без автора»: если она есть, `cmf_author` у версий Eva не +раскрывает, и авторы в истории будут пустыми (их не исправит повторная синхронизация). ## Разработка diff --git a/openspec/changes/wiki-versions/design.md b/openspec/changes/wiki-versions/design.md index fbe0f0e..77280cb 100644 --- a/openspec/changes/wiki-versions/design.md +++ b/openspec/changes/wiki-versions/design.md @@ -9,8 +9,8 @@ - `CmfDocumentHistory.list` с `filter=[["parent_id", "==", "CmfDocument:"]]` работает; `parent.code` — ошибка `-32001` (вложенный фильтр). - Поля версии: `code` (`DOCH-…`), `cmf_created_at` (ISO со смещением `+03:00`), `cmf_author_id`, `text` (полный HTML, - до ~56 тыс. символов), `has_published`, `cur_workflow`. `cmf_author` по имени поля раскрывается в объект с `name` и - `login` (как у `CmfDocument`). + до ~56 тыс. символов), `has_published`, `cur_workflow`. У `CmfDocument` поле `cmf_author` по имени раскрывается в объект с `name` и + `login`; у `CmfDocumentHistory` проба проверяла только `fields: ["**"]` — раскрытие по имени поля не проверено (I-1). - Черновик (`has_published = false`) Eva меняет на месте. ## Goals / Non-Goals @@ -69,3 +69,14 @@ сам. Цена ошибки: нет. - 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` + синхронизация стоит до выдачи прав. diff --git a/src/agent.py b/src/agent.py index 2bf994b..4c4bc37 100644 --- a/src/agent.py +++ b/src/agent.py @@ -981,6 +981,7 @@ class Executor: 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) @@ -1002,12 +1003,18 @@ class Executor: full = self.eva.get_version(vcode) if not full: raise _VersionMissing("версия %s страницы %s не найдена или нет прав" % (vcode, page_code)) - html = full.get("text") if isinstance(full.get("text"), str) else "" + 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) - out.add({"page_code": page_code, "code": vcode, "created_at": created, - "author": version_author(v.get("cmf_author")), "html": html, + 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: diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 3caff51..0c7263a 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -382,6 +382,41 @@ class WikiVersionsTest(SyncBase): 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): -- 2.47.2