18 KiB
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
- Дерево Eva с циклом (страница — родитель сама себе или через
tree_parent) и страница с двумя родителями — обход конечен, страница в отчёте один раз:test_tree_cycle_and_two_parents(Task 4). - HTML с относительными и абсолютными адресами, миниатюрами
.meta/thumbnail.jpg,static/, чужим хостом,DOC-в адресе другого хоста — классификация как в design 6:tests/test_links.py(Task 3). - Eva отвечает 429 с
Retry-After, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговыйfailedс текстом без токена:tests/test_eva_client.py(Task 2). - Вложение без
Content-Lengthи больше 10 МБ — чтение обрывается на 10 МБ + 1 байт,failedс текстом спеки:test_fetch_too_big_streaming(Task 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: Падающие тесты:
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 равен атрибуту байт в байт (с & → как отдаёт 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: Падающие тесты:
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-agentfetch_attachment). -
Step 1: Падающие тесты:
test_fetch_ok— 300 КБ;done,size307200; файл у fake_hub совпадает побайтно;test_fetch_foreign_host—failed«чужой хост», у fake_eva нет запросов;test_fetch_too_big_header—Content-Length11 МБ, тело не читается;test_fetch_too_big_streaming— безContent-Length;test_fetch_404— «файл не найден в Eva»;test_fetch_empty_file— 0 байт;done,size0: хаб принимает пустой файл от агента.
-
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.
- каталог рядом с jira-agent (например
-
Step 1: тесты
test_check(хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS. -
Step 2:
bash scripts/e2e-hub.sh→ «e2e: ok». Хаб — только из worktreefeat/91-wikiи только во временномJIRA_HUB_HOME. -
Step 3: документация.
-
Step 4: весь набор — PASS.
-
Step 5: Commit «check, README, сквозной прогон с хабом (#1)».