Финальное ревью: версии без автора в лог, повтор кода версии, нет text — failed (#7, I-1, M-1, M-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 12:15:33 +04:00
parent 15ff652912
commit 060a2c6a1f
4 changed files with 61 additions and 6 deletions

View File

@ -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 не
раскрывает, и авторы в истории будут пустыми (их не исправит повторная синхронизация).
## Разработка

View File

@ -9,8 +9,8 @@
- `CmfDocumentHistory.list` с `filter=[["parent_id", "==", "CmfDocument:<uuid>"]]` работает; `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`
синхронизация стоит до выдачи прав.

View File

@ -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:

View File

@ -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):