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:
parent
3597495a6a
commit
acdd52742e
@ -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).
|
||||
|
||||
@ -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, обход дерева,
|
||||
|
||||
35
README.md
35
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:<id>`, `wiki_fetch` вложения и его содержимое.
|
||||
|
||||
@ -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 <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
108
scripts/e2e-hub.sh
Executable 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
38
scripts/fake_eva_serve.py
Normal 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())
|
||||
@ -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",
|
||||
|
||||
28
src/agent.py
28
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__":
|
||||
|
||||
77
tests/test_check.py
Normal file
77
tests/test_check.py
Normal 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()
|
||||
@ -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):
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user