41 KiB
Версии страниц в 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_atEva как есть (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 — из основного checkoutsdlc-hub(только запуск во временномSDLC_HUB_HOME). - Коммиты — по-русски, последней строкой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
Review Focus
- Тестовый и живой хаб без истории (основной checkout
sdlc-hubдо merge hub#140) —known_versionsв параметрах нет; агент не зовётCmfDocumentHistoryи не шлёт/agent/wiki/versions(там 404):test_versions_skipped_without_known(Task 3) и e2e (Task 4). - 5xx хаба на пачке версий —
Runnerповторяет команду целиком: страницы уходят второй раз, версии — заново все новые, отчёт один с числом последнего прогона:test_versions_hub_503_retried_by_runner(Task 3). - Eva не соблюдает
sliceуCmfDocumentHistory.list— список не крутится бесконечно, в логе имя класса:test_list_versions_slice_ignored(Task 1). - Автор закрыт ACL (поля нет,
_acl_fields: {"login": "deny"}), не раскрыт (строка-id) или отсутствует — версия уходит сnullв закрытых полях, команда не падает:test_versions_author_missing(Task 3). - Битый
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; иначе печатается пропуск. Блок проверяется только с хабом, где реализован changewiki-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не целое — 422bad_request; не список 1..50 — 422bad_versions«versions — список от 1 до 50 версий»; нет команды — 404; неwiki_syncили неclaimed— 409not_claimed; по версии — 422bad_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 (id1) —DOCH-112026-10-27T10:00:00+03:00<p>корень v1</p>,DOCH-122026-10-27T11:00:00+03:00<p>корень</p>,DOCH-13черновик (has_published=False); DOC-2 (id2) —DOCH-212026-10-26T10:00:00+03:00.fake_eva_serve.publish_more(eva)— добавляет DOC-1DOCH-142026-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, changewiki-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)"