From acdd52742e634aed75f53c7d54411fe679e69b5a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:32:32 +0400 Subject: [PATCH] =?UTF-8?q?check,=20README,=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=20=D1=81=20=D1=85=D0=B0=D0=B1=D0=BE=D0=BC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_check проверяет хаб и Eva (код 0 или 3), флаг run --once, scripts/e2e-hub.sh поднимает тестовый хаб во временном JIRA_HUB_HOME. Решения 33–35 в design.md. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 8 +- CLAUDE.md | 8 +- README.md | 35 +++++++- openspec/changes/eva-agent-mvp/design.md | 19 ++++ scripts/e2e-hub.sh | 108 +++++++++++++++++++++++ scripts/fake_eva_serve.py | 38 ++++++++ src/agent.example.json | 2 +- src/agent.py | 28 ++++-- tests/test_check.py | 77 ++++++++++++++++ tests/test_clients.py | 2 +- tests/test_folder.py | 7 +- tests/test_self_update_e2e.py | 5 +- 12 files changed, 315 insertions(+), 22 deletions(-) create mode 100755 scripts/e2e-hub.sh create mode 100644 scripts/fake_eva_serve.py create mode 100644 tests/test_check.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da5b7c9..dca0393 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` | | Самообновление | `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`, `check`, `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` | +| 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` | ## Запускатель `src/run.py` @@ -36,5 +36,9 @@ - Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429. - Тесты не ходят во внешние сервисы. +## Сквозной прогон +`scripts/e2e-hub.sh`: настоящий хаб (`jira-hub serve` из worktree `jira-hub/.wt/wiki`, временный `JIRA_HUB_HOME`, свободный +порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются. + ## Открытые вопросы -- Проверка Eva в `check` и клиент JSON-RPC — задачи 2 и 6 плана. +- Живая проверка на виртуалке с настоящей Eva (после merge hub#92). diff --git a/CLAUDE.md b/CLAUDE.md index 8fdc5b1..79f5f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,9 @@ - `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор. - `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом). -- `/usr/bin/python3 src/agent.py check` — доступность хаба (и Eva — после задачи 6 плана), без записи. +- `/usr/bin/python3 src/agent.py check` — доступность хаба и Eva (`CmfDocument.list`, 1 запись), без записи; код 0 или 3. +- `/usr/bin/python3 src/agent.py run --once` — выполнить выданное и выйти при первом пустом claim. +- `bash scripts/e2e-hub.sh` — сквозной прогон с тестовым хабом из `../../../jira-hub/.wt/wiki` (временный `JIRA_HUB_HOME`, свободный порт, фейковая Eva); «e2e: ok». Живой хаб не трогать. - `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`). - `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет). - `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3. @@ -38,12 +40,12 @@ ## Тесты - Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`, - `tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py` (появится вместе с клиентом Eva). + `tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py`; `scripts/fake_eva_serve.py` поднимает тот же фейк отдельным процессом для e2e. - `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`. - Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`, `test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.py` (самообновление), `test_launcher.py` (запускатель + `test_run_py_is_copy`), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py`). -- Golden-фикстур пока нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический. +- Фикстуры страниц — `tests/fixtures/pages/`. Golden-сценариев нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический. ## Первая подсистема MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева, diff --git a/README.md b/README.md index 39a9105..bae4b19 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,36 @@ Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, один файл `src/agent.py` и запускатель `src/run.py`. -Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`). +## Установка на виртуалку -Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить). -Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`. +1. Каталог рядом с jira-agent, например `C:\...\eva-agent\` или `~/eva-agent/`. +2. Положить в него `agent.py` и `run.py` (из `src/`) и `agent.json` (из `agent.example.json`): + - `hub_url` — тот же `folder:<путь>`, что у jira-agent (общая папка с хабом; при VPN виртуалка не видит Mac mini по + сети), либо `http://<хост>:8790`; + - `hub_token` — строка «агент wiki» из `jira-hub tokens` (токен именно агента wiki, не jira и не клиентский); + - `eva.base_url` — адрес Eva; `eva.token` — токен из профиля Eva (Безопасность); + - `verify_ssl`, `ca_bundle` — при корпоративном сертификате. + `agent.json` содержит токены: не коммитить, не выносить с виртуалки. +3. Проверка: `python3 agent.py check` — печатает «хаб: доступен…» и «eva: ok»; код 0, при недоступности хаба или Eva — 3. +4. Запуск: `python3 run.py` (запускатель следит за версиями и самообновлением). `python3 agent.py run` — без запускателя. +5. Автозапуск — как у jira-agent (планировщик задач / launchd по тому же образцу); запускатель сам перезапускает агента. + +Другие команды: `python3 agent.py run --once` (выполнить выданное и выйти при первом пустом claim), `selftest` +(версия + `check`, код 0 или 3), `prune` (чистка журнала). + +## Выкатка новых версий + +Из основного checkout репозитория (не из worktree), при поднятой версии `AGENT_VERSION` в `src/agent.py`: + + uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py + +Агент подтянет её сам (`auto_update`); `min_agent_version` хаба поднимать после того, как `agent_status` покажет новую. + +## Разработка + +Настройки — `src/agent.example.json`. Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` + +Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из +`../../../jira-hub/.wt/wiki` (переменная `HUB_SRC`), запускается на свободном порту с временным `JIRA_HUB_HOME`; +живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой +`attachment:`, `wiki_fetch` вложения и его содержимое. diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 121ef5d..bcfda91 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -193,3 +193,22 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. + +33. **`check` (задача 6): код возврата 0 или 3, оба узла проверяются всегда.** + - Хаб — `GET /agent/settings`, Eva — `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva. + - Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; `selftest` по-прежнему 0 или 3. Тесты каркаса приведены: `test_check_command` и `test_check_folder_silent_relay` ждут 3; `FolderIntegrationTest` и `SelfUpdateE2E` поднимают `FakeEva` (selftest = check, без доступной Eva самообновление откладывается). + - У Eva в `check` одна попытка (`ATTEMPTS = 1`): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на `***` (решение 23). + + Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там `selftest`). + +34. **Флаг `agent.py run --once` для сквозного прогона.** Ставит `runner.idle_limit = 1`: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, `Runner.idle_limit` уже был в каркасе (используют тесты). + Обоснование: прогон делает две фазы (`wiki_sync`, затем `wiki_fetch`, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с. + +35. **Сквозной прогон: `scripts/e2e-hub.sh` + `scripts/fake_eva_serve.py`.** + - Хаб — `uv run --project --no-sync jira-hub serve --host 127.0.0.1 --port <свободный>` с `JIRA_HUB_HOME=$(mktemp -d)/hub-home`; порт 8790 отвергается явно. `--no-sync`, чтобы прогон не менял `.venv` соседнего worktree. + - Токены берутся из `jira-hub tokens` (клиентский и «агент wiki»); транспорт агента — прямой HTTP, `auto_update=false`. + - Фейковая Eva (`tests/fake_eva.py`) запускается отдельным процессом, 5 страниц в проекте `E2E`, картинка `/files/obj/e2e/scheme.png`; проект создаётся `POST /projects {label, name, wiki_root}`, затем `wiki_sync`, `GET /wiki/status|tree|pages/DOC-2`, `POST /wiki/attachments//fetch`, `wiki_fetch`, `GET …/content` и сверка sha256. + - Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с. + - Хаб принял всё, что требует его спека (расхождений с хабом не найдено). + + Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных. diff --git a/scripts/e2e-hub.sh b/scripts/e2e-hub.sh new file mode 100755 index 0000000..2fae2f3 --- /dev/null +++ b/scripts/e2e-hub.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Сквозной прогон eva-agent с настоящим хабом (модуль wiki) и фейковой Eva. Печатает «e2e: ok». +# Хаб — ТОЛЬКО из worktree jira-hub/.wt/wiki, ТОЛЬКО с временным JIRA_HUB_HOME и портом не 8790; живой хаб не трогаем. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")/.." && pwd)" +HUB_SRC="${HUB_SRC:-$HERE/../../../jira-hub/.wt/wiki}" +HUB_SRC="$(cd "$HUB_SRC" && pwd)" +PORT="${E2E_PORT:-$(/usr/bin/python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')}" +[ "$PORT" != "8790" ] || { echo "e2e: порт 8790 занят живым хабом" >&2; exit 2; } + +TMP="$(mktemp -d)" +export JIRA_HUB_HOME="$TMP/hub-home" +mkdir -p "$JIRA_HUB_HOME" +PIDS=() +stop_tree() { # остановить процесс и его детей (uv run запускает хаб дочерним процессом) + local p="$1" c + for c in $(pgrep -P "$p" 2>/dev/null || true); do stop_tree "$c"; done + kill "$p" 2>/dev/null || true +} +cleanup() { + trap - EXIT INT TERM + for p in ${PIDS[@]+"${PIDS[@]}"}; do stop_tree "$p"; done + for _ in 1 2 3 4 5 6 7 8 9 10; do + alive=0 + for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -0 "$p" 2>/dev/null && alive=1; done + [ "$alive" = 0 ] && break + sleep 0.2 + done + for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -9 "$p" 2>/dev/null || true; done + rm -rf "$TMP" +} +trap cleanup EXIT INT TERM + +hub() { uv run --project "$HUB_SRC" --no-sync jira-hub "$@"; } +fail() { echo "e2e: ОШИБКА — $*" >&2; echo "--- лог хаба ---" >&2; tail -n 30 "$TMP/hub.log" >&2 || true; exit 1; } +jget() { /usr/bin/python3 -c 'import json,sys; d=json.load(sys.stdin) +for k in sys.argv[1].split("."): + d = d[int(k)] if isinstance(d, list) else d[k] +print(d)' "$1"; } + +# --- токены (создают базу во временном каталоге) +TOKENS="$(hub tokens)" +CLIENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^клиентский токен: //p')" +AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')" +[ -n "$CLIENT_TOKEN" ] && [ -n "$AGENT_TOKEN" ] || fail "токены не выданы: $TOKENS" + +# --- фейковая 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+=($!) +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")" + +# --- хаб +hub serve --host 127.0.0.1 --port "$PORT" >"$TMP/hub.log" 2>&1 & +PIDS+=($!) +HUB="http://127.0.0.1:$PORT" +for _ in $(seq 1 100); do + nc -z 127.0.0.1 "$PORT" 2>/dev/null && break + kill -0 "${PIDS[1]}" 2>/dev/null || fail "хаб завершился при старте" + sleep 0.1 +done +nc -z 127.0.0.1 "$PORT" 2>/dev/null || fail "хаб не открыл порт $PORT" + +api() { # api METHOD PATH [json] + local m="$1" p="$2"; shift 2 + if [ $# -gt 0 ]; then + curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" -H 'Content-Type: application/json' -d "$1" "$HUB$p" + else + curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" "$HUB$p" + fi +} + +# --- конфиг агента (прямой HTTP к тестовому хабу, без самообновления) +cat >"$TMP/agent.json" < пакет wiki_sync +api POST /projects '{"label":"E2E","name":"Сквозной прогон","wiki_root":"DOC-1"}' >"$TMP/project.json" || fail "POST /projects" +BATCH="$(jget batch_id <"$TMP/project.json")" +agent run --once || fail "agent.py run --once (wiki_sync)" + +STATUS="$(api GET "/wiki/status?project=E2E")" +[ "$(printf '%s' "$STATUS" | jget pages)" = "5" ] || fail "wiki/status: ожидалось 5 страниц: $STATUS" +TREE="$(api GET "/wiki/tree?project=E2E")" +[ "$(printf '%s' "$TREE" | /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["pages"]))')" = "5" ] || fail "wiki/tree: $TREE" + +PAGE="$(api GET "/wiki/pages/DOC-2?project=E2E")" +printf '%s' "$PAGE" | jget markdown | grep -q 'attachment:[0-9]' || fail "в Markdown DOC-2 нет ссылки attachment:: $PAGE" +ATT_ID="$(printf '%s' "$PAGE" | jget attachments.0.id)" + +# --- вложение по запросу -> wiki_fetch +api POST "/wiki/attachments/$ATT_ID/fetch" '{}' >/dev/null || fail "POST attachments/$ATT_ID/fetch" +agent run --once || fail "agent.py run --once (wiki_fetch)" +api GET "/wiki/attachments/$ATT_ID/content" >"$TMP/content.bin" || fail "GET attachments/$ATT_ID/content" +EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import fake_eva_serve as f; sys.stdout.buffer.write(f.PNG)' "$HERE/scripts" | shasum -a 256 | cut -d' ' -f1)" +GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)" +[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало" + +echo "e2e: ok" diff --git a/scripts/fake_eva_serve.py b/scripts/fake_eva_serve.py new file mode 100644 index 0000000..c319169 --- /dev/null +++ b/scripts/fake_eva_serve.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл. + +Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом. +""" +import os +import sys +import threading + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests")) +from fake_eva import FakeEva # noqa: E402 + +PNG = bytes.fromhex("89504e470d0a1a0a") + b"e2e-attachment-content" + + +def build(eva: FakeEva) -> None: + img = '

Схема:

Схема' + eva.add_doc(1, "DOC-1", "Корень e2e", project="E2E", text="

корень

") + eva.add_doc(2, "DOC-2", "Требования", parent="DOC-1", project="E2E", orderno=1, text=img) + eva.add_doc(3, "DOC-3", "Процессы", parent="DOC-1", project="E2E", orderno=2, + text='

см. требования

') + 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) + + +def main() -> int: + eva = FakeEva() + if len(sys.argv) > 1: + eva.token = sys.argv[1] + build(eva) + print("PORT %d" % eva.server.server_port, flush=True) + threading.Event().wait() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agent.example.json b/src/agent.example.json index 1bd733b..2457a8c 100644 --- a/src/agent.example.json +++ b/src/agent.example.json @@ -1,5 +1,5 @@ { - "hub_url": "http://192.168.0.106:8790", + "hub_url": "folder:/путь/к/общей/папке/jira-bridge", "hub_token": "токен агента wiki из `jira-hub tokens`", "eva": { "base_url": "https://eva.example.ru", diff --git a/src/agent.py b/src/agent.py index 8c71d34..fee23fd 100644 --- a/src/agent.py +++ b/src/agent.py @@ -6,8 +6,8 @@ jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/. Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. - python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи - python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py check # доступ к хабу и Eva, без записи + python3 agent.py run # основной цикл (по умолчанию); run --once — выполнить выданное и выйти (сквозной прогон) python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней @@ -1258,13 +1258,13 @@ def acquire_lock(journal_path: str): return f -def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: +def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int: lock = acquire_lock(journal_path) if lock is None: log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) return EXIT_LOCKED try: - return _run(cfg, journal_path, config_path) + return _run(cfg, journal_path, config_path, once) finally: lock.close() @@ -1292,8 +1292,10 @@ def launcher_version() -> Optional[int]: return None -def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: +def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None, once: bool = False) -> int: runner = Runner(cfg, HubClient(cfg), EvaClient(cfg), Journal(journal_path)) + if once: + runner.idle_limit = 1 # выполнить всё, что выдаст хаб, и выйти при первом пустом claim runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path) runner.updater.should_stop = lambda: runner.stop @@ -1333,13 +1335,20 @@ def cmd_check(cfg: dict) -> int: except (ConnectionError, HttpError) as e: log("хаб: НЕДОСТУПЕН — %s" % e) ok = False - # проверка Eva — в задаче 6 плана - return 0 if ok else 1 + eva = EvaClient(cfg) + eva.ATTEMPTS = 1 # проверка отвечает сразу, без повторов с паузами + try: + eva.call("CmfDocument.list", filter=[], fields=["code"], slice=[0, 1]) + log("eva: ok") + except (ConnectionError, HttpError, EvaError) as e: + log("eva: НЕДОСТУПНА — %s" % e if isinstance(e, ConnectionError) else "eva: ошибка — %s" % e) + ok = False + return 0 if ok else 3 def cmd_selftest(cfg: dict) -> int: """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: - 0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" + 0 — хаб и Eva доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" log("eva-agent v%d" % AGENT_VERSION) return 0 if cmd_check(cfg) == 0 else 3 @@ -1348,6 +1357,7 @@ def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("--once", action="store_true", help="для run: выполнить выданные команды и выйти при первом пустом claim") ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"]) args = ap.parse_args(argv) cfg = load_config(args.config) @@ -1357,7 +1367,7 @@ def main(argv=None) -> int: return cmd_selftest(cfg) if args.cmd == "prune": return cmd_prune(args.journal) - return cmd_run(cfg, args.journal, args.config) + return cmd_run(cfg, args.journal, args.config, args.once) if __name__ == "__main__": diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..a36bc21 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,77 @@ +import contextlib +import io +import unittest + +from _common import SETTINGS, agent, eva_cfg +from fake_eva import FakeEva +from fake_hub import FakeHub + + +class CheckTest(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.hub = FakeHub(SETTINGS) + self.addCleanup(self.eva.stop) + self.addCleanup(self.hub.stop) + self.eva.add_doc(1, "DOC-1", "К", project="P") + + def check(self, **over): + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, **over) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = agent.cmd_check(cfg) + return code, out.getvalue() + + def test_all_ok(self): + code, out = self.check() + self.assertEqual(code, 0, out) + self.assertIn("хаб: ", out) + self.assertIn("eva: ok", out) + call = self.eva.api_calls[-1]["body"] + self.assertEqual(call["method"], "CmfDocument.list") + self.assertEqual(call["kwargs"], {"filter": [], "fields": ["code"], "slice": [0, 1]}) + + def test_eva_401(self): + self.eva.token = "другой" + code, out = self.check() + self.assertEqual(code, 3) + self.assertIn("eva: ", out) + self.assertIn("401", out) + self.assertNotIn("eva-secret", out) + + def test_eva_unreachable(self): + cfg = eva_cfg("http://127.0.0.1:1", hub_url=self.hub.base_url, hub_token=self.hub.token) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.cmd_check(cfg), 3) + self.assertIn("eva: НЕДОСТУПНА", out.getvalue()) + + def test_hub_down_but_eva_checked(self): + cfg = eva_cfg(self.eva.base_url) # хаб: порт 1 + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(agent.cmd_check(cfg), 3) + self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) + self.assertIn("eva: ok", out.getvalue()) + + def test_selftest_zero_when_ok(self): + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(agent.cmd_selftest(cfg), 0) + + def test_run_once_exits_on_idle(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, auto_update=False) + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f) + with contextlib.redirect_stdout(io.StringIO()): + code = agent.main(["--config", path, "--journal", os.path.join(d, "j.jsonl"), "run", "--once"]) + self.assertEqual(code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_clients.py b/tests/test_clients.py index 1d07005..67dd2ad 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -236,7 +236,7 @@ class ClientsTest(unittest.TestCase): json.dump(eva_cfg(), f) out = io.StringIO() with contextlib.redirect_stdout(out): - self.assertEqual(agent.main(["--config", path, "check"]), 1) # хаб недоступен (порт 1) + self.assertEqual(agent.main(["--config", path, "check"]), 3) # хаб и Eva недоступны (порт 1) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) def test_selftest_command(self): diff --git a/tests/test_folder.py b/tests/test_folder.py index 7b8bfc0..32fe055 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -10,6 +10,7 @@ from contextlib import redirect_stdout from datetime import datetime, timezone from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_eva import FakeEva from fake_hub import FakeHub from fake_relay import FakeRelay @@ -187,15 +188,17 @@ class FolderIntegrationTest(unittest.TestCase): self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") os.makedirs(self.root) self.hub = FakeHub(SETTINGS) + self.eva = FakeEva() # check ходит и в Eva (design 10) self.relay = FakeRelay(self.root, self.hub.base_url).start() def tearDown(self): self.relay.stop() self.hub.stop() + self.eva.stop() self.tmp.cleanup() def cfg(self): - return eva_cfg(hub_url="folder:" + self.root, hub_token=self.hub.token) + return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): self.assertEqual(agent.AGENT_VERSION, 1) @@ -228,7 +231,7 @@ class FolderIntegrationTest(unittest.TestCase): t0 = time.monotonic() with redirect_stdout(out): code = agent.cmd_check(self.cfg()) - self.assertEqual(code, 1) + self.assertEqual(code, 3) self.assertLess(time.monotonic() - t0, 13) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) self.assertIn("не ответил через папку", out.getvalue()) diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py index e0cad78..23da247 100644 --- a/tests/test_self_update_e2e.py +++ b/tests/test_self_update_e2e.py @@ -11,6 +11,7 @@ import time import unittest from _common import SETTINGS, SRC +from fake_eva import FakeEva from fake_hub import FakeHub @@ -28,13 +29,14 @@ class SelfUpdateE2E(unittest.TestCase): def setUp(self): self.hub = FakeHub(SETTINGS) + self.eva = FakeEva() # selftest = check: нужна доступная Eva self.tmp = tempfile.TemporaryDirectory() d = self.tmp.name shutil.copy(os.path.join(SRC, "run.py"), d) shutil.copy(os.path.join(SRC, "agent.py"), d) with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f: json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2, - "eva": {"base_url": "http://127.0.0.1:1", "token": "eva-secret"}}, f) + "eva": {"base_url": self.eva.base_url, "token": "eva-secret"}}, f) with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: src = f.read() self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1)) @@ -54,6 +56,7 @@ class SelfUpdateE2E(unittest.TestCase): self.proc.wait() self.log.close() self.hub.stop() + self.eva.stop() self.tmp.cleanup() def state(self):