eva-agent/docs/superpowers/plans/2026-10-24-eva-agent-mvp.md
Andrey Epifancev 487391eddc docs: change eva-agent-mvp и план (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:30:41 +04:00

203 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# eva-agent MVP — план реализации
> **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` хаба: `wiki_sync` (дерево EVA Wiki под корнем → пачки страниц хабу) и `wiki_fetch` (вложение до 10 МБ → хранилище хаба), с журналом, транспортом через общую папку и самообновлением, как jira-agent.
**Architecture:** один файл `src/agent.py` (Python 3.9, stdlib) — копия общих частей jira-agent v14 (HTTP, `FolderTransport`, `HubClient`, `Journal`, `Runner`, `Updater`, CLI) плюс `EvaClient` (JSON-RPC 2.2), `LinkParser` (классификация ссылок HTML) и `Executor` с `do_wiki_sync`/`do_wiki_fetch`; `src/run.py` — дословная копия запускателя.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
**Spec:** контракт — `../../jira-hub/.wt/wiki/openspec/changes/wiki-module/` (worktree хаба, ветка `feat/91-wiki`, PR jira-hub#92): `specs/wiki/spec.md` («Синхронизация вики», «Вложения вики по запросу»), `design.md` решения 10, 11, 14 и Context; решения агента — `openspec/changes/eva-agent-mvp/design.md` этого репозитория (1–11). Код хаба, принимающий агента: `src/jira_hub/modules/wiki/{stage.py,sync.py,attachments.py,commands.py}` в том же worktree хаба.
## Global Constraints
- Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ (`match`, `X | Y` в аннотациях функций) и сторонние импорты запрещены — ловит `tests/test_py39.py`.
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный весь набор после каждой задачи.
- `AGENT_VERSION = 1` — ровно одна строка `AGENT_VERSION = <целое>` в `src/agent.py`.
- `src/run.py` — байт в байт `../../jira-agent/src/run.py` (сверка `cmp` в тесте `test_run_py_is_copy`, путь к jira-agent — `../../jira-agent/src/run.py` от корня worktree; если файла нет — тест пропускается с причиной).
- Токен Eva — только в `agent.json` (не в git: `.gitignore` получает `agent.json`, `*.jsonl`, `update.json`, `*.lock`); в логах и отчётах его нет (тест `test_token_not_logged`).
- Протокол хаба: `POST /agent/claim`, `GET /agent/settings`, `GET /agent/commands/{id}`, `POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited}`, `GET /agent/release`, `POST /agent/files {content}` → `{file, size}`, `POST /agent/wiki/pages {command_id, pages}`. Форма страницы — `stage._check` хаба.
- Не трогать репозитории `jira-agent` (только читать), `jira-hub` (только читать и запускать тестовый хаб во временном `JIRA_HUB_HOME`), живой хаб и `~/.local/share/jira-hub`.
- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Дерево Eva с циклом (страница — родитель сама себе или через `tree_parent`) и страница с двумя родителями — обход конечен, страница в отчёте один раз: `test_tree_cycle_and_two_parents` (Task 4).
2. HTML с относительными и абсолютными адресами, миниатюрами `.meta/thumbnail.jpg`, `static/`, чужим хостом, `DOC-` в адресе другого хоста — классификация как в design 6: `tests/test_links.py` (Task 3).
3. Eva отвечает 429 с `Retry-After`, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговый `failed` с текстом без токена: `tests/test_eva_client.py` (Task 2).
4. Вложение без `Content-Length` и больше 10 МБ — чтение обрывается на 10 МБ + 1 байт, `failed` с текстом спеки: `test_fetch_too_big_streaming` (Task 5).
5. Перезапуск агента посреди `wiki_sync` (журнал: started без finished) — команда выполняется заново, хаб получает полный набор пачек и один отчёт: `test_recover_resyncs` (Task 4).
---
### Task 1: Каркас — общие части jira-agent, конфиг, запускатель
**Files:**
- Create: `src/agent.py`, `src/run.py` (копия), `src/agent.example.json`, `tests/{__init__.py,_common.py,fake_hub.py,fake_relay.py,test_py39.py,test_clients.py,test_folder.py,test_journal.py,test_runner.py,test_update.py,test_launcher.py,test_self_update_e2e.py}`, `.gitignore` (дописать)
- Modify: `CLAUDE.md`, `ARCHITECTURE.md` (заполнить шаблоны по образцу `../../jira-agent/CLAUDE.md` и `ARCHITECTURE.md`, для eva-agent), `README.md` (создать: назначение, установка — Task 6 допишет)
**Interfaces:**
- Produces:
- `load_config(path) -> dict` (обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; `folder:` — абсолютный путь; умолчания как у jira-agent).
- Копия из jira-agent без изменений смысла: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `FolderTransport`, `Journal`, `Updater`, `acquire_lock`, `launcher_version`, `cmd_run`, `cmd_prune`, `main`.
- `HubClient` — без jira-маршрутов (`state`, `known_keys`, `history`), плюс `wiki_pages(command_id, pages) -> dict`.
- `Runner` — вызывает `Executor.run(cmd) -> dict` (`{status, result, error, jira_calls, rate_limited}`), как в jira-agent.
- `Executor(eva, hub, settings)` с `do_wiki_sync`/`do_wiki_fetch`, в этой задаче — `failed` «не реализовано».
- `cmd_check(cfg) -> int` проверяет только хаб; Eva добавит Task 6.
- Тесты общих частей переносятся из `../../jira-agent/tests/` с заменой: Jira → нет, `ensure_*` → команда-заглушка `wiki_fetch` с `failed`. Сценарии журнала, папки, 401/403-паузы, самообновления и запускателя сохраняются.
- [ ] **Step 1:** перенести тесты общих частей, они упадут — `agent.py` нет.
- [ ] **Step 2:** собрать `src/agent.py` из jira-agent v14:
- вырезать `JiraClient`, jira-методы `Executor`, `ConfigGap` и проверки Jira;
- `AGENT_VERSION = 1`;
- `run.py` скопировать.
- [ ] **Step 3:** весь набор — PASS; `test_run_py_is_copy` и `test_py39` зелёные.
- [ ] **Step 4: Commit** «Каркас eva-agent: общие части jira-agent v14 (#1)».
---
### Task 2: Клиент Eva
**Files:**
- Modify: `src/agent.py` (класс `EvaClient(Http)` после `HubClient`)
- Create: `tests/fake_eva.py`, `tests/test_eva_client.py`
**Interfaces:**
- Produces:
- `EvaClient(cfg_eva: dict, timeout, verify_ssl, ca_bundle)`, его методы:
- `call(method, **kwargs) -> Any` — `POST <base>/api/?m=<method>`, `Bearer`; повторы по design 4; `error` в ответе — `EvaError("<method>: <error>")`;
- `get_doc(code, fields) -> dict | None`;
- `list_docs(flt, fields) -> list[dict]` — страницы по 200 через `slice`; обёрнутый результат `{items|result}` разворачивается;
- `download(url, limit) -> bytes` — поток с обрывом на `limit + 1`; больше лимита — `TooBig`.
- Свойства: `calls` (число вызовов), `rate_limited` (число 429), `pacing` (пауза, с; ставит Runner из `wiki_pacing`).
- `tests/fake_eva.py: FakeEva` — `http.server` на порту 0:
- хранит документы `{id, code, name, parent, tree_parent, project, orderno, text}`;
- поддерживает фильтры `code ==`, `project.code ==`, `tree_parent.code ==`, `slice`;
- сценарии сбоев: `fail_next(status, times, retry_after=None)`, `rpc_error_next(text)`;
- файлы `/files/...` с `Content-Length` или без него;
- проверяет `Authorization`.
- [ ] **Step 1: Падающие тесты:**
- `test_call_ok`;
- `test_retry_429_retry_after` — ждал `Retry-After` через подменённый `sleep`;
- `test_retry_503_then_ok`;
- `test_gives_up_after_4`;
- `test_rpc_error`;
- `test_list_pages_200` — 450 документов за три вызова;
- `test_pacing_between_calls`;
- `test_download_limit` — без `Content-Length`, 10 МБ + 1;
- `test_token_not_logged` — токен не встречается ни в логе, ни в тексте ошибок.
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Клиент Eva JSON-RPC (#1)».
---
### Task 3: Разбор ссылок страницы
**Files:**
- Modify: `src/agent.py` (функция `classify_links(html, base_url) -> tuple[list, list]` и `class _LinkParser(HTMLParser)`)
- Create: `tests/test_links.py`, `tests/fixtures/pages/*.html` (синтетические, форматы адресов из design хаба, Context; текст ТЗ не копировать)
**Interfaces:**
- Produces: `classify_links(html: str, base_url: str) -> (attachments: [{ref, url, name}], links: [{ref, code}])` по design 6–7, в порядке появления, без склейки повторов.
- [ ] **Step 1: Падающие тесты:**
```python
def test_image_relative_files() # src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/img.png?version=1" →
# url "https://eva.test/files/obj/…/img.png" (без query), name "img.png"
def test_thumbnail_maps_to_file() # ".../model.xlsx.meta/thumbnail.jpg?version=1" → url ".../model.xlsx", name из alt или "model.xlsx"
def test_file_link_same_url() # <a href=".../model.xlsx">model.xlsx</a> → тот же url, что у миниатюры
def test_static_ignored() # src="static/attach-file.png" → ни в одном списке
def test_page_link_relative() # href="project/Document/DOC-019834" → links [{ref, code: "DOC-019834"}]
def test_foreign_host() # href="https://other.test/files/x.png" и "https://other.test/project/Document/DOC-1" → пусто
def test_name_unquoted() # ".../%D0%9F%D0%9C%D0%9F.xlsx" без alt и текста → name "ПМП.xlsx"
def test_ref_exact() # ref равен атрибуту байт в байт (с &amp; → как отдаёт HTMLParser: unescape; проверить, что хаб ищет так же — см. convert.py хаба)
```
Для `test_ref_exact` надо сверить, с чем хаб сравнивает `ref`: с сырым значением атрибута или с unescape. Это видно в `../../jira-hub/.wt/wiki/src/jira_hub/modules/wiki/convert.py`. Записать, как сделано, в отчёт и в design (решение 12).
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Разбор ссылок страниц Eva (#1)».
---
### Task 4: Команда `wiki_sync`
**Files:**
- Modify: `src/agent.py` (`Executor.do_wiki_sync`, функции `build_children(docs)`, `walk(root, children)`), `tests/fake_hub.py` (`POST /agent/wiki/pages` — записывает пачки; проверка формы по образцу `stage._check` хаба: `code`, `title`, `html` строки; ≤ 50 страниц)
- Create: `tests/test_wiki_sync.py`
**Interfaces:**
- Consumes: `EvaClient` (Task 2), `classify_links` (Task 3), `HubClient.wiki_pages` (Task 1).
- Produces: `Executor.do_wiki_sync(params {label, root}) -> {status, result {root_title, codes, eva_calls}, error, jira_calls, rate_limited}` по design 5; пачки — `MAX_PAGES = 50`, `MAX_BATCH_BYTES = 4 * 1024 * 1024`.
- [ ] **Step 1: Падающие тесты:**
```python
def test_sync_tree_order() # корень DOC-1 (project P), дети DOC-3 (orderno 1), DOC-2 (orderno 2), внук DOC-4 под DOC-3 →
# codes [DOC-1, DOC-3, DOC-4, DOC-2]; parent_code; url ".../project/Document/DOC-3"
def test_sync_batches_50() # 120 страниц → пачки 50, 50, 20; codes — 120
def test_sync_batch_bytes() # страницы по 1,5 МБ → пачка не больше 4 МБ
def test_tree_cycle_and_two_parents() # A.parent=B, B.tree_parent=A; C с parent=корень и tree_parent=DOC-3 → каждый код один раз
def test_root_without_project() # у корня нет project → обход через tree_parent.code
def test_root_missing() # get_doc → None → failed «страница DOC-9 не найдена или нет прав»
def test_eva_failure_reported() # Eva 503 четыре раза подряд → failed, текст содержит "503", токена в тексте нет
def test_hub_rejects_batch() # fake_hub 422 на пачку → failed с detail хаба
def test_recover_resyncs() # журнал started без finished, команда claimed в хабе → выполняется заново: полные пачки, один done
def test_counts() # jira_calls == eva.calls, rate_limited == числу 429
```
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Команда wiki_sync (#1)».
---
### Task 5: Команда `wiki_fetch`
**Files:**
- Modify: `src/agent.py` (`Executor.do_wiki_fetch`)
- Create: `tests/test_wiki_fetch.py`
**Interfaces:**
- Produces: `Executor.do_wiki_fetch(params {attachment_id, url}) -> {status, result {file, size}, …}` по design 8; использует `HubClient.upload_file` (как у jira-agent `fetch_attachment`).
- [ ] **Step 1: Падающие тесты:**
- `test_fetch_ok` — 300 КБ; `done`, `size` 307200; файл у fake_hub совпадает побайтно;
- `test_fetch_foreign_host` — `failed` «чужой хост», у fake_eva нет запросов;
- `test_fetch_too_big_header` — `Content-Length` 11 МБ, тело не читается;
- `test_fetch_too_big_streaming` — без `Content-Length`;
- `test_fetch_404` — «файл не найден в Eva»;
- `test_fetch_empty_file` — 0 байт; `done`, `size` 0: хаб принимает пустой файл от агента.
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «Команда wiki_fetch (#1)».
---
### Task 6: `check`, документация, сквозной прогон с хабом
**Files:**
- Modify: `src/agent.py` (`cmd_check` — хаб и Eva по design 10), `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/eva-agent-mvp/design.md` (решения исполнителей), `src/agent.example.json`
- Create: `scripts/e2e-hub.sh`, `tests/test_check.py`
**Interfaces:**
- `scripts/e2e-hub.sh`:
- поднимает тестовый хаб из `../../jira-hub/.wt/wiki`: `uv run --project <путь> jira-hub serve --port 0`, неудобно — тогда фиксированный свободный порт, например 8795, с `JIRA_HUB_HOME=$(mktemp -d)`;
- поднимает `FakeEva` с деревом из 5 страниц, картинкой и файлом;
- заводит проект `E2E` с `wiki_root` через `POST /projects` клиентским токеном из `jira-hub tokens`;
- запускает агент `src/agent.py run` с токеном агента `wiki` до первого `done`; нужен флаг `--once` или тайм-аут — решает исполнитель, без изменения протокола;
- проверяет `GET /wiki/tree?project=E2E` (5 страниц), `GET /wiki/pages/<код>` (Markdown со ссылкой `attachment:`), затем `POST /wiki/attachments/<id>/fetch` и `GET …/content`;
- печатает «e2e: ok»; хаб и фейк гасятся по `trap`.
- README — установка на виртуалку:
- каталог рядом с jira-agent (например `C:\…\eva-agent\` или `~/eva-agent/`);
- файлы `agent.py`, `run.py`, `agent.json` (из примера: `hub_url` = тот же `folder:<путь>`, что у jira-agent, `hub_token` = «агент wiki» из `jira-hub tokens`, `eva.base_url`, `eva.token` — профиль Eva → Безопасность);
- `python3 agent.py check`, затем `python3 run.py`;
- автозапуск — как у jira-agent;
- выкатка новых версий — `uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py`.
- [ ] **Step 1:** тесты `test_check` (хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS.
- [ ] **Step 2:** `bash scripts/e2e-hub.sh` → «e2e: ok». Хаб — только из worktree `feat/91-wiki` и только во временном `JIRA_HUB_HOME`.
- [ ] **Step 3:** документация.
- [ ] **Step 4:** весь набор — PASS.
- [ ] **Step 5: Commit** «check, README, сквозной прогон с хабом (#1)».