eva-agent v2: версии в e2e, документация, решения плана (#7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b98b963ae1
commit
c711a119b9
@ -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/<id>.json` → реле хаба → `resp/<id>.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).
|
||||
|
||||
@ -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».
|
||||
|
||||
|
||||
@ -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:<id>`, `wiki_fetch` вложения и его содержимое.
|
||||
`attachment:<id>`, `wiki_fetch` вложения и его содержимое; если тестовый хаб знает историю правок (`history_at` в
|
||||
`GET /wiki/status`), — ещё первую загрузку версий и догрузку новой версии `DOCH-14`.
|
||||
|
||||
@ -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 шаг версий проверяется только юнит-тестами.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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="<p>подпроцесс</p>")
|
||||
eva.add_doc(5, "DOC-5", "Приложение", parent="DOC-1", project="E2E", orderno=3, text="<p>приложение</p>")
|
||||
eva.add_file("/files/obj/e2e/scheme.png", PNG)
|
||||
eva.add_version(1, "DOCH-11", "2026-10-27T10:00:00+03:00", "<p>корень v1</p>")
|
||||
eva.add_version(1, "DOCH-12", "2026-10-27T11:00:00+03:00", "<p>корень</p>")
|
||||
eva.add_version(1, "DOCH-13", "2026-10-27T12:00:00+03:00", "<p>черновик</p>", 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", "<p>корень v3</p>")
|
||||
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
|
||||
|
||||
@ -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__))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user