diff --git a/docs/work-items/ET-010/01-brd.md b/docs/work-items/ET-010/01-brd.md new file mode 100644 index 0000000..98fca3a --- /dev/null +++ b/docs/work-items/ET-010/01-brd.md @@ -0,0 +1,245 @@ +--- +type: brd +work_item_id: ET-010 +title: "BRD: [F-2b] Очередь задач вместо in-process daemon-потоков" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-013" # S-4: worktree per task — инфра-предусловие для concurrency > 1 +target_repo: orchestrator +--- + +# Business Requirements Document — ET-010 (F-2b) + +## 1. Контекст + +Orchestrator (`/repos/orchestrator`) запускает Claude CLI-агентов +(`analyst`, `architect`, `developer`, `reviewer`, `tester`, `deployer`) +через `AgentLauncher.launch()` в `src/agents/launcher.py`. Текущая +реализация: + +1. Webhook-обработчик (Plane `comment.created`, Gitea `push`, + Gitea PR-events) вызывает `launcher.launch(...)` **синхронно из + запроса FastAPI**. +2. `launch()` стартует `subprocess.Popen([...])` для `claude.exe` + и **два daemon-потока** уровня процесса uvicorn: + - `_watchdog` — `time.sleep(timeout)` и `os.kill(pid, SIGKILL)`; + - `_monitor_agent` — `proc.wait()` → закрытие лога → + `git fetch/checkout/add/commit/push` → `_try_advance_stage()` → + (возможно) рекурсивный `self.launch(next_agent, ...)`. +3. Состояние run-а **существует только в памяти процесса**: pid, + handle файла лога, `proc`-объект, поток-наблюдатель. В БД лежит + запись `agent_runs` (id, started_at, output_path), но **нет** + способа возобновить наблюдение за процессом после рестарта. + +Эта архитектурная договорённость зафиксирована как **известное +ограничение** в `/repos/orchestrator/docs/ARCHITECTURE.md`: + +> **In-process daemon-потоки.** Агенты запускаются в daemon-потоках +> uvicorn. При рестарте uvicorn запущенные агенты осиротевают → +> ловит orphan-recovery (M-1). Целевая архитектура — очередь задач +> (F-2b, отдельно). + +ET-010 закрывает это ограничение. + +## 2. Бизнес-проблема + +| # | Проблема | Симптом / последствие | +|---|----------|-----------------------| +| P1 | Daemon-потоки умирают на рестарте uvicorn | Реально запущенный `claude.exe` отвязывается от своего monitor-потока. Файл лога пишется до завершения, но post-run `git commit/push` и `_try_advance_stage` **не выполняются**. M-1 помечает run-у `exit=-1` и шлёт Telegram «нужна ручная проверка». Задача застревает между стадиями | +| P2 | Webhook обрабатывает запрос синхронно с heavy lifting | `handle_work_item_created` блокируется на `launcher.launch(...)` (Popen + 2 thread spawn + DB-update). При пике webhook-ов очередь uvicorn растёт; Plane/Gitea отдают timeout, ретраят → дубликаты event-ов в `events` | +| P3 | Нет управления concurrency | `launch()` всегда сразу стартует subprocess. При параллельных задачах (ET-013 разблокирует) ничего не мешает запустить 10 агентов одновременно, упереться в ulimit / RAM / Claude API rate-limit. Backpressure отсутствует | +| P4 | Нет идемпотентности при ретрае webhook-а | Если Gitea ретраит `push` → orchestrator снова запускает того же агента (новый `agent_runs.id`, дубликат subprocess). Сейчас прикрыто `_ensure_pr` (поиск открытого PR), но для analyst/architect защиты нет | +| P5 | Нет видимости «в очереди» | В таблице `tasks` есть `stage`, но нет понятия «агент запланирован, ждёт окно». Owner не понимает, почему `stage=architecture` уже минуту, а agent_run ещё не появился | +| P6 | Невозможна корректная graceful shutdown | `docker compose restart orchestrator` обрывает daemon-потоки. Нет канала «дай завершиться текущим, новые не бери» | +| P7 | Сложно тестировать | `launch()` запускает реальный subprocess и threading; unit-тесты вынуждены мочить весь Popen-стек. Интеграционные тесты бегают через настоящий Claude CLI и/или фейковый бинарь | +| P8 | Авто-advance делается из monitor-потока | `_monitor_agent` → `_try_advance_stage` → `self.launch(next_agent)`. Цепочка стадий выполняется в стеке daemon-потока, утяжеляет его жизнь и роняет всю цепочку при любом рестарте между шагами | + +## 3. Цели + +1. **G-1.** Заменить in-process daemon-потоки **персистентной + очередью**, состояние которой переживает рестарт оркестратора. +2. **G-2.** Webhook-хендлеры **только ставят задачу в очередь** + (быстрый возврат `202 Accepted`); запуск агента и post-run + действия выполняет **worker** (одна или несколько short-lived + корутин/процессов orchestrator-а). +3. **G-3.** Worker умеет: пикапить queued job, запускать subprocess, + ждать завершения, post-run git операции, авто-advance, поставить + следующего агента в очередь. Заменяет логику `launch()` + + `_watchdog` + `_monitor_agent` + `_try_advance_stage`. +4. **G-4.** Concurrency cap — конфигурируем (`MAX_CONCURRENT_AGENTS`, + default = 1 до выкатки ET-013; 2-3 — после). +5. **G-5.** При рестарте оркестратора **не теряются** ни: + - задачи в статусе `queued` (worker подберёт), + - задачи в статусе `running`, чей claude-процесс ещё жив + (worker возобновляет watch), + - задачи в статусе `running`, чей claude-процесс умер вместе + с предыдущим оркестратором (worker помечает `failed` и + инициирует retry/эскалацию). +6. **G-6.** Корректный graceful shutdown: SIGTERM → worker + перестаёт пикапить новые, ждёт завершения текущего job-а + до `SHUTDOWN_GRACE_SEC`, затем kill. +7. **G-7.** Видимость состояния в `/status`: количество + `queued/running/done/failed` jobs за последние N часов; + оценочное время до пикапа. +8. **G-8.** Идемпотентность enqueue: повторный запрос «поставить + агента A для task T» **не создаёт** дубликат, если уже есть + `queued`/`running` job того же агента для той же задачи. +9. **G-9.** Сохранить совместимость с существующим публичным + контрактом `launcher.launch(agent, repo, task_content, task_id)` + ровно настолько, насколько того требует **минимизация + diff в вызывающих местах**. Сигнатура может измениться (например, + возвращать `job_id` вместо `run_id`), но семантика «после вызова + агент будет запущен» — сохраняется. + +## 4. Не-цели + +- Менять модель агентов (по-прежнему Claude CLI как subprocess, + системные промпты в `.openclaw/agents/*.md`). +- Менять FastAPI на другой web-стек. +- Вводить внешний message broker (Redis/RabbitMQ/Kafka) — см. + «Принципы». +- Менять контракт webhook-ов Plane/Gitea. +- Менять формат `docs/work-items//`. +- Менять state-machine стадий (`STAGE_TRANSITIONS`). +- Параллельно запускать **два разных агента в одной задаче** + (агент-цепочка остаётся последовательной). +- Реализовывать distributed workers (multi-host). Все воркеры — + внутри одного orchestrator-контейнера. +- Закрывать ET-013 (worktree-изоляция) — это **отдельная** задача + и **жёсткое предусловие** для `MAX_CONCURRENT_AGENTS > 1`. + +## 5. Стейкхолдеры + +| Роль | Заинтересованность | +|------|--------------------| +| Owner (Слава) | Стабильность пайплайна: задача не «теряется» из-за рестарта оркестратора. Видимость очереди в `/status` и Telegram | +| Orchestrator | Корректное состояние `agent_runs`/`tasks` после crash-loop; отсутствие orphan-процессов | +| Plane / Gitea webhooks | Быстрый возврат (≤ 1 сек) — не упираться в их retry-окно | +| Agents (Claude CLI) | По-прежнему получают `cd && claude.exe --print ...`. Поведение subprocess неотличимо от текущего | +| DevOps | Контролируемая нагрузка на mva154; graceful shutdown позволяет деплоить без abort-а активных run-ов | +| ET-013 | F-2b — её прямой потребитель: только при наличии очереди concurrency > 1 имеет смысл | + +## 6. Бизнес-ценность + +- Устранение P1 (потеря состояния на рестарте) убирает класс + инцидентов «orphan agent run, нужна ручная проверка». M-1 + становится резервным механизмом, а не штатным сценарием. +- Быстрый возврат webhook-ов (P2) убирает дубликаты event-ов и + ретраи Plane/Gitea, делая `events`-таблицу чище. +- Concurrency cap (P3) защищает mva154 от резкого скачка нагрузки + при бэклоге из нескольких задач. +- Видимость очереди (G-7) делает воспроизводимым ответ на + вопрос «почему стадия не двигается» — owner видит позицию + в очереди вместо догадок. +- Очередь — **технический предусловие** для будущих фич: + параллельные задачи (ET-013), приоритизация (urgent vs + backlog), pause/resume по теме, лимит «не больше 1 deployer-а + одновременно». + +## 7. Принципы + +| Принцип | Импликация | +|---------|------------| +| Минимум внешних зависимостей | **Не вводить** Redis/RabbitMQ/Celery/RQ. Очередь — таблица в существующей SQLite, поллинг — корутины внутри FastAPI-процесса | +| Stateless webhooks | Хендлеры пишут `events` + `enqueue()`. Никакого heavy lifting в обработчике webhook-а | +| At-least-once execution | Worker гарантирует, что job в статусе `queued` будет выполнен ≥ 1 раз; идемпотентность лежит на стороне агента/`_ensure_pr`. **Никаких exactly-once.** | +| Visibility-first | Все переходы статуса фиксируются в БД, доступны через `/status`. Все ошибки — в Telegram | +| Graceful by default | SIGTERM **никогда** не убивает agent run в середине; SIGKILL — только после `SHUTDOWN_GRACE_SEC` | +| Совместимость БД | `agent_runs` — не переименовывать; вводимая таблица `agent_jobs` (или эквивалентная) дополняет, не заменяет | + +## 8. Ограничения + +- **SQLite как очередь.** Поллинг (1-2 сек интервал) приемлем + для текущих масштабов (≤ 20 jobs/час). Если на горизонте + ≥ 100 jobs/час — нужна перепроверка (выходит за рамки F-2b). +- **Один процесс — один воркер-пул.** Worker-корутины крутятся + в том же uvicorn-процессе, что и FastAPI. Это уменьшает + изоляцию, но соответствует «минимум зависимостей». Уход на + отдельный worker-процесс — будущая оптимизация (за рамками + F-2b). +- **Concurrency = 1 на старте.** Пока ET-013 не закрыта, два + параллельных агента на одной `/repos/` гарантированно + испортят чужой checkout. `MAX_CONCURRENT_AGENTS` хардкод-default + = 1; повышается только после merge ET-013. +- **Per-task последовательность.** Внутри одной задачи цепочка + агентов идёт строго последовательно (`analyst` → `architect` → + ... → `deployer`); очередь это поддерживает, но не «обгоняет» + стадии одной задачи параллельно. +- **Webhook → enqueue идемпотентность по (task_id, agent)**, а не + по (webhook delivery id). Это покрывает «Gitea ретраит push», + но не покрывает «Plane дублирует event с другим payload-ом» + (последнее — ответственность webhook-хендлера, не очереди). + +## 9. Риски и зависимости + +| ID | Риск | Митигация | +|----|------|-----------| +| R-1 | SQLite-поллинг создаёт лишний writer-locks / `BEGIN IMMEDIATE` | Поллинг через `SELECT` (без `UPDATE`); `BEGIN IMMEDIATE` только на момент пикапа job-а (`UPDATE ... WHERE id=? AND status='queued'`). Покрыть нагрузочным тестом 100 jobs | +| R-2 | Worker умирает в середине job-а (например, OOM) | По старту воркер проверяет: jobs со статусом `running` + heartbeat_at старше N сек → `failed/orphan`. Шлёт Telegram, помечает run-у `exit=-1` | +| R-3 | Гонка enqueue: два webhook-а приходят одновременно за тем же `(task_id, agent)` | UNIQUE INDEX `(task_id, agent)` на jobs со статусом ∈ {queued, running}; на дубликате — лог + 200 OK | +| R-4 | Watchdog (kill-on-timeout) теряется при рестарте | Worker при пикапе running-job-а вычисляет `now - started_at`; если > `AGENT_TIMEOUT` — сразу `SIGKILL` + `failed`. Watchdog становится свойством worker-цикла, не отдельного потока | +| R-5 | `_try_advance_stage` сейчас инкрементально запускает следующего агента **из стека monitor-потока**. После переноса в worker нужно **enqueue следующего job-а**, а не вызывать его inline | TRZ зафиксирует это явно как обязательное изменение | +| R-6 | Зависимость от ET-013 (worktree-изоляция) | F-2b сам по себе валиден при `MAX_CONCURRENT_AGENTS=1`. Повышение лимита — отдельный шаг после ET-013 | +| R-7 | Существующие активные задачи на момент деплоя | Скрипт миграции (один раз): для каждой `tasks.stage != done` сверить с `agent_runs` (есть ли live process). Если live — создать `agent_jobs` со статусом `running`, привязать. Если нет — создать `queued` (если QG не пройден) или ничего (если задача между стадиями) | +| R-8 | Поведение `claude.exe` зависит от `cd` в нужный каталог. При смене стратегии запуска (worker-корутина vs subprocess) нужно сохранить cwd | TRZ зафиксирует: всё команда формируется так же, как сегодня — `bash -c "cd && claude.exe ..."` | +| R-9 | Длинные agent-job-ы (developer ≤ 30 мин) могут «застрять» worker-слот | Worker-слот ≠ блокирующий поток; пикап job-а спавнит subprocess + регистрирует наблюдение, освобождает слот *только когда job завершится*. Это **по дизайну** ограничивает concurrency, но не блокирует другие задачи (concurrency > 1 = больше слотов) | +| R-10 | Cancel-механизм нужен (например, owner отменил задачу в Plane) | Поле `jobs.cancel_requested=1` → worker при следующем check'е (раз в N сек) SIGKILL-ит subprocess и помечает `failed/cancelled`. Минимально-достаточная реализация | + +## 10. Метрики успеха (KPI) + +| KPI | Целевое значение | +|-----|------------------| +| `agent_runs.exit_code=-1` (M-1 orphan recovery) на 100 task-runs | ≤ 1 (сейчас — ≥ 5 при крашах) | +| Время от webhook → 200/202 OK | ≤ 500 мс (сейчас включает spawn subprocess + 2 threads) | +| Время от enqueue → пикап (при пустой очереди) | ≤ 3 сек | +| Job-ы, потерянные при `docker compose restart orchestrator` | 0 (running-jobs возобновлены или корректно помечены) | +| Видимость очереди в `/status` | Endpoint `GET /queue` отдаёт `queued/running/recent_failed` (требование AC) | +| Webhook → дубликат job-а (Gitea retry) | 0 (UNIQUE-индекс) | +| Backward compatibility | 100% задач в стадиях `analysis..deploy` на момент деплоя ET-010 продолжают работать | + +## 11. Out-of-scope + +- Реализация worktree-изоляции (ET-013/S-4). +- Распределённые воркеры (multi-host). +- Динамическое масштабирование concurrency (auto-scale в зависимости + от длины очереди). +- Приоритизация jobs (urgent vs backlog). +- Замена SQLite на PostgreSQL для целей очереди. +- Cancel-from-UI (cancel-механизм есть, но интерфейс «отменить из + Plane» — отдельная фича, см. R-10 — реализуется минимально: + `tasks.stage='done'` => worker помечает все queued job-ы этой + задачи как `cancelled`). +- Замена `_ensure_pr` / `_auto_merge_pr` (остаются в worker-цикле). +- Замена `notify_*` функций (вызовы остаются на тех же точках + жизненного цикла job-а). + +## 12. Допущения + +- Worker-корутина внутри FastAPI-процесса — допустимая модель + (без отдельного docker-сервиса). Подтверждено принципом + «минимум зависимостей» и текущей архитектурой. +- ET-013 (worktree per task) — в работе **параллельно** или + **раньше**; ET-010 не блокируется ею, но `MAX_CONCURRENT_AGENTS` + по умолчанию остаётся 1 до её мерджа. +- SQLite-таблица `agent_jobs` (или её эквивалент по решению + архитектора) добавляется через миграцию в `db.init_db()` без + rebuild контейнера (DDL — IDempotent CREATE). +- Логи jobs продолжают писаться в `/app/data/runs/{run_id}.log` + (формат и путь не меняются). + +## 13. Связанные документы + +- `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные + ограничения» (источник постановки задачи). +- `/repos/orchestrator/src/agents/launcher.py` — место реализации. +- `docs/work-items/ET-013/01-brd.md` — параллельная задача + worktree-изоляции; жёсткая зависимость для concurrency > 1. +- `docs/work-items/ET-010/02-trz.md` — ТЗ. +- `docs/work-items/ET-010/03-acceptance-criteria.md` — критерии + приёмки. +- `docs/work-items/ET-010/04-test-plan.yaml` — план тестирования. diff --git a/docs/work-items/ET-011/02-trz.md b/docs/work-items/ET-011/02-trz.md new file mode 100644 index 0000000..996b9f7 --- /dev/null +++ b/docs/work-items/ET-011/02-trz.md @@ -0,0 +1,911 @@ +--- +type: trz +work_item_id: ET-011 +title: "ТЗ: Деплой и rollback без git checkout в shared /repos (S-2/S-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-010" + - "ET-013" +--- + +# ТЗ — ET-011: Деплой и rollback без git checkout в shared /repos + +## 0. Контекст для разработчика + +Прочесть перед началом: +1. `docs/work-items/ET-011/01-brd.md` — что и зачем. +2. `docs/work-items/ET-011/03-acceptance-criteria.md` — критерии приёмки. +3. Текущий `.openclaw/agents/deployer.md` — то, что переписываем. +4. `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные ограничения». +5. `docs/work-items/ET-009/14-deploy-log.md` — реальный пример deploy + + описание двух багов deploy-хука (см. § 1.2 follow-ups). +6. `docs/operations/runbook.md` — куда добавлять секцию Rollback. + +## 1. Структура изменений + +``` +enduro-trails/ +├── .openclaw/agents/ +│ └── deployer.md # rewrite (§ 2) +├── scripts/ +│ ├── enduro-deploy-hook.sh # new (§ 3) +│ └── lint_deployer_prompt.py # new (§ 4) +├── tests/ +│ ├── integration/ +│ │ └── test_deployer_prompt.py # new (§ 5) +│ └── fixtures/deployer/ +│ ├── deployer-bad-checkout.md # new (§ 5.2) +│ └── deployer-good.md # new (§ 5.2) +├── Makefile # edit (§ 6) +├── docs/operations/runbook.md # edit (§ 7) +└── docs/work-items/ET-011/ + ├── 01-brd.md + ├── 02-trz.md # этот файл + ├── 03-acceptance-criteria.md + ├── 04-test-plan.yaml + └── 06-adr/ADR-NNNN-deploy-rollback-no-shared-checkout.md # architect +``` + +## 2. `.openclaw/agents/deployer.md` — переписать + +### 2.1 Полный новый текст агента (минимально-инвазивно) + +Сохранить frontmatter, описание роли, среды. Переписать раздел +«Алгоритм (выполняй строго по порядку)». Ниже — целевая структура +с командами. **Каждый bash-блок — копипастабельный**; конкретные +переменные (BRANCH, WORK_ITEM_ID, NEW_TAG и т.д.) уже использует +текущая версия. + +#### Шаг 0. Контекст задачи (новый, read-only) + +```bash +# Прочитать ветку из task-файла (без git) +BRANCH=$(grep "^Branch:" .task-deploy.md | awk '{print $2}') +WORK_ITEM_ID=$(grep "^Work item:" .task-deploy.md | awk '{print $3}') + +GITEA_TOKEN=$ORCH_GITEA_TOKEN +GITEA_API="http://localhost:3000/api/v1" +REPO_OWNER="admin" +REPO_NAME="enduro-trails" +GITEA_REPO_URL="${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}" + +if [ -z "$BRANCH" ] || [ -z "$WORK_ITEM_ID" ] || [ -z "$GITEA_TOKEN" ]; then + echo "FATAL: BRANCH/WORK_ITEM_ID/ORCH_GITEA_TOKEN missing" + exit 1 +fi +``` + +Запрещено: `cd /repos/enduro-trails && git ...`. Все операции — через +API/SSH или read-only без `cd`. + +#### Шаг 1. Merge PR (без изменений) + +```bash +PR_NUMBER=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ + "${GITEA_REPO_URL}/pulls?state=open&head=${BRANCH}" \ + | python3 -c "import sys,json; prs=json.load(sys.stdin); print(prs[0]['number'] if prs else '')") + +if [ -z "$PR_NUMBER" ]; then + echo "ERROR: No open PR for $BRANCH" + exit 1 +fi + +MERGE_RESP=$(curl -s -o /tmp/merge.json -w "%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_REPO_URL}/pulls/${PR_NUMBER}/merge" \ + -d '{"Do":"merge"}') + +if [ "$MERGE_RESP" != "200" ]; then + echo "ERROR: PR merge failed ($MERGE_RESP): $(cat /tmp/merge.json)" + exit 1 +fi + +# Получить merge-commit +MERGE_SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ + "${GITEA_REPO_URL}/pulls/${PR_NUMBER}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['merge_commit_sha'])") +``` + +#### Шаг 2. Tag через Gitea API (S-2) + +```bash +# Получить предыдущий тег (без git describe) +LAST_TAG=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ + "${GITEA_REPO_URL}/tags?limit=1" \ + | python3 -c "import sys,json; tags=json.load(sys.stdin); print(tags[0]['name'] if tags else 'v0.0.0')") + +# Инкремент patch +MAJOR=$(echo $LAST_TAG | cut -d. -f1 | tr -d v) +MINOR=$(echo $LAST_TAG | cut -d. -f2) +PATCH=$(echo $LAST_TAG | cut -d. -f3) +NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH+1))" + +# Создать тег через API +TAG_RESP=$(curl -s -o /tmp/tag.json -w "%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_REPO_URL}/tags" \ + -d "{\"tag_name\":\"${NEW_TAG}\",\"target\":\"${MERGE_SHA}\",\"message\":\"Release ${NEW_TAG} (${WORK_ITEM_ID})\"}") + +if [ "$TAG_RESP" != "201" ] && [ "$TAG_RESP" != "200" ]; then + echo "ERROR: tag create failed ($TAG_RESP): $(cat /tmp/tag.json)" + exit 1 +fi +echo "Tag ${NEW_TAG} created on ${MERGE_SHA}" +``` + +ЗАПРЕЩЕНО: `git tag`, `git push origin `, `git fetch` (в shared +`/repos`). + +#### Шаг 3. Deploy через SSH-хук (без изменений по существу) + +```bash +DEPLOY_USER=${DEPLOY_SSH_USER:-slin} +DEPLOY_HOST=${DEPLOY_SSH_HOST:-127.0.0.1} +HOOK=${DEPLOY_HOOK_SCRIPT:-/home/slin/bin/enduro-deploy-hook.sh} + +ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + ${DEPLOY_USER}@${DEPLOY_HOST} \ + "bash ${HOOK} --ref main" +if [ $? -ne 0 ]; then + echo "ERROR: Deploy hook failed" + exit 1 +fi +echo "Deploy OK (ref=main)" +``` + +Добавлено: явный аргумент `--ref main`. Хук теперь параметризован +(см. § 3). + +#### Шаг 4. Healthcheck (без изменений) + +```bash +STATUS="" +for i in $(seq 1 12); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + https://openclaw.mva154.duckdns.org/enduro/ 2>/dev/null) + if [ "$STATUS" = "200" ]; then + echo "Healthcheck OK" + break + fi + sleep 5 +done +if [ "$STATUS" != "200" ]; then + echo "ERROR: Healthcheck failed (HTTP $STATUS)" + SMOKE_FAILED=1 +fi +``` + +#### Шаг 5. Smoke (без изменений по существу, но без `exit 1` — теперь только маркер) + +```bash +SMOKE_FAILED=${SMOKE_FAILED:-0} +for ENDPOINT in \ + https://openclaw.mva154.duckdns.org/enduro/ \ + https://openclaw.mva154.duckdns.org/enduro/static/style.json \ + https://openclaw.mva154.duckdns.org/enduro/static/app.js \ +; do + curl -sf "$ENDPOINT" > /dev/null || { echo "Smoke fail: $ENDPOINT"; SMOKE_FAILED=1; } +done +if [ "$SMOKE_FAILED" = "0" ]; then + echo "Smoke PASS" +fi +``` + +#### Шаг 6. Rollback через SSH-хук (S-3) — переписан + +```bash +if [ "$SMOKE_FAILED" = "1" ]; then + echo "Smoke/Healthcheck FAILED — rollback to $LAST_TAG" + + if [ "$LAST_TAG" = "v0.0.0" ] || [ -z "$LAST_TAG" ]; then + echo "ROLLBACK SKIPPED: no previous tag" + # Сообщить в Plane и в Telegram (см. шаг 7-fail) + exit 1 + fi + + # Откатить через SSH-хук на хосте — БЕЗ локального git checkout + ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + ${DEPLOY_USER}@${DEPLOY_HOST} \ + "bash ${HOOK} --rollback --to ${LAST_TAG}" + RB_RC=$? + if [ $RB_RC -ne 0 ]; then + echo "FATAL: Rollback hook failed (rc=$RB_RC)" + exit 1 + fi + + # Smoke after rollback + RB_SMOKE_OK=1 + for ENDPOINT in \ + https://openclaw.mva154.duckdns.org/enduro/ \ + https://openclaw.mva154.duckdns.org/enduro/api/health \ + ; do + curl -sf "$ENDPOINT" > /dev/null || { echo "Post-rollback smoke fail: $ENDPOINT"; RB_SMOKE_OK=0; } + done + if [ "$RB_SMOKE_OK" = "0" ]; then + echo "FATAL: Post-rollback smoke failed — manual intervention needed" + exit 1 + fi + + echo "ROLLED BACK to $LAST_TAG, smoke OK" + # Дальше — записать deploy-log как ROLLED_BACK через API (см. шаг 7) + ROLLBACK_PERFORMED=1 +fi +``` + +ЗАПРЕЩЕНО: `git checkout`, `git reset --hard`, любое локальное +изменение HEAD shared `/repos`. + +#### Шаг 7. Финализация через Gitea API (S-2) — переписан + +```bash +# Подготовить контент deploy-log в /tmp (не в /repos) +DEPLOY_LOG_PATH="docs/work-items/${WORK_ITEM_ID}/14-deploy-log.md" +DATE_UTC=$(date -u +"%Y-%m-%d %H:%M UTC") + +if [ "${ROLLBACK_PERFORMED:-0}" = "1" ]; then + STATUS_LINE="ROLLED_BACK_TO_${LAST_TAG}" + HEALTH_LINE="FAIL (rolled back to ${LAST_TAG})" + SMOKE_LINE="FAIL (rolled back to ${LAST_TAG})" +else + STATUS_LINE="SUCCESS" + HEALTH_LINE="PASS" + SMOKE_LINE="PASS" +fi + +cat > /tmp/deploy-log.md < /tmp/changelog-block.md < /tmp/changelog-new.md +import pathlib +old = pathlib.Path('/tmp').joinpath('changelog-old.md').read_text() if False else """${CL_OLD}""" +block = pathlib.Path('/tmp/changelog-block.md').read_text() +# Найти первую секцию ## [vX.Y.Z] и вставить новый блок ПЕРЕД ней +import re +m = re.search(r'^## \[v', old, flags=re.M) +if m: + new = old[:m.start()] + block + old[m.start():] +else: + new = old.rstrip() + '\n\n' + block +print(new, end='') +PY + +CL_NEW_B64=$(base64 -w0 /tmp/changelog-new.md) +CL_RESP=$(curl -s -o /tmp/cl.json -w "%{http_code}" -X PUT \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_REPO_URL}/contents/CHANGELOG.md" \ + -d "{\"branch\":\"${DEPLOY_BRANCH}\",\"sha\":\"${CL_SHA}\",\"content\":\"${CL_NEW_B64}\",\"message\":\"deploy(${WORK_ITEM_ID}): CHANGELOG ${NEW_TAG}\",\"author\":{\"name\":\"claude-bot\",\"email\":\"claude-bot@mva154.local\"}}") +if [ "$CL_RESP" != "200" ] && [ "$CL_RESP" != "201" ]; then + echo "ERROR: CHANGELOG update failed ($CL_RESP): $(cat /tmp/cl.json)" + exit 1 +fi + +# Создать deploy-PR через API +PR_BODY=$(python3 -c "import json; print(json.dumps('Auto-generated deploy log + CHANGELOG for ${NEW_TAG} (${WORK_ITEM_ID})'))") +PR_RESP=$(curl -s -o /tmp/pr.json -w "%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_REPO_URL}/pulls" \ + -d "{\"title\":\"deploy(${WORK_ITEM_ID}): deploy log ${NEW_TAG} + CHANGELOG\",\"head\":\"${DEPLOY_BRANCH}\",\"base\":\"main\",\"body\":${PR_BODY}}") +if [ "$PR_RESP" != "201" ] && [ "$PR_RESP" != "200" ]; then + echo "ERROR: deploy-PR create failed ($PR_RESP): $(cat /tmp/pr.json)" + exit 1 +fi +DEPLOY_PR_NUM=$(cat /tmp/pr.json | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])") + +# Merge deploy-PR +MERGE_DPR_RESP=$(curl -s -o /tmp/mdpr.json -w "%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_REPO_URL}/pulls/${DEPLOY_PR_NUM}/merge" \ + -d '{"Do":"merge"}') +if [ "$MERGE_DPR_RESP" != "200" ]; then + echo "ERROR: deploy-PR merge failed ($MERGE_DPR_RESP): $(cat /tmp/mdpr.json)" + exit 1 +fi + +echo "Finalization OK: deploy-log + CHANGELOG merged via deploy-PR #${DEPLOY_PR_NUM}" +``` + +ЗАПРЕЩЕНО: писать файлы в `/repos/enduro-trails/docs/...` или +`/repos/enduro-trails/CHANGELOG.md`. Только `/tmp/*` + API. + +### 2.2 Секция «Запрещено» — расширить + +```markdown +## Запрещено + +- Менять исходный код (src/, tests/) +- Деплоить без merge +- Force push +- Игнорировать failed healthcheck/smoke +- **(S-2/S-3) Любые git-команды, мутирующие HEAD/working-tree/index + shared `/repos/enduro-trails`:** `git checkout`, `git pull`, + `git merge`, `git reset`, `git revert`, `git restore`, `git stash`, + `git rebase`, `git apply`, `git am`, `git tag` (с push), любые + команды, изменяющие `.git/HEAD` или working tree. +- **(S-2)** Локальная запись файлов в `/repos/enduro-trails/docs/`, + `/repos/enduro-trails/CHANGELOG.md`, `/repos/enduro-trails/src/`, + `/repos/enduro-trails/tests/`. +- **(S-3)** Локальные команды rollback. Откат идёт только через + SSH-хук с `--rollback --to `. + +## Разрешено (явно) + +- `curl` к `http://localhost:3000/api/v1/...` (Gitea API) и к + публичным/внутренним URL приложения. +- `ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh ...`. +- Read-only git: `git -C /repos/enduro-trails log `, + `git -C /repos/enduro-trails show `, `git -C /repos/enduro-trails + rev-parse `, `git -C /repos/enduro-trails ls-remote`, + `git -C /repos/enduro-trails fetch origin --no-tags` (refs-only, + не меняет working tree). +- Запись в `/tmp/*`. +``` + +### 2.3 Раздел «Среды» — без изменений + +Сохранить как есть. + +## 3. `scripts/enduro-deploy-hook.sh` — новый файл + +### 3.1 Расположение в репо и на хосте + +- В репо: `scripts/enduro-deploy-hook.sh`, права `755`. +- На хосте: `/home/slin/bin/enduro-deploy-hook.sh` — копия (или + симлинк) на файл из репо. Процедура синхронизации + документируется в `docs/operations/runbook.md` § + «Синхронизация deploy-хука». + +### 3.2 Содержимое + +```bash +#!/usr/bin/env bash +# enduro-deploy-hook.sh — deploy и rollback для enduro-trails на mva154 +# +# Использование: +# enduro-deploy-hook.sh # = --ref main +# enduro-deploy-hook.sh --ref vX.Y.Z # deploy конкретного тега +# enduro-deploy-hook.sh --rollback --to vX.Y.Z # rollback на тег +# +# Все операции делаются в /home/slin/repos/enduro-trails +# (НЕ в shared /repos контейнера orchestrator). +# +# Логи: $LOG_DIR/deploy-hook.log; fallback при отсутствии прав — /tmp/. + +set -euo pipefail + +REPO_DIR="${ENDURO_HOST_REPO:-/home/slin/repos/enduro-trails}" +LOG_DIR="${ENDURO_HOOK_LOG_DIR:-/var/log/enduro-trails}" + +# Логирование с fallback +if mkdir -p "$LOG_DIR" 2>/dev/null && touch "${LOG_DIR}/deploy-hook.log" 2>/dev/null; then + LOG="${LOG_DIR}/deploy-hook.log" +else + LOG="/tmp/enduro-deploy-hook.log" + touch "$LOG" +fi + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG" +} + +# Парсинг аргументов +MODE="deploy" +REF="main" +while [ $# -gt 0 ]; do + case "$1" in + --rollback) MODE="rollback"; shift;; + --ref) REF="$2"; shift 2;; + --to) REF="$2"; shift 2;; + *) log "ERROR: unknown argument: $1"; exit 2;; + esac +done + +log "MODE=${MODE} REF=${REF} REPO=${REPO_DIR}" + +cd "$REPO_DIR" + +# Получить актуальные refs +git fetch origin --tags --prune 2>&1 | tee -a "$LOG" + +# Проверить, что ref существует +if ! git rev-parse --verify "$REF" >/dev/null 2>&1; then + if ! git rev-parse --verify "origin/${REF}" >/dev/null 2>&1; then + if ! git rev-parse --verify "refs/tags/${REF}" >/dev/null 2>&1; then + log "FATAL: ref ${REF} not found locally or in origin" + exit 3 + fi + fi +fi + +# В этой копии checkout — БЕЗОПАСЕН, это НЕ shared /repos +if [ "$MODE" = "deploy" ]; then + log "DEPLOY: checkout ${REF}" + git checkout main 2>&1 | tee -a "$LOG" + git pull --ff-only origin main 2>&1 | tee -a "$LOG" +else + log "ROLLBACK: detached checkout ${REF}" + git -c advice.detachedHead=false checkout "${REF}" 2>&1 | tee -a "$LOG" +fi + +# Пересборка и рестарт контейнера +log "docker compose build app" +docker compose build app 2>&1 | tee -a "$LOG" + +log "docker compose up -d app" +docker compose up -d app 2>&1 | tee -a "$LOG" + +# Дать контейнеру стартовать +sleep 3 + +# Локальный smoke +HC=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5556/api/health || true) +log "Local healthcheck: HTTP ${HC}" +if [ "$HC" != "200" ]; then + log "FATAL: local healthcheck failed" + exit 4 +fi + +# Текущий HEAD +HEAD_SHA=$(git rev-parse HEAD) +log "OK MODE=${MODE} REF=${REF} HEAD=${HEAD_SHA}" +echo "{\"mode\":\"${MODE}\",\"ref\":\"${REF}\",\"head\":\"${HEAD_SHA}\",\"status\":\"ok\"}" +exit 0 +``` + +### 3.3 Свойства + +- `set -euo pipefail` — падает на первой ошибке. +- Логирование с fallback на `/tmp/` — решает баг ET-009 §1.2. +- JSON-summary в stdout — deployer может распарсить. +- `git -c advice.detachedHead=false checkout` — для rollback без + warning'а. +- `docker compose build app` — нужно, потому что Dockerfile копирует + `scripts/` и `docs/` (см. e2bf99d), для rollback на старый тег + нужно перебилдить с тем deploy-кодом. + +## 4. `scripts/lint_deployer_prompt.py` — новый файл + +### 4.1 Назначение + +Парсит `.openclaw/agents/deployer.md`, ищет в ```bash-блоках` +запрещённые команды. Exit 0 если чисто, exit 1 с детализацией если +нет. + +### 4.2 Реализация + +```python +#!/usr/bin/env python3 +""" +lint_deployer_prompt.py — проверка deployer-агента на запрещённые +git-операции в shared /repos. + +Парсит ```bash блоки в .openclaw/agents/deployer.md и в любом +файле, переданном через CLI. Регэкспы: + + FORBIDDEN_CMD = [ + r"\bgit\s+checkout\b", + r"\bgit\s+pull\b", + r"\bgit\s+merge\b(?!\-base)", + r"\bgit\s+reset\s+--hard\b", + r"\bgit\s+reset\s+--mixed\b", + r"\bgit\s+revert\b", + r"\bgit\s+restore\b", + r"\bgit\s+stash\b", + r"\bgit\s+rebase\b", + r"\bgit\s+apply\b", + r"\bgit\s+am\b", + r"\bgit\s+tag\s+\w+\s", # git tag NEWTAG + r"\bgit\s+push\s+\w+\s+(refs/tags|v\d|tag)\b", + ] + +Исключение: команды внутри single-quoted строки в `ssh ... '...'` — +это команды на host, не в shared /repos. Линтер ищет паттерн +`ssh\s+[^'\"]*['\"]` и пропускает их содержимое. + +Exit code: + 0 — чисто; + 1 — найдены нарушения (с указанием line + match); + 2 — файл не найден / ошибка парсинга. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +FORBIDDEN = [ + (re.compile(r"\bgit\s+checkout\b"), "git checkout"), + (re.compile(r"\bgit\s+pull\b"), "git pull"), + (re.compile(r"\bgit\s+merge\b(?!-base)"), "git merge"), + (re.compile(r"\bgit\s+reset\s+--hard\b"), "git reset --hard"), + (re.compile(r"\bgit\s+reset\s+--mixed\b"), "git reset --mixed"), + (re.compile(r"\bgit\s+revert\b"), "git revert"), + (re.compile(r"\bgit\s+restore\b"), "git restore"), + (re.compile(r"\bgit\s+stash\b"), "git stash"), + (re.compile(r"\bgit\s+rebase\b"), "git rebase"), + (re.compile(r"\bgit\s+apply\b"), "git apply"), + (re.compile(r"\bgit\s+am\b"), "git am"), + (re.compile(r"\bgit\s+tag\s+\S+\s+\S"), "git tag "), + (re.compile(r"\bgit\s+push\s+\S+\s+(refs/tags|v\d|tag)\b"), + "git push origin "), +] + + +def extract_bash_blocks(text: str) -> list[tuple[int, str]]: + """Возвращает (start_line, block_content) для каждого ```bash блока.""" + blocks = [] + in_block = False + start_line = 0 + cur: list[str] = [] + for lineno, line in enumerate(text.splitlines(), start=1): + s = line.strip() + if not in_block and s.startswith("```bash"): + in_block = True + start_line = lineno + 1 + cur = [] + continue + if in_block and s == "```": + blocks.append((start_line, "\n".join(cur))) + in_block = False + continue + if in_block: + cur.append(line) + return blocks + + +def strip_ssh_inner(block: str) -> str: + """Удаляет содержимое ssh-команд (всё, что в кавычках после ssh ...).""" + # ssh ... "bash ${HOOK} ..." → ssh ... "" + block = re.sub(r'ssh\s+[^\n]*"[^"]*"', 'ssh ', block) + block = re.sub(r"ssh\s+[^\n]*'[^']*'", "ssh ", block) + return block + + +def lint_file(path: Path) -> list[str]: + if not path.exists(): + return [f"FILE_NOT_FOUND: {path}"] + text = path.read_text(encoding="utf-8") + blocks = extract_bash_blocks(text) + violations = [] + for start_line, block in blocks: + stripped = strip_ssh_inner(block) + for lineno_in_block, line in enumerate(stripped.splitlines()): + # Игнорировать строки-комментарии + if line.lstrip().startswith("#"): + continue + for pattern, name in FORBIDDEN: + if pattern.search(line): + abs_line = start_line + lineno_in_block + violations.append( + f"{path}:{abs_line}: forbidden '{name}': {line.strip()}" + ) + return violations + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + targets = [ + Path(arg) for arg in sys.argv[1:] + ] or [repo_root / ".openclaw" / "agents" / "deployer.md"] + + all_violations: list[str] = [] + for t in targets: + all_violations.extend(lint_file(t)) + + if all_violations: + print("DEPLOYER PROMPT LINT FAILED:", file=sys.stderr) + for v in all_violations: + print(f" {v}", file=sys.stderr) + return 1 + print(f"OK ({len(targets)} files clean)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +### 4.3 Использование + +```bash +# По умолчанию — проверяет .openclaw/agents/deployer.md +python3 scripts/lint_deployer_prompt.py + +# Явно +python3 scripts/lint_deployer_prompt.py .openclaw/agents/deployer.md + +# Для тестов — через CLI +python3 scripts/lint_deployer_prompt.py tests/fixtures/deployer/deployer-bad-checkout.md +# → exit 1 +``` + +## 5. Тесты + +### 5.1 `tests/integration/test_deployer_prompt.py` + +```python +""" +Integration-test для deployer.md и линтера ET-011. +""" +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEPLOYER_MD = REPO_ROOT / ".openclaw" / "agents" / "deployer.md" +LINTER = REPO_ROOT / "scripts" / "lint_deployer_prompt.py" +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "deployer" + + +def test_linter_passes_on_real_deployer_md(): + """Финальная версия deployer.md должна проходить линтер.""" + r = subprocess.run( + ["python3", str(LINTER), str(DEPLOYER_MD)], + capture_output=True, text=True, timeout=10, + ) + assert r.returncode == 0, f"linter failed on real deployer.md:\n{r.stderr}" + + +def test_linter_catches_bad_fixture(): + """Анти-паттерн (deployer-bad-checkout.md) должен ловиться.""" + r = subprocess.run( + ["python3", str(LINTER), str(FIXTURES / "deployer-bad-checkout.md")], + capture_output=True, text=True, timeout=10, + ) + assert r.returncode == 1 + assert "git checkout" in r.stderr + + +def test_linter_passes_on_good_fixture(): + """Sanity-fixture (deployer-good.md) проходит.""" + r = subprocess.run( + ["python3", str(LINTER), str(FIXTURES / "deployer-good.md")], + capture_output=True, text=True, timeout=10, + ) + assert r.returncode == 0 + + +def test_deployer_md_has_required_sections(): + """deployer.md должен иметь все 7 шагов.""" + text = DEPLOYER_MD.read_text(encoding="utf-8") + for section in [ + "### 1.", # Merge PR + "### 2.", # Tag + "### 3.", # Deploy + "### 4.", # Healthcheck + "### 5.", # Smoke + "### 6.", # Rollback + "### 7.", # Финализация + ]: + assert section in text, f"missing section {section} in deployer.md" + + +def test_deployer_md_no_local_changelog_write(): + """В deployer.md не должно быть локальной записи в CHANGELOG.md + (только через API).""" + text = DEPLOYER_MD.read_text(encoding="utf-8") + # Запрещено: cat > CHANGELOG.md или > /repos/.../CHANGELOG.md + forbidden_patterns = [ + ">>?\\s*CHANGELOG\\.md", + "/repos/enduro-trails/CHANGELOG\\.md", + "/repos/enduro-trails/docs/work-items/.*/14-deploy-log\\.md", + ] + import re + for p in forbidden_patterns: + assert not re.search(p, text), f"forbidden local-write pattern: {p}" + + +def test_deployer_md_uses_gitea_api_for_tag(): + """Шаг 2 (Tag) должен идти через API, не через git tag.""" + text = DEPLOYER_MD.read_text(encoding="utf-8") + assert "/tags" in text, "Gitea API tags endpoint not referenced" + assert "POST" in text + + +def test_deployer_md_uses_ssh_hook_for_rollback(): + """Шаг 6 (Rollback) должен звать SSH-хук с --rollback.""" + text = DEPLOYER_MD.read_text(encoding="utf-8") + assert "--rollback" in text, "rollback hook flag not referenced" + assert "--to" in text, "rollback target tag flag not referenced" +``` + +### 5.2 Fixtures + +#### `tests/fixtures/deployer/deployer-bad-checkout.md` + +```markdown +# Bad fixture — для тестирования линтера + +```bash +# Откатить +git checkout v0.0.1 +``` +``` + +#### `tests/fixtures/deployer/deployer-good.md` + +```markdown +# Good fixture — sanity + +```bash +# Read-only — разрешено +git -C /repos/enduro-trails log --oneline -1 +ssh slin@host "bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to v0.0.1" +curl -X POST http://localhost:3000/api/v1/repos/admin/enduro-trails/tags +``` +``` + +## 6. Makefile — добавить lint-цель + +```diff + .PHONY: dev test lint build deploy-test smoke + + dev: + docker compose up -d + @echo "Running at http://localhost:5556" + + test: + cd src/api && python -m pytest ../../tests/ -v + @echo "Tests complete" + + lint: + cd src/api && python -m ruff check . ++ python3 scripts/lint_deployer_prompt.py + @echo "Lint complete" + + build: + docker build -t enduro-trails:latest . +``` + +## 7. `docs/operations/runbook.md` — добавить секции + +В конец файла добавить: + +```markdown +## Rollback + +Откат на предыдущий тег выполняется на mva154 — **не** в shared +`/repos` оркестратора (см. ET-011 §1). + +### Автоматический rollback + +Deployer-агент при failed smoke вызывает rollback автоматически: +``` +ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to +``` + +### Ручной rollback оператором + +1. Узнать предыдущий тег: + ```bash + curl -s http://localhost:3000/api/v1/repos/admin/enduro-trails/tags?limit=5 \ + | python3 -c "import sys, json; [print(t['name']) for t in json.load(sys.stdin)]" + ``` +2. Запустить rollback: + ```bash + ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to vX.Y.Z + ``` +3. Проверить: + ```bash + curl -sf https://openclaw.mva154.duckdns.org/enduro/api/health + ``` + +### Синхронизация deploy-хука + +Скрипт `scripts/enduro-deploy-hook.sh` живёт в репозитории. Чтобы +обновить версию на хосте mva154 после merge: + +```bash +ssh slin@mva154 'cd /home/slin/repos/enduro-trails && git pull origin main && \ + sudo install -m 755 scripts/enduro-deploy-hook.sh /home/slin/bin/' +``` + +Эта операция выполняется ОДИН раз после merge ET-011 — оператором +вручную (она не входит в обычный deploy-flow, потому что обновляет +сам хук). +``` + +## 8. Порядок реализации (для developer-агента) + +1. Создать `scripts/lint_deployer_prompt.py` (§ 4) + fixtures (§ 5.2) + + unit-проверка (`python3 scripts/lint_deployer_prompt.py tests/fixtures/deployer/deployer-bad-checkout.md` → exit 1; `... deployer-good.md` → exit 0). +2. Создать `scripts/enduro-deploy-hook.sh` (§ 3) с правами `755`. +3. Переписать `.openclaw/agents/deployer.md` (§ 2). После каждой + правки гонять `python3 scripts/lint_deployer_prompt.py` локально. +4. Создать `tests/integration/test_deployer_prompt.py` (§ 5.1). +5. Добавить новую цель в `Makefile` (§ 6). +6. Обновить `docs/operations/runbook.md` (§ 7). +7. `make lint && make test` локально — оба должны быть зелёными. +8. Commit + push в feature-ветку. Reviewer проверяет: запреты в + prompt'е действительно покрывают S-2/S-3, линтер ловит + реалистичные нарушения, тесты не флакают. + +## 9. Открытые вопросы для architect-агента + +A1. **Где живёт deploy-хук**: в репо (`scripts/`) с ручной +синхронизацией на хост, или на хосте отдельно + симлинк? В TRZ +выбран вариант «в репо + ручная установка через `runbook.md`»; +архитектор может предложить cron-sync. + +A2. **Параллельные deploy-операции**: возможно ли, что две задачи +одновременно дойдут до шага 7 (PUT CHANGELOG.md) и получат +конфликт по SHA? Решение в TRZ — retry-3 (R-2). Архитектор может +оформить это в ADR / pattern. + +A3. **Gitea API support**: гарантированно ли установленная версия +Gitea поддерживает `POST /repos/{owner}/{repo}/tags`? Если нет — +fallback на SSH к хосту с `git tag && git push` в +`/home/slin/repos/enduro-trails` (НЕ в shared `/repos`). Архитектор +проверяет swagger и фиксирует решение. + +A4. **Smoke after-rollback**: какие endpoint'ы достаточны? В TRZ +выбраны `/` + `/api/health`. Архитектор может расширить до +`/static/style.json`. + +A5. **`docker compose build app` для rollback**: rollback на старый +тег требует rebuild image (Dockerfile может тоже отличаться). В § 3.2 +build всегда. Архитектор может предложить тегированные образы +(`enduro-trails:vX.Y.Z`) и `docker compose up` без build — это +ускорит rollback, но требует CI/CD изменения (out of scope ET-011). diff --git a/docs/work-items/ET-011/03-acceptance-criteria.md b/docs/work-items/ET-011/03-acceptance-criteria.md new file mode 100644 index 0000000..0719e6c --- /dev/null +++ b/docs/work-items/ET-011/03-acceptance-criteria.md @@ -0,0 +1,267 @@ +--- +type: acceptance-criteria +work_item_id: ET-011 +title: "Acceptance Criteria: Деплой и rollback без git checkout в shared /repos (S-2/S-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +--- + +# Acceptance Criteria — ET-011 + +Критерии в Gherkin-стиле. Все критерии — обязательные. Задача +считается принятой, когда **каждый** прошёл проверку в test-среде +или в автоматическом прогоне CI/integration-тестов. + +Источники для AC: `docs/work-items/ET-011/01-brd.md` § 3 и § 5, +`docs/work-items/ET-011/02-trz.md` § 2–7. + +## AC-01 — Линтер deployer.md существует и работает + +**Given** репозиторий после слияния ET-011 +**When** оператор запускает `python3 scripts/lint_deployer_prompt.py` +**Then** +- exit-code 0; +- stdout содержит строку `OK`. + +## AC-02 — Линтер ловит запрещённые команды на анти-фикстуре + +**Given** файл `tests/fixtures/deployer/deployer-bad-checkout.md` +содержит ```bash блок с `git checkout v0.0.1` +**When** оператор запускает +`python3 scripts/lint_deployer_prompt.py tests/fixtures/deployer/deployer-bad-checkout.md` +**Then** +- exit-code 1; +- stderr содержит `forbidden 'git checkout'`. + +## AC-03 — Линтер не даёт false positive на разрешённых командах + +**Given** файл `tests/fixtures/deployer/deployer-good.md` содержит +read-only `git log`, `ssh ... "bash ... --rollback --to v0.0.1"`, +`curl` к Gitea API +**When** запуск линтера +**Then** exit-code 0. + +## AC-04 — `make lint` подключает линтер deployer + +**Given** репо после слияния ET-011 +**When** `make lint` +**Then** +- exit-code 0; +- в выводе видна строка из `lint_deployer_prompt.py` (например, `OK + (1 files clean)`). + +**And given** временная подмена `.openclaw/agents/deployer.md` на анти- +фикстуру (`cp tests/fixtures/deployer/deployer-bad-checkout.md +.openclaw/agents/deployer.md`) +**When** `make lint` +**Then** exit-code 1. + +## AC-05 — deployer.md не содержит запрещённых команд + +**Given** финальная версия `.openclaw/agents/deployer.md` +**When** парсинг ```bash блоков (исключая ssh-inner и комментарии) +**Then** не содержит ни одной из команд: +`git checkout`, `git pull`, `git merge` (без `-base`), +`git reset --hard`, `git reset --mixed`, `git revert`, +`git restore`, `git stash`, `git rebase`, `git apply`, `git am`, +`git tag `, `git push `. + +**And** содержит явные read-only/ref-only паттерны (`curl`, +`ssh ... bash`, `git -C ... log`, `git fetch origin --no-tags`). + +## AC-06 — deployer.md содержит все 7 шагов + +**Given** `.openclaw/agents/deployer.md` +**When** проверка структуры markdown +**Then** найдены секции: +- `### 1.` (Merge PR) +- `### 2.` (Tag) +- `### 3.` (Deploy) +- `### 4.` (Healthcheck) +- `### 5.` (Smoke) +- `### 6.` (Rollback) +- `### 7.` (Финализация) + +Каждая секция содержит ровно один ```bash блок (или несколько +последовательных), описывающий шаг. + +## AC-07 — deployer.md ссылается на API endpoint для tag + +**Given** deployer.md +**When** поиск по тексту +**Then** в секции `### 2.` встречаются: +- `POST` (метод); +- `/tags` (endpoint); +- упоминание `MERGE_SHA` или `target` (что тег ставится на merge-commit). + +И **не** встречается `git tag $NEW_TAG` / `git push origin $NEW_TAG`. + +## AC-08 — deployer.md в шаге 6 использует SSH-хук с `--rollback` + +**Given** deployer.md +**When** поиск по тексту секции `### 6.` +**Then** найдены: +- `ssh` (любые опции); +- `--rollback` (флаг хука); +- `--to ${LAST_TAG}` или `--to $LAST_TAG` (тег для отката). + +И **не** встречаются `git checkout`, `git reset`, `git revert`, +`git restore`. + +## AC-09 — deployer.md в шаге 7 использует API для deploy-log + CHANGELOG + +**Given** deployer.md +**When** поиск по тексту секции `### 7.` +**Then** найдены: +- `/contents/${DEPLOY_LOG_PATH}` или эквивалент (POST); +- `/contents/CHANGELOG.md` (PUT); +- создание deploy-ветки через `/branches`; +- создание deploy-PR через `/pulls`; +- merge deploy-PR через `/pulls/.../merge`. + +И **не** встречаются: +- запись в `/repos/enduro-trails/CHANGELOG.md`; +- запись в `/repos/enduro-trails/docs/work-items/.../14-deploy-log.md`; +- `git add`, `git commit`, `git push` (в shared `/repos`). + +## AC-10 — `scripts/enduro-deploy-hook.sh` существует и валиден + +**Given** репозиторий +**When** проверка +```bash +[ -x scripts/enduro-deploy-hook.sh ] && bash -n scripts/enduro-deploy-hook.sh +``` +**Then** +- exit-code 0 (исполняемый + синтаксис bash валиден). + +## AC-11 — Хук поддерживает `--ref` и `--rollback --to` + +**Given** скрипт `scripts/enduro-deploy-hook.sh` +**When** парсинг case-блока +**Then** найдены обработчики `--rollback`, `--ref`, `--to`. Команды +без аргументов (`bash enduro-deploy-hook.sh`) интерпретируются как +`--ref main`. + +## AC-12 — Хук имеет fallback-логирование + +**Given** `scripts/enduro-deploy-hook.sh` +**When** чтение секции логирования +**Then** скрипт пытается писать в `${LOG_DIR}/deploy-hook.log`, при +ошибке записи переключается на `/tmp/enduro-deploy-hook.log` +(закрывает баг ET-009 deploy-log §1.2). + +## AC-13 — Integration-тест существует и зелёный + +**Given** ветка после ET-011 +**When** `pytest tests/integration/test_deployer_prompt.py -v` +**Then** +- exit-code 0; +- проходят минимум 6 кейсов: + - `test_linter_passes_on_real_deployer_md` + - `test_linter_catches_bad_fixture` + - `test_linter_passes_on_good_fixture` + - `test_deployer_md_has_required_sections` + - `test_deployer_md_no_local_changelog_write` + - `test_deployer_md_uses_gitea_api_for_tag` + - `test_deployer_md_uses_ssh_hook_for_rollback` + +## AC-14 — Существующие тесты не сломаны + +**Given** ветка после ET-011 +**When** `make test` +**Then** +- exit-code 0; +- количество failed-тестов = 0; +- количество passed-тестов ≥ baseline (значение из `main` до ET-011). + +## AC-15 — Анти-фикстура и хорошая фикстура существуют + +**Given** репо +**When** проверка файлов +**Then** существуют и не пустые: +- `tests/fixtures/deployer/deployer-bad-checkout.md` +- `tests/fixtures/deployer/deployer-good.md` + +## AC-16 — Runbook обновлён + +**Given** репо +**When** чтение `docs/operations/runbook.md` +**Then** содержит заголовок `## Rollback` с подсекциями: +- Автоматический rollback; +- Ручной rollback оператором; +- Синхронизация deploy-хука. + +В разделе «Ручной rollback оператором» приведён пример команды +`ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to vX.Y.Z`. + +## AC-17 — Документация work item полная + +**Given** `docs/work-items/ET-011/` +**When** проверка файлов +**Then** существуют и не пустые: +- `00-business-request.md` +- `01-brd.md` +- `02-trz.md` +- `03-acceptance-criteria.md` +- `04-test-plan.yaml` +- `06-adr/ADR-NNNN-deploy-rollback-no-shared-checkout.md` (от + архитектора) +- `07-infra-requirements.md` (от архитектора) +- `10-tech-risks.md` (от архитектора) + +## AC-18 — Shared `/repos` не меняется при сухом прогоне deployer-prompt + +**Given** оператор делает `git -C /repos/enduro-trails rev-parse HEAD` +(state-A) на main +**When** ВРУЧНУЮ выполняет шаги 1–5 из нового `deployer.md` (без +шагов 6/7, потому что фейкового deploy нет; шаги 1, 4, 5 — read-only; +шаг 2 не выполняется без merged PR; шаг 3 — SSH к stub-хуку, который +ничего не делает) с `BRANCH=main` +**Then** `git -C /repos/enduro-trails rev-parse HEAD` после прогона = +state-A; `git -C /repos/enduro-trails status --porcelain` пустой. + +**Note.** Этот AC — структурный/manual; полная проверка возможна +только при реальном deploy следующей задачи. Если ручной dry-run +неудобен, AC заменяется на code-review checklist. + +## AC-19 — Реальный deploy следующей задачи проходит + +**Given** ET-011 смерджен; следующая задача (любая, например ET-013 +или ET-010) дошла до стадии deploy +**When** deployer-агент проходит шаги 1–7 нового prompt'а +**Then** +- Merge PR — 200; +- Tag создан через API — есть в Gitea; +- SSH deploy-хук — exit 0; +- Healthcheck/smoke — PASS; +- deploy-log + CHANGELOG смерджены через deploy-PR в main; +- `git -C /repos/enduro-trails diff` после deployer-а — пусто; +- ни одна другая активная задача не получила перетёртой рабочей + копии (проверяется по логам launcher). + +**Note.** AC-19 не блокирует merge ET-011 (real-deploy случится +позже), но **обязательно** для закрытия задачи: tester или +reviewer фиксируют в `13-test-report.md` отметку «AC-19 — pending +real deploy» при отсутствии возможности проверить. + +## AC-20 — Plane/ADR следы + +**Given** репо +**When** проверка +**Then**: +- В Plane комментарий `:approved:` от Owner на work item ET-011; +- ADR файла `docs/work-items/ET-011/06-adr/ADR-NNNN-deploy-rollback-no-shared-checkout.md` существует, со `status: accepted`. + +## AC-21 — Защита от регрессии в deployer.md через CI + +**Given** репо +**When** developer случайно вернул `git checkout` в deployer.md +**Then** `make lint` (и CI) падает с exit-1 и в выводе видно +`forbidden 'git checkout'` + путь и строка. + +(Это поведение покрывается AC-04 + integration-test, AC-21 фиксирует +его как явное требование к CI.) diff --git a/docs/work-items/ET-012/00-business-request.md b/docs/work-items/ET-012/00-business-request.md new file mode 100644 index 0000000..334ecc8 --- /dev/null +++ b/docs/work-items/ET-012/00-business-request.md @@ -0,0 +1,89 @@ +--- +type: business-request +work_item_id: ET-012 +title: "[M-3] Единый stage-engine — убрать дубль _try_advance_stage" +version: 1 +status: draft +created_at: 2026-06-02 +authors: + - "owner" +related: + - "M-1" + - "M-2" +target_repo: "orchestrator" +--- + +# Business Request — ET-012 + +## Контекст + +Оркестратор (`/repos/orchestrator`) — внешний сервис, который ведёт +жизненный цикл задач (`created → analysis → architecture → development → +review → testing → deploy → done`). Логика «после завершения шага — +проверить QG и продвинуть стадию» (`_try_advance_stage`) реализована +**дважды**: + +1. `src/agents/launcher.py:_try_advance_stage` — синхронный метод + `AgentLauncher`. Вызывается, когда подпроцесс агента закончился + (`exit_code == 0`). Содержит ~170 строк бизнес-логики: + QG-диспетчинг, специальная ветка для analyst (запрос approve в Plane), + откаты `reviewer REQUEST_CHANGES → development`, + `tester FAIL → development`, `architect conflict → analysis`, + `update_task_stage` + `notify_stage_change` + `plane_notify_stage` + + `launcher.launch(next_agent)`. + +2. `src/webhooks/plane.py:_try_advance_stage` — асинхронная функция. + Вызывается, когда на комментарии в Plane появляется реакция + `:approved:` (ручное одобрение оператора). Содержит ~60 строк: + QG-диспетчинг (другой набор веток), `update_task_stage` + те же + notify-функции + `launcher.launch(agent_for_current_stage)`. + +## Проблема + +- **Дубль кода**: QG-диспетчинг написан дважды с расходящимися + правилами (например, обращение к `check_review_approved` есть только + в plane.py-варианте, а специальная обработка `check_analysis_approved` + — только в launcher-варианте). +- **Расхождение поведения**: после auto-flow и после ручного approve + стадия двигается по разным веткам, что усложняет отладку + и регрессионное тестирование (M-2 закрыл связанные баги, но + фундаментальная двойная точка истины осталась). +- **Sync/async граница** уже размыта: launcher (sync) вынужден дёргать + Plane API, plane.py (async) дёргает `launcher.launch` (sync). +- **Тесты**: unit-покрытие двух функций раздельное, регрессии типа + «QG_X не запускается из plane-ветки» проходят незамеченными. + +## Желаемый результат + +В оркестраторе остаётся **одна каноническая точка** продвижения +стадии: `StageEngine.advance(task_id, trigger, ctx)`. Оба входа +(launcher после завершения подпроцесса, plane.py после `:approved:`) +зовут этот единый API. Откаты, специальные ветки (analyst approve +request, reviewer REQUEST_CHANGES, tester FAIL, architect conflict) +вынесены в стратегии/хуки, привязанные к парам `(stage, agent, qg)`. + +После рефакторинга: +- Удалена одна из двух функций `_try_advance_stage`; вторая — + тонкая обёртка над `StageEngine.advance` (если нужна совсем). +- Поведение auto-flow и manual-approve различимо только триггером, + результат — идентичный. +- Все существующие тесты проходят; добавлены 6–10 unit-тестов на + `StageEngine`. + +## Out of scope + +- Изменение последовательности стадий (`STAGE_TRANSITIONS` остаётся). +- Изменение QG-функций в `qg/checks.py`. +- Изменение UI Plane или Telegram-уведомлений. +- Замена SQLite на PostgreSQL. +- Миграция launcher из sync в async (только локальная нормализация на + границах StageEngine). + +## Ссылки + +- Код: `/repos/orchestrator/src/agents/launcher.py:327-507`, + `/repos/orchestrator/src/webhooks/plane.py:287-360`. +- Stage-машина: `/repos/orchestrator/src/stages.py`. +- QG: `/repos/orchestrator/src/qg/checks.py`. +- Milestone roadmap: M-1 — webhooks; M-2 — QG-регистр; M-3 (этот WI) — + Единый stage-engine. diff --git a/docs/work-items/ET-012/01-brd.md b/docs/work-items/ET-012/01-brd.md new file mode 100644 index 0000000..4894e0a --- /dev/null +++ b/docs/work-items/ET-012/01-brd.md @@ -0,0 +1,165 @@ +--- +type: brd +work_item_id: ET-012 +title: "BRD: Единый stage-engine оркестратора (M-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "M-1" + - "M-2" +target_repo: "orchestrator" +--- + +# BRD — ET-012: Единый stage-engine оркестратора + +## 1. Цель + +Свести логику «после события — проверить Quality Gate и продвинуть +стадию задачи» в **одну** точку — `StageEngine` — для всего оркестратора. +После рефакторинга обе текущие точки (`AgentLauncher._try_advance_stage` +и `webhooks.plane._try_advance_stage`) либо удалены, либо являются +тонкими адаптерами над `StageEngine.advance(...)`. + +Цель — техническая (refactor, не feature). Видимое пользователю +поведение оркестратора **не меняется**: задачи проходят те же стадии, +QG проверяются тем же набором функций, уведомления в Plane и Telegram +имеют тот же текст. Изменяются только внутренние модули. + +## 2. Контекст + +### 2.1. Текущее состояние + +- `src/stages.py` (54 строки) — хранит `STAGE_TRANSITIONS` и геттеры + `get_next_stage / get_qg_for_stage / get_agent_for_stage / + get_previous_stage`. +- `src/agents/launcher.py:_try_advance_stage` (lines 327–507, ~180 строк) + — синхронный метод. Запускается из `_run_agent_subprocess` после + `exit_code == 0`. Делает: чтение task по `(repo, branch)`, + диспетчинг QG-функции (4 ветки по типу аргументов), специальные + ветки для: + - `analyst` + `check_analysis_approved` (запрос ручного approve в + Plane, fallback на questions, fallback на «без артефактов»); + - `reviewer` + REQUEST_CHANGES (откат `current → development`, + перезапуск developer с retry-счётчиком ≤ 3); + - `tester` + `check_tests_passed` FAIL (откат `current → + development`, перезапуск developer с retry-счётчиком ≤ 3); + - `architect` + `check_architecture_done` FAIL + наличие + `10-conflict.md` (откат `current → analysis`, перезапуск analyst). + + При успехе QG — `update_task_stage(task_id, next_stage)`, + `notify_stage_change`, `plane_notify_stage`, + `launcher.launch(next_agent_for_next_stage)`. + +- `src/webhooks/plane.py:_try_advance_stage` (lines 287–360, ~70 строк) + — асинхронная функция. Вызывается из обработчика `:approved:` + реакции. Делает: диспетчинг QG-функции (4 ветки, **другой набор + правил**), специальная ветка для `check_review_approved` (находит + PR по branch через Gitea API, fallback на наличие файла + `12-review.md`/`09-review.md`). При успехе — те же + `update_task_stage`/`notify_stage_change`/`plane_notify_stage`/ + `launcher.launch(agent_for_current_stage)`. + +### 2.2. Расхождения двух реализаций (источник багов) + +| Аспект | launcher-вариант | plane.py-вариант | +| ---------------------------- | ---------------- | ---------------- | +| Сигнатура | `(run_id, agent, repo, branch)` | `(task_id, current_stage, repo, work_item_id, branch)` | +| Тип | sync method | async function | +| Чтение task | по `(repo, branch)` через SELECT | передаётся снаружи | +| `check_analysis_approved` | специальная ветка (request approve) | возвращает not-passed без действия | +| `check_review_approved` | нет (не доходит) | специальная ветка (Gitea PR) | +| Rollback `reviewer REQUEST_CHANGES` | есть | нет | +| Rollback `tester FAIL` | есть | нет | +| Rollback `architect conflict`| есть | нет | +| Аргумент `agent` для `launch` | `get_agent_for_stage(current)` = агент, который **только что отработал** для next-stage | `get_agent_for_stage(current)` — **то же**, но без учёта только что отработавшего | +| Логирование | `f"Task {task_id}: {old} -> {new} (auto-advance after {agent})"` | `f"Task {task_id}: launched agent '{agent}', run_id={run_id}"` | + +### 2.3. Сценарии, где это даёт сбой + +- **Manual approve analyst → architecture**: реакция `:approved:` + попадает в plane.py-ветку. `check_analysis_approved` там нет + специальной обработки → если в registry он есть и возвращает + `(False, …)`, manual approve просто молча игнорируется. + M-2 это поправил вручную, но решение хрупкое. +- **Reviewer ставит `:approved:` руками** (вместо CI/launcher + auto-flow): plane.py-ветка не знает про REQUEST_CHANGES, не знает + про `check_review_approved` для случая «PR не найден, но файл + `12-review.md` есть». Возможен silent stuck. +- **Тестирование**: невозможно проверить «логику продвижения стадии» + одним unit-тестом — нужно дёргать обе функции и сверять, что + поведение совпадает. + +## 3. Scope + +### In scope + +| # | Изменение | +| ----- | --------- | +| F-01 | Создать модуль `src/stage_engine.py` с классом `StageEngine` и единственным публичным методом `advance(task_id: int, trigger: StageTrigger, ctx: AdvanceContext) -> AdvanceResult`. | +| F-02 | Описать перечисление `StageTrigger` со значениями: `AGENT_FINISHED`, `MANUAL_APPROVE`, `EXTERNAL_EVENT` (зарезервировано). | +| F-03 | Вынести в `StageEngine` чтение task по `task_id` (одна точка) и общий QG-диспетчинг (по таблице соответствия `qg_name → arg_shape`). | +| F-04 | Вынести специальные post-finish правила в реестр хуков `RollbackRule`/`PostQgHook`, привязанных к ключу `(trigger, agent, qg_name, qg_passed)`. Существующие правила: analyst-approve-request, reviewer-REQUEST_CHANGES, tester-FAIL, architect-conflict. | +| F-05 | Переписать `AgentLauncher._try_advance_stage` в тонкую обёртку: собрать `AdvanceContext`, позвать `StageEngine.advance(..., trigger=AGENT_FINISHED)`. Метод приватный, остаётся как точка вызова из `_run_agent_subprocess`. | +| F-06 | Переписать `webhooks.plane._try_advance_stage` в тонкую async-обёртку: собрать `AdvanceContext`, позвать `StageEngine.advance(..., trigger=MANUAL_APPROVE)` через `asyncio.to_thread` (StageEngine синхронен; общение с Gitea и Plane через httpx остаётся). | +| F-07 | QG-диспетчинг (resolve args) вынести в одну таблицу. Существующие группы: `(repo, work_item_id)`, `(repo, branch)`, `(repo, pr_number)`. Поиск PR по branch (как в plane.py-варианте) — отдельная утилита `find_pr_by_branch(repo, branch)`. | +| F-08 | Все вызовы `update_task_stage / notify_stage_change / plane_notify_stage / launcher.launch` идут только из `StageEngine`. | +| F-09 | Retry-счётчики (developer ≤ 3, analyst ≤ 4) живут внутри соответствующих хуков, формула считается на основании `agent_runs` (как сейчас). | +| F-10 | Покрыть `StageEngine.advance` unit-тестами: успешный путь по каждой стадии, отказ QG по каждой стадии, каждый rollback-хук, оба триггера. Минимум 12 тест-кейсов. | +| F-11 | Регрессия: все существующие `tests/test_launcher.py` и `tests/test_webhooks.py` проходят без изменений семантики (тексты ассертов могут поменяться, но поведение остаётся). | + +### Out of scope + +- Изменение текста уведомлений в Plane / Telegram. +- Изменение `STAGE_TRANSITIONS` (последовательность стадий). +- Изменение `QG_CHECKS` (имена и сигнатуры функций). +- Миграция launcher на async (только обёртка `to_thread` на границе + plane.py). +- Замена SQLite, удаление `notify_stage_change` (sync) в пользу + очереди. +- UI-изменения в Plane. +- Новые стадии или новые QG. +- Изменение поведения для оркестратора Telegram-бота сверх того, что + диктует F-01..F-11. + +## 4. Stakeholders + +- **Owner / Operator** — приёмка: видимое поведение оркестратора (тексты в + Plane, переходы стадий) не отличается от M-2. +- **Agents (analyst/architect/developer/...)**: продолжают писать те же + артефакты в тех же путях; ничего о StageEngine не знают. +- **CI/Gitea** — без изменений. + +## 5. Бизнес-риски + +| ID | Риск | Митигация | +| ----- | ---- | --------- | +| R-1 | Регрессия в auto-flow: задача застревает между стадиями. | Сценарные тесты на каждый full-path: `created → … → done`. CI-эмулятор stage-движка. | +| R-2 | Регрессия в manual-approve: реакция `:approved:` не двигает стадию. | Отдельный тест `MANUAL_APPROVE` для каждой стадии, где это допустимо. | +| R-3 | Расхождение текстов уведомлений → operator получает «новое» сообщение. | Snapshot-тест на текст уведомления для каждого хука. | +| R-4 | Retry-счётчики developer/analyst сломаются → бесконечный цикл или преждевременный block. | Тест на `agent_runs`-фикстуры с count = 0, 2, 3, 4. | +| R-5 | Sync/async границы: deadlock между launcher и plane.py. | StageEngine синхронен; plane.py зовёт через `asyncio.to_thread`. Не блокировать event loop. | +| R-6 | Гонка: одновременно прилетел `:approved:` и завершился агент. | StageEngine читает `task.stage` под лёгкой защитой (`SELECT ... FOR ...` SQLite не поддерживает; решение — повторное чтение стадии после lock в `update_task_stage`, проверка идемпотентности). | +| R-7 | QG_CHECKS изменится после рефакторинга → таблица arg-shapes устареет. | Регресс-тест на каждый QG, фиксирующий arg-shape. Если изменится — fail. | + +## 6. Метрики успеха + +| Метрика | Цель | +| ------- | ---- | +| LOC `_try_advance_stage` в launcher.py | ≤ 30 (тонкая обёртка) | +| LOC `_try_advance_stage` в plane.py | ≤ 20 (тонкая async-обёртка) или удалён | +| Покрытие `stage_engine.py` unit-тестами | ≥ 90% line coverage | +| Регрессионные тесты `test_launcher.py + test_webhooks.py` | проходят без изменений названий и количества кейсов | +| Время одного тура «manual approve → advance» | не выросло > 50 мс (замер локальный) | +| Количество мест, обновляющих `tasks.stage` в БД напрямую (вне `StageEngine`) | 0 (только внутри хуков rollback) | + +## 7. Допущения и зависимости + +- M-1 (webhooks) и M-2 (QG-registry) завершены и приняты. +- `STAGE_TRANSITIONS` и список QG_CHECKS не меняются в этой задаче. +- БД оркестратора — SQLite, без новых миграций. +- Тесты идут в существующем `pytest`-окружении оркестратора. +- Доступ к `/repos/orchestrator` для разработки имеется у developer-агента. diff --git a/docs/work-items/ET-012/02-trz.md b/docs/work-items/ET-012/02-trz.md new file mode 100644 index 0000000..0a1ad17 --- /dev/null +++ b/docs/work-items/ET-012/02-trz.md @@ -0,0 +1,546 @@ +--- +type: trz +work_item_id: ET-012 +title: "ТЗ: Единый stage-engine оркестратора (M-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "M-1" + - "M-2" +target_repo: "orchestrator" +--- + +# ТЗ — ET-012: Единый stage-engine оркестратора + +## 1. Терминология + +- **Stage** — позиция задачи в последовательности + `STAGE_TRANSITIONS` (`src/stages.py`). +- **Trigger** — событие, побудившее проверку «можно ли продвинуть + стадию». Поддерживаются два: + - `AGENT_FINISHED` — агентский подпроцесс закончился + (`exit_code == 0`). Источник вызова — `AgentLauncher`. + - `MANUAL_APPROVE` — на комментарии задачи в Plane появилась + реакция `:approved:`. Источник — `webhooks.plane`. +- **QG (Quality Gate)** — функция из `QG_CHECKS`, возвращающая + `(passed: bool, reason: str)`. +- **Hook** — пользовательский обработчик, привязанный к ключу + `(trigger, agent, qg_name, qg_passed_or_skipped)`, выполняющийся + до канонического «advance». Хук может: (a) выполнить rollback + стадии, (b) перезапустить агента, (c) поставить «pending approve» + в Plane, (d) объявить блокировку. Хук возвращает `HookOutcome`: + `ADVANCE`, `ROLLBACK`, `HOLD`, `BLOCKED`. +- **StageEngine** — единственная сущность, имеющая право + вызывать `update_task_stage`, `notify_stage_change`, + `plane_notify_stage`, `launcher.launch` для целей продвижения + стадии. Живёт в `src/stage_engine.py`. + +## 2. Архитектурные опоры + +ET-012 не добавляет новых внешних интеграций. Используются: + +- `src/stages.py` — STAGE_TRANSITIONS, геттеры. **Без изменений.** +- `src/qg/checks.py:QG_CHECKS` — реестр QG-функций. **Без + изменений в составе и сигнатурах**; ET-012 описывает каждую + функцию таблицей arg-shapes (REQ-A-04). +- `src/db.py:update_task_stage`, `get_db`. **Без изменений.** +- `src/notifications.py:notify_stage_change`, `notify_qg_failure`, + `notify_approve_requested`, `notify_error`, `send_telegram`. + **Без изменений.** +- `src/plane_sync.py:notify_stage_change (=plane_notify_stage)`, + `add_comment`, `notify_qg_failure (=plane_notify_qg)`, + `set_issue_in_review/in_progress/needs_input/blocked`. + **Без изменений.** +- `src/agents/launcher.py:AgentLauncher.launch`. **Без изменений + в сигнатуре.** + +## 3. Требования + +### 3.1. Модуль stage_engine + +#### REQ-F-01 — Класс `StageEngine` + +Файл `src/stage_engine.py`. Содержит: + +```python +class StageTrigger(str, Enum): + AGENT_FINISHED = "agent_finished" + MANUAL_APPROVE = "manual_approve" + +@dataclass +class AdvanceContext: + repo: str + branch: str + work_item_id: str + agent_just_finished: str | None # для AGENT_FINISHED; None для MANUAL_APPROVE + +@dataclass +class AdvanceResult: + outcome: Literal["advanced", "rolled_back", "held", "blocked", "noop"] + from_stage: str + to_stage: str | None + reason: str + next_agent_launched: str | None + next_run_id: int | None + +class StageEngine: + def __init__(self, launcher: AgentLauncher): ... + def advance(self, task_id: int, trigger: StageTrigger, + ctx: AdvanceContext) -> AdvanceResult: ... +``` + +Метод `advance` — синхронный. Не имеет побочных эффектов сверх: +запись в БД через `update_task_stage`, отправка уведомлений через +`notifications` и `plane_sync`, запуск агента через `launcher.launch`. + +#### REQ-F-02 — Чтение текущей стадии + +`StageEngine.advance` начинает с чтения task по `task_id`: + +```sql +SELECT id, stage, work_item_id, repo, branch FROM tasks WHERE id = ? +``` + +Если запись не найдена — `AdvanceResult(outcome="noop", reason="task not found")`, +лог `warning`. + +Поле `current_stage` берётся из БД (не из `ctx`), чтобы исключить +гонку между триггерами. + +#### REQ-F-03 — Терминальная стадия + +Если `get_next_stage(current_stage) is None` (то есть `done`) — +`AdvanceResult(outcome="noop", reason="terminal stage")`, +лог `info: already at terminal stage`. Уведомлений нет. + +#### REQ-F-04 — Запуск QG + +Если `qg_name = get_qg_for_stage(current_stage)`: + +1. Если `qg_name is None` → QG отсутствует, переходим к Advance + (REQ-F-07) с `qg_passed=True, qg_reason=""`. +2. Если `qg_name not in QG_CHECKS` → лог `error: QG '{name}' not in + registry`, `AdvanceResult(outcome="noop", reason="qg missing")`. +3. Иначе: + - Разрешить аргументы по таблице REQ-A-04. + - Вызвать `qg_func(*args)`. + - Получить `(qg_passed, qg_reason)`. + +#### REQ-F-05 — Прогон хуков + +После QG (или после `qg_passed=True` без QG) пройти реестр +`HOOKS: list[Hook]` в порядке регистрации: + +```python +@dataclass +class HookKey: + trigger: StageTrigger | None # None = «любой» + agent: str | None # None = «любой» + qg_name: str | None # None = «любой» + qg_passed: bool | None # None = «любой» (включая «QG нет») + +@dataclass +class HookOutcome: + action: Literal["advance", "rollback", "hold", "blocked"] + target_stage: str | None # для rollback + relaunch_agent: str | None # любой target — может перезапустить агента + plane_comment: str | None # текст в Plane + plane_state: str | None # one of: in_review|in_progress|needs_input|blocked + notify_telegram: str | None # сообщение в Telegram + skip_remaining_hooks: bool # обычно True + skip_default_advance: bool # True если hook сам решил, что делать + +Hook = Callable[[StageEngine, AdvanceContext, str, str, str | None, + bool, str], HookOutcome | None] +# args: (engine, ctx, current_stage, next_stage, qg_name, qg_passed, qg_reason) +``` + +Если хук вернул `None` или его ключ не подходит — пропускаем. +Если хук вернул `HookOutcome` — применяем его (REQ-F-06, REQ-F-07, +REQ-F-08, REQ-F-09). + +#### REQ-F-06 — Действие `rollback` + +`StageEngine` вызывает: +1. `update_task_stage(task_id, target_stage)`; +2. `notify_stage_change(task_id, current_stage, target_stage)`; +3. `plane_notify_stage(work_item_id, current_stage, target_stage)`; +4. Если `outcome.plane_state` задан — соответствующий + `set_issue_*(work_item_id)`; +5. Если `outcome.plane_comment` задан — + `plane_sync.add_comment(work_item_id, outcome.plane_comment)`; +6. Если `outcome.notify_telegram` задан — `send_telegram(...)`; +7. Если `outcome.relaunch_agent` задан — `launcher.launch(agent, + repo, task_desc, task_id=task_id)`, где `task_desc` собирается из + `(work_item_id, repo, branch, target_stage, notes)`. + +Возврат: `AdvanceResult(outcome="rolled_back", from_stage=current, +to_stage=target, …)`. + +#### REQ-F-07 — Действие `advance` (дефолт) + +Если ни один хук не сработал и `qg_passed=True`: +1. `update_task_stage(task_id, next_stage)`; +2. `notify_stage_change(task_id, current_stage, next_stage)`; +3. `plane_notify_stage(work_item_id, current_stage, next_stage)`; +4. Если `get_agent_for_stage(current_stage)` (= агент, который + запускается ПРИ ВХОДЕ в next_stage) задан → + `launcher.launch(agent, repo, task_desc, task_id=task_id)`, + `task_desc = "Work item: {wid}\nRepo: {repo}\nBranch: {branch}\nStage: {next_stage}"`. + +Возврат: `AdvanceResult(outcome="advanced", from_stage=current, +to_stage=next, next_agent_launched=, …)`. + +#### REQ-F-08 — Действие `hold` + +`StageEngine`: +- НЕ меняет `tasks.stage`; +- если задан `plane_state` / `plane_comment` / + `notify_telegram` — выполняет их (REQ-F-06 шаги 4–6); +- логирует `info: stage held: {reason}`. + +Возврат: `AdvanceResult(outcome="held", from_stage=current, +to_stage=None, reason=…)`. + +#### REQ-F-09 — Действие `blocked` + +Как `hold`, плюс `set_issue_blocked(work_item_id)` если ещё не +поставлено, плюс обязательное Telegram-уведомление, если не задан +явный текст — дефолт «🚨 {work_item_id}: blocked». +Возврат: `outcome="blocked"`. + +#### REQ-F-10 — Если QG не прошёл и ни один хук не сработал + +`StageEngine`: +- логирует `info: Task {task_id}: QG '{qg}' not passed after + {agent}: {reason}`; +- вызывает `notify_qg_failure(task_id, current_stage, qg, reason)`; +- вызывает `plane_notify_qg(work_item_id, current_stage, qg, reason)`; +- возвращает `AdvanceResult(outcome="held", reason="qg failed: {qg}")`. + +### 3.2. Реестр хуков (миграция текущего поведения) + +Реестр `HOOKS` объявляется в конце `stage_engine.py` или в +отдельном `src/stage_hooks.py`. Порядок важен: первый сработавший +хук побеждает (`skip_remaining_hooks=True` по умолчанию). + +#### REQ-H-01 — Hook `analyst_approve_request` + +**Ключ:** trigger=`AGENT_FINISHED`, agent=`analyst`, +qg_name=`check_analysis_approved`, qg_passed=`False` (любой +not-passed). + +**Логика** (миграция из launcher.py:347–390): +1. Запустить `check_analysis_complete(repo, work_item_id)`: + - если `True` → outcome `hold` с: + - `plane_state = "in_review"`; + - `plane_comment = "📋 BRD/ТЗ/AC/TestPlan готовы. Прошу + review и реакцию :approved: для продвижения в Architecture."`; + - `notify_approve_requested(task_id)` (через `notify_telegram`- + поле движок не вызывает; см. ниже). + - если `False`: + - проверить наличие + `repos_dir/{repo}/docs/work-items/{wid}/01-questions.md`; + - если есть → outcome `hold`: + - `plane_state = "needs_input"`; + - `plane_comment = "❓ Analyst нуждается в уточнении:\n\n{questions_text}"`; + - `notify_telegram = "❓ {wid}: Analyst задаёт вопросы. Ответь в Plane."`. + - если нет → outcome `hold`: + - `plane_comment = "⚠️ Analyst завершился без артефактов и без вопросов. Проверьте лог."`; + - `plane_state = None`, `notify_telegram = None`. +2. `skip_remaining_hooks=True`, `skip_default_advance=True`. + +**Примечание.** Вызов `notify_approve_requested(task_id)` — это +отдельная telegram-нотификация по task_id. Чтобы не дублировать +интерфейс хука, разрешается ввести опциональное поле +`notify_approve_request_task_id: int | None` в `HookOutcome` — +или хук вызывает `notify_approve_requested` напрямую, не через +движок. Выбор оставлен Architect (см. open question Q-2). + +#### REQ-H-02 — Hook `reviewer_request_changes` + +**Ключ:** trigger=`AGENT_FINISHED`, agent=`reviewer`, +qg_name=`check_reviewer_verdict`, qg_passed=`False`. + +**Условие применимости** (внутри хука): в `qg_reason` есть подстрока +`REQUEST_CHANGES`. + +**Логика** (миграция из launcher.py:402–426): +1. Если `REQUEST_CHANGES` нет в reason → `return None` (другой + обработчик «QG failed» отработает). +2. Посчитать `retry_count = SELECT COUNT(*) FROM agent_runs WHERE + task_id=? AND agent='developer'`. +3. Если `retry_count < 3` → outcome `rollback`: + - `target_stage = "development"`; + - `relaunch_agent = "developer"` с `task_desc`: + ``` + Work item: {wid} + Repo: {repo} + Branch: {branch} + Stage: development + Note: REQUEST_CHANGES from reviewer (attempt {retry_count+1}/3). + Fix findings in docs/work-items/{wid}/12-review.md + ``` +4. Если `retry_count >= 3` → outcome `blocked`: + - `notify_telegram = "⚠️ {wid}: Max developer retries (3) reached. + Manual intervention needed."`; + - `target_stage = None` (без rollback); + - лог `error: max retries reached`. + +#### REQ-H-03 — Hook `tester_fail` + +**Ключ:** trigger=`AGENT_FINISHED`, agent=`tester`, +qg_name=`check_tests_passed`, qg_passed=`False`. + +**Логика** (миграция из launcher.py:429–456): +1. outcome `rollback`: + - `target_stage = "development"`; + - `plane_state = "in_progress"`; + - `plane_comment = "❌ Тесты не прошли: {qg_reason}. Developer + перезапущен для фикса."`; +2. Посчитать `retry_count` так же, как в REQ-H-02. +3. Если `retry_count < 3` → relaunch developer с `task_desc`: + ``` + Work item: {wid} + Repo: {repo} + Branch: {branch} + Stage: development + Note: Tests FAILED. Fix failures described in + docs/work-items/{wid}/13-test-report.md + ``` +4. Если `retry_count >= 3` → outcome `blocked`: + - `notify_telegram = "🚨 {wid}: Tests still failing after 3 + developer retries. Manual intervention needed."`; + - `plane_state = "blocked"`; + - rollback всё равно происходит (стадия → development), чтобы + visible state совпадал с предыдущим поведением. **Уточнить с + Architect (Q-3): сейчас в launcher.py `set_issue_blocked` + ставится в Plane, но `update_task_stage` не отменяется — + задача остаётся в БД на `development`, в Plane — на `blocked`. + Сохраняем этот контракт.** + +#### REQ-H-04 — Hook `architect_conflict` + +**Ключ:** trigger=`AGENT_FINISHED`, agent=`architect`, +qg_name=`check_architecture_done`, qg_passed=`False`. + +**Условие применимости**: существует файл +`repos_dir/{repo}/docs/work-items/{wid}/10-conflict.md`. + +**Логика** (миграция из launcher.py:459–485): +1. Прочитать `conflict_text = open(10-conflict.md).read()[:500]`. +2. outcome `rollback`: + - `target_stage = "analysis"`; + - `plane_state = "in_progress"`; + - `plane_comment = "⚠️ Architect нашёл конфликт с ТЗ. Возврат в Analysis.\n\n{conflict_text}"`; + - `relaunch_agent = "analyst"` с `task_desc`: + ``` + Work item: {wid} + Repo: {repo} + Branch: {branch} + Stage: analysis + Note: Architect conflict. Revise TRZ. + See docs/work-items/{wid}/10-conflict.md + ``` + +#### REQ-H-05 — Hook `analyst_questions_reanswered` + +**Источник:** не из текущего `_try_advance_stage`, а из +`webhooks.plane` (lines 257–280). Эту логику в M-3 **не +переносить в StageEngine**, она привязана к обработке комментариев +не-`:approved:` и остаётся в `webhooks.plane`. См. Q-4. + +#### REQ-H-06 — Hook `check_review_approved_pr_fallback` + +**Ключ:** trigger=`MANUAL_APPROVE`, agent=`None`, +qg_name=`check_review_approved`, qg_passed=`None`. + +**Логика** (миграция из plane.py:309–334): +1. Найти PR через `find_pr_by_branch(repo, branch)` + (новая утилита, REQ-A-05). +2. Если PR найден → вызвать `check_review_approved(repo, pr_number)`, + получить `(passed, reason)`. Если `passed=True` → outcome `advance` + (skip_default=True, движок повторно не дёргает QG). Если `False` + → outcome `hold` с `notify_qg_failure` + `plane_notify_qg` + (движок их вызовет в дефолтной ветке, поэтому хук может вернуть + `None`, чтобы движок отработал «QG failed»). +3. Если PR не найден: + - проверить файлы `docs/work-items/{wid}/12-review.md` или + `09-review.md`; + - если есть → outcome `advance` (skip_default=True); + - если нет → outcome `hold` (движок сообщит QG failed с reason + "No open PR found and no review file"). + +**Примечание.** Этот хук — единственный, где QG-проверка должна +выполняться внутри хука (а не движком), потому что QG-функция +требует особого ресолва аргументов (PR number). Допустимо ввести +поле `qg_already_evaluated: bool` в `HookOutcome` для пропуска +дефолтной QG-логики. + +### 3.3. Утилиты и таблицы + +#### REQ-A-04 — Таблица arg-shapes QG-функций + +```python +QG_ARG_SHAPES = { + "check_analysis_approved": ("repo", "work_item_id"), + "check_analysis_complete": ("repo", "work_item_id"), + "check_architecture_done": ("repo", "work_item_id"), + "check_tests_passed": ("repo", "work_item_id"), + "check_reviewer_verdict": ("repo", "work_item_id"), + "check_tests_local": ("repo", "branch"), + "check_ci_green": ("repo", "branch"), + "check_review_approved": ("repo", "pr_number"), # resolve через find_pr_by_branch +} +``` + +`StageEngine._resolve_qg_args(qg_name, ctx)` строит кортеж по таблице. +Для `check_review_approved` ресолв происходит внутри хука +`REQ-H-06`, движок не вызывает напрямую. + +Регресс-тест `test_qg_arg_shapes.py` (REQ-T-12) фиксирует, что +имена ключей таблицы совпадают с реально зарегистрированными +QG-функциями в `QG_CHECKS`. + +#### REQ-A-05 — Утилита `find_pr_by_branch` + +Файл `src/qg/gitea.py` (новый) или `src/utils/gitea.py`. Подпись: + +```python +def find_pr_by_branch(repo: str, branch: str) -> int | None: ... +``` + +Внутри — текущая реализация из `plane.py:312–325`. Перенос +один-в-один, без изменения семантики. + +### 3.4. Интеграция в существующий код + +#### REQ-I-01 — `AgentLauncher._try_advance_stage` + +Метод сохраняется как приватный, его сигнатура +`(run_id: int, agent: str, repo: str, branch: str)` тоже сохраняется. +Тело сокращается до: + +```python +def _try_advance_stage(self, run_id, agent, repo, branch): + try: + task = get_task_by_repo_branch(repo, branch) + if not task: + return + ctx = AdvanceContext( + repo=repo, branch=branch, + work_item_id=task.work_item_id, + agent_just_finished=agent, + ) + self.stage_engine.advance( + task.id, StageTrigger.AGENT_FINISHED, ctx + ) + except Exception as e: + logger.error(f"Auto-advance failed for run_id={run_id}: {e}") +``` + +`AgentLauncher.__init__` принимает `stage_engine: StageEngine` +(или создаёт его лениво с self-reference). + +#### REQ-I-02 — `webhooks.plane._try_advance_stage` + +Функция переписывается: + +```python +async def _try_advance_stage( + task_id: int, current_stage: str, repo: str, + work_item_id: str, branch: str +): + ctx = AdvanceContext( + repo=repo, branch=branch, + work_item_id=work_item_id, + agent_just_finished=None, + ) + await asyncio.to_thread( + stage_engine.advance, task_id, StageTrigger.MANUAL_APPROVE, ctx + ) +``` + +Сигнатура остаётся прежней для совместимости с вызовом из +`webhooks.plane:230`. Аргумент `current_stage` фактически больше не +нужен (движок читает из БД), но удалять его в M-3 не обязательно — +оставить как deprecated, удалить в M-4. + +#### REQ-I-03 — Регресс-сохранение текстов уведомлений + +Все тексты `plane_add_comment`, `send_telegram`, кодовые точки +вызова `notify_*` должны давать **идентичный** результат +(text-perfect match) для каждого сценария. Регрессионный тест на +текст уведомлений — `tests/test_notifications_snapshot.py` +(REQ-T-08). + +#### REQ-I-04 — Импорты + +`stage_engine.py` импортирует: +- `from .stages import STAGE_TRANSITIONS, get_next_stage, + get_qg_for_stage, get_agent_for_stage, get_previous_stage`; +- `from .qg.checks import QG_CHECKS`; +- `from .db import get_db, update_task_stage`; +- `from .notifications import notify_stage_change, notify_qg_failure, + notify_approve_requested, send_telegram, notify_error`; +- `from .plane_sync import notify_stage_change as plane_notify_stage, + notify_qg_failure as plane_notify_qg, add_comment as plane_add_comment, + set_issue_in_review, set_issue_in_progress, set_issue_needs_input, + set_issue_blocked`; +- `from .config import settings`. + +`AgentLauncher` импортирует `StageEngine`, отсоединяет прямые +импорты `QG_CHECKS / update_task_stage / notify_stage_change` — +оставляет только то, что нужно вне `_try_advance_stage`. + +`webhooks.plane` — то же; конкретный набор импортов уточняет +developer по итогам diff. + +## 4. Структура файлов + +``` +src/ + stage_engine.py # NEW: StageEngine, StageTrigger, + # AdvanceContext, HookOutcome, + # _resolve_qg_args, _run_hooks + stage_hooks.py # NEW: HOOKS list, по одной функции на REQ-H-* + qg/ + gitea.py # NEW: find_pr_by_branch (или утилита из M-1) + checks.py # без изменений + agents/ + launcher.py # MODIFIED: _try_advance_stage стал тонким + webhooks/ + plane.py # MODIFIED: _try_advance_stage стал тонким +tests/ + test_stage_engine.py # NEW: REQ-T-01..REQ-T-11 + test_stage_hooks.py # NEW: REQ-T-13..REQ-T-18 + test_qg_arg_shapes.py # NEW: REQ-T-12 + test_notifications_snapshot.py # NEW: REQ-T-08 + test_launcher.py # MODIFIED: смена импортов, мокаем StageEngine + test_webhooks.py # MODIFIED: смена импортов, мокаем StageEngine +``` + +## 5. Открытые вопросы + +| ID | Вопрос | Решение по умолчанию | +| --- | ------ | --------------------- | +| Q-1 | StageEngine — singleton (module-level) или DI через AgentLauncher? | Architect выбирает. Дефолт: DI; `AgentLauncher.__init__(stage_engine)`. | +| Q-2 | Как хук вызывает `notify_approve_requested(task_id)` — через `HookOutcome` или напрямую? | Дефолт: напрямую через `notifications.notify_approve_requested`. Architect может ввести поле в `HookOutcome`. | +| Q-3 | tester FAIL ставит `set_issue_blocked` ПЛЮС rollback stage. Сохраняем этот двойственный контракт? | Да. Точная копия текущего поведения. См. REQ-H-03. | +| Q-4 | analyst-re-answer (повторный запуск analyst после ответа на вопросы) остаётся в `webhooks.plane` или переезжает в StageEngine? | Дефолт: остаётся в webhooks.plane (не вписывается в `(trigger, agent, qg)`-ключ). | +| Q-5 | Хук-реестр — list или dict-by-key? | list (порядок важен для скоринга). | +| Q-6 | Логи — какой logger использовать? | `logging.getLogger("orchestrator.stage_engine")`. | + +## 6. Не входит в работу + +- Перенос обработчиков `webhooks.gitea` в StageEngine (M-4). +- Перевод stage_engine в async (M-4 или позже). +- Новые QG-функции. +- Изменение `STAGE_TRANSITIONS`. +- Изменение лимитов retry (3/4). +- Миграция БД. +- UI Plane. diff --git a/docs/work-items/ET-012/04-test-plan.yaml b/docs/work-items/ET-012/04-test-plan.yaml new file mode 100644 index 0000000..5dbab2a --- /dev/null +++ b/docs/work-items/ET-012/04-test-plan.yaml @@ -0,0 +1,472 @@ +--- +type: test-plan +work_item_id: ET-012 +title: "Test Plan: Единый stage-engine оркестратора (M-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +target_repo: "orchestrator" + +scope_note: > + ET-012 — рефакторинг внутренней логики оркестратора, видимое + поведение не меняется. Тест-план фокусируется на (1) контракте + нового StageEngine, (2) точной миграции четырёх специальных + веток (analyst-approve-request, reviewer-REQUEST_CHANGES, tester- + FAIL, architect-conflict, review-PR-fallback), (3) snapshot- + совпадении текстов уведомлений с baseline до рефакторинга, + (4) регрессии существующих тестов launcher/webhooks, (5) + идемпотентности при одновременных триггерах. UI-тестов нет — + оркестратор не имеет UI; задача не затрагивает enduro-trails + фронтенд. + +test_suites: + + - name: unit-stage-engine-core + type: unit + description: "StageEngine.advance — базовые контрактные тесты" + cases: + - id: UT-SE-01 + name: "advance возвращает noop для несуществующего task_id" + input: "task_id = 999999, MANUAL_APPROVE, любой ctx" + expected: | + AdvanceResult(outcome='noop', reason содержит 'task not found'), + update_task_stage НЕ вызван, + никаких notify_* не вызвано + + - id: UT-SE-02 + name: "advance возвращает noop в терминальной стадии 'done'" + input: "task.stage='done', AGENT_FINISHED, agent='deployer'" + expected: | + AdvanceResult(outcome='noop', reason содержит 'terminal stage'), + update_task_stage НЕ вызван, + notify_stage_change НЕ вызван + + - id: UT-SE-03 + name: "advance без QG (next_stage есть, qg_name=None) — прямой advance" + input: "task.stage='deploy', AGENT_FINISHED, agent=None (или deployer)" + expected: | + tasks.stage становится 'done', + notify_stage_change('deploy','done') вызван, + plane_notify_stage(wid,'deploy','done') вызван, + launcher.launch НЕ вызван (для 'done' агента нет) + + - id: UT-SE-04 + name: "advance с QG-функцией не из реестра" + input: "patched STAGE_TRANSITIONS[stage].qg='check_missing_xyz'" + expected: | + AdvanceResult(outcome='noop', reason содержит 'qg missing'), + в логе error 'QG ... not in registry' + + - id: UT-SE-05 + name: "QG False, нет ни одного подходящего хука → notify_qg_failure" + input: "task.stage='development' (qg=check_tests_local), AGENT_FINISHED, agent='developer', mock QG returns (False, 'pytest failed')" + expected: | + AdvanceResult(outcome='held', reason 'qg failed: check_tests_local'), + notify_qg_failure(task_id, 'development', 'check_tests_local', 'pytest failed') вызван, + plane_notify_qg(wid, 'development', 'check_tests_local', 'pytest failed') вызван, + tasks.stage не меняется + + - id: UT-SE-06 + name: "QG True, хуков нет → advance + launch next agent" + input: "task.stage='development', QG check_tests_local mock True, AGENT_FINISHED, agent='developer'" + expected: | + tasks.stage='review', + launcher.launch('reviewer', repo, task_desc, task_id=...) вызван, + task_desc содержит 'Stage: review' и work_item_id + + - id: UT-SE-07 + name: "QG arg-shape — repo+work_item_id" + input: "QG check_architecture_done, ctx={repo='enduro-trails', work_item_id='ET-099'}" + expected: | + qg-функция вызвана с args=('enduro-trails', 'ET-099') + + - id: UT-SE-08 + name: "QG arg-shape — repo+branch" + input: "QG check_ci_green, ctx={repo='enduro-trails', branch='feature/x'}" + expected: | + qg-функция вызвана с args=('enduro-trails', 'feature/x') + + - id: UT-SE-09 + name: "advance читает stage из БД, а не из ctx" + input: "в БД task.stage='review' (зафиксировано), но в коде вызывающего сторого мы думаем что 'development'" + expected: | + QG, соответствующий 'review' (check_reviewer_verdict), вызван, + QG для 'development' НЕ вызван + + - id: UT-SE-10 + name: "повторный вызов advance после успешного advance — noop" + input: "task.stage стал 'architecture' после первого advance; второй вызов с теми же args" + expected: | + второй вызов: AdvanceResult.outcome ∈ {'noop','held'}, + update_task_stage НЕ вызван второй раз, + launcher.launch НЕ вызван второй раз для того же агента + + - name: unit-stage-hooks + type: unit + description: "Каждый хук — отдельный тест" + cases: + - id: UT-HK-01 + name: "Hook analyst_approve_request — все артефакты есть" + input: | + AGENT_FINISHED, agent='analyst', qg=check_analysis_approved (False), + check_analysis_complete mock returns (True, ''), + ctx.work_item_id='ET-099' + expected: | + set_issue_in_review('ET-099') вызван, + plane_add_comment текст='📋 BRD/ТЗ/AC/TestPlan готовы. Прошу review и реакцию :approved: для продвижения в Architecture.', + notify_approve_requested(task_id) вызван, + tasks.stage='analysis' (не меняется), + AdvanceResult.outcome='held' + + - id: UT-HK-02 + name: "Hook analyst_approve_request — артефактов нет, questions есть" + input: | + check_analysis_complete returns (False, '...'), + в FS есть docs/work-items/ET-099/01-questions.md с текстом 'Q1: ...' + expected: | + set_issue_needs_input('ET-099') вызван, + plane_add_comment начинается с '❓ Analyst нуждается в уточнении:' и содержит 'Q1: ...', + send_telegram текст='❓ ET-099: Analyst задаёт вопросы. Ответь в Plane.', + tasks.stage не меняется + + - id: UT-HK-03 + name: "Hook analyst_approve_request — нет ни артефактов, ни questions" + input: | + check_analysis_complete returns (False, '...'), + файл 01-questions.md отсутствует + expected: | + plane_add_comment текст='⚠️ Analyst завершился без артефактов и без вопросов. Проверьте лог.', + set_issue_* НЕ вызван, + send_telegram НЕ вызван + + - id: UT-HK-04 + name: "Hook reviewer_request_changes — есть REQUEST_CHANGES, retry=0" + input: | + AGENT_FINISHED, agent='reviewer', qg=check_reviewer_verdict (False, 'REQUEST_CHANGES: foo'), + в agent_runs 0 записей с agent='developer' для task_id + expected: | + tasks.stage='development', + launcher.launch('developer', ...) вызван 1 раз, + task_desc содержит 'Note: REQUEST_CHANGES from reviewer (attempt 1/3)' и + 'docs/work-items//12-review.md' + + - id: UT-HK-05 + name: "Hook reviewer_request_changes — REQUEST_CHANGES, retry=2 (2 предыдущих запуска)" + input: "agent_runs содержит 2 записи с agent='developer'" + expected: | + task_desc содержит 'attempt 3/3', + launcher.launch вызван + + - id: UT-HK-06 + name: "Hook reviewer_request_changes — REQUEST_CHANGES, retry=3 (max)" + input: "agent_runs содержит 3 записи с agent='developer'" + expected: | + launcher.launch developer НЕ вызван, + send_telegram текст='⚠️ {wid}: Max developer retries (3) reached. Manual intervention needed.', + в логе error 'max retries reached' + + - id: UT-HK-07 + name: "Hook reviewer_request_changes — QG False без REQUEST_CHANGES" + input: "qg returns (False, 'other reason')" + expected: | + хук пропускает (return None), + движок отрабатывает дефолтный QG-failed: notify_qg_failure вызван + + - id: UT-HK-08 + name: "Hook tester_fail — retry=0" + input: | + AGENT_FINISHED, agent='tester', qg=check_tests_passed (False, 'test_x FAILED'), + agent_runs developer=0 + expected: | + tasks.stage='development', + set_issue_in_progress(wid) вызван, + plane_add_comment='❌ Тесты не прошли: test_x FAILED. Developer перезапущен для фикса.', + launcher.launch('developer', ..., task_desc содержит 'Note: Tests FAILED' и + 'docs/work-items//13-test-report.md') + + - id: UT-HK-09 + name: "Hook tester_fail — retry=3" + input: "agent_runs developer=3" + expected: | + set_issue_blocked(wid) вызван, + send_telegram='🚨 {wid}: Tests still failing after 3 developer retries. Manual intervention needed.', + launcher.launch developer НЕ вызван, + tasks.stage='development' (согласно Q-3 — двойственный контракт сохранён) + + - id: UT-HK-10 + name: "Hook architect_conflict — файл 10-conflict.md есть" + input: | + AGENT_FINISHED, agent='architect', qg=check_architecture_done (False, '...'), + FS содержит docs/work-items//10-conflict.md с текстом 'CONFLICT: TRZ vs reality' + expected: | + tasks.stage='analysis', + set_issue_in_progress(wid) вызван, + plane_add_comment начинается с '⚠️ Architect нашёл конфликт с ТЗ. Возврат в Analysis.' и + содержит 'CONFLICT: TRZ vs reality', + launcher.launch('analyst', ..., task_desc содержит 'Note: Architect conflict. Revise TRZ.' и + '10-conflict.md') + + - id: UT-HK-11 + name: "Hook architect_conflict — файл 10-conflict.md отсутствует" + input: "FS не содержит 10-conflict.md, QG False с обычным reason" + expected: | + хук возвращает None (не его случай), + дефолт: notify_qg_failure вызван, + tasks.stage не меняется + + - id: UT-HK-12 + name: "Hook check_review_approved_pr_fallback — PR найден, approved" + input: | + MANUAL_APPROVE, qg=check_review_approved, + find_pr_by_branch returns 42, + mock check_review_approved(repo, 42) returns (True, 'approved by 2') + expected: | + tasks.stage='testing', + launcher.launch('tester', ...) вызван + + - id: UT-HK-13 + name: "Hook check_review_approved_pr_fallback — PR не найден, файл 12-review.md есть" + input: | + find_pr_by_branch returns None, + FS содержит docs/work-items//12-review.md + expected: | + tasks.stage='testing', + launcher.launch('tester', ...) вызван + + - id: UT-HK-14 + name: "Hook check_review_approved_pr_fallback — PR не найден, нет файлов" + input: "find_pr_by_branch None, FS не содержит ни 12-review.md ни 09-review.md" + expected: | + tasks.stage='review' (не меняется), + notify_qg_failure вызван с reason 'No open PR found and no review file', + plane_notify_qg вызван + + - name: unit-qg-arg-shapes + type: unit + description: "Регрессия таблицы аргументов QG" + cases: + - id: UT-QG-01 + name: "Таблица QG_ARG_SHAPES покрывает все QG в QG_CHECKS" + input: "set(QG_ARG_SHAPES.keys()) и set(QG_CHECKS.keys())" + expected: | + set(QG_ARG_SHAPES.keys()) ⊇ set(QG_CHECKS.keys()) \ {'check_review_approved'} + (check_review_approved обрабатывается внутри хука, + поэтому в общей таблице не обязан быть; этот случай задокументирован). + + - id: UT-QG-02 + name: "Каждое имя в таблице соответствует реальной callable в QG_CHECKS" + input: "QG_ARG_SHAPES" + expected: | + ∀ name ∈ QG_ARG_SHAPES.keys() ⇒ callable(QG_CHECKS[name]) + + - name: unit-notifications-snapshot + type: unit + description: "Тексты комментариев и Telegram-сообщений совпадают с baseline" + cases: + - id: UT-NS-01 + name: "snapshot: analyst-approve-request comment text" + input: "AC-04 сценарий" + expected: "exact match текста '📋 BRD/ТЗ/AC/TestPlan готовы. Прошу review и реакцию :approved: для продвижения в Architecture.'" + + - id: UT-NS-02 + name: "snapshot: analyst-questions comment prefix" + input: "AC-05 сценарий" + expected: "comment начинается с '❓ Analyst нуждается в уточнении:\\n\\n'" + + - id: UT-NS-03 + name: "snapshot: reviewer-max-retries Telegram" + input: "AC-08" + expected: "'⚠️ {wid}: Max developer retries (3) reached. Manual intervention needed.'" + + - id: UT-NS-04 + name: "snapshot: tester-FAIL comment" + input: "AC-09" + expected: "'❌ Тесты не прошли: {reason}. Developer перезапущен для фикса.'" + + - id: UT-NS-05 + name: "snapshot: tester-FAIL max retries Telegram" + input: "AC-09 с retry=3" + expected: "'🚨 {wid}: Tests still failing after 3 developer retries. Manual intervention needed.'" + + - id: UT-NS-06 + name: "snapshot: architect-conflict comment prefix" + input: "AC-10" + expected: "comment начинается с '⚠️ Architect нашёл конфликт с ТЗ. Возврат в Analysis.\\n\\n'" + + - name: integration-launcher-wiring + type: integration + description: "AgentLauncher собран с StageEngine, поведение auto-flow цельное" + cases: + - id: IT-LW-01 + name: "AgentLauncher._try_advance_stage делегирует в StageEngine" + input: "Real AgentLauncher с замоканным StageEngine; вызов из _run_agent_subprocess" + expected: | + stage_engine.advance вызван 1 раз с + trigger=StageTrigger.AGENT_FINISHED и + ctx.agent_just_finished == agent + + - id: IT-LW-02 + name: "AgentLauncher не имеет прямых вызовов update_task_stage в _try_advance_stage" + input: "AST-инспекция launcher.py" + expected: | + В теле метода _try_advance_stage 0 вызовов update_task_stage, + 0 обращений к QG_CHECKS, 0 вызовов notify_stage_change, + 0 вызовов self.launch(...) + + - id: IT-LW-03 + name: "Существующие test_launcher.py кейсы проходят" + input: "pytest tests/test_launcher.py" + expected: | + все ранее существовавшие тесты зелёные; разрешены только + адаптации импортов и моков StageEngine + + - name: integration-webhooks-wiring + type: integration + description: "webhooks.plane.handle_comment + _try_advance_stage" + cases: + - id: IT-WW-01 + name: "MANUAL_APPROVE от :approved: вызывает StageEngine.advance" + input: "POST webhook с reaction :approved:; replaced stage_engine.advance with mock" + expected: | + stage_engine.advance вызван 1 раз с trigger=MANUAL_APPROVE, + ctx.agent_just_finished is None + + - id: IT-WW-02 + name: ":approved: в стадии analysis → architecture (end-to-end)" + input: "task.stage='analysis', :approved: реакция, реальный StageEngine" + expected: | + tasks.stage='architecture', + plane_notify_stage('analysis','architecture') вызван, + launcher.launch('architect', ...) вызван + + - id: IT-WW-03 + name: "webhooks.plane не делает прямых update_task_stage" + input: "AST-инспекция plane.py для функции _try_advance_stage" + expected: | + 0 вызовов update_task_stage, 0 вызовов QG_CHECKS, + 0 вызовов launcher.launch внутри _try_advance_stage + + - id: IT-WW-04 + name: "test_webhooks.py проходит без регрессии" + input: "pytest tests/test_webhooks.py" + expected: "все ранее существовавшие тесты зелёные" + + - name: integration-end-to-end + type: integration + description: "Полный путь задачи через все стадии (mock агентов)" + cases: + - id: IT-E2E-01 + name: "Полный happy path: created → … → done" + input: | + fixture task в 'created', + все QG моканы как True, + все агенты моканы (subprocess сразу возвращает exit=0), + ручной :approved: симулируется в 'analysis' и 'review' + expected: | + tasks.stage финально 'done', + цепочка update_task_stage вызывалась ровно 7 раз + (created→analysis→architecture→development→review→testing→deploy→done), + порядок запуска агентов: analyst, architect, developer, + reviewer, tester, deployer + + - id: IT-E2E-02 + name: "Цикл с reviewer REQUEST_CHANGES и успешным повтором" + input: | + 1й проход reviewer: QG returns (False, 'REQUEST_CHANGES'), + 2й проход reviewer: QG returns (True, '') + expected: | + tasks.stage прошёл review → development → review → testing, + developer запускался 2 раза, + reviewer запускался 2 раза + + - id: IT-E2E-03 + name: "tester FAIL + retry с успехом" + input: | + tester: 1й проход FAIL, 2й — pass + expected: | + цепочка: testing → development → testing → deploy + + - id: IT-E2E-04 + name: "Reviewer REQUEST_CHANGES, 3 retries исчерпаны" + input: "все 4 цикла reviewer возвращают REQUEST_CHANGES" + expected: | + tasks.stage в финале не 'done', + Telegram-сообщение 'Max developer retries' отправлено, + developer запускался ровно 3 раза, не 4 + + - name: integration-idempotency + type: integration + description: "Идемпотентность при параллельных триггерах" + cases: + - id: IT-ID-01 + name: "Одновременный AGENT_FINISHED и MANUAL_APPROVE" + input: | + два потока: один вызывает advance(AGENT_FINISHED), + другой — advance(MANUAL_APPROVE) на одном task_id + и task.stage='analysis' + expected: | + tasks.stage стал 'architecture' (не пропущена через две стадии), + в agent_runs ровно одна запись 'architect' с task_id, + допустим один из вызовов вернул outcome='noop' или 'held' + с reason 'stage already advanced' + + - id: IT-ID-02 + name: "Двойной MANUAL_APPROVE" + input: | + две одинаковые реакции :approved: пришли подряд + (повторный webhook от Plane) + expected: | + tasks.stage продвинута ровно один раз, + launcher.launch для следующего агента вызван ровно один раз + + - name: lint-and-coverage + type: static + description: "Качество кода нового модуля" + cases: + - id: LT-01 + name: "ruff check src/stage_engine.py src/stage_hooks.py" + expected: "0 errors" + + - id: LT-02 + name: "mypy src/stage_engine.py src/stage_hooks.py" + expected: "0 errors (strict mode по уровню остального кода оркестратора)" + + - id: LT-03 + name: "Coverage report" + input: "pytest --cov=src/stage_engine --cov=src/stage_hooks --cov-report=term" + expected: "line coverage ≥ 90% для каждого из модулей" + + - id: LT-04 + name: "Линия count в launcher._try_advance_stage" + input: "AST-измерение тела метода" + expected: "тело метода ≤ 30 строк без блока try/except" + + - id: LT-05 + name: "Линия count в plane._try_advance_stage" + input: "AST-измерение тела функции" + expected: "тело функции ≤ 20 строк" + +ci_command: | + cd /repos/orchestrator && \ + pytest tests/ -v --cov=src/stage_engine --cov=src/stage_hooks \ + --cov-report=term --cov-fail-under=90 && \ + ruff check src/ && \ + mypy src/stage_engine.py src/stage_hooks.py + +fixtures_required: + - "tests/fixtures/tasks_db.sql — фикстура БД с парой задач в разных стадиях" + - "tests/fixtures/agent_runs_retry.sql — agent_runs c 0/1/2/3 developer-запусками" + - "tests/fixtures/repos//docs/work-items//01-questions.md" + - "tests/fixtures/repos//docs/work-items//10-conflict.md" + - "tests/fixtures/repos//docs/work-items//12-review.md" + - "tests/fixtures/notifications_baseline/*.txt — snapshot текстов комментариев и Telegram-сообщений до рефакторинга" + +non_goals: + - "UI-тесты (нет UI у оркестратора и нет UI-изменений в enduro-trails)" + - "Нагрузочное тестирование stage_engine (out of scope в M-3)" + - "Тесты на новые QG-функции (их нет в M-3)" + - "Миграция БД (нет изменений схемы)" +--- diff --git a/docs/work-items/ET-014/01-brd.md b/docs/work-items/ET-014/01-brd.md new file mode 100644 index 0000000..a349e7b --- /dev/null +++ b/docs/work-items/ET-014/01-brd.md @@ -0,0 +1,266 @@ +--- +type: brd +work_item_id: ET-014 +title: "BRD: [M-7] Идемпотентность webhook (дедуп по delivery-id)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-010" +--- + +# BRD — ET-014: [M-7] Идемпотентность webhook (дедуп по delivery-id) + +## 0. Предупреждение об отсутствии формализованного бизнес-запроса + +На момент создания BRD файл `00-business-request.md` для ET-014 отсутствует +в репозитории. Бизнес-контекст реконструирован из: + +- заголовка задачи `[M-7] Идемпотентность webhook (дедуп по delivery-id)`; +- смежной задачи **ET-010** `[F-2b] Очередь задач вместо in-process + daemon-потоков`, идущей параллельно; +- общей архитектуры enduro-trails (FastAPI backend, SQLite, GPS-pipeline, + Gitea CI/CD); +- стандартной семантики «delivery-id» как заголовка webhook-протоколов + (Gitea `X-Gitea-Delivery`, GitHub `X-GitHub-Delivery`, Plane.so + `X-Webhook-Delivery`, Stripe-стиль `Idempotency-Key` и аналоги). + +**Все принятые допущения помечены `⚑ ASSUMPTION` и подлежат подтверждению +владельцем продукта в первые 24 часа жизни work item.** Если допущение +не подтверждено — задача возвращается в Анализ. + +## 1. Цель + +Сделать обработку входящих webhook-событий в backend enduro-trails +**идемпотентной**: одно и то же событие, доставленное несколько раз +(штатные ретраи отправителя, сетевые сбои, ручной повтор), должно +произвести побочный эффект **ровно один раз**, при этом каждый ретрай +должен получать корректный HTTP-ответ. + +Идемпотентность достигается **дедупликацией по уникальному +identifier’у доставки**, который отправитель кладёт в HTTP-заголовок +(каноническое имя — `X-Delivery-Id`; для известных провайдеров +поддерживаются альтернативы — `X-Gitea-Delivery`, `X-GitHub-Delivery`, +`X-Webhook-Delivery`). + +## 2. Контекст + +### 2.1 Зачем сейчас + +- ⚑ ASSUMPTION-1. В рамках серии `[M-*] Maintenance / Reliability` + командой планируется ввод одной или нескольких webhook-точек: + - триггер пересборки тайлов / запуска GPS-pipeline по push в Gitea; + - приём уведомлений от внешних поставщиков GPS-треков о новых данных; + - оркестрация со стороны `.openclaw` (CI/CD события). + Конкретный список endpoint-ов определяется параллельными задачами; ET-014 + строит **общий слой идемпотентности**, который они будут переиспользовать. + +- ET-010 (`[F-2b]`) выносит долгие операции из in-process daemon-потоков + в очередь. После ET-010 webhook-handler перестаёт делать тяжёлую работу + inline и **ставит задачу в очередь**. Это делает идемпотентность + особенно критичной: повторная постановка задачи в очередь = повторное + её исполнение = двойной импорт GPS-треков, двойной запуск пересборки, + расход внешних API-квот и т. п. + +### 2.2 Что есть на момент старта ET-014 + +- FastAPI backend `src/api/main.py`. **Ни одного webhook-endpoint + не существует.** В коде нет упоминаний `webhook`, `delivery`, `idempotency-key`. +- Бэкенд использует SQLite (`data/centralfederal.sqlite`, `data/gps_tracks.sqlite`). + Шаблон создания таблиц через `migrations/*.sql` + `db.init_db()` уже + отработан (см. `migrations/gps_tracks_001_init.sql`). +- Gitea Actions CI (`.gitea/workflows/ci.yml`) не настроен на webhook + обратно в backend — деплой ручной. +- ⚑ ASSUMPTION-2. **Webhook-провайдеры на этапе ET-014 не активируются**. + ET-014 поставляет инфраструктурный слой и **один технический endpoint + `/api/webhooks/test`** для интеграционного тестирования. Реальные + endpoint-ы (GPS-poll-trigger, Gitea-push, и т. п.) подключаются + отдельными work item’ами после приёмки ET-014. + +### 2.3 Что НЕ контекст + +- Это **не безопасность подписи** (HMAC, X-Hub-Signature, signing secrets). + Подпись — отдельный концерн, может покрываться задачей `[M-8]` или + аналогом; в ET-014 не входит (см. Out of scope). +- Это **не rate-limiting** входящих webhook'ов. +- Это **не общий task queue** (это делает ET-010). + +## 3. Scope + +### In scope + +| # | Функция | +| ----- | ------- | +| F-01 | Новая SQLite-таблица `webhook_deliveries` (миграция `webhook_001_init.sql`) с PK по `delivery_id` и доп. полями: `provider`, `received_at`, `status`, `response_status`, `response_body`, `expires_at`. | +| F-02 | Модуль `src/api/webhooks/` (`__init__.py`, `dedup.py`, `endpoint.py`, `models.py`, `config.py`) — общий слой идемпотентности webhook'ов. | +| F-03 | Конфиг `config/webhooks.yaml` со списком провайдеров и каноничным именем delivery-header для каждого. | +| F-04 | Декоратор / FastAPI dependency `idempotent_webhook(provider: str)` — оборачивает любой webhook-endpoint, читает delivery-id из header, проверяет дедуп, при коллизии возвращает сохранённый ответ, иначе — пропускает обработку и кэширует результат. | +| F-05 | Endpoint `POST /api/webhooks/test` (технический; принимает любой JSON, возвращает `{"received": true, "delivery_id": "…", "replay": false\|true}`) — используется для интеграционных тестов и smoke-проверок в проде. Аутентификация: shared secret в заголовке `X-Webhook-Secret` (env `WEBHOOK_TEST_SECRET`). | +| F-06 | Endpoint `GET /api/webhooks/health` — возвращает `{"deliveries_count": N, "last_delivery_at": ISO\|null, "providers": [...], "expired_pending_gc": N}`. | +| F-07 | TTL для записей `webhook_deliveries`: по умолчанию **30 дней**. Реализовано как поле `expires_at` (ISO timestamp). | +| F-08 | GC-функция `purge_expired(conn)` в `dedup.py`, удаляет записи с `expires_at < now`. Вызывается из startup-event FastAPI (lazy GC) **и** опционально из cron-скрипта `scripts/webhook_gc.py` (создаётся в ET-014). | +| F-09 | Атомарность дедупликации: вставка через `INSERT OR IGNORE INTO webhook_deliveries(delivery_id,...) VALUES (...)` на UNIQUE-индексе; параллельный второй запрос с тем же delivery-id попадает в ветку «replay» без race condition. | +| F-10 | Стратегия по отсутствию delivery-id в заголовке: **strict mode по умолчанию** — endpoint возвращает HTTP 400 `{"error": "missing X-Delivery-Id"}`. Опционально включаемый `derive_from_body=true` per-provider в конфиге — fallback к SHA-256 хэшу тела как идентификатору; в ET-014 фича объявлена, но включена только для `test`-провайдера. | +| F-11 | Логирование: каждое получение webhook'а логируется в `logger.info` с полями `provider`, `delivery_id`, `replay` (true\|false), `processing_ms`. | +| F-12 | Метрики (в формате полей health-эндпоинта): счётчик уникальных deliveries за последние 24h, счётчик replay-попаданий за 24h, провайдер с наибольшей долей replay. | +| F-13 | Unit-тесты модуля `dedup.py`: первичная вставка, повторная вставка (replay), TTL-фильтр, GC, race condition (через два параллельных insert на одной и той же in-memory SQLite-БД). | +| F-14 | Integration-тесты на endpoint `/api/webhooks/test`: 200 первый раз, 200 второй раз с теми же телом и delivery-id (response совпадает), 400 без header, 401 неверный secret. | +| F-15 | Документация в `docs/work-items/ET-014/06-adr/ADR-014-webhook-idempotency.md` (новый ADR) — формализация стратегии: header-first vs body-hash, TTL, формат таблицы, контракт декоратора. | +| F-16 | Документация для последующих work item’ов: `docs/work-items/ET-014/15-webhook-howto.md` — «как добавить новый webhook-провайдер за 30 минут» (минимальный гайд для будущих авторов). | + +### Out of scope + +- **Активация реальных webhook-провайдеров** (Gitea push → CI, plane.so → orchestrator, GPS-poll-trigger). Каждое подключение — отдельный work item. +- **Проверка подписи (HMAC / signing secret per provider)**. Отдельный концерн (`[M-8]`). +- **Очередь задач** для отложенного выполнения. Делает ET-010 (`[F-2b]`). +- **Replay-инициатива со стороны системы** (UI кнопки «replay webhook»). Не нужно в ET-014. +- **Multi-tenant / per-region** дедупликация. Дедуп глобальный по `(provider, delivery_id)`. +- **Cross-instance дедупликация** (если запущено N инстансов API). На текущем этапе backend деплоится в single-instance режиме (см. CLAUDE.md `docker compose up -d на mva154`). При переходе на multi-instance — заводится отдельный work item на миграцию SQLite → Postgres или Redis для дедуп-стора. +- **Метрики Prometheus / OpenTelemetry**. Метрики ET-014 — простые поля JSON в health-эндпоинте. + +## 4. Сценарии использования (use cases) + +### UC-01. Первичная доставка webhook +1. Внешняя система отправляет `POST /api/webhooks/` с заголовком `X-Delivery-Id: abc-123` и телом JSON. +2. Backend проверяет наличие записи `webhook_deliveries(provider, 'abc-123')`. Записи нет. +3. Backend атомарно вставляет запись со статусом `pending`. +4. Backend вызывает business-handler провайдера (в ET-014 — заглушка `echo`). +5. Backend обновляет запись: `status='ok'`, `response_status=200`, `response_body=…`. +6. Backend возвращает клиенту HTTP 200, тело `{"received": true, "delivery_id": "abc-123", "replay": false}`. + +### UC-02. Повторная доставка (retry) +1. Та же внешняя система отправляет тот же запрос (delivery-id `abc-123`). +2. Backend находит запись в `webhook_deliveries`, `status='ok'`. +3. Backend **не вызывает** business-handler. +4. Backend возвращает сохранённый ответ: HTTP 200, тело включает `"replay": true`. + +### UC-03. Параллельная гонка двух retry +1. Два запроса с одинаковым `X-Delivery-Id: abc-123` приходят в один и тот же миллисекунд (worker 1 и worker 2 FastAPI). +2. Worker 1 выполняет `INSERT OR IGNORE`, успех (rowcount=1), идёт по UC-01. +3. Worker 2 выполняет `INSERT OR IGNORE`, неудача (rowcount=0); ждёт записи у worker 1 (polling с экспоненциальным backoff, потолок 5 секунд); как только `status != 'pending'` — возвращает сохранённый ответ. +4. ⚑ ASSUMPTION-3. Если worker 1 не успел завершить за 5 сек — worker 2 возвращает HTTP 409 `{"error": "still processing", "retry_after": 5}`. Эту стратегию архитектор может изменить на «дождаться без таймаута»; default — короткий таймаут, чтобы не зависал отправитель. + +### UC-04. Webhook без delivery-id (strict mode) +1. Внешняя система отправляет POST без `X-Delivery-Id`. +2. Backend возвращает HTTP 400 `{"error": "missing X-Delivery-Id header"}`. +3. Запись в `webhook_deliveries` **не создаётся**. + +### UC-05. Webhook без delivery-id (lenient mode, derive_from_body=true) +1. Внешняя система отправляет POST без `X-Delivery-Id`. В конфиге провайдера `derive_from_body: true`. +2. Backend считает `sha256(body)` → `derived-`, использует как delivery-id. +3. Дальше — как UC-01 / UC-02. + +### UC-06. TTL и GC +1. Запись `delivery_id=abc-123` создана 35 дней назад. `expires_at` 5 дней назад. +2. При запуске FastAPI (startup event) вызывается `purge_expired(conn)`; запись удаляется. +3. Через 35 дней, если та же `delivery_id=abc-123` приходит снова, она обрабатывается как новая (UC-01). + +### UC-07. Health-эндпоинт +1. Оператор делает `GET /api/webhooks/health`. +2. Backend возвращает агрегаты: total deliveries, last_delivery_at, count by provider, count replays за 24h. + +## 5. Метрики успеха + +| Метрика | Критерий | +| --- | --- | +| Идемпотентность под нагрузкой | В integration-тесте с 100 параллельными retry того же delivery-id — ровно одна запись в `webhook_deliveries`, ровно один вызов business-handler, 99 ответов помечены `replay=true`. | +| Производительность дедупа | На 10⁶ записей в `webhook_deliveries` lookup по `delivery_id` (UNIQUE индекс) занимает p95 ≤ 5 мс на test-сервере mva154 (бенч-load: `pytest tests/integration/test_webhook_perf.py`). | +| Покрытие тестами | ≥ 90% line coverage для `src/api/webhooks/` (без учёта `endpoint.py`-glue). | +| Документация для расширений | После ET-014 разработчик с нуля по гайду `15-webhook-howto.md` за ≤ 30 мин добавляет новый провайдер и пишет integration-тест. Проверяется субъективно ревьюером. | +| Чистота миграции | `migrations/webhook_001_init.sql` применяется на пустой БД и на БД с уже существующими таблицами (idempotent через `CREATE TABLE IF NOT EXISTS`). | +| Чистый health | После 24h работы в test-среде `/api/webhooks/health.errors_count == 0`. | +| GC работает | После ручной вставки записи с `expires_at` в прошлом и вызова `purge_expired` — запись удалена; счётчик `deliveries_count` уменьшился. | +| Регрессии нет | Все существующие тесты ET-008, ET-009 проходят без изменений. ET-014 не трогает GPS-tracks модуль. | + +## 6. Риски + +| # | Риск | Вероятность | Влияние | Митигация | +| ---- | ---- | ----------- | ------- | --------- | +| R-1 | SQLite-блокировка при высокой параллельности webhook-запросов (write-lock) | Средняя | Среднее | WAL-режим (уже включён в схеме). `INSERT OR IGNORE` — единственная пишущая операция в hot-path; обновление `status` — отдельной короткой транзакцией. При устойчивых блокировках — мигрировать дедуп-store в Postgres (отдельный work item). | +| R-2 | TTL 30 дней приводит к ложным «новым» событиям, если отправитель ретраит через 31 день | Низкая | Низкое | Стандартные webhook-провайдеры (Gitea, GitHub, Stripe) ретраят максимум часы–дни; 30 дней — безопасный потолок. Если провайдер ретраит дольше — TTL правится в `config/webhooks.yaml` per-provider (поле `ttl_days`). | +| R-3 | Race condition в UC-03 — оба worker’а думают, что они первые | Низкая | Высокое | UNIQUE constraint на `(provider, delivery_id)` + `INSERT OR IGNORE` атомарны в SQLite (запись в WAL — атомарная операция). Unit-тест UT-WH-09 покрывает явно. | +| R-4 | `derive_from_body` нестабилен: отправитель меняет порядок JSON-полей → разный hash → ретрай не задедуплицируется | Высокая (если включить) | Среднее | Default — `derive_from_body: false`. Включается только если провайдер явно не отдаёт delivery-id и архитектор принимает риск. Документируется в ADR-014. | +| R-5 | Worker 2 ждёт worker 1 до 5 сек → отправитель тоже ждёт → нарушение SLA | Средняя | Низкое | После 5 сек worker 2 отдаёт HTTP 409 `retry_after=5`. Отправитель ретраит через 5 сек, тогда worker 1 уже завершил → ответ корректный. Документируется в UC-03 и ADR-014. | +| R-6 | Реальные webhook-провайдеры используют **разные имена** delivery-header; жёсткое требование `X-Delivery-Id` сломает интеграцию | Высокая | Высокое | Конфиг `webhooks.yaml` per-provider задаёт `delivery_header`. Декоратор `idempotent_webhook` читает конкретное имя для конкретного провайдера. По умолчанию `X-Delivery-Id`. Для `gitea` — `X-Gitea-Delivery`. | +| R-7 | Тяжёлый business-handler удерживает запись в `pending` статусе → другие retry бесконечно крутятся в UC-03 polling | Средняя | Среднее | После ET-010 (очередь) heavy work уезжает в worker; webhook-handler становится коротким (≤ 100 мс). До ET-010 — отдельный таймаут на business-handler 30 сек, при превышении запись помечается `status='timeout'`, ответ HTTP 504. | +| R-8 | Тестирование требует знать формат delivery-header реальных провайдеров, которых ещё нет в системе | Высокая | Низкое | ET-014 ставит инфраструктуру и **технический provider `test`**. Реальные провайдеры тестируются в своих work item’ах, опираясь на инфру ET-014. | +| R-9 | Конфликт схемы БД: `webhook_deliveries` может пересекаться по имени с будущей таблицей внешней интеграции | Низкая | Низкое | Префикс таблицы `webhook_*` зарезервирован за этим модулем. Документируется в `15-webhook-howto.md`. | +| R-10 | Owner отвергает допущения ASSUMPTION-1..3 → задача переписывается | Средняя | Высокое | В первые 24 часа после создания BRD оркестратор обязан получить подтверждение по ASSUMPTION-1..3 от Owner; до этого момента разработчик к коду не приступает. | + +## 7. Зависимости + +### Backend + +- `src/api/main.py` — изменения только в секции «подключение роутеров»: добавляется `from src.api.webhooks.endpoint import create_webhook_router; app.include_router(create_webhook_router())`. Старые роуты не трогаются. +- Новый модуль `src/api/webhooks/`: + - `__init__.py` + - `models.py` — pydantic-модели (`DeliveryRecord`, `WebhookConfig`, `ProviderConfig`) + - `config.py` — загрузка `config/webhooks.yaml` + - `dedup.py` — функции `record_delivery`, `is_replay`, `get_stored_response`, `mark_processed`, `purge_expired` + - `endpoint.py` — `create_webhook_router`, dependency `idempotent_webhook(provider)`, endpoint `POST /api/webhooks/test`, endpoint `GET /api/webhooks/health` +- Опциональный скрипт `scripts/webhook_gc.py` — крутится в cron на mva154 раз в сутки. + +### Миграция + +- `migrations/webhook_001_init.sql` — новая, idempotent (`CREATE TABLE IF NOT EXISTS`). +- Применяется автоматически в startup-event FastAPI (как сейчас делается для `gps_tracks_001_init.sql` через `init_db`). + +### Конфиг + +- `config/webhooks.yaml` — новый файл. Минимальное содержимое: + ```yaml + providers: + - id: test + delivery_header: X-Delivery-Id + derive_from_body: false + ttl_days: 30 + secret_env: WEBHOOK_TEST_SECRET + ``` + +### БД + +- Файл БД для webhook_deliveries: ⚑ ASSUMPTION-4 — используется **существующий файл** `data/gps_tracks.sqlite` (одна schema-namespace, проще GC и backup). Если архитектор требует отдельный файл — `data/webhooks.sqlite`, путь конфигурируется через env `WEBHOOK_DB_PATH`. Default в коде — `gps_tracks.sqlite`. **Сменить по решению архитектора без переписывания BRD.** + +### Фронтенд + +- Не затрагивается. UI-тесты не требуются. + +### Тестовые фикстуры + +- `tests/fixtures/webhooks/sample-payload.json` — пример входящего payload (произвольный JSON, для теста `test` провайдера). +- `tests/fixtures/webhooks/sample-payload-2.json` — второй пример (для теста разных тел при одинаковом delivery-id; ET-014 не различает тела при дедупе — это часть контракта). + +### Инфра + +- mva154: входящие HTTP-запросы на `/api/webhooks/*` через тот же reverse-proxy, что и остальная API (см. `docker-compose.yml`). Никаких новых портов. +- Размер `gps_tracks.sqlite` после 1 месяца webhook-трафика (оценка: 100 deliveries/день × 30 дней × ~500 B = 1.5 MB) — пренебрежимо мал. +- Env-переменная `WEBHOOK_TEST_SECRET` добавляется в `.env.example` и в production `.env` mva154 (рандомная строка ≥ 32 символов). + +### Документация + +- `docs/work-items/ET-014/06-adr/ADR-014-webhook-idempotency.md` — формализация стратегии. +- `docs/work-items/ET-014/15-webhook-howto.md` — гайд для добавления провайдеров. +- Обновление `docs/architecture/README.md` (по итогу): добавить упоминание модуля `webhooks`. + +### Связи с другими work items + +- **ET-010** (`[F-2b]` queue) — параллельная задача. Координация по интерфейсу: после ET-010 webhook-handler ставит задачу в очередь вместо синхронной обработки. Это **не блокирует** ET-014: до ET-010 синхронная обработка тоже работает идемпотентно. +- **ET-013** — параллельная задача, на момент написания BRD папка пуста; координация не требуется. +- **Будущий `[M-8] Подпись webhook'ов`** — следующий слой поверх ET-014. ET-014 закладывает поле `signature_valid` в `webhook_deliveries` (зарезервировано, NULL по умолчанию) — потом будет использовано M-8. + +## 8. Открытые вопросы (для подтверждения Owner’ом) + +| # | Вопрос | Default (если Owner молчит) | +| ----- | ------ | --------------------------- | +| OQ-1 | Какие реальные провайдеры будут подключены первыми после ET-014? | `gitea` (push events → trigger pipeline) и `gps-poll` (внешний планировщик дёргает sync) — но в **ET-014 не подключаются**. | +| OQ-2 | TTL дедуп-записей: 30 дней — устраивает? | Да, 30 дней. Per-provider override. | +| OQ-3 | Hard-strict mode (нет delivery-id → 400) — ок? | Да. `derive_from_body` опционально per-provider. | +| OQ-4 | Где хранить дедуп: тот же `gps_tracks.sqlite` или отдельный файл? | `gps_tracks.sqlite` (см. ASSUMPTION-4). Архитектор может сменить в ADR-014 без правки BRD. | +| OQ-5 | Что делает worker 2 в UC-03 если worker 1 завис? | Polling с backoff до 5 сек, потом HTTP 409 `retry_after=5`. | +| OQ-6 | Нужен ли `POST /api/webhooks/test` endpoint в проде? | Да (smoke-тесты). Защищён shared secret. | +| OQ-7 | Нужен ли отдельный cron-скрипт GC, или достаточно lazy GC при старте процесса? | Достаточно lazy GC при старте + опциональный `scripts/webhook_gc.py` для ручного запуска. | + +Все вопросы подлежат закрытию **до старта разработки**. Default-значения зафиксированы выше как fallback; явный отказ Owner’а от default требует пересмотра BRD/TRZ. diff --git a/docs/work-items/ET-014/04-test-plan.yaml b/docs/work-items/ET-014/04-test-plan.yaml new file mode 100644 index 0000000..289fcc9 --- /dev/null +++ b/docs/work-items/ET-014/04-test-plan.yaml @@ -0,0 +1,389 @@ +--- +type: test-plan +work_item_id: ET-014 +title: "Test Plan: [M-7] Идемпотентность webhook (дедуп по delivery-id)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-010" + +scope_note: > + ET-014 поставляет общий слой идемпотентности webhook'ов: новая SQLite + таблица, dedup-логика, FastAPI dependency, технический endpoint + /api/webhooks/test и health-эндпоинт. Тест-план фокусируется на + (1) атомарности dedup-вставки под параллельной нагрузкой, (2) корректности + replay-поведения, (3) strict/lenient mode, (4) аутентификации по shared + secret, (5) TTL и GC, (6) производительности на больших dedup-store, + (7) отсутствии регрессии в gps_tracks. UI-тестов нет — ET-014 не + затрагивает фронтенд. + +test_suites: + + - name: unit-webhook-dedup + type: unit + description: "Низкоуровневые функции dedup.py на in-memory и файловой SQLite" + cases: + - id: UT-WH-01 + name: "init_webhook_db применяет миграцию идемпотентно" + input: "Пустая sqlite3.connect(':memory:')" + expected: | + После init_webhook_db: SELECT name FROM sqlite_master WHERE type='table' + содержит 'webhook_deliveries'. SELECT name FROM sqlite_master WHERE type='index' + содержит 'idx_webhook_expires' и 'idx_webhook_received'. + Повторный вызов init_webhook_db не падает. + + - id: UT-WH-02 + name: "record_delivery — первичная вставка" + input: "Пустая БД; provider='test', delivery_id='aaa-111', ttl_days=30, body_hash='deadbeef'" + expected: | + Возвращает (True, None). + SELECT * FROM webhook_deliveries → одна строка: + provider='test', delivery_id='aaa-111', status='pending', + received_at ~ now ±1 сек, expires_at ~ now+30d ±1 сек, + request_body_hash='deadbeef'. + + - id: UT-WH-03 + name: "record_delivery — повторная вставка возвращает существующую запись" + input: "После UT-WH-02 — снова record_delivery(... aaa-111 ...)" + expected: | + Возвращает (False, DeliveryRecord(...)) с теми же полями, что в БД. + В БД по-прежнему 1 строка. + + - id: UT-WH-04 + name: "mark_processed обновляет статус и response" + input: "После UT-WH-02; mark_processed(provider='test', delivery_id='aaa-111', response_status=200, response_body='{\"ok\":true}', final_status='ok')" + expected: | + В БД status='ok', response_status=200, response_body='{"ok":true}'. + Поля received_at, expires_at не изменились. + + - id: UT-WH-05 + name: "get_stored_response после mark_processed" + input: "После UT-WH-04" + expected: "Возвращает DeliveryRecord со status='ok', response_status=200, response_body='{\"ok\":true}'." + + - id: UT-WH-06 + name: "purge_expired удаляет просроченные, оставляет активные" + input: | + Вставлены 2 записи прямым SQL: + - delivery_id='alive', expires_at=now+1d + - delivery_id='dead', expires_at=now-1d + expected: | + purge_expired(conn) возвращает 1. + SELECT delivery_id FROM webhook_deliveries → ['alive']. + + - id: UT-WH-07 + name: "wait_for_processed — ok сразу, pending до таймаута" + input: | + Сценарий A: запись status='ok' → вызов wait_for_processed(timeout_sec=2) + Сценарий B: запись status='pending' → вызов wait_for_processed(timeout_sec=0.3, poll_interval_sec=0.05) + expected: | + A: возвращает DeliveryRecord немедленно (< 50 мс). + B: возвращает None через ≈ 0.3 сек (±0.1 сек). + + - id: UT-WH-08 + name: "Разные провайдеры с одним и тем же delivery_id — две независимые записи" + input: | + record_delivery(conn, 'p1', 'same-id', 30, 'h1') + record_delivery(conn, 'p2', 'same-id', 30, 'h2') + expected: | + Оба вызова возвращают (True, None). + SELECT * FROM webhook_deliveries → 2 строки, разные provider. + + - id: UT-WH-09 + name: "Race condition — 2 потока, одинаковый delivery_id" + input: | + Файловая SQLite (WAL). + 50 итераций цикла, в каждой: + t1 = Thread(target=lambda: record_delivery(conn1, 'test', f'race-{i}', 30, 'h')) + t2 = Thread(target=lambda: record_delivery(conn2, 'test', f'race-{i}', 30, 'h')) + start; join + expected: | + В каждой итерации: + - один поток получил (True, None); + - другой — (False, rec); + - в БД ровно одна строка с delivery_id=f'race-{i}'. + Случаев «оба получили True» — 0 за 50 итераций. + + - id: UT-WH-10 + name: "load_webhook_config парсит yaml, валидирует id" + input: | + Сценарий A: валидный YAML с провайдером 'test'. + Сценарий B: YAML с id 'Test' (с большой буквы). + Сценарий C: YAML с ttl_days=0. + Сценарий D: YAML с ttl_days=500. + expected: | + A: WebhookConfig с одним провайдером, id='test'. + B: pydantic.ValidationError. + C: ValidationError (ttl_days < 1). + D: ValidationError (ttl_days > 365). + + - id: UT-WH-11 + name: "get_provider — найден / не найден" + input: "cfg с провайдером 'test'" + expected: | + get_provider(cfg, 'test') → ProviderConfig. + get_provider(cfg, 'missing') → KeyError. + + - name: integration-webhook-endpoint + type: integration + description: "POST /api/webhooks/test и GET /api/webhooks/health через TestClient" + cases: + - id: IT-WH-01 + name: "Первичная доставка — 200, replay=false, запись в БД" + input: | + Setup: tmp SQLite, env WEBHOOK_TEST_SECRET='topsecret'. + POST /api/webhooks/test + Headers: X-Delivery-Id=aaa-111, X-Webhook-Secret=topsecret + Body: {"event":"ping"} + expected: | + HTTP 200. + Body: {"received": true, "delivery_id": "aaa-111", "replay": false}. + SELECT * FROM webhook_deliveries → 1 строка status='ok' response_status=200. + + - id: IT-WH-02 + name: "Replay — 200, replay=true, ответ совпадает" + input: "После IT-WH-01 — тот же запрос ещё раз" + expected: | + HTTP 200. + Body: {"received": true, "delivery_id": "aaa-111", "replay": true}. + В БД по-прежнему 1 строка. + Счётчик business-handler (monkeypatch) = 1 (не вырос). + + - id: IT-WH-03 + name: "Strict mode — без X-Delivery-Id → 400" + input: | + POST /api/webhooks/test + Headers: X-Webhook-Secret=topsecret + Body: {"event":"ping"} + expected: | + HTTP 400. + Body: {"error": "missing X-Delivery-Id header"}. + В БД ничего не появилось. + + - id: IT-WH-04 + name: "Нет X-Webhook-Secret → 401" + input: "POST с X-Delivery-Id, но без X-Webhook-Secret" + expected: | + HTTP 401. + Body: {"error": "invalid secret"}. + В БД ничего. + + - id: IT-WH-05 + name: "Неверный X-Webhook-Secret → 401" + input: "POST с X-Webhook-Secret='wrong'" + expected: "HTTP 401, в БД ничего." + + - id: IT-WH-06 + name: "GET /api/webhooks/health после 3 успешных доставок" + input: "3 × IT-WH-01 с разными delivery_id (uuid)" + expected: | + HTTP 200. + Body содержит: deliveries_count=3, by_provider.test.count=3, replays_24h=0, + last_delivery_at ~ now ±60s, providers=['test']. + + - id: IT-WH-07 + name: "Health отражает replay-счётчик" + input: "После IT-WH-02 — GET /api/webhooks/health" + expected: "by_provider.test.replays_24h ≥ 1." + + - id: IT-WH-08 + name: "Параллельная нагрузка 100 retry — ровно 1 первичный" + input: | + asyncio.gather(*[ + client.post('/api/webhooks/test', + headers={'X-Delivery-Id': 'race-001', 'X-Webhook-Secret': 'topsecret'}, + json={'event': 'ping'} + ) for _ in range(100) + ]) + Business-handler заменён на счётчик через monkeypatch. + expected: | + Ровно 1 ответ с replay=false. + Ровно 99 ответов с replay=true. + В БД ровно 1 строка с delivery_id='race-001'. + Счётчик business-handler = 1. + + - id: IT-WH-09 + name: "Длинный delivery_id → 400" + input: | + POST /api/webhooks/test + Headers: X-Delivery-Id='a'*300, X-Webhook-Secret=topsecret + expected: | + HTTP 400. + Body: {"error": "delivery_id too long"}. + В БД ничего. + + - id: IT-WH-10 + name: "Неизвестный provider в URL → 404" + input: "POST /api/webhooks/unknown_xyz с всеми правильными headers" + expected: "HTTP 404. Body: {\"error\": \"unknown provider\"}. В БД ничего." + + - name: perf-webhook + type: performance + pytest_marker: perf + description: "Производительность под нагрузкой (не запускается в обычном CI)" + cases: + - id: PT-WH-01 + name: "Lookup p95 на БД 10⁶ записей ≤ 5 мс" + input: | + Setup: предзаполнение БД 10⁶ строк скриптом scripts/seed_webhook.py. + Test: 10 000 вызовов record_delivery (повторных, на существующих delivery_id), + измерение latency каждого через time.perf_counter. + expected: "p95 ≤ 5 мс на mva154." + + - id: PT-WH-02 + name: "1000 RPS на /api/webhooks/test, 5 секунд" + input: | + Setup: запущен uvicorn 4 worker'а. + Test: httpx.AsyncClient в pool=200, 1000 RPS уникальных delivery_id. + expected: "Errors=0, p99 latency ≤ 200 мс." + + - name: ttl-and-gc + type: integration + description: "TTL и сборка мусора" + cases: + - id: TT-WH-01 + name: "purge_expired удаляет просроченные через CLI скрипт" + input: | + Прямой SQL INSERT: delivery_id='expired-001', expires_at=now-1h. + Вызов: python scripts/webhook_gc.py data/test.sqlite + expected: | + stdout содержит 'purged 1 expired webhook deliveries'. + SELECT COUNT(*) FROM webhook_deliveries WHERE delivery_id='expired-001' → 0. + + - id: TT-WH-02 + name: "Lazy GC при startup" + input: | + Прямой SQL INSERT: 5 просроченных + 5 активных записей. + Запуск FastAPI (startup event вызывает purge_expired). + expected: | + После startup в БД ровно 5 записей (только активные). + GET /api/webhooks/health → deliveries_count=5, expired_pending_gc=0. + + - id: TT-WH-03 + name: "TTL per-provider" + input: | + Конфиг с двумя провайдерами: + - id: test, ttl_days: 30 + - id: short, ttl_days: 1 + POST /api/webhooks/test и /api/webhooks/short с разными delivery_id. + expected: | + В БД у записи provider='test' expires_at ~ now+30d. + У записи provider='short' expires_at ~ now+1d. + + - name: smoke-prod + type: smoke + pytest_marker: smoke + description: "Smoke-тесты на test-среде после деплоя" + cases: + - id: SM-WH-01 + name: "Smoke: первичная доставка на test-среде" + input: | + curl -X POST https://openclaw.mva154.duckdns.org/enduro/api/webhooks/test \ + -H 'X-Delivery-Id: smoke-{uuid}' \ + -H 'X-Webhook-Secret: ${WEBHOOK_TEST_SECRET}' \ + -H 'Content-Type: application/json' \ + -d '{"smoke": true}' + expected: "HTTP 200, body.replay=false." + + - id: SM-WH-02 + name: "Smoke: replay тот же запрос" + input: "Тот же curl что в SM-WH-01" + expected: "HTTP 200, body.replay=true." + + - id: SM-WH-03 + name: "Smoke: без secret → 401" + input: | + curl -X POST https://openclaw.mva154.duckdns.org/enduro/api/webhooks/test \ + -H 'X-Delivery-Id: probe' \ + -H 'Content-Type: application/json' \ + -d '{}' + expected: "HTTP 401." + + - id: SM-WH-04 + name: "Smoke: health-эндпоинт публично доступен и валиден" + input: "curl https://openclaw.mva154.duckdns.org/enduro/api/webhooks/health" + expected: | + HTTP 200. + JSON содержит ключи: status, deliveries_count, last_delivery_at, + providers, by_provider, expired_pending_gc. + + - name: regression + type: regression + description: "Регрессионные тесты — ET-014 не сломал gps_tracks и роутинг" + cases: + - id: RG-WH-01 + name: "Все unit-тесты gps_tracks остаются зелёными" + input: "pytest tests/unit/test_gps_tracks_*.py -v" + expected: "Exit-code 0, все ранее зелёные тесты остаются зелёными." + + - id: RG-WH-02 + name: "Все integration-тесты gps_tracks остаются зелёными" + input: "pytest tests/integration/test_gps_tracks_*.py -v" + expected: "Exit-code 0." + + - id: RG-WH-03 + name: "GET /api/gps-tracks работает после миграции ET-014" + input: | + После применения миграции webhook_001_init.sql: + GET /api/gps-tracks?bbox=37.0,55.0,38.0,56.0 + expected: | + HTTP 200. + Структура FeatureCollection соответствует контракту ET-008. + + - id: RG-WH-04 + name: "GET /api/health (root) работает" + input: "GET /api/health" + expected: "HTTP 200, ответ {\"status\": \"ok\", ...}." + + - id: RG-WH-05 + name: "POST /api/route работает (OSRM-роутинг)" + input: | + POST /api/route + Body: {"waypoints":[{"lon":37.0,"lat":55.0},{"lon":38.0,"lat":56.0}]} + expected: "HTTP 200 или 404 (если нет OSRM в CI), но не 500 — стабильность не нарушена." + + - name: lint-and-types + type: static + description: "Линтер и форматирование" + cases: + - id: LT-WH-01 + name: "ruff чистый на новом коде" + input: "ruff check src/api/webhooks/ scripts/webhook_gc.py tests/unit/test_webhook_dedup.py tests/integration/test_webhook_endpoint.py" + expected: "Exit-code 0, 0 warnings." + + - id: LT-WH-02 + name: "Типы pydantic в моделях работают на Python 3.12" + input: "python -c 'from src.api.webhooks.models import DeliveryRecord; DeliveryRecord(provider=\"t\", delivery_id=\"x\", received_at=\"2026-01-01T00:00:00Z\", expires_at=\"2026-02-01T00:00:00Z\", status=\"ok\")'" + expected: "Exit-code 0, без ошибок." + + - name: security + type: security + description: "Безопасность: hmac, секреты, длины" + cases: + - id: SC-WH-01 + name: "compare_digest используется для сверки secret" + input: "Grep по src/api/webhooks/ на 'compare_digest'" + expected: "≥ 1 совпадение в endpoint.py." + + - id: SC-WH-02 + name: "Body не пишется в БД целиком" + input: | + IT-WH-01 с большим body (1 MB JSON). + SELECT * FROM webhook_deliveries WHERE delivery_id='aaa-111'. + expected: | + В поле request_body_hash — sha256-хэш (64 символа). + В поле response_body — наш короткий ответ. + Размер строки в БД < 5 KB (input body не сохранён). + + - id: SC-WH-03 + name: "delivery_id обрезается в логах до 64 символов" + input: "POST с X-Delivery-Id длиной 64 символа (валидный) и 257 символов (невалидный, см. IT-WH-09)" + expected: | + Для 64 — в логах строка с полным id. + Для 257 — запрос отклонён (HTTP 400), но если логируется до проверки — + в логе только первые 64 символа + '…'. +```