check, README, сквозной прогон с хабом (#1)

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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 05:32:32 +04:00
parent 3597495a6a
commit acdd52742e
12 changed files with 315 additions and 22 deletions

View File

@ -18,7 +18,7 @@
| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду: `do_wiki_sync`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` | | Исполнитель | `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` | | Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` | | Цикл | `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` ## Запускатель `src/run.py`
@ -36,5 +36,9 @@
- Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429. - Поля протокола `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).

View File

@ -30,7 +30,9 @@
- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор. - `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
- `/usr/bin/python3 -W error::ResourceWarning -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` — основной цикл (нужен `agent.json` рядом с `agent.py`).
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет). - `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет).
- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3. - `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3.
@ -38,12 +40,12 @@
## Тесты ## Тесты
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`, - Тесты в `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()`. - `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`.
- Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`, - Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`,
`test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.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`). `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, обход дерева, MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,

View File

@ -4,7 +4,36 @@
Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека, Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
один файл `src/agent.py` и запускатель `src/run.py`. один файл `src/agent.py` и запускатель `src/run.py`.
Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`). ## Установка на виртуалку
Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить). 1. Каталог рядом с jira-agent, например `C:\...\eva-agent\` или `~/eva-agent/`.
Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`. 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:<id>`, `wiki_fetch` вложения и его содержимое.

View File

@ -193,3 +193,22 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
- Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva).
Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. Обоснование: сверка с кодом хаба (`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 <jira-hub/.wt/wiki> --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/<id>/fetch`, `wiki_fetch`, `GET …/content` и сверка sha256.
- Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с.
- Хаб принял всё, что требует его спека (расхождений с хабом не найдено).
Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных.

108
scripts/e2e-hub.sh Executable file
View File

@ -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" <<JSON
{"hub_url": "$HUB", "hub_token": "$AGENT_TOKEN",
"eva": {"base_url": "http://127.0.0.1:$EVA_PORT", "token": "$EVA_TOKEN"},
"verify_ssl": true, "ca_bundle": null, "timeout": 30, "poll_seconds": 0, "auto_update": false}
JSON
agent() { /usr/bin/python3 "$HERE/src/agent.py" --config "$TMP/agent.json" --journal "$TMP/journal.jsonl" "$@"; }
agent check || fail "agent.py check"
# --- проект с корнем вики -> пакет 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:<id>: $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"

38
scripts/fake_eva_serve.py Normal file
View File

@ -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 = '<p>Схема:</p><img alt="Схема" src="/files/obj/e2e/scheme.png">'
eva.add_doc(1, "DOC-1", "Корень e2e", project="E2E", text="<p>корень</p>")
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='<p>см. <a href="/project/Document/DOC-2">требования</a></p>')
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)
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())

View File

@ -1,5 +1,5 @@
{ {
"hub_url": "http://192.168.0.106:8790", "hub_url": "folder:/путь/к/общей/папке/jira-bridge",
"hub_token": "токен агента wiki из `jira-hub tokens`", "hub_token": "токен агента wiki из `jira-hub tokens`",
"eva": { "eva": {
"base_url": "https://eva.example.ru", "base_url": "https://eva.example.ru",

View File

@ -6,8 +6,8 @@
jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/. jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/.
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи python3 agent.py check # доступ к хабу и Eva, без записи
python3 agent.py run # основной цикл (по умолчанию) python3 agent.py run # основной цикл (по умолчанию); run --once — выполнить выданное и выйти (сквозной прогон)
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
@ -1258,13 +1258,13 @@ def acquire_lock(journal_path: str):
return f 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) lock = acquire_lock(journal_path)
if lock is None: if lock is None:
log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path)
return EXIT_LOCKED return EXIT_LOCKED
try: try:
return _run(cfg, journal_path, config_path) return _run(cfg, journal_path, config_path, once)
finally: finally:
lock.close() lock.close()
@ -1292,8 +1292,10 @@ def launcher_version() -> Optional[int]:
return None 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)) 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 = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path)
runner.updater.should_stop = lambda: runner.stop runner.updater.should_stop = lambda: runner.stop
@ -1333,13 +1335,20 @@ def cmd_check(cfg: dict) -> int:
except (ConnectionError, HttpError) as e: except (ConnectionError, HttpError) as e:
log("хаб: НЕДОСТУПЕН — %s" % e) log("хаб: НЕДОСТУПЕН — %s" % e)
ok = False ok = False
# проверка Eva — в задаче 6 плана eva = EvaClient(cfg)
return 0 if ok else 1 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: def cmd_selftest(cfg: dict) -> int:
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" 0 — хаб и Eva доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
log("eva-agent v%d" % AGENT_VERSION) log("eva-agent v%d" % AGENT_VERSION)
return 0 if cmd_check(cfg) == 0 else 3 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 = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) 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("--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"]) ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"])
args = ap.parse_args(argv) args = ap.parse_args(argv)
cfg = load_config(args.config) cfg = load_config(args.config)
@ -1357,7 +1367,7 @@ def main(argv=None) -> int:
return cmd_selftest(cfg) return cmd_selftest(cfg)
if args.cmd == "prune": if args.cmd == "prune":
return cmd_prune(args.journal) 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__": if __name__ == "__main__":

77
tests/test_check.py Normal file
View File

@ -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()

View File

@ -236,7 +236,7 @@ class ClientsTest(unittest.TestCase):
json.dump(eva_cfg(), f) json.dump(eva_cfg(), f)
out = io.StringIO() out = io.StringIO()
with contextlib.redirect_stdout(out): 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()) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
def test_selftest_command(self): def test_selftest_command(self):

View File

@ -10,6 +10,7 @@ from contextlib import redirect_stdout
from datetime import datetime, timezone from datetime import datetime, timezone
from _common import SETTINGS, agent, eva_cfg, no_sleep from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
from fake_hub import FakeHub from fake_hub import FakeHub
from fake_relay import FakeRelay 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") self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
os.makedirs(self.root) os.makedirs(self.root)
self.hub = FakeHub(SETTINGS) self.hub = FakeHub(SETTINGS)
self.eva = FakeEva() # check ходит и в Eva (design 10)
self.relay = FakeRelay(self.root, self.hub.base_url).start() self.relay = FakeRelay(self.root, self.hub.base_url).start()
def tearDown(self): def tearDown(self):
self.relay.stop() self.relay.stop()
self.hub.stop() self.hub.stop()
self.eva.stop()
self.tmp.cleanup() self.tmp.cleanup()
def cfg(self): 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): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 1) self.assertEqual(agent.AGENT_VERSION, 1)
@ -228,7 +231,7 @@ class FolderIntegrationTest(unittest.TestCase):
t0 = time.monotonic() t0 = time.monotonic()
with redirect_stdout(out): with redirect_stdout(out):
code = agent.cmd_check(self.cfg()) code = agent.cmd_check(self.cfg())
self.assertEqual(code, 1) self.assertEqual(code, 3)
self.assertLess(time.monotonic() - t0, 13) self.assertLess(time.monotonic() - t0, 13)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue()) self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
self.assertIn("не ответил через папку", out.getvalue()) self.assertIn("не ответил через папку", out.getvalue())

View File

@ -11,6 +11,7 @@ import time
import unittest import unittest
from _common import SETTINGS, SRC from _common import SETTINGS, SRC
from fake_eva import FakeEva
from fake_hub import FakeHub from fake_hub import FakeHub
@ -28,13 +29,14 @@ class SelfUpdateE2E(unittest.TestCase):
def setUp(self): def setUp(self):
self.hub = FakeHub(SETTINGS) self.hub = FakeHub(SETTINGS)
self.eva = FakeEva() # selftest = check: нужна доступная Eva
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
d = self.tmp.name d = self.tmp.name
shutil.copy(os.path.join(SRC, "run.py"), d) shutil.copy(os.path.join(SRC, "run.py"), d)
shutil.copy(os.path.join(SRC, "agent.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: 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, 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: with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
src = f.read() src = f.read()
self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1)) 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.proc.wait()
self.log.close() self.log.close()
self.hub.stop() self.hub.stop()
self.eva.stop()
self.tmp.cleanup() self.tmp.cleanup()
def state(self): def state(self):