analyst(ET): auto-commit from analyst run_id=53
This commit is contained in:
245
docs/work-items/ET-010/01-brd.md
Normal file
245
docs/work-items/ET-010/01-brd.md
Normal file
@@ -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/<id>/`.
|
||||
- Менять 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 <workspace> && 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/<repo>` гарантированно
|
||||
испортят чужой 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 <path> && 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` — план тестирования.
|
||||
911
docs/work-items/ET-011/02-trz.md
Normal file
911
docs/work-items/ET-011/02-trz.md
Normal file
@@ -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 <tag>`, `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 <<EOF
|
||||
# Deploy Log — ${WORK_ITEM_ID}
|
||||
|
||||
- **Version:** ${NEW_TAG}
|
||||
- **Date:** ${DATE_UTC}
|
||||
- **PR:** #${PR_NUMBER}
|
||||
- **Branch:** ${BRANCH}
|
||||
- **Merge commit:** ${MERGE_SHA}
|
||||
- **Environment:** test
|
||||
- **Healthcheck:** ${HEALTH_LINE}
|
||||
- **Smoke:** ${SMOKE_LINE}
|
||||
- **Status:** ${STATUS_LINE}
|
||||
EOF
|
||||
|
||||
# Подготовить новый блок CHANGELOG в /tmp
|
||||
cat > /tmp/changelog-block.md <<EOF
|
||||
## [${NEW_TAG}] — $(date -u +%Y-%m-%d)
|
||||
|
||||
### ${WORK_ITEM_ID}
|
||||
- ${WORK_ITEM_ID}: см. \`docs/work-items/${WORK_ITEM_ID}/14-deploy-log.md\`. PR #${PR_NUMBER}, tag ${NEW_TAG}.
|
||||
|
||||
EOF
|
||||
|
||||
# Создать deploy-ветку через API
|
||||
DEPLOY_BRANCH="deploy/${WORK_ITEM_ID}-${NEW_TAG}"
|
||||
BR_RESP=$(curl -s -o /tmp/branch.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_REPO_URL}/branches" \
|
||||
-d "{\"new_branch_name\":\"${DEPLOY_BRANCH}\",\"old_branch_name\":\"main\"}")
|
||||
if [ "$BR_RESP" != "201" ] && [ "$BR_RESP" != "200" ]; then
|
||||
echo "ERROR: deploy-branch create failed ($BR_RESP): $(cat /tmp/branch.json)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Залить 14-deploy-log.md (новый файл) через API
|
||||
DL_B64=$(base64 -w0 /tmp/deploy-log.md)
|
||||
DL_RESP=$(curl -s -o /tmp/dl.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_REPO_URL}/contents/${DEPLOY_LOG_PATH}" \
|
||||
-d "{\"branch\":\"${DEPLOY_BRANCH}\",\"content\":\"${DL_B64}\",\"message\":\"deploy(${WORK_ITEM_ID}): deploy log ${NEW_TAG}\",\"author\":{\"name\":\"claude-bot\",\"email\":\"claude-bot@mva154.local\"}}")
|
||||
if [ "$DL_RESP" != "201" ] && [ "$DL_RESP" != "200" ]; then
|
||||
echo "ERROR: deploy-log create failed ($DL_RESP): $(cat /tmp/dl.json)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Обновить CHANGELOG.md через API (нужен текущий SHA файла)
|
||||
CL_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"${GITEA_REPO_URL}/contents/CHANGELOG.md?ref=${DEPLOY_BRANCH}")
|
||||
CL_SHA=$(echo "$CL_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['sha'])")
|
||||
CL_OLD=$(echo "$CL_INFO" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())")
|
||||
|
||||
# Вставить новый блок ПОСЛЕ заголовка
|
||||
python3 <<PY > /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 <tag>`.
|
||||
|
||||
## Разрешено (явно)
|
||||
|
||||
- `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 <ref>`,
|
||||
`git -C /repos/enduro-trails show <ref>`, `git -C /repos/enduro-trails
|
||||
rev-parse <ref>`, `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 <commit>
|
||||
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 <name> <target>"),
|
||||
(re.compile(r"\bgit\s+push\s+\S+\s+(refs/tags|v\d|tag)\b"),
|
||||
"git push origin <tag>"),
|
||||
]
|
||||
|
||||
|
||||
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 ... "<SSH_INNER>"
|
||||
block = re.sub(r'ssh\s+[^\n]*"[^"]*"', 'ssh <SSH_INNER>', block)
|
||||
block = re.sub(r"ssh\s+[^\n]*'[^']*'", "ssh <SSH_INNER>", 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 <previous-tag>
|
||||
```
|
||||
|
||||
### Ручной 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).
|
||||
267
docs/work-items/ET-011/03-acceptance-criteria.md
Normal file
267
docs/work-items/ET-011/03-acceptance-criteria.md
Normal file
@@ -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 <name> <target>`, `git push <remote> <tag>`.
|
||||
|
||||
**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.)
|
||||
89
docs/work-items/ET-012/00-business-request.md
Normal file
89
docs/work-items/ET-012/00-business-request.md
Normal file
@@ -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.
|
||||
165
docs/work-items/ET-012/01-brd.md
Normal file
165
docs/work-items/ET-012/01-brd.md
Normal file
@@ -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-агента.
|
||||
546
docs/work-items/ET-012/02-trz.md
Normal file
546
docs/work-items/ET-012/02-trz.md
Normal file
@@ -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=<agent|None>, …)`.
|
||||
|
||||
#### 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.
|
||||
472
docs/work-items/ET-012/04-test-plan.yaml
Normal file
472
docs/work-items/ET-012/04-test-plan.yaml
Normal file
@@ -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/<wid>/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/<wid>/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/<wid>/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/<wid>/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/<repo>/docs/work-items/<wid>/01-questions.md"
|
||||
- "tests/fixtures/repos/<repo>/docs/work-items/<wid>/10-conflict.md"
|
||||
- "tests/fixtures/repos/<repo>/docs/work-items/<wid>/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)"
|
||||
- "Миграция БД (нет изменений схемы)"
|
||||
---
|
||||
266
docs/work-items/ET-014/01-brd.md
Normal file
266
docs/work-items/ET-014/01-brd.md
Normal file
@@ -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/<provider>` с заголовком `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-<hash[:16]>`, использует как 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.
|
||||
389
docs/work-items/ET-014/04-test-plan.yaml
Normal file
389
docs/work-items/ET-014/04-test-plan.yaml
Normal file
@@ -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 символа + '…'.
|
||||
```
|
||||
Reference in New Issue
Block a user