eva-agent/docs/superpowers/plans/2026-10-28-wiki-versions.md
Andrey Epifancev 11e39b348d План: версии страниц в wiki_sync (#7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:41:52 +04:00

41 KiB
Raw Permalink Blame History

Версии страниц в wiki_sync — план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: команда wiki_sync после страниц шлёт хабу опубликованные версии страниц дерева, которых нет в known_versions, и отчитывается result.versions; без known_versions агент v2 ведёт себя как v1.

Architecture: всё в src/agent.py. EvaClient получает общий постраничный список по классу (list_all) и методы CmfDocumentHistory (list_versions, get_version); HubClient — маршрут wiki_versions; Executor.do_wiki_sync после отправки страниц выполняет шаг версий (_sync_versions), пачки страниц и версий режет общий _Batcher, отправку с разбором 413 делает общий _send_batch. Фейки Eva и хаба получают версии и маршрут; e2e проверяет историю, только если тестовый хаб её знает.

Tech Stack: Python 3.9.6 (/usr/bin/python3), только stdlib, unittest; bash для e2e.

Spec: решения агента — openspec/changes/wiki-versions/design.md (решения 1–9) и proposal.md этого worktree; контракт с хабом (единственный источник истины по протоколу) — /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/wiki-history/openspec/changes/wiki-history/specs/wiki/spec.md, требование «История правок страниц», и design.md рядом (раздел «Что известно об API Eva», решения 1–5, 10).

Global Constraints

  • Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ и сторонние импорты запрещены (tests/test_py39.py; X | Y в аннотациях ловит прогон на /usr/bin/python3).
  • Тесты из корня worktree: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . — зелёный весь набор после каждой задачи.
  • AGENT_VERSION = 2 — ровно одна строка AGENT_VERSION = <целое> в src/agent.py (её читает Updater.VERSION_RE).
  • Протокол: параметр known_versions — объект {<код страницы>: [<код версии>…]}; маршрут POST /agent/wiki/versions {command_id, versions}; версия — {page_code, code, created_at, author: {login, name}, html, attachments, links}; created_at — cmf_created_at Eva как есть (ISO-8601 со смещением); login, name — строка или null; в пачке ≤ 50 версий; отчёт done — {root_title, codes, eva_calls, versions}, versions — число присланных версий.
  • Eva: CmfDocumentHistory.list с filter=[["parent_id", "==", <id страницы из обхода>]], fields=["code", "cmf_created_at", "has_published", "cmf_author"], slice по PAGE_SIZE (200); CmfDocumentHistory.get с filter=[["code", "==", <код версии>]], fields=["code", "text"]. Черновики (has_published не True) не отправляются и их текст не запрашивается.
  • Пачка версий — ≤ 50 версий и ≤ 4 МБ JSON (MAX_BATCH_BYTES), как у страниц.
  • Eva только читается; токены — только в agent.json, в тексты ошибок не попадают.
  • В основной checkout eva-agent и sdlc-hub не писать; хаб для e2e — из основного checkout sdlc-hub (только запуск во временном SDLC_HUB_HOME).
  • Коммиты — по-русски, последней строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Тестовый и живой хаб без истории (основной checkout sdlc-hub до merge hub#140) — known_versions в параметрах нет; агент не зовёт CmfDocumentHistory и не шлёт /agent/wiki/versions (там 404): test_versions_skipped_without_known (Task 3) и e2e (Task 4).
  2. 5xx хаба на пачке версий — Runner повторяет команду целиком: страницы уходят второй раз, версии — заново все новые, отчёт один с числом последнего прогона: test_versions_hub_503_retried_by_runner (Task 3).
  3. Eva не соблюдает slice у CmfDocumentHistory.list — список не крутится бесконечно, в логе имя класса: test_list_versions_slice_ignored (Task 1).
  4. Автор закрыт ACL (поля нет, _acl_fields: {"login": "deny"}), не раскрыт (строка-id) или отсутствует — версия уходит с null в закрытых полях, команда не падает: test_versions_author_missing (Task 3).
  5. Битый known_versions (не объект, значение не список строк) — failed до первого вызова Eva, а не полная перекачка истории: test_known_versions_malformed (Task 3).

Решения плана (дописать в design.md, Task 4)

  • P-1. Автор — по полям. login/name берутся из cmf_author, каждое — строка или null отдельно: закрытое ACL поле в ответе Eva отсутствует (проба 28.10: _acl_fields со значением deny), открытое приходит строкой. cmf_author не объект (нет, строка-id) — {null, null}. Уточняет решение 4 («поля закрыты — {null, null}»).
  • P-2. Страница обхода без id (Eva не отдала) — версии этой страницы не запрашиваются, строка в лог; команда продолжается.
  • P-3. Битый known_versions — failed «wiki_sync: known_versions — не объект {код страницы: [коды версий]}» до вызовов Eva. Пустой {} — первый прогон (решение 6).
  • P-4. Версия исчезла между list и get — failed «версия <код> страницы <код> не найдена или нет прав», как пропавшая страница.
  • P-5. Порядок версий в отчёте — порядок обхода страниц, внутри страницы — порядок ответа Eva; хаб сортирует сам (по времени, затем по коду).
  • P-6. e2e с историей — по признаку хаба. Блок истории в scripts/e2e-hub.sh выполняется, только если GET /wiki/status отдаёт ключ history_at; иначе печатается пропуск. Блок проверяется только с хабом, где реализован change wiki-history (после merge hub#140 или HUB_SRC=<worktree хаба>).

Task 1: Клиент Eva — общий список по классу и версии страниц

Model: haiku

Files:

  • Modify: src/agent.py (класс EvaClient, ~строки 255–351; константы рядом с TREE_FIELDS, ~строка 733)
  • Modify: tests/fake_eva.py
  • Test: tests/test_eva_client.py

Interfaces:

  • Produces:

    • EvaClient.list_all(self, cls: str, flt: list, fields: List[str]) -> List[dict] — нынешнее тело list_docs с методом "%s.list" % cls; лог несоблюдения slice: " Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out)).
    • EvaClient.list_docs(self, flt, fields) -> List[dict] — list_all("CmfDocument", flt, fields) (поведение прежнее).
    • VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"], VERSION_TEXT_FIELDS = ["code", "text"] — модульные константы.
    • EvaClient.list_versions(self, doc_id: str) -> List[dict] — list_all("CmfDocumentHistory", [["parent_id", "==", doc_id]], VERSION_FIELDS).
    • EvaClient.get_version(self, code: str) -> Optional[dict] — call("CmfDocumentHistory.get", filter=[["code", "==", code]], fields=VERSION_TEXT_FIELDS).
    • Фейк: FakeEva.versions: list; FakeEva.add_version(self, parent_id, code, created_at="2026-10-27T17:59:00.123456+03:00", text="", has_published=True, author=AUTHOR) хранит {"id": "CmfDocumentHistory:" + str(code), "parent_id": str(parent_id), "code", "cmf_created_at", "has_published", "cmf_author": author, "text"} (author — как есть, сырое значение поля Eva); модульная AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}; rpc отвечает на CmfDocumentHistory.list (фильтр, slice, поля — {k: v.get(k) for k in fields}) и CmfDocumentHistory.get по self.versions; FakeEva.rpc_error_on(self, method: str, text: str) — разовая ошибка JSON-RPC на первом вызове этого метода (не трогает rpc_errors).
  • Step 1: Написать падающие тесты в tests/test_eva_client.py

def test_list_versions_filter_and_fields(self):
    self.eva.add_version("CmfDocument:1", "DOCH-1")
    self.eva.add_version("CmfDocument:1", "DOCH-2", has_published=False)
    self.eva.add_version("CmfDocument:2", "DOCH-9")
    got = self.cli.list_versions("CmfDocument:1")
    self.assertEqual([v["code"] for v in got], ["DOCH-1", "DOCH-2"])
    self.assertEqual(got[0], {"code": "DOCH-1", "cmf_created_at": "2026-10-27T17:59:00.123456+03:00",
                              "has_published": True, "cmf_author": fake_eva.AUTHOR})
    req = self.eva.api_calls[0]
    self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.list")
    self.assertEqual(req["body"]["kwargs"]["filter"], [["parent_id", "==", "CmfDocument:1"]])
    self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "cmf_created_at", "has_published", "cmf_author"])
    self.assertEqual(req["body"]["kwargs"]["slice"], [0, 200])

def test_list_versions_pages_200(self):
    for i in range(450):
        self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
    self.assertEqual(len(self.cli.list_versions("CmfDocument:1")), 450)
    self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]])

def test_list_versions_slice_ignored(self):
    orig = self.eva.rpc
    self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
    for i in range(450):
        self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
    out = io.StringIO()
    with redirect_stdout(out):
        got = self.cli.list_versions("CmfDocument:1")
    self.assertEqual(len(got), 450)
    self.assertEqual(len(self.eva.api_calls), 2)
    self.assertIn("CmfDocumentHistory.list повторяет записи", out.getvalue())

def test_get_version(self):
    self.eva.add_version("CmfDocument:1", "DOCH-1", text="<p>v1</p>")
    self.assertEqual(self.cli.get_version("DOCH-1"), {"code": "DOCH-1", "text": "<p>v1</p>"})
    self.assertIsNone(self.cli.get_version("DOCH-404"))
    req = self.eva.api_calls[0]
    self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.get")
    self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOCH-1"]])
    self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "text"])
    self.assertEqual(self.cli.calls, 2)

def test_rpc_error_on_method(self):
    self.eva.add_doc(1, "DOC-1", project="P")
    self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
    self.assertIsNotNone(self.cli.get_doc("DOC-1", ["code"]))
    with self.assertRaises(agent.EvaError) as cm:
        self.cli.list_versions("1")
    self.assertEqual(str(cm.exception), "CmfDocumentHistory.list: нет прав")
    self.assertEqual(self.cli.list_versions("1"), [])     # ошибка разовая

В test_list_slice_ignored_returns_all добавить self.assertIn("CmfDocument.list повторяет записи", out.getvalue()). Импорт: from tests import fake_eva рядом с from tests.fake_eva import FakeEva.

  • Step 2: Запустить — падают

Run: /usr/bin/python3 -m unittest tests.test_eva_client -v Expected: FAIL/ERROR — AttributeError: 'FakeEva' object has no attribute 'add_version' / 'EvaClient' object has no attribute 'list_versions'.

  • Step 3: Фейк — add_version, AUTHOR, rpc_error_on, ветки CmfDocumentHistory.* в FakeEva.rpc (tests/fake_eva.py)

rpc выбирает источник по классу метода (CmfDocument.* — self.docs через _out, CmfDocumentHistory.* — self.versions без преобразования ссылок); фильтр — тот же _match. Разовая ошибка по методу проверяется в handle под self.lock по body["method"]. Докстринг модуля — упомянуть CmfDocumentHistory.get/list.

  • Step 4: list_all, list_docs через него, list_versions, get_version, VERSION_FIELDS, VERSION_TEXT_FIELDS в src/agent.py

  • Step 5: Весь набор — зелёный

Run: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . Expected: OK.

  • Step 6: Commit
git add src/agent.py tests/fake_eva.py tests/test_eva_client.py
git commit -m "EvaClient: общий список по классу и версии страниц CmfDocumentHistory (#7)"

Task 2: Маршрут версий хаба, фейк хаба и общая отправка пачек

Model: haiku

Files:

  • Modify: src/agent.py (HubClient ~строки 533–573; раздел «Дерево» — константы; Executor._send_pages ~строки 860–871 и цикл пачек в do_wiki_sync ~873–911)
  • Modify: tests/fake_hub.py
  • Test: tests/test_clients.py, tests/test_wiki_sync.py

Interfaces:

  • Consumes: ничего из Task 1.

  • Produces:

    • HubClient.wiki_versions(self, command_id, versions: list) -> dict — POST /agent/wiki/versions {"command_id": command_id, "versions": versions}.
    • MAX_VERSIONS = 50 — модульная константа (рядом с MAX_PAGES).
    • class _Batcher (модуль, раздел «Исполнитель»): __init__(self, send: Callable[[list], None], max_items: int, max_bytes: int = MAX_BATCH_BYTES); add(self, item: dict) -> None — размер элемента len(json.dumps(item, ensure_ascii=False).encode("utf-8")) + 2, перед добавлением отправляет накопленное, если пачка непуста и len >= max_items или size + isize > max_bytes; flush(self) -> None — отправляет непустой остаток; sent: int — число отправленных элементов. Логика — та, что сейчас в цикле do_wiki_sync.
    • Executor._send_batch(self, send: Callable[[int, list], dict], command_id, batch: list, one: str, many: str) -> None — заменяет _send_pages; 413 → HubError(HttpError(413, "<что> больше 16 МБ — хаб не принял: <текст[:300]>")), где <что> = "%s %s" % (one, batch[0]["code"]) для одного элемента, иначе "%s %s…%s (%d шт.)" % (many, batch[0]["code"], batch[-1]["code"], len(batch)); прочие HttpError/ConnectionError → HubError. Для страниц: one="страница", many="пачка страниц"; для версий (Task 3): one="версия", many="пачка версий".
    • Фейк хаба: check_versions(command, command_id, versions) — как check_pages: command_id не целое — 422 bad_request; не список 1..50 — 422 bad_versions «versions — список от 1 до 50 версий»; нет команды — 404; не wiki_sync или не claimed — 409 not_claimed; по версии — 422 bad_versions с detail "версия %d: code" (нет code-строки) или "<code>: <поле>" для page_code (непустая строка), created_at (строка, datetime.fromisoformat с tzinfo), author (объект, login/name — строка или None), html (строка), attachments, links (как у страниц). FakeHub.versions: list — тела принятых пачек; FakeHub.reject_versions = None — (код, тело) на каждый запрос; FakeHub.fail_versions = 0 — сколько ближайших запросов ответить 503 {"error": "busy"}; FakeHub.wiki_posts: list — пути принятых и отклонённых POST /agent/wiki/pages|versions по порядку.
  • Step 1: Написать падающие тесты

В tests/test_clients.py, в test_hub_client_claim_and_wiki_pages_routes после upload_file:

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, новый класс:

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
git add src/agent.py tests/fake_hub.py tests/test_clients.py tests/test_wiki_sync.py
git commit -m "HubClient.wiki_versions, фейк маршрута версий, общая отправка пачек (#7)"

Task 3: Шаг версий в do_wiki_sync

Model: sonnet

Files:

  • Modify: src/agent.py (раздел «Исполнитель»: Executor.do_wiki_sync, новые _sync_versions, version_author, known_versions_ok)
  • Test: tests/test_wiki_sync.py

Interfaces:

  • Consumes: Task 1 — EvaClient.list_versions(doc_id: str) -> List[dict], EvaClient.get_version(code: str) -> Optional[dict], FakeEva.add_version(...), fake_eva.AUTHOR, FakeEva.rpc_error_on(method, text); Task 2 — HubClient.wiki_versions, MAX_VERSIONS, _Batcher, Executor._send_batch, FakeHub.versions|reject_versions|fail_versions|wiki_posts.
  • Produces:
    • known_versions_ok(value) -> bool — True, если value — dict, у которого каждое значение — list строк.
    • version_author(ref) -> dict — {"login": <str|None>, "name": <str|None>} (P-1): ref не dict — оба None; иначе каждое поле — значение, если это непустая строка, иначе None.
    • Executor._sync_versions(self, command_id: int, tree: List[Tuple[str, Optional[str]]], known: Dict[str, List[str]]) -> int — tree — (код страницы, id страницы или None) в порядке обхода; возвращает число отправленных версий (_Batcher.sent). По странице: id is None — лог " у страницы %s нет id — версии не запрошены" % code, дальше; иначе list_versions(id); по версии: has_published is not True — пропуск молча; code не непустая строка — лог " версия без code у %s — пропущена" % page_code; cmf_created_at не непустая строка — лог " версия %s у %s без cmf_created_at — пропущена" % (vcode, page_code); код в known.get(page_code, []) — пропуск без get; иначе get_version(vcode); ответ None — raise _VersionMissing("версия %s страницы %s не найдена или нет прав" % (vcode, page_code)) (P-4).
    • class _VersionMissing(Exception) — раздел «Исполнитель»; do_wiki_sync ловит его вокруг _sync_versions и отвечает self._out("failed", error=str(e)). html = text, если строка, иначе ""; attachments, links = classify_links(html, self.eva.base). Пачки — _Batcher(lambda b: self._send_batch(self.hub.wiki_versions, command_id, b, "версия", "пачка версий"), MAX_VERSIONS).
    • do_wiki_sync: в начале, если "known_versions" in p и not known_versions_ok(p["known_versions"]) — failed "wiki_sync: known_versions — не объект {код страницы: [коды версий]}" до вызовов Eva (P-3); в цикле страниц копит tree ((code, str(doc["id"]) if doc.get("id") is not None else None)); после flush страниц — _sync_versions, только если ключ known_versions есть (решение 6); результат {"root_title", "codes", "eva_calls", "versions"} (ключ versions — только при шаге версий); eva_calls = self.eva.calls считается после шага версий (решение 8).

Общий помощник теста (в SyncBase): sync(self, root="DOC-1", label="tz", **extra) — params = dict({"label": label, "root": root}, **extra) и для enqueue, и для run; sent_versions(self) — [v for body in self.hub.versions for v in body["versions"]]; history_calls(self, method) — [r for r in self.eva.api_calls if r["path"] == "/api/?m=" + method]. Метод tree2(self) в WikiVersionsTest строит дерево по умолчанию: add_doc("CmfDocument:1", "DOC-1", "Корень", project="P"), add_doc("CmfDocument:2", "DOC-2", "Б", parent="DOC-1", project="P", orderno=1).

  • Step 1: Написать падающие тесты (tests/test_wiki_sync.py, класс WikiVersionsTest(SyncBase))
def test_versions_skipped_without_known(self):
    self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-11")
    out = self.sync()
    self.assertEqual(out["status"], "done", out)
    self.assertNotIn("versions", out["result"])
    self.assertEqual(self.hub.versions, [])
    self.assertFalse([r for r in self.eva.api_calls if "CmfDocumentHistory" in r["path"]])

def test_versions_first_load(self):          # сценарий «Первая загрузка истории»
    self.tree2(); e = self.eva
    e.add_version("CmfDocument:1", "DOCH-11", "2026-10-27T17:59:00.123456+03:00", "<p>v1</p>")
    e.add_version("CmfDocument:1", "DOCH-12", "2026-10-27T18:02:00+03:00", "<p>v2</p>")
    e.add_version("CmfDocument:1", "DOCH-13", "2026-10-27T18:05:00+03:00", "<p>v3</p>")
    e.add_version("CmfDocument:1", "DOCH-19", "2026-10-27T18:10:00+03:00", "<p>черновик</p>", has_published=False)
    e.add_version("CmfDocument:2", "DOCH-21", "2026-08-01T10:00:00+03:00", "<p>a</p>")
    e.add_version("CmfDocument:2", "DOCH-22", "2026-08-02T10:00:00+03:00", "<p>b</p>")
    out = self.sync(known_versions={})
    self.assertEqual(out["status"], "done", out)
    self.assertEqual(out["result"]["versions"], 5)
    self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-11", "DOCH-12", "DOCH-13", "DOCH-21", "DOCH-22"])
    self.assertEqual(self.sent_versions()[0], {
        "page_code": "DOC-1", "code": "DOCH-11", "created_at": "2026-10-27T17:59:00.123456+03:00",
        "author": {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"},
        "html": "<p>v1</p>", "attachments": [], "links": []})
    self.assertEqual(self.hub.versions[0]["command_id"], self.cid)
    self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages", "/agent/wiki/versions"])
    self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.list")],
                     [[["parent_id", "==", "CmfDocument:1"]], [["parent_id", "==", "CmfDocument:2"]]])
    self.assertEqual(len(self.history_calls("CmfDocumentHistory.get")), 5)       # черновик не читается
    self.assertEqual(out["result"]["eva_calls"], self.client.calls)
    self.assertEqual(out["result"]["eva_calls"], len(self.eva.api_calls))

def test_versions_incremental(self):          # сценарий «Догрузка новых версий»
    self.tree2()
    for code in ("DOCH-10", "DOCH-11", "DOCH-12"):
        self.eva.add_version("CmfDocument:1", code)
    out = self.sync(known_versions={"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-77": ["DOCH-1"]})
    self.assertEqual(out["result"]["versions"], 1)
    self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-12"])
    self.assertEqual([r["body"]["kwargs"]["filter"] for r in self.history_calls("CmfDocumentHistory.get")],
                     [[["code", "==", "DOCH-12"]]])

def test_versions_none_in_eva(self):
    self.tree2()
    out = self.sync(known_versions={})
    self.assertEqual(out["result"]["versions"], 0)
    self.assertEqual(self.hub.wiki_posts, ["/agent/wiki/pages"])

def test_versions_author_missing(self):
    self.tree2(); e = self.eva
    e.add_version("CmfDocument:1", "DOCH-1", author=None)
    e.add_version("CmfDocument:1", "DOCH-2", author="CmfPerson:6c2b")
    e.add_version("CmfDocument:1", "DOCH-3", author={"id": "CmfPerson:1", "_acl_fields": {"login": "deny", "name": "deny"}, "_acl_obj": "deny"})
    e.add_version("CmfDocument:1", "DOCH-4", author={"id": "CmfPerson:2", "login": "x@y.ru", "_acl_fields": {"name": "deny"}})
    out = self.sync(known_versions={})
    self.assertEqual(out["status"], "done", out)
    self.assertEqual([v["author"] for v in self.sent_versions()],
                     [{"login": None, "name": None}] * 3 + [{"login": "x@y.ru", "name": None}])

def test_versions_without_code_or_time_skipped(self):
    self.tree2(); e = self.eva
    e.add_version("CmfDocument:1", None)
    e.add_version("CmfDocument:1", "DOCH-31", created_at=None)
    e.add_version("CmfDocument:1", "DOCH-32")
    buf = io.StringIO()
    with redirect_stdout(buf):
        out = self.sync(known_versions={})
    self.assertEqual(out["result"]["versions"], 1)
    self.assertEqual([v["code"] for v in self.sent_versions()], ["DOCH-32"])
    self.assertIn("версия без code у DOC-1 — пропущена", buf.getvalue())
    self.assertIn("версия DOCH-31 у DOC-1 без cmf_created_at — пропущена", buf.getvalue())

def test_has_published_strict(self):
    self.tree2()
    for i, flag in enumerate(("true", 1, None, False)):
        self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, has_published=flag)
    out = self.sync(known_versions={})
    self.assertEqual(out["result"]["versions"], 0)
    self.assertEqual(self.history_calls("CmfDocumentHistory.get"), [])

def test_version_html_and_links(self):
    self.tree2()
    self.eva.add_version("CmfDocument:1", "DOCH-1", text='<a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема">')
    self.eva.add_version("CmfDocument:1", "DOCH-2", text=None)
    self.sync(known_versions={})
    v1, v2 = self.sent_versions()
    self.assertEqual(v1["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
    self.assertEqual(v1["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png", "name": "Схема"}])
    self.assertEqual((v2["html"], v2["attachments"], v2["links"]), ("", [], []))

def test_versions_batches_50(self):
    self.tree2()
    for i in range(120):
        self.eva.add_version("CmfDocument:1", "DOCH-%03d" % i)
    out = self.sync(known_versions={})
    self.assertEqual([len(b["versions"]) for b in self.hub.versions], [50, 50, 20])
    self.assertEqual(out["result"]["versions"], 120)

def test_versions_batch_bytes(self):
    self.tree2()
    big = "<p>" + "x" * (1500 * 1024) + "</p>"
    for i in (1, 2, 3):
        self.eva.add_version("CmfDocument:1", "DOCH-%d" % i, text=big)
    self.sync(known_versions={})
    sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.versions]
    self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
    self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2, 1])

def test_versions_413(self):
    for n, expect in ((1, "версия DOCH-11 больше 16 МБ"), (3, "пачка версий DOCH-11…DOCH-13 (3 шт.) больше 16 МБ")):
        with self.subTest(n=n):
            self.setUp(); self.tree2()
            for i in range(n):
                self.eva.add_version("CmfDocument:1", "DOCH-%d" % (11 + i))
            self.hub.reject_versions = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
            out = self.sync(known_versions={})
            self.assertEqual(out["status"], "failed")
            self.assertIn(expect, out["error"])
            self.assertFalse(out.get("retryable"))

def test_versions_hub_409(self):
    self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
    self.hub.reject_versions = (409, {"error": "not_claimed", "detail": "команда 1 не выдана агенту как wiki_sync"})
    out = self.sync(known_versions={})
    self.assertEqual(out["status"], "failed")
    self.assertIn("409", out["error"])
    self.assertIn("не выдана агенту", out["error"])

def test_versions_eva_rpc_error(self):
    self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-1")
    self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
    out = self.sync(known_versions={})
    self.assertEqual(out["status"], "failed")
    self.assertEqual(out["error"], "Eva: CmfDocumentHistory.list: нет прав")
    self.assertEqual(len(self.hub.pages), 1)           # страницы ушли, версии — нет
    self.assertEqual(self.hub.versions, [])

def test_version_vanished(self):
    self.tree2(); self.eva.add_version("CmfDocument:1", "DOCH-12")
    self.client.get_version = lambda code: None
    out = self.sync(known_versions={})
    self.assertEqual(out["status"], "failed")
    self.assertEqual(out["error"], "версия DOCH-12 страницы DOC-1 не найдена или нет прав")

def test_page_without_id(self):
    self.eva.add_doc("CmfDocument:1", "DOC-1", "Корень", project="P")
    self.eva.add_doc(None, "DOC-2", "Б", parent="DOC-1", project="P")
    buf = io.StringIO()
    with redirect_stdout(buf):
        out = self.sync(known_versions={})
    self.assertEqual(out["status"], "done", out)
    self.assertEqual(len(self.history_calls("CmfDocumentHistory.list")), 1)
    self.assertIn("у страницы DOC-2 нет id — версии не запрошены", buf.getvalue())

def test_known_versions_malformed(self):
    for bad in (None, [], {"DOC-1": "DOCH-1"}, {"DOC-1": [1]}):
        with self.subTest(bad=bad):
            self.setUp(); self.tree2()
            out = self.sync(known_versions=bad)
            self.assertEqual(out["status"], "failed")
            self.assertEqual(out["error"], "wiki_sync: known_versions — не объект {код страницы: [коды версий]}")
            self.assertEqual(self.eva.api_calls, [])
            self.assertEqual(self.hub.pages, [])

def test_versions_hub_503_retried_by_runner(self):
    self.tree2()
    self.eva.add_version("CmfDocument:1", "DOCH-1"); self.eva.add_version("CmfDocument:2", "DOCH-2")
    self.hub.fail_versions = 1
    cid = self.hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1", "known_versions": {}})
    tmp = tempfile.TemporaryDirectory()
    self.addCleanup(tmp.cleanup)
    runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.path.join(tmp.name, "journal.jsonl")))
    runner.sleep = lambda s: None                         # и пауза RETRY_MIN_WAIT перед повтором (wait_seconds)
    runner.idle_limit = 1
    runner.loop()
    self.assertEqual(self.hub.results[cid]["status"], "done", self.hub.results[cid])
    self.assertEqual(self.hub.results[cid]["result"]["versions"], 2)
    self.assertEqual(len(self.hub.pages), 2)              # команда выполнена заново целиком
    self.assertEqual([len(b["versions"]) for b in self.hub.versions], [2])

Импорт io, from contextlib import redirect_stdout в начало файла (tempfile, os, json уже импортированы).

  • Step 2: Запустить — падают

Run: /usr/bin/python3 -m unittest tests.test_wiki_sync -v Expected: FAIL новых тестов (KeyError: 'versions', лишние/нет вызовов CmfDocumentHistory); прежние тесты класса WikiSyncTest — PASS.

  • Step 3: known_versions_ok, version_author, _VersionMissing, Executor._sync_versions и правка do_wiki_sync в src/agent.py — по Interfaces этой задачи; докстринг do_wiki_sync дополнить шагом версий.

  • Step 4: Весь набор — зелёный

Run: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . Expected: OK.

  • Step 5: Commit
git add src/agent.py tests/test_wiki_sync.py
git commit -m "wiki_sync: опубликованные версии страниц хабу, result.versions (#7)"

Task 4: Версия 2, сквозной прогон и документация

Model: sonnet

Files:

  • Modify: src/agent.py:37 (AGENT_VERSION)
  • Modify: scripts/fake_eva_serve.py, scripts/e2e-hub.sh
  • Modify: README.md, ARCHITECTURE.md, CLAUDE.md, openspec/changes/wiki-versions/design.md

Interfaces:

  • Consumes: Task 1 — FakeEva.add_version(parent_id, code, created_at, text, has_published, author); Task 3 — поведение шага версий.

  • Produces:

    • fake_eva_serve.build(eva) добавляет версии: DOC-1 (id 1) — DOCH-11 2026-10-27T10:00:00+03:00 <p>корень v1</p>, DOCH-12 2026-10-27T11:00:00+03:00 <p>корень</p>, DOCH-13 черновик (has_published=False); DOC-2 (id 2) — DOCH-21 2026-10-26T10:00:00+03:00.
    • fake_eva_serve.publish_more(eva) — добавляет DOC-1 DOCH-14 2026-10-28T09:00:00+03:00 <p>корень v3</p> и печатает VERSION DOCH-14 (flush); вызывается обработчиком SIGUSR1, который main ставит до печати PORT.
  • Step 1: AGENT_VERSION = 2

Run: grep -c '^AGENT_VERSION = 2$' src/agent.py Expected: 1.

  • Step 2: scripts/fake_eva_serve.py — версии в build, publish_more, signal.signal(signal.SIGUSR1, …); докстринг — «дерево из 5 страниц, версии DOC-1/DOC-2, картинка и файл; SIGUSR1 — опубликовать DOCH-14»

  • Step 3: scripts/e2e-hub.sh — блок истории перед echo "e2e: ok" (P-6)

  • сохранить pid фейковой Eva (EVA_PID=$!) при запуске;

  • признак: printf '%s' "$STATUS" | /usr/bin/python3 -c 'import json,sys; print("yes" if "history_at" in json.load(sys.stdin) else "no")';

  • no → echo "e2e: хаб без истории правок — шаг версий не проверялся";

  • yes →

    • GET /wiki/status?project=E2E: history_at не None;
    • GET /wiki/pages/DOC-1/versions?project=E2E: длина versions = 2;
    • GET /wiki/pages/DOC-2/versions?project=E2E: длина versions = 1;
    • kill -USR1 "$EVA_PID", ждать строку VERSION DOCH-14 в $TMP/eva.out (до 5 с);
    • api POST /wiki/sync '{"project":"E2E"}', затем agent run --once;
    • DOC-1: длина versions = 3, versions.0.version = DOCH-14;
    • каждая неудача — fail "<что>: <ответ>".

Run: bash scripts/e2e-hub.sh Expected: строка e2e: хаб без истории правок — шаг версий не проверялся, затем e2e: ok (хаб основного checkout sdlc-hub истории не знает: до merge hub#140 ветка yes не исполняется; её проверка — HUB_SRC=/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/wiki-history bash scripts/e2e-hub.sh, когда в том worktree будет код change wiki-history; если его ещё нет — записать в отчёт задачи как невыполненную проверку).

  • Step 4: Документация

  • CLAUDE.md: «Версия агента — 1» → «Версия агента — 2 (версии страниц в wiki_sync, change wiki-versions, aep/eva-agent#7)»; в «Что это» — шаг версий после страниц и ссылка на спеку хаба ../sdlc-hub/openspec/changes/wiki-history/; в «Архитектура» — Executor шлёт и версии (wiki_versions).

  • ARCHITECTURE.md: строка «Eva» — list_all (общий постраничный список по классу), list_versions, get_version; «Хаб» — wiki_versions (POST /agent/wiki/versions); «Исполнитель» — шаг версий: только при known_versions, has_published is True, текст только новых, автор {login, name} или null, пачки 50 версий или 4 МБ, 413 с кодом версии/границами пачки, result.versions; _Batcher, _send_batch; «Сквозной прогон» — блок истории по признаку history_at; «Открытые вопросы» — раскрытие cmf_author по имени поля у CmfDocumentHistory.list проверить при первой живой синхронизации v2.

  • README.md: в абзаце о wiki_sync — версии страниц; в «Разработка» — e2e проверяет историю (первая загрузка, догрузка DOCH-14), только если тестовый хаб её знает; в «Выкатка» — после выкатки v2 поднять wiki_min_agent_version до 2 (Migration Plan хаба, п. 3).

  • openspec/changes/wiki-versions/design.md, раздел ## Decisions made without discussion: решения P-1…P-6 из раздела «Решения плана» этого плана — дата 28.10.2026, решение, обоснование, цена ошибки.

  • Step 5: Весь набор и e2e — зелёные

Run: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . и bash scripts/e2e-hub.sh Expected: OK; e2e: ok.

  • Step 6: Commit
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)"