From c711a119b9356f4b017c7a536d3f2e1399935faa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 11:59:56 +0400 Subject: [PATCH] =?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__))