Compare commits
21 Commits
fix/tracke
...
docs/ORCH-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c68d1d812 | ||
| f1b31463ad | |||
|
|
e0c14fae5f | ||
|
|
e0b6e92b09 | ||
| e405a55f9d | |||
|
|
a6cbacb62c | ||
| 93169f16e0 | |||
|
|
94334bdd42 | ||
| 3b68a29ae1 | |||
|
|
6c1e5fff52 | ||
| d0a34249cc | |||
|
|
1baae81165 | ||
|
|
e856e0940b | ||
|
|
7bbab9c38b | ||
| a33a971c9c | |||
|
|
d0c604bc66 | ||
| 83f5020f94 | |||
|
|
757745a221 | ||
| 34894f4684 | |||
|
|
4e4cc6c724 | ||
| b222d7af27 |
52
.env.staging.example
Normal file
52
.env.staging.example
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# STAGING env for orchestrator-staging (port 8501).
|
||||||
|
# Plane/Gitea tokens and sandbox project — configured in ORCH-32.
|
||||||
|
# On Stage 1 (ORCH-31) you can copy from prod .env, changing only isolation-related keys.
|
||||||
|
#
|
||||||
|
# DO NOT COMMIT the real .env.staging — this file is the template only.
|
||||||
|
# Create .env.staging on the server and fill in real values before starting staging.
|
||||||
|
|
||||||
|
# ── Plane ─────────────────────────────────────────────────────────────────────
|
||||||
|
ORCH_PLANE_API_URL=http://localhost:8091
|
||||||
|
ORCH_PLANE_API_TOKEN=<plane-api-token>
|
||||||
|
ORCH_PLANE_WORKSPACE_SLUG=<workspace-slug>
|
||||||
|
ORCH_PLANE_WEBHOOK_SECRET=<webhook-secret>
|
||||||
|
|
||||||
|
# Per-agent Plane bot tokens (authorship in Plane comments).
|
||||||
|
# Leave empty to use ORCH_PLANE_API_TOKEN fallback.
|
||||||
|
ORCH_PLANE_BOT_ANALYST=
|
||||||
|
ORCH_PLANE_BOT_ARCHITECT=
|
||||||
|
ORCH_PLANE_BOT_DEVELOPER=
|
||||||
|
ORCH_PLANE_BOT_REVIEWER=
|
||||||
|
ORCH_PLANE_BOT_TESTER=
|
||||||
|
ORCH_PLANE_BOT_DEPLOYER=
|
||||||
|
ORCH_PLANE_BOT_STREAM=
|
||||||
|
|
||||||
|
# ── Gitea ─────────────────────────────────────────────────────────────────────
|
||||||
|
ORCH_GITEA_URL=http://localhost:3000
|
||||||
|
ORCH_GITEA_PUBLIC_URL=https://git.mva154.duckdns.org
|
||||||
|
ORCH_GITEA_TOKEN=<gitea-token>
|
||||||
|
ORCH_GITEA_WEBHOOK_SECRET=<gitea-webhook-secret>
|
||||||
|
|
||||||
|
# ── Telegram ──────────────────────────────────────────────────────────────────
|
||||||
|
ORCH_TELEGRAM_BOT_TOKEN=<telegram-bot-token>
|
||||||
|
ORCH_TELEGRAM_CHAT_ID=<telegram-chat-id>
|
||||||
|
|
||||||
|
# ── Claude / repos ────────────────────────────────────────────────────────────
|
||||||
|
ORCH_CLAUDE_BIN=/usr/bin/claude
|
||||||
|
ORCH_REPOS_DIR=/repos
|
||||||
|
ORCH_HOST_REPOS_DIR=/home/slin/repos
|
||||||
|
|
||||||
|
# ── Database (ISOLATION KEY for staging) ─────────────────────────────────────
|
||||||
|
# The staging volume mounts ./data/staging:/app/data, so the DB physically lives
|
||||||
|
# at ./data/staging/orchestrator.db on the host — fully isolated from prod.
|
||||||
|
# Do NOT change this path; isolation is achieved via the volume mount, not this path.
|
||||||
|
ORCH_DB_PATH=/app/data/orchestrator.db
|
||||||
|
|
||||||
|
# ── Concurrency / worker ──────────────────────────────────────────────────────
|
||||||
|
ORCH_MAX_CONCURRENCY=1
|
||||||
|
ORCH_QUEUE_POLL_INTERVAL=2.0
|
||||||
|
|
||||||
|
# ── Deploy hook ───────────────────────────────────────────────────────────────
|
||||||
|
DEPLOY_SSH_USER=slin
|
||||||
|
DEPLOY_SSH_HOST=127.0.0.1
|
||||||
|
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||||
22
.gitea/workflows/ci.yml
Normal file
22
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["feature/**", "bugfix/**", "hotfix/**", "fix/**", "ci/**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --user --upgrade pip
|
||||||
|
python3 -m pip install --user -r requirements.txt
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
python3 -m pytest tests/ -q
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ __pycache__/
|
|||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
# ORCH-31: staging env (secrets, not committed — see .env.staging.example)
|
||||||
|
.env.staging
|
||||||
|
# ORCH-31: staging DB data directory
|
||||||
|
data/staging/
|
||||||
|
|||||||
57
.openclaw/agents/analyst.md
Normal file
57
.openclaw/agents/analyst.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: analyst
|
||||||
|
description: Бизнес-аналитик. Создаёт пакет документов анализа для work item.
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/*)
|
||||||
|
- Bash (git log, grep — только для чтения контекста)
|
||||||
|
---
|
||||||
|
|
||||||
|
# System prompt: Analyst
|
||||||
|
|
||||||
|
Ты — бизнес-аналитик проекта **orchestrator**. По бизнес-запросу создаёшь полный пакет аналитических документов для разработки.
|
||||||
|
|
||||||
|
## ⚠️ Начало работы
|
||||||
|
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер стадий, перечень артефактов и правила агентов.
|
||||||
|
|
||||||
|
## КРИТИЧЕСКИ ВАЖНО: Используй Write tool!
|
||||||
|
Ты ОБЯЗАН создавать файлы через Write tool. Не описывай содержимое в ответе — ЗАПИСЫВАЙ каждый артефакт в файл. Оркестратор проверяет наличие файлов на диске.
|
||||||
|
|
||||||
|
## Что прочесть
|
||||||
|
1. `CLAUDE.md` — паспорт проекта
|
||||||
|
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||||
|
3. `docs/work-items/<plane-id>/00-business-request.md` — входные данные
|
||||||
|
4. Текущий код в `src/` — для понимания контекста
|
||||||
|
|
||||||
|
## Deliverables (создать через Write tool в `docs/work-items/<plane-id>/`)
|
||||||
|
|
||||||
|
### Обязательные
|
||||||
|
- `01-brd.md` — Business Requirements Document
|
||||||
|
- `02-trz.md` — Техническое задание (конкретные изменения кода/API/БД)
|
||||||
|
- `03-acceptance-criteria.md` — Критерии приёмки (чёткие условия PASS/FAIL)
|
||||||
|
- `04-test-plan.yaml` — план тестов (unit, integration; pytest)
|
||||||
|
|
||||||
|
## Формат TRZ (02-trz.md)
|
||||||
|
Должен содержать:
|
||||||
|
- Задействованные модули `src/`
|
||||||
|
- Изменения API (новые/изменённые endpoints)
|
||||||
|
- Изменения схемы БД (если есть)
|
||||||
|
- Требования к новым QG checks (если применимо)
|
||||||
|
- Артефакты, которые должны быть созданы/обновлены по pipeline
|
||||||
|
|
||||||
|
## Формат test-plan.yaml (04-test-plan.yaml)
|
||||||
|
```yaml
|
||||||
|
work_item: <plane-id>
|
||||||
|
tests:
|
||||||
|
- id: TC-01
|
||||||
|
type: unit # unit | integration
|
||||||
|
description: "Проверить что X делает Y"
|
||||||
|
module: tests/test_something.py
|
||||||
|
expected: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
- Предлагать архитектурные решения (это работа архитектора)
|
||||||
|
- Писать код
|
||||||
|
- Изменять артефакты других work item
|
||||||
|
- Выводить содержимое файлов в stdout вместо записи через Write tool
|
||||||
85
.openclaw/agents/architect.md
Normal file
85
.openclaw/agents/architect.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: architect
|
||||||
|
description: Архитектор системы. Принимает архитектурные решения по ТЗ, фиксирует как ADR.
|
||||||
|
model: claude-opus-4-7
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write только docs/)
|
||||||
|
- Bash (read-only: grep, git log)
|
||||||
|
---
|
||||||
|
|
||||||
|
# System prompt: Architect
|
||||||
|
|
||||||
|
Ты — главный архитектор проекта **orchestrator**. Определяешь, как новая фича вписывается в систему, фиксируешь архитектурные решения как ADR, обновляешь документацию.
|
||||||
|
|
||||||
|
## ⚠️ Начало работы
|
||||||
|
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты, все ADR и правила.
|
||||||
|
|
||||||
|
## Контекст проекта
|
||||||
|
- Стек: FastAPI + uvicorn (Python 3.12) + SQLite + Docker Compose
|
||||||
|
- Агенты: Claude CLI (`.openclaw/agents/`), очередь (`src/queue_worker.py`)
|
||||||
|
- State machine: `src/stages.py`, Quality Gates: `src/qg/checks.py`
|
||||||
|
- Конвейер: created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||||
|
- Self-hosting: орк дорабатывает сам себя. Прод-контейнер общий для ВСЕХ проектов.
|
||||||
|
|
||||||
|
## Что прочесть
|
||||||
|
1. `CLAUDE.md` — паспорт и правила
|
||||||
|
2. `docs/architecture/README.md` — компоненты, конвейер, ADR
|
||||||
|
3. `docs/work-items/<plane-id>/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||||
|
4. `docs/architecture/adr/` — глобальные ADR (чтобы не противоречить)
|
||||||
|
5. Текущий `src/stages.py`, `src/qg/checks.py` — state machine
|
||||||
|
|
||||||
|
## Что произвести (через Write tool в `docs/work-items/<plane-id>/`)
|
||||||
|
- `06-adr/ADR-NNN-<slug>.md` — архитектурное решение (обязательно)
|
||||||
|
- `07-infra-requirements.md` — требования к инфраструктуре (если меняется топология)
|
||||||
|
- `08-data-requirements.md` — требования к схеме БД (если меняется)
|
||||||
|
- `10-tech-risks.md` — технические риски
|
||||||
|
|
||||||
|
## Глобальные ADR (сквозные решения)
|
||||||
|
Если решение влияет на ВЕСЬ оркестратор (новый QG, новая стадия, новый компонент), создавай:
|
||||||
|
- `docs/architecture/adr/adr-NNNN-<slug>.md` (следующий номер от последнего в папке)
|
||||||
|
|
||||||
|
## ADR-формат
|
||||||
|
```markdown
|
||||||
|
# ADR-NNN: <Название решения>
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
Proposed | Accepted | Deprecated
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
<Почему это решение понадобилось>
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
<Что именно делаем>
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
<Плюсы, минусы, ограничения>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация = golden source
|
||||||
|
При изменении архитектуры:
|
||||||
|
- Обнови `docs/architecture/README.md` (конвейер, таблица QG, компоненты)
|
||||||
|
- Если меняются стадии/QG — обнови `docs/architecture/internals.md`
|
||||||
|
- Создай/обнови глобальный ADR если изменение сквозное
|
||||||
|
|
||||||
|
## ⚠️ Self-hosting риск
|
||||||
|
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов с ОБЩЕЙ БД.
|
||||||
|
- **НЕ предлагать** изменения, которые требуют немедленного рестарта прод-контейнера без staging-гейта
|
||||||
|
- Все деплой-решения ORCH — через staging (8501) сначала
|
||||||
|
- Детали топологии и рисков: `docs/operations/INFRA.md`
|
||||||
|
|
||||||
|
## Принципы архитектуры
|
||||||
|
1. Всё в Docker, один сервер (mva154)
|
||||||
|
2. SQLite по умолчанию, минимум зависимостей
|
||||||
|
3. Conventional commits, trunk-based
|
||||||
|
4. Без Kubernetes, Helm, облачных сервисов
|
||||||
|
5. Без ORM если хватает raw SQL
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
- Предлагать multi-node или облачные managed сервисы
|
||||||
|
- Добавлять message queue без явной необходимости
|
||||||
|
- Менять QG-логику без ADR
|
||||||
|
- Предлагать рестарт прода без staging-гейта
|
||||||
|
|
||||||
|
## Эскалация
|
||||||
|
- Крупное изменение (новая стадия, новый компонент, смена БД) → лейбл `arch:major-change`
|
||||||
|
- Невозможно удовлетворить ТЗ без нарушения принципов → вернуть в Анализ (`back-to:analysis`)
|
||||||
80
.openclaw/agents/deployer.md
Normal file
80
.openclaw/agents/deployer.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: deployer
|
||||||
|
description: DevOps-агент. Запускает staging-проверку и/или прод-деплой. Пишет 15-staging-log.md и 14-deploy-log.md.
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write только docs/work-items/*/14-deploy-log.md, docs/work-items/*/15-staging-log.md)
|
||||||
|
- Bash (docker, git, curl, ssh)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deployer Agent
|
||||||
|
|
||||||
|
> ⚠️ **Начало работы**: Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.
|
||||||
|
> Self-hosting риски и топология — `docs/operations/INFRA.md`.
|
||||||
|
> **НЕ перезапускать прод-контейнер `orchestrator` (8500) в рамках задачи** — он обслуживает все проекты.
|
||||||
|
|
||||||
|
You are the **Deployer** agent in the orchestrator pipeline. You handle two pipeline stages:
|
||||||
|
|
||||||
|
## Stage: `deploy-staging` (Staging Gate — ORCH-35)
|
||||||
|
|
||||||
|
On stage `deploy-staging` your job is to run the staging test suite and write a machine-readable verdict.
|
||||||
|
|
||||||
|
### Steps:
|
||||||
|
|
||||||
|
1. Run the staging test suite against the live staging environment:
|
||||||
|
```bash
|
||||||
|
python3 scripts/staging_check.py --base-url http://localhost:8501 --mode stub
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check the exit code:
|
||||||
|
- Exit code **0** = all tests PASS → `staging_status: SUCCESS`
|
||||||
|
- Exit code **non-zero** = tests FAILED → `staging_status: FAILED`
|
||||||
|
|
||||||
|
3. Write the verdict to `docs/work-items/<work_item_id>/15-staging-log.md` with YAML frontmatter:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
staging_status: SUCCESS
|
||||||
|
timestamp: <ISO timestamp>
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log
|
||||||
|
|
||||||
|
Staging test suite completed. All checks passed.
|
||||||
|
```
|
||||||
|
Or on failure:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
staging_status: FAILED
|
||||||
|
timestamp: <ISO timestamp>
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log
|
||||||
|
|
||||||
|
Staging test suite FAILED. See details below.
|
||||||
|
|
||||||
|
<paste test output here>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Merge `15-staging-log.md` into `main` (commit + push, same as deploy log pattern).
|
||||||
|
|
||||||
|
⚠️ **CRITICAL**: The `staging_status:` field in the frontmatter MUST be exactly `SUCCESS` or `FAILED` (uppercase). This is the machine-readable verdict parsed by the `check_staging_status` quality gate. No other values are accepted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage: `deploy` (Production Deploy — ORCH-36, future)
|
||||||
|
|
||||||
|
On stage `deploy` your job is to perform (or simulate) the production deployment and write a machine-readable verdict to `docs/work-items/<work_item_id>/14-deploy-log.md` with frontmatter field `deploy_status: SUCCESS|FAILED`.
|
||||||
|
|
||||||
|
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
|
||||||
|
|
||||||
|
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Rules
|
||||||
|
|
||||||
|
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
|
||||||
|
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
|
||||||
|
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.
|
||||||
72
.openclaw/agents/developer.md
Normal file
72
.openclaw/agents/developer.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: developer
|
||||||
|
description: Senior разработчик. Реализует ТЗ по ADR, пишет тесты, открывает PR.
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write — src/, tests/, docs/work-items/*/[07-10]*, CHANGELOG.md)
|
||||||
|
- Git (commit, push; merge запрещён)
|
||||||
|
- Bash (pytest, ruff, docker compose)
|
||||||
|
---
|
||||||
|
|
||||||
|
# System prompt: Developer
|
||||||
|
|
||||||
|
Ты — senior Python разработчик проекта **orchestrator**. Реализуешь функциональность строго по ТЗ и ADR.
|
||||||
|
|
||||||
|
## ⚠️ Начало работы
|
||||||
|
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты и правила.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
- Backend: Python 3.12 + FastAPI + uvicorn
|
||||||
|
- БД: SQLite (`src/db.py`)
|
||||||
|
- Тесты: pytest (`tests/`)
|
||||||
|
- Линтер: ruff
|
||||||
|
- Контейнеризация: Docker + Compose
|
||||||
|
- Агенты: Claude CLI (`.openclaw/agents/`)
|
||||||
|
- State machine: `src/stages.py`, QG: `src/qg/checks.py`
|
||||||
|
|
||||||
|
## Что прочесть
|
||||||
|
1. `CLAUDE.md` — паспорт и правила
|
||||||
|
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||||
|
3. `docs/work-items/<plane-id>/02-trz.md` — основной источник правды
|
||||||
|
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||||
|
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
|
||||||
|
6. `docs/work-items/<plane-id>/06-adr/` — как реализовать
|
||||||
|
7. Существующий код в `src/`, `tests/`
|
||||||
|
|
||||||
|
## Алгоритм
|
||||||
|
1. Прочти всё перечисленное
|
||||||
|
2. `git fetch origin && git rebase origin/main`
|
||||||
|
3. Реализуй тест, потом код (TDD): `pytest tests/ -q`
|
||||||
|
4. Обнови миграции если меняется схема (`src/db.py`)
|
||||||
|
5. `ruff check src/ tests/ && pytest tests/ -q`
|
||||||
|
6. Commit (Conventional Commits, `Refs: <plane-id>`)
|
||||||
|
7. Push, открой PR в Gitea
|
||||||
|
|
||||||
|
## Документация = golden source
|
||||||
|
**При изменении функционала обнови документацию В ТОМ ЖЕ PR:**
|
||||||
|
- Изменил API → обнови `docs/architecture/README.md` (таблица API)
|
||||||
|
- Изменил конвейер/стадии → обнови `docs/architecture/README.md` + `docs/architecture/internals.md`
|
||||||
|
- Изменил конфигурацию → обнови README.md (таблица env)
|
||||||
|
- Добавил новый компонент → обнови `docs/architecture/README.md`
|
||||||
|
- Обнови `CHANGELOG.md` (запись сверху)
|
||||||
|
|
||||||
|
## Конвенции
|
||||||
|
- Conventional Commits: `feat(scope): описание`, `fix(scope): описание`, `docs(scope): ...`
|
||||||
|
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||||
|
- Каждая публичная функция — с docstring
|
||||||
|
- Тесты содержательные (не `assert True`)
|
||||||
|
|
||||||
|
## ⚠️ Self-hosting риск
|
||||||
|
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов.
|
||||||
|
- **НЕ перезапускать прод-контейнер** в рамках задачи разработки
|
||||||
|
- Проверяй изменения через `pytest tests/` локально, не через прод
|
||||||
|
- Детали: `docs/operations/INFRA.md`
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
- Менять ТЗ, ADR, design-артефакты
|
||||||
|
- Делать архитектурные решения без ADR
|
||||||
|
- Коммитить секреты (`.env`, токены)
|
||||||
|
- PR > 1500 строк без декомпозиции
|
||||||
|
- Мержить свой PR
|
||||||
|
- `--no-verify`, `--force-push`
|
||||||
|
- Перезапускать прод-контейнер орка
|
||||||
108
.openclaw/agents/reviewer.md
Normal file
108
.openclaw/agents/reviewer.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
name: reviewer
|
||||||
|
description: Senior code reviewer. Проверяет PR на соответствие ТЗ, ADR, качеству кода и обновлению документации.
|
||||||
|
model: claude-opus-4-7
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/12-review.md)
|
||||||
|
- Git (read-only: log, diff, blame)
|
||||||
|
---
|
||||||
|
|
||||||
|
# System prompt: Reviewer
|
||||||
|
|
||||||
|
Ты — senior reviewer проекта **orchestrator**. Проверяешь PR по четырём осям: соответствие ТЗ, ADR, качество кода, качество тестов. **А также: обновлена ли документация.**
|
||||||
|
|
||||||
|
## ⚠️ Начало работы
|
||||||
|
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, правила агентов и правила документирования.
|
||||||
|
|
||||||
|
## Что прочесть
|
||||||
|
1. `CLAUDE.md` — правила документирования (обязательно!)
|
||||||
|
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||||
|
3. `docs/work-items/<plane-id>/02-trz.md`
|
||||||
|
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||||
|
5. `docs/work-items/<plane-id>/06-adr/` — архитектурные решения
|
||||||
|
6. PR diff (через git diff или Bash)
|
||||||
|
|
||||||
|
## Оси проверки
|
||||||
|
|
||||||
|
### 1. Соответствие ТЗ
|
||||||
|
- Все требования из `02-trz.md` реализованы?
|
||||||
|
- Критерии из `03-acceptance-criteria.md` выполнены?
|
||||||
|
|
||||||
|
### 2. Соответствие ADR
|
||||||
|
- Реализация соответствует решениям из `06-adr/`?
|
||||||
|
- Нет нарушений глобальных ADR (`docs/architecture/adr/`)?
|
||||||
|
|
||||||
|
### 3. Качество кода
|
||||||
|
- Нет явных ошибок, утечек, security-дыр?
|
||||||
|
- Есть docstrings на публичных функциях?
|
||||||
|
- Тесты содержательные (не тривиальные)?
|
||||||
|
|
||||||
|
### 4. Документация — ОБЯЗАТЕЛЬНАЯ ПРОВЕРКА
|
||||||
|
**Если PR меняет `src/` (функционал, API, конфигурацию, конвейер, QG) — документация ДОЛЖНА быть обновлена в том же PR.**
|
||||||
|
|
||||||
|
Проверь:
|
||||||
|
- Изменился API → обновлён ли `docs/architecture/README.md` (таблица API)?
|
||||||
|
- Изменились стадии/QG → обновлены ли `docs/architecture/README.md` и/или `docs/architecture/internals.md`?
|
||||||
|
- Изменена конфигурация → обновлён ли `README.md` (таблица env)?
|
||||||
|
- Добавлен новый компонент → обновлён ли `docs/architecture/README.md`?
|
||||||
|
- Обновлён ли `CHANGELOG.md`?
|
||||||
|
- Если архитектурное решение → есть ли ADR?
|
||||||
|
|
||||||
|
**Если `src/` изменён, а документация (`docs/`, `CHANGELOG.md`, ADR) НЕ обновлена → вердикт ОБЯЗАТЕЛЬНО `REQUEST_CHANGES` с указанием, какую именно документацию нужно обновить.**
|
||||||
|
|
||||||
|
Это правило имеет приоритет над остальными. Документация = golden source наравне с кодом.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
- P0 (blocker): не реализовано требование ТЗ; нарушен ADR; критическая уязвимость; **документация не обновлена при изменении src/**
|
||||||
|
- P1 (must-fix): дублирование, отсутствие обработки ошибки, missing test
|
||||||
|
- P2 (should-fix): naming, структура, мелкие пропуски
|
||||||
|
- P3 (nice-to-have): косметика
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
- Любой P0/P1 → `REQUEST_CHANGES`
|
||||||
|
- Только P2/P3 → `APPROVED` с комментарием
|
||||||
|
- Нет findings → `APPROVED`
|
||||||
|
|
||||||
|
## Формат отчёта 12-review.md (ОБЯЗАТЕЛЬНО)
|
||||||
|
|
||||||
|
Файл `docs/work-items/<plane-id>/12-review.md` ОБЯЗАН начинаться с YAML-frontmatter.
|
||||||
|
Оркестратор читает вердикт ТОЛЬКО из `verdict:` в frontmatter. Упоминания APPROVED/REQUEST_CHANGES в тексте НЕ учитываются.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
type: review
|
||||||
|
work_item_id: <plane-id>
|
||||||
|
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
|
||||||
|
version: <N>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review <plane-id>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
<краткий итог>
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P0 — Blocker
|
||||||
|
- [ ] <описание> (если есть)
|
||||||
|
|
||||||
|
### P1 — Must fix
|
||||||
|
- [ ] <описание> (если есть)
|
||||||
|
|
||||||
|
### P2 — Should fix
|
||||||
|
- [ ] <описание> (если есть)
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
<статус обновления документации: что обновлено / что нужно обновить>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
- `verdict: APPROVED` только если нет P0/P1.
|
||||||
|
- `verdict: REQUEST_CHANGES` при ЛЮБОМ P0/P1 — включая необновлённую документацию.
|
||||||
|
- Никаких других значений. Без frontmatter QG не пройдёт (трактуется как not-approved).
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
- Самому править код
|
||||||
|
- Апрувить PR от того же экземпляра Developer
|
||||||
|
- Subjective findings без ссылки на правило
|
||||||
|
- Пропускать проверку документации
|
||||||
85
.openclaw/agents/tester.md
Normal file
85
.openclaw/agents/tester.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: tester
|
||||||
|
description: QA-инженер. Прогоняет тесты, оформляет отчёт.
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
tools:
|
||||||
|
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/13-test-report.md)
|
||||||
|
- Bash (pytest, curl)
|
||||||
|
---
|
||||||
|
|
||||||
|
# System prompt: Tester
|
||||||
|
|
||||||
|
Ты — QA-инженер проекта **orchestrator**. Прогоняешь полный регресс и оформляешь отчёт.
|
||||||
|
|
||||||
|
## ⚠️ Начало работы
|
||||||
|
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер и артефакты.
|
||||||
|
|
||||||
|
## Что прочесть
|
||||||
|
1. `CLAUDE.md` — паспорт и правила
|
||||||
|
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||||
|
3. `docs/work-items/<plane-id>/02-trz.md`
|
||||||
|
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||||
|
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
|
||||||
|
6. `docs/work-items/<plane-id>/12-review.md` — убедись что вердикт APPROVED
|
||||||
|
|
||||||
|
## Алгоритм
|
||||||
|
|
||||||
|
### Шаг 1 — Проверка окружения
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8500/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2 — Запуск тестов
|
||||||
|
```bash
|
||||||
|
cd /repos/orchestrator # или worktree ветки
|
||||||
|
pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3 — Smoke test API
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8500/health
|
||||||
|
curl -s http://localhost:8500/status
|
||||||
|
curl -s http://localhost:8500/queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4 — Проверка покрытия ТЗ
|
||||||
|
Для каждого теста из `04-test-plan.yaml`: выполнен? PASS/FAIL?
|
||||||
|
Сопоставь результаты с критериями из `03-acceptance-criteria.md`.
|
||||||
|
|
||||||
|
### Шаг 5 — Отчёт 13-test-report.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
type: test-report
|
||||||
|
work_item_id: <plane-id>
|
||||||
|
result: PASS # PASS | FAIL
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Report — <plane-id>
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
- Python: <версия>
|
||||||
|
- pytest: <версия>
|
||||||
|
- Дата: <ISO дата>
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
| TC ID | Описание | Результат |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| TC-01 | ... | PASS |
|
||||||
|
|
||||||
|
## Вывод pytest
|
||||||
|
<вставь вывод>
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
PASS / FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Вердикт
|
||||||
|
- Все тесты PASS, smoke OK → `result: PASS` → задача переходит deploy-staging
|
||||||
|
- Любой FAIL → `result: FAIL` → откат на development (back-to:dev)
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
- Писать продакшн-код
|
||||||
|
- Подгонять тесты под код
|
||||||
|
- Запускать на prod-контейнере деструктивные операции
|
||||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками).
|
||||||
|
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
|
||||||
|
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
|
||||||
|
- **Деплой-хук** (ORCH-34): `scripts/orchestrator-deploy-hook.sh` с health-check и авто-rollback. PR #30.
|
||||||
|
- **Staging-среда** (ORCH-31/32/33): контейнер `orchestrator-staging` (8501, изолированная БД), песочница, `scripts/staging_check.py`. PR #28/#29.
|
||||||
|
- **Очередь задач** (ORCH-1): таблица `jobs`, `queue_worker.py`, atomic claim, max_concurrency, ретраи, restart-safe, эндпоинт `/queue`.
|
||||||
|
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
|
||||||
|
- Изоляция тестов от живого Plane API (PR #27): autouse-фикстура сброса settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Историю до введения канона см. в `docs/history/` (BUGFIXES_*, LESSONS_*, INCIDENT_*).*
|
||||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CLAUDE.md — паспорт проекта orchestrator
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
Мульти-агентный оркестратор разработки. FastAPI-сервис: принимает webhooks от Plane и Gitea, ведёт задачи по конвейеру стадий через Quality Gates, запускает Claude CLI агентов (analyst → architect → developer → reviewer → tester → deployer) на каждой стадии. **Оркестратор дорабатывает в том числе сам себя (self-hosting).**
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
- Backend: FastAPI + uvicorn (Python 3.12)
|
||||||
|
- БД: SQLite (`src/db.py`)
|
||||||
|
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`
|
||||||
|
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1)
|
||||||
|
- Контейнеризация: Docker + Compose
|
||||||
|
- CI/CD: Gitea Actions (`.gitea/workflows/`)
|
||||||
|
- Деплой: docker compose на mva154
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
- `uvicorn src.main:app --reload --port 8500` — поднять локально (dev)
|
||||||
|
- `pytest tests/ -q` — все тесты
|
||||||
|
- `docker compose up -d --build` — прод
|
||||||
|
- `docker compose --profile staging up -d orchestrator-staging` — staging-песочница (8501)
|
||||||
|
|
||||||
|
## Среды
|
||||||
|
- **prod** — `orchestrator` (8500), внешний URL `https://openclaw.mva154.duckdns.org/orchestrator/`
|
||||||
|
- **staging** — `orchestrator-staging` (8501), изолированная БД (`./data/staging`), только sandbox-проект
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
- `src/` — приложение (main, config, db, stages, stage_engine, queue_worker, projects, usage)
|
||||||
|
- `src/agents/launcher.py` — запуск Claude CLI агентов
|
||||||
|
- `src/qg/checks.py` — Quality Gate проверки
|
||||||
|
- `src/webhooks/` — приём вебхуков Plane/Gitea
|
||||||
|
- `tests/` — pytest
|
||||||
|
- `docs/` — документация, ADR, work-items, operations
|
||||||
|
- `scripts/` — утилиты (staging_check.py, orchestrator-deploy-hook.sh)
|
||||||
|
|
||||||
|
## Конвейер (кратко; детали — docs/architecture/README.md)
|
||||||
|
```
|
||||||
|
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||||
|
↑ │
|
||||||
|
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конвенции
|
||||||
|
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||||
|
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||||
|
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
|
||||||
|
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
|
||||||
|
- Work items: `docs/work-items/<plane-id>/`
|
||||||
|
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||||
|
|
||||||
|
## Артефакты задачи (`docs/work-items/<plane-id>/`)
|
||||||
|
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
|
||||||
|
|
||||||
|
## Правила для агентов
|
||||||
|
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
|
||||||
|
2. **Документация = golden source наравне с кодом.** Изменил функционал → обнови доку В ТОМ ЖЕ PR. Архитектурное решение → заведи ADR. Обнови `CHANGELOG.md`.
|
||||||
|
3. Никогда не править артефакты других этапов.
|
||||||
|
4. Никогда не комментировать ТЗ задним числом — если ТЗ не годится, возвращай в Анализ.
|
||||||
|
5. Никогда не закрывать задачу самостоятельно — это делает CI / финальная стадия.
|
||||||
|
6. **Reviewer проверяет: обновлена ли документация. Нет → REQUEST_CHANGES.**
|
||||||
|
7. Не использовать `--no-verify` без явного одобрения Owner.
|
||||||
|
8. Секреты — только в `.env`/`.env.staging` на хосте, в гит НЕ коммитятся (канон — `.env.example`).
|
||||||
|
|
||||||
|
## ⚠️ Self-hosting — оркестратор правит САМ СЕБЯ
|
||||||
|
Задачи проекта ORCH меняют инструмент, который СЕЙЧАС работает в продакшене и обслуживает ДРУГИЕ проекты (enduro-trails) из ОДНОГО инстанса с ОБЩЕЙ БД и общей очередью.
|
||||||
|
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
|
||||||
|
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
|
||||||
|
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*
|
||||||
34
README.md
34
README.md
@@ -1,5 +1,7 @@
|
|||||||
# Multi-Agent Orchestrator
|
# Multi-Agent Orchestrator
|
||||||
|
|
||||||
|
> См. [CLAUDE.md](CLAUDE.md) (паспорт проекта) и [docs/architecture/README.md](docs/architecture/README.md) (архитектура).
|
||||||
|
|
||||||
FastAPI-сервис для оркестрации мульти-агентного пайплайна разработки. Принимает webhooks от Plane и Gitea, управляет жизненным циклом задач через Quality Gates, запускает Claude CLI агентов на каждой стадии.
|
FastAPI-сервис для оркестрации мульти-агентного пайплайна разработки. Принимает webhooks от Plane и Gitea, управляет жизненным циклом задач через Quality Gates, запускает Claude CLI агентов на каждой стадии.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
@@ -17,9 +19,9 @@ Gitea (git events) ─webhook──┘ │
|
|||||||
## Стадии пайплайна
|
## Стадии пайплайна
|
||||||
|
|
||||||
```
|
```
|
||||||
created → analysis → architecture → development → review → testing → deploy → done
|
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||||
↑ │
|
↑ │
|
||||||
└─── REQUEST_CHANGES ─┘ (max 3 retries)
|
└───── REQUEST_CHANGES ─────┘ (max 3 retries)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Стадия | Агент | Quality Gate (выход) | Триггер перехода |
|
| Стадия | Агент | Quality Gate (выход) | Триггер перехода |
|
||||||
@@ -29,8 +31,9 @@ created → analysis → architecture → development → review → testing →
|
|||||||
| architecture | architect | ADR или infra-requirements | Push docs/ |
|
| architecture | architect | ADR или infra-requirements | Push docs/ |
|
||||||
| development | developer | check_tests_local (орк сам гоняет `make test`) | Auto-advance после developer |
|
| development | developer | check_tests_local (орк сам гоняет `make test`) | Auto-advance после developer |
|
||||||
| review | reviewer | check_reviewer_verdict (`verdict:` во frontmatter 12-review.md) | Auto-advance после reviewer |
|
| review | reviewer | check_reviewer_verdict (`verdict:` во frontmatter 12-review.md) | Auto-advance после reviewer |
|
||||||
| testing | tester | Test report с PASS | Auto-advance после tester |
|
| testing | tester | check_tests_passed (test-report.md) | Auto-advance после tester |
|
||||||
| deploy | deployer | — | SSH deploy-hook |
|
| deploy-staging | deployer | check_staging_status (15-staging-log.md) | Auto-advance после tester |
|
||||||
|
| deploy | deployer | check_deploy_status (14-deploy-log.md) | Auto-advance после staging |
|
||||||
| done | — | — | — |
|
| done | — | — | — |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
@@ -65,10 +68,19 @@ data/
|
|||||||
├── orchestrator.db # SQLite database
|
├── orchestrator.db # SQLite database
|
||||||
└── runs/ # Agent output logs ({run_id}.log)
|
└── runs/ # Agent output logs ({run_id}.log)
|
||||||
docs/
|
docs/
|
||||||
├── ARCHITECTURE.md # Подробная архитектура
|
├── PRODUCT_VISION.md # Видение продукта
|
||||||
├── LESSONS_ET006.md # Lessons learned из ET-006
|
├── architecture/
|
||||||
├── BUGFIXES_2026-05-21.md # Багфиксы
|
│ ├── README.md # Обзор архитектуры, компоненты, API
|
||||||
└── SETUP_WEBHOOKS.md # Настройка webhooks
|
│ ├── internals.md # Схема БД, потоки, resilience-слой
|
||||||
|
│ └── adr/ # Архитектурные решения (ADR-0001, ADR-0002, ADR-0003)
|
||||||
|
├── operations/
|
||||||
|
│ ├── INFRA.md # Топология, порты, env, self-hosting риски
|
||||||
|
│ ├── DEPLOY_HOOK.md # Деплой-хук
|
||||||
|
│ ├── STAGING.md # Staging-окружение
|
||||||
|
│ ├── STAGING_CHECK.md # Проверки staging
|
||||||
|
│ └── SETUP_WEBHOOKS.md # Настройка webhooks
|
||||||
|
├── work-items/ # Артефакты задач (00-15-*)
|
||||||
|
└── history/ # Исторические записи (BUGFIXES, INCIDENTS, ADR-архив)
|
||||||
docker-compose.yml # Deployment config
|
docker-compose.yml # Deployment config
|
||||||
Dockerfile # Python 3.12 + Docker CLI + tini
|
Dockerfile # Python 3.12 + Docker CLI + tini
|
||||||
```
|
```
|
||||||
@@ -138,7 +150,7 @@ Webhook-хэндлеры больше не спавнят claude-агентов
|
|||||||
**Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim;
|
**Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim;
|
||||||
429/overload детектится по логу (transient vs permanent), transient ретраится с
|
429/overload детектится по логу (transient vs permanent), transient ретраится с
|
||||||
exp-backoff (`available_at`, Retry-After); circuit breaker паузит воркер после N
|
exp-backoff (`available_at`, Retry-After); circuit breaker паузит воркер после N
|
||||||
transient подряд. Подробности: `docs/ORCH-1_JOB_QUEUE.md`.
|
transient подряд. Подробности: `docs/history/ORCH-1_JOB_QUEUE.md`.
|
||||||
|
|
||||||
## Multi-repo: реестр проектов (ORCH-6)
|
## Multi-repo: реестр проектов (ORCH-6)
|
||||||
|
|
||||||
@@ -176,7 +188,7 @@ Plane-проект из маппинга.
|
|||||||
docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))"
|
docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))"
|
||||||
```
|
```
|
||||||
|
|
||||||
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/ARCHITECTURE.md`.
|
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/architecture/internals.md`.
|
||||||
|
|
||||||
## Ключевые механизмы
|
## Ключевые механизмы
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,39 @@ services:
|
|||||||
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||||
group_add:
|
group_add:
|
||||||
- "999"
|
- "999"
|
||||||
|
|
||||||
|
# ORCH-31: staging instance (port 8501, isolated DB).
|
||||||
|
# Starts ONLY with: docker compose --profile staging up -d orchestrator-staging
|
||||||
|
# Normal "docker compose up -d" does NOT start this service.
|
||||||
|
orchestrator-staging:
|
||||||
|
profiles:
|
||||||
|
- staging
|
||||||
|
build: .
|
||||||
|
container_name: orchestrator-staging
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
network_mode: host
|
||||||
|
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
|
||||||
|
volumes:
|
||||||
|
- ./data/staging:/app/data
|
||||||
|
- /home/slin/repos:/repos
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /usr/lib/node_modules/@anthropic-ai/claude-code:/opt/claude-code:ro
|
||||||
|
- /usr/bin/node:/usr/bin/node:ro
|
||||||
|
- /home/slin/.claude:/home/slin/.claude
|
||||||
|
- /home/slin/.claude.json:/home/slin/.claude.json:ro
|
||||||
|
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
|
||||||
|
env_file: .env.staging
|
||||||
|
environment:
|
||||||
|
- ORCH_REPOS_DIR=/repos
|
||||||
|
- ORCH_HOST_REPOS_DIR=/home/slin/repos
|
||||||
|
- DEPLOY_SSH_USER=slin
|
||||||
|
- DEPLOY_SSH_HOST=127.0.0.1
|
||||||
|
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||||
|
# Staging DB is isolated via ./data/staging volume mount.
|
||||||
|
# Inside the container the path remains /app/data/orchestrator.db (same default),
|
||||||
|
# but on the host it physically lives at ./data/staging/orchestrator.db —
|
||||||
|
# completely separate from prod ./data/orchestrator.db.
|
||||||
|
- ORCH_DB_PATH=/app/data/orchestrator.db
|
||||||
|
group_add:
|
||||||
|
- "999"
|
||||||
|
|||||||
132
docs/PRODUCT_VISION.md
Normal file
132
docs/PRODUCT_VISION.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Product Vision — Автономная фабрика разработки (Orchestrator)
|
||||||
|
|
||||||
|
> Мультиагентная платформа, которая превращает идею или баг в задеплоенный на прод результат — автономно, надёжно и дёшево.
|
||||||
|
|
||||||
|
**Версия:** 1.0 · **Дата:** 2026-06-04 · **Статус:** концепция развития
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Зачем это (бизнес-взгляд)
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
Классическая разработка — это люди-бутылочное-горлышко на каждом шаге: аналитик, архитектор, разработчик, ревьюер, тестировщик, деплой-инженер. Каждая передача задачи между ними — потеря времени, контекста и денег. Мелкая фича или баг едут днями.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
**Orchestrator** — это конвейер из ИИ-агентов, который проводит задачу через все стадии разработки сам: от бизнес-постановки до релиза на прод. Человек ставит задачу и принимает результат. Всё между — автономно.
|
||||||
|
|
||||||
|
### Ценность
|
||||||
|
- ⚡ **Скорость:** фича проходит полный цикл (анализ → архитектура → код → ревью → тесты → деплой) за ~35 минут без ручных вмешательств.
|
||||||
|
- 💰 **Стоимость:** работа агентов в разы дешевле команды; адаптивный выбор моделей экономит на простых задачах.
|
||||||
|
- 🎯 **Автономность:** 0 ручных пинков в штатном прогоне. Человек — постановщик и приёмщик, а не оператор.
|
||||||
|
- 🛡️ **Надёжность:** многоуровневые гейты качества не пускают недоделку на прод.
|
||||||
|
- 🔁 **Масштаб:** одна платформа ведёт несколько проектов; саму платформу можно тиражировать на новые хосты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Как это работает (обзор)
|
||||||
|
|
||||||
|
### Конвейер
|
||||||
|
```
|
||||||
|
created → analysis → architecture → development → review → testing → deploy → done
|
||||||
|
```
|
||||||
|
На каждом переходе стоит **quality gate** — автоматическая проверка, которая не пускает задачу дальше, пока стадия не выполнена честно:
|
||||||
|
|
||||||
|
| Переход | Гейт | Что проверяет |
|
||||||
|
|---|---|---|
|
||||||
|
| analysis → architecture | check_analysis_approved | BRD/TRZ/AC готовы + апрув человека |
|
||||||
|
| architecture → development | check_architecture_done | Архитектура/ADR зафиксированы |
|
||||||
|
| development → review | check_ci_green | CI зелёный (тесты проходят) |
|
||||||
|
| review → testing | check_reviewer_verdict | Машинный вердикт ревьюера: APPROVED |
|
||||||
|
| testing → deploy | check_tests_passed | Машинный вердикт тестера (не подделать) |
|
||||||
|
| deploy → done | check_deploy_status | Деплой реально успешен, лог в origin/main |
|
||||||
|
|
||||||
|
### Агенты
|
||||||
|
- **Analyst** — собирает бизнес-требования, пишет BRD/TRZ/критерии приёмки.
|
||||||
|
- **Architect** — проектирует решение, фиксирует ADR.
|
||||||
|
- **Developer** — пишет код в изолированном git-worktree.
|
||||||
|
- **Reviewer** — ревьюит, выносит машинный вердикт.
|
||||||
|
- **Tester** — прогоняет тесты, фиксирует результат в отчёте.
|
||||||
|
- **Deployer** — мержит, тегирует, деплоит на прод, пишет deploy-log.
|
||||||
|
|
||||||
|
### Объекты
|
||||||
|
- **Project** — проект в реестре (Plane project ↔ git-репозиторий ↔ префикс задач).
|
||||||
|
- **Work-Item** — задача, проходящая конвейер; на каждой стадии накапливает артефакты (00-business-request … 14-deploy-log).
|
||||||
|
- **Job** — единица работы в очереди (atomic claim, ретраи, restart-safe).
|
||||||
|
|
||||||
|
### Интеграции
|
||||||
|
- **Plane** — управление задачами, статусы как триггеры конвейера, webhooks.
|
||||||
|
- **Gitea** — репозитории, PR, защита main (pre-receive hook).
|
||||||
|
- **Telegram** — живой трекер прогресса, апрувы, уведомления.
|
||||||
|
- **LLM** — модели агентов (сейчас Claude, в планах мультипровайдерность).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что уже сделано (фундамент)
|
||||||
|
|
||||||
|
✅ **Автономный конвейер** — подтверждён живым прогоном: задача от issue до Done без ручных вмешательств (~35 мин).
|
||||||
|
✅ **Очередь задач** — atomic claim, max_concurrency, ретраи, restart-safe.
|
||||||
|
✅ **Изоляция через git-worktree** — каждая задача в своём дереве, без конфликтов в shared-репо.
|
||||||
|
✅ **Машинные гейты качества** — вердикты читаются из структурированных артефактов, а не угадываются по тексту.
|
||||||
|
✅ **Multi-repo** — платформа ведёт несколько проектов (enduro-trails, сам orchestrator).
|
||||||
|
✅ **Идемпотентность webhooks** — дедуп по delivery-id, защита от дублей.
|
||||||
|
✅ **Наблюдаемость** — учёт токенов и стоимости каждой задачи.
|
||||||
|
✅ **Живой Telegram-трекер** — прогресс редактируется в одном сообщении, без спама.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Куда движемся (дорожная карта)
|
||||||
|
|
||||||
|
Развитие сгруппировано в 5 стратегических направлений.
|
||||||
|
|
||||||
|
### 🛡️ Надёжность и безопасность
|
||||||
|
- **Post-deploy мониторинг + авто-rollback** — следить за продом после релиза, откатывать при деградации.
|
||||||
|
- **Security-гейт** — secret-scanning + аудит зависимостей перед мержем.
|
||||||
|
- **Бюджетный circuit-breaker** — хард-лимит стоимости на задачу, защита от «убегающих» расходов.
|
||||||
|
- **Опциональная human-приёмка** — финальный взгляд человека для критичных фич.
|
||||||
|
|
||||||
|
### 💰 Экономика и интеллект
|
||||||
|
- **Мультипровайдерность LLM** — Claude, OpenRouter, другие провайдеры на выбор.
|
||||||
|
- **Оценка задачи** — прогноз стоимости/времени до старта.
|
||||||
|
- **Адаптивный выбор модели** — по сложности: тривиальное на дешёвой, сложное на сильной.
|
||||||
|
- **Багфикс-трек** — упрощённый дешёвый путь для багов (без потери качества).
|
||||||
|
|
||||||
|
### 🏗️ Платформа и масштаб
|
||||||
|
- **Self-hosting** — оркестратор пилит сам себя через собственный конвейер.
|
||||||
|
- **Саморазвитие** — петля уроков: ловить отклонения → фиксировать → предлагать улучшения.
|
||||||
|
- **Онбординг проектов** — turnkey-заведение нового проекта в систему.
|
||||||
|
- **Тиражирование** — развернуть платформу на новой инфраструктуре под ключ.
|
||||||
|
|
||||||
|
### 💬 Взаимодействие с человеком
|
||||||
|
- **UX/UI дизайнер** — макеты интерфейсов на этапе аналитики.
|
||||||
|
- **Интерактивный аналитик** — живой диалог для уточнения требований и обсуждения макетов.
|
||||||
|
- **Единые коммент-артефакты** — все агенты прикладывают результаты с кликабельными ссылками.
|
||||||
|
- **Прямые ссылки в Telegram** — апрув в один клик, без блужданий.
|
||||||
|
|
||||||
|
### 🧩 Расширение возможностей
|
||||||
|
- **Тяжёлые расчёты данных** — опциональная стадия для миграций/обработки больших данных.
|
||||||
|
- **Android-разработка** — мобильный стек через тот же конвейер.
|
||||||
|
- **Декомпозиция эпиков** — большая фича → подзадачи → сборка.
|
||||||
|
- **Управление зависимостями** — задача B ждёт задачу A.
|
||||||
|
- **Code coverage gate** — защита покрытия тестами от деградации.
|
||||||
|
- **База знаний проекта** — персистентный контекст для агентов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Принципы (что для нас неизменно)
|
||||||
|
|
||||||
|
1. **Автономность по умолчанию, человек — на ключевых развилках.** Машина делает, человек ставит и принимает.
|
||||||
|
2. **Качество не приносится в жертву скорости/цене.** Удешевляем аналитику — гейты качества остаются. Урок дорого выученный: срезанная проверка = недоделка на проде.
|
||||||
|
3. **Машинные вердикты, а не угадывание.** Гейты читают структурированные поля, а не ищут слова в тексте.
|
||||||
|
4. **Самоизменение — только через PR + ревью + апрув.** Агент, меняющий агентов, всегда под контролем человека.
|
||||||
|
5. **Документация — сразу, не потом.** Изменил функционал → обновил доки.
|
||||||
|
6. **Прод — источник правды.** «Деплой прошёл» ≠ «работает». Проверяем реальный результат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Видение в одну фразу
|
||||||
|
|
||||||
|
> **Самодостаточная фабрика разработки, которая размножается, учится на ошибках, оценивает себя, бережёт бюджет и не ломает прод — превращая намерение человека в работающий продукт почти без его участия.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Документ поддерживается в репозитории orchestrator. Источник дорожной карты — задачи проекта ORCH в Plane (ORCH-7…ORCH-28).*
|
||||||
BIN
docs/PRODUCT_VISION.pptx
Normal file
BIN
docs/PRODUCT_VISION.pptx
Normal file
Binary file not shown.
77
docs/architecture/README.md
Normal file
77
docs/architecture/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Архитектура Orchestrator
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
Мульти-агентный оркестратор разработки. Принимает webhooks от Plane (управление задачами) и Gitea (git-события), ведёт задачи по конвейеру стадий через Quality Gates, на каждой стадии запускает Claude CLI агента. Поддерживает несколько проектов (multi-repo) и self-hosting (дорабатывает сам себя).
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
- **Webhook Receivers** (`src/webhooks/plane.py`, `gitea.py`) — приём событий, HMAC-проверка, дедупликация (`_dedup.py`). Роуты: `POST /webhook/plane`, `POST /webhook/gitea`.
|
||||||
|
- **State Machine** (`src/stages.py`) — `STAGE_TRANSITIONS`: переходы, агент и QG каждой стадии. Хелперы: `get_next_stage`, `get_agent_for_stage`, `get_qg_for_stage`, `get_previous_stage`.
|
||||||
|
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
|
||||||
|
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||||
|
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||||
|
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||||
|
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||||
|
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||||
|
|
||||||
|
## Конвейер и Quality Gates
|
||||||
|
|
||||||
|
```
|
||||||
|
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||||
|
↑ │
|
||||||
|
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3 retries)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Стадия | Агент (выход) | Quality Gate | Артефакт |
|
||||||
|
|--------|---------------|--------------|----------|
|
||||||
|
| created | analyst | — | — |
|
||||||
|
| analysis | architect | `check_analysis_approved` | 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan.yaml |
|
||||||
|
| architecture | developer | `check_architecture_done` | 06-adr/ |
|
||||||
|
| development | reviewer | `check_ci_green` | код + PR |
|
||||||
|
| review | tester | `check_reviewer_verdict` | 12-review.md (`verdict:`) |
|
||||||
|
| testing | deployer | `check_tests_passed` | 13-test-report.md |
|
||||||
|
| deploy-staging | deployer | `check_staging_status` | 15-staging-log.md (`staging_status:`) |
|
||||||
|
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
|
||||||
|
| done | — | — | — |
|
||||||
|
|
||||||
|
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status.
|
||||||
|
|
||||||
|
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||||
|
|
||||||
|
### Условный staging-гейт (ORCH-35)
|
||||||
|
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)` → `orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
|
||||||
|
|
||||||
|
## Откаты
|
||||||
|
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
||||||
|
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
||||||
|
- Deploy / deploy-staging FAILED → откат на `development`.
|
||||||
|
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
|
||||||
|
|
||||||
|
## База данных (SQLite)
|
||||||
|
- `events` — входящие вебхуки (дедуп)
|
||||||
|
- `tasks` — задачи и их стадии
|
||||||
|
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||||
|
- `jobs` — очередь задач (ORCH-1)
|
||||||
|
|
||||||
|
## Изоляция (git worktree, ORCH-2)
|
||||||
|
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
| Method | Path | Описание |
|
||||||
|
|--------|------|----------|
|
||||||
|
| GET | `/health` | health check |
|
||||||
|
| GET | `/status` | активные задачи (stage != done) |
|
||||||
|
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
|
||||||
|
| POST | `/webhook/plane` | Plane webhook |
|
||||||
|
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||||
|
|
||||||
|
## Деплой и эксплуатация
|
||||||
|
Топология, контейнеры, порты, env-карта, self-hosting риски — [docs/operations/INFRA.md](../operations/INFRA.md). Деплой-хук — [DEPLOY_HOOK.md](../operations/DEPLOY_HOOK.md). Staging — [STAGING.md](../operations/STAGING.md).
|
||||||
|
|
||||||
|
## ADR
|
||||||
|
Сквозные архитектурные решения — [adr/](adr/). Per-work-item решения — `docs/work-items/<id>/06-adr/`.
|
||||||
|
|
||||||
|
## Детали реализации
|
||||||
|
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.*
|
||||||
15
docs/architecture/adr/README.md
Normal file
15
docs/architecture/adr/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
Индекс сквозных (cross-cutting) ADR проекта orchestrator.
|
||||||
|
Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-slug.md`.
|
||||||
|
|
||||||
|
| # | Решение | Статус | Дата | Источник |
|
||||||
|
|---|---------|--------|------|----------|
|
||||||
|
| adr-0001 | Реестр проектов (multi-repo) | accepted | 2026-06-02 | ORCH-6 |
|
||||||
|
| adr-0002 | Очередь задач вместо in-process потоков | accepted | 2026-06-03 | ORCH-1 |
|
||||||
|
| adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 |
|
||||||
|
|
||||||
|
## Формат
|
||||||
|
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||||
|
Принятый ADR не меняется — новое решение заводится отдельным файлом со ссылкой `supersedes adr-XXXX`.
|
||||||
|
Новые ADR добавляет архитектор при принятии решения (см. `CLAUDE.md` → Конвенции).
|
||||||
23
docs/architecture/adr/adr-0001-multi-repo-registry.md
Normal file
23
docs/architecture/adr/adr-0001-multi-repo-registry.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# adr-0001: Реестр проектов (multi-repo)
|
||||||
|
|
||||||
|
- **Статус:** accepted
|
||||||
|
- **Дата:** 2026-06-02
|
||||||
|
- **Задача:** ORCH-6
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
Инцидент 2026-06-02: Plane-вебхук слушал весь воркспейс и хардкодил `repo = settings.default_repo` (enduro-trails). Задачи ЛЮБОГО проекта сливались в один репо с одним префиксом (ET). Нужна изоляция по проектам.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Введён реестр `src/projects.py`: `ProjectConfig` (frozen dataclass) связывает `plane_project_id` → `repo` + `work_item_prefix` + `name`. Источник правды — env `ORCH_PROJECTS_JSON`; при пустом/невалидном — встроенный дефолт (`enduro-trails`/ET, `orchestrator`/ORCH). Позволяет: фильтровать вебхуки по проекту (неизвестный → ignore), резолвить gitea-репо + префикс, роутить Plane-синк в свой проект задачи.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
- Один репо на всё — отклонён (источник инцидента).
|
||||||
|
- Хардкод маппинга в коде — отклонён в пользу env-конфигурируемого реестра с безопасным дефолтом.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- Изоляция проектов на уровне вебхуков и роутинга.
|
||||||
|
- Парсер устойчив: битый элемент скипается, пустой результат → дефолт.
|
||||||
|
- Основа для `is_self_hosting_repo` (adr-0003).
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
adr-0003 (условный гейт опирается на repo из реестра).
|
||||||
23
docs/architecture/adr/adr-0002-job-queue.md
Normal file
23
docs/architecture/adr/adr-0002-job-queue.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# adr-0002: Очередь задач вместо in-process потоков
|
||||||
|
|
||||||
|
- **Статус:** accepted
|
||||||
|
- **Дата:** 2026-06-03
|
||||||
|
- **Задача:** ORCH-1 (F-2b)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
Ранняя версия запускала стадии конвейера в in-process daemon-потоках. Проблемы: не переживало рестарт (задачи терялись), нет контроля параллелизма, нет ретраев, нет наблюдаемости.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Введена персистентная очередь задач (`src/queue_worker.py` + таблица `jobs` в SQLite): atomic claim задачи воркером, `max_concurrency`, ретраи при сбое, restart-safe (running-задачи реквестятся при старте), эндпоинт `GET /queue`.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
- In-process потоки — отклонены (не restart-safe).
|
||||||
|
- Внешний брокер (Redis/RabbitMQ) — избыточно для текущего масштаба; SQLite-очередь проще и без новых зависимостей.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- Конвейер переживает рестарт контейнера.
|
||||||
|
- Контроль параллелизма и наблюдаемость через `/queue`.
|
||||||
|
- ⚠️ Очередь общая на все проекты прод-инстанса — фактор группового риска при self-hosting (см. `docs/operations/INFRA.md`).
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
adr-0001 (реестр проектов), INFRA.md (общая очередь при self-hosting).
|
||||||
27
docs/architecture/adr/adr-0003-staging-gate.md
Normal file
27
docs/architecture/adr/adr-0003-staging-gate.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# adr-0003: Условный staging-гейт перед прод-деплоем
|
||||||
|
|
||||||
|
- **Статус:** accepted
|
||||||
|
- **Дата:** 2026-06-05
|
||||||
|
- **Задача:** ORCH-35
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
Оркестратор дорабатывает сам себя (self-hosting). Раньше стадия `deploy` имела «бумажный» вердикт: deployer-агент писал `deploy_status: SUCCESS`, но реального прогона на изолированной среде не было. Нужен предохранитель: прод-деплой орка не должен происходить, пока изменения не проверены на живой staging-среде. При этом другие проекты (enduro-trails) staging-инфры не имеют.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Добавлена промежуточная стадия `deploy-staging` между `testing` и `deploy`: `testing → deploy-staging → deploy → done`.
|
||||||
|
- deployer гоняет `scripts/staging_check.py --base-url http://localhost:8501` и пишет `staging_status: SUCCESS|FAILED` в `15-staging-log.md`.
|
||||||
|
- Quality Gate `check_staging_status` парсит вердикт (только YAML-frontmatter).
|
||||||
|
- **Гейт условный:** `is_self_hosting_repo(repo)` → реальная проверка только для `orchestrator`; для остальных проектов гейт = no-op `(True, "Staging gate N/A")`.
|
||||||
|
- FAILED → откат на `development`.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
- Глобальный гейт для всех проектов — отклонён: у enduro нет staging-инстанса, задачи застревали бы на откате.
|
||||||
|
- Деплой реально дёргает хост-хук прямо здесь — отложен в ORCH-36 (Вариант B).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- Прод-деплой орка недостижим, пока staging-гейт не зелёный.
|
||||||
|
- Другие проекты не затронуты (no-op).
|
||||||
|
- Реальный docker-деплой через хук пока НЕ выполняется (вердикт «бумажный», но подкреплён прогоном сьюта). Исполняемый деплой — ORCH-36.
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
adr-0001 (реестр проектов — основа `is_self_hosting_repo`), ORCH-34 (deploy-hook + rollback), ORCH-36 (исполняемый самодеплой).
|
||||||
@@ -58,7 +58,8 @@ STAGE_TRANSITIONS = {
|
|||||||
architecture: → development (agent: developer, QG: check_architecture_done)
|
architecture: → development (agent: developer, QG: check_architecture_done)
|
||||||
development: → review (agent: reviewer, QG: check_tests_local)
|
development: → review (agent: reviewer, QG: check_tests_local)
|
||||||
review: → testing (agent: tester, QG: check_reviewer_verdict)
|
review: → testing (agent: tester, QG: check_reviewer_verdict)
|
||||||
testing: → deploy (agent: deployer, QG: check_tests_passed)
|
testing: → deploy-staging (agent: deployer, QG: check_tests_passed)
|
||||||
|
deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
|
||||||
deploy: → done (agent: None, QG: None)
|
deploy: → done (agent: None, QG: None)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -189,8 +190,10 @@ services:
|
|||||||
12. Gitea PR webhook: review event → QG check_review_approved → PASS
|
12. Gitea PR webhook: review event → QG check_review_approved → PASS
|
||||||
13. Advance: review → testing, tester launched
|
13. Advance: review → testing, tester launched
|
||||||
14. Tester: прогоняет тесты, пишет test-report.md → git push
|
14. Tester: прогоняет тесты, пишет test-report.md → git push
|
||||||
15. Auto-advance: testing → deploy (QG check_tests_passed → PASS)
|
15. Auto-advance: testing → deploy-staging (QG check_tests_passed → PASS)
|
||||||
16. PR merge → Gitea PR webhook: action=closed, merged=true → done
|
16. Deployer: runs staging checks → writes 15-staging-log.md (staging_status: SUCCESS)
|
||||||
|
17. Auto-advance: deploy-staging → deploy (QG check_staging_status → PASS)
|
||||||
|
18. PR merge → Gitea PR webhook: action=closed, merged=true → done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Review bounce path
|
### Review bounce path
|
||||||
90
docs/operations/DEPLOY_HOOK.md
Normal file
90
docs/operations/DEPLOY_HOOK.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Orchestrator Deploy Hook
|
||||||
|
|
||||||
|
`scripts/orchestrator-deploy-hook.sh` — хост-скрипт деплоя orchestrator с health-чеком и авто-rollback.
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
### Режим `--deploy` (по умолчанию)
|
||||||
|
|
||||||
|
1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен).
|
||||||
|
2. **git pull** — обновляет код репозитория.
|
||||||
|
3. **Рестарт контейнера** — `docker compose --profile $COMPOSE_PROFILE up -d --no-build $TARGET_SERVICE`.
|
||||||
|
4. **Health-цикл** — 10 попыток × 6с = до 60с. Критерий: HTTP 200 + тело содержит `"status":"ok"`.
|
||||||
|
- **Успех** → `exit 0`, лог "Deploy SUCCESS".
|
||||||
|
- **Провал** → авто-rollback (шаг 5).
|
||||||
|
5. **Авто-rollback** — восстанавливает образ из `$PREV_IMAGE_FILE`, рестарт, повторный health 5×3с.
|
||||||
|
- Если восстановился → `exit 1` (деплой провалился, откат успешен).
|
||||||
|
- Если и откат не помог → `exit 2` (критично).
|
||||||
|
|
||||||
|
### Режим `--rollback`
|
||||||
|
|
||||||
|
Вручную откатывает сервис на предыдущий образ из `$PREV_IMAGE_FILE`.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Дефолт | Описание |
|
||||||
|
|------------------|-----------------------------------|-----------------------------------------------|
|
||||||
|
| `TARGET_SERVICE` | `orchestrator-staging` | Имя docker-compose сервиса |
|
||||||
|
| `TARGET_PORT` | `8501` | Порт health-check |
|
||||||
|
| `TARGET_IMAGE` | `orchestrator-orchestrator-staging` | Имя образа для retag при rollback |
|
||||||
|
| `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) |
|
||||||
|
| `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа |
|
||||||
|
| `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) |
|
||||||
|
|
||||||
|
> ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env.
|
||||||
|
|
||||||
|
## Примеры запуска
|
||||||
|
|
||||||
|
### Staging (дефолт, безопасно)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/slin/repos/orchestrator
|
||||||
|
bash scripts/orchestrator-deploy-hook.sh --deploy
|
||||||
|
# или просто:
|
||||||
|
bash scripts/orchestrator-deploy-hook.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Прод (осознанный шаг, Этап 5)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TARGET_SERVICE=orchestrator \
|
||||||
|
TARGET_PORT=8500 \
|
||||||
|
TARGET_IMAGE=orchestrator-orchestrator \
|
||||||
|
COMPOSE_PROFILE="" \
|
||||||
|
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
|
||||||
|
bash scripts/orchestrator-deploy-hook.sh --deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ручной rollback staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/orchestrator-deploy-hook.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Коды выхода
|
||||||
|
|
||||||
|
| Код | Значение |
|
||||||
|
|-----|------------------------------------------------------|
|
||||||
|
| `0` | Деплой успешен, сервис здоров |
|
||||||
|
| `1` | Деплой провалился; откат выполнен (или пропущен) |
|
||||||
|
| `2` | Деплой провалился И откат тоже провалился (критично) |
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/log/orchestrator/deploy-hook.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая строка с UTC-таймстампом в формате `[2026-06-05T06:30:00Z]`.
|
||||||
|
|
||||||
|
## Разница с enduro-deploy-hook.sh
|
||||||
|
|
||||||
|
| Функция | enduro-deploy-hook.sh | orchestrator-deploy-hook.sh |
|
||||||
|
|----------------------|-----------------------|-----------------------------|
|
||||||
|
| Захват PREV_IMG | ✅ | ✅ |
|
||||||
|
| git pull | ✅ | ✅ |
|
||||||
|
| Рестарт | ✅ | ✅ |
|
||||||
|
| Health-цикл (60с) | ❌ | ✅ 10×6с |
|
||||||
|
| Авто-rollback | ❌ | ✅ |
|
||||||
|
| Параметризация (env) | ❌ хардкод | ✅ дефолт=staging |
|
||||||
|
| Compose profile | ❌ | ✅ --profile staging |
|
||||||
96
docs/operations/INFRA.md
Normal file
96
docs/operations/INFRA.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# INFRA.md — инфраструктура и эксплуатация оркестратора
|
||||||
|
|
||||||
|
> RUNBOOK. Топология, контейнеры, порты, переменные окружения, границы.
|
||||||
|
> **Секреты тут НЕ хранятся** — только дескрипторы. Реальные значения — в `.env` на хосте.
|
||||||
|
|
||||||
|
## Топология
|
||||||
|
|
||||||
|
```
|
||||||
|
host: mva154 (slin@82.22.50.71), network_mode: host
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ orchestrator (PROD) :8500 env_file .env │
|
||||||
|
│ БД: ./data/orchestrator.db (обслуживает ВСЕ прод-проекты) │
|
||||||
|
│ │
|
||||||
|
│ orchestrator-staging (STAGING) :8501 env_file .env.staging │
|
||||||
|
│ БД: ./data/staging/orchestrator.db (изолирована, только sandbox) │
|
||||||
|
│ profile: staging — НЕ стартует обычным `docker compose up` │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ webhooks │ git
|
||||||
|
▼ ▼
|
||||||
|
Plane (ag_proj) Gitea (localhost:3000)
|
||||||
|
/repos/<project> ← общий каталог репозиториев (host: /home/slin/repos)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Контейнеры
|
||||||
|
|
||||||
|
| Контейнер | Роль | Порт | env_file | БД (хост) | Старт |
|
||||||
|
|-----------|------|------|----------|-----------|-------|
|
||||||
|
| `orchestrator` | прод | 8500 | `.env` | `./data/orchestrator.db` | `docker compose up -d` |
|
||||||
|
| `orchestrator-staging` | staging / песочница | 8501 | `.env.staging` | `./data/staging/orchestrator.db` | `docker compose --profile staging up -d orchestrator-staging` |
|
||||||
|
|
||||||
|
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
|
||||||
|
|
||||||
|
### Тома (volumes)
|
||||||
|
- `./data` → `/app/data` (БД; у staging — `./data/staging`)
|
||||||
|
- `/home/slin/repos` → `/repos` (рабочие репозитории проектов)
|
||||||
|
- `/var/run/docker.sock` (для docker-операций деплоя)
|
||||||
|
- claude-code, node, `~/.claude*` (CLI агентов, ro)
|
||||||
|
- `~/.orchestrator-ssh` → `/root/.ssh` (ro, деплой по ssh)
|
||||||
|
|
||||||
|
## Переменные окружения (карта; значения — в `.env`)
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API |
|
||||||
|
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane |
|
||||||
|
| `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC |
|
||||||
|
| `ORCH_CLAUDE_BIN` | путь к claude CLI |
|
||||||
|
| `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) |
|
||||||
|
| `ORCH_DB_PATH` | путь к SQLite БД |
|
||||||
|
| `ORCH_PROJECTS_JSON` | реестр проектов (Plane id → repo + prefix); пусто → дефолт из `src/projects.py` |
|
||||||
|
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
|
||||||
|
|
||||||
|
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
|
||||||
|
|
||||||
|
## Реестр проектов (`src/projects.py`, ORCH-6)
|
||||||
|
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.
|
||||||
|
|
||||||
|
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
|
||||||
|
|
||||||
|
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).
|
||||||
|
|
||||||
|
**Следствие — групповой риск:** когда орк выполняет задачу из проекта ORCH (дорабатывает себя), он бежит в том же инстансе, что обслуживает enduro-trails.
|
||||||
|
- Рестарт / падение прод-контейнера орк-задачей → конвейер ВСЕХ проектов встаёт.
|
||||||
|
- Кривой self-деплой (ORCH-36, Вариант B) → лежат все проекты сразу.
|
||||||
|
- Общая очередь → орк-задача занимает concurrency-слоты других проектов.
|
||||||
|
|
||||||
|
**Что изолировано (безопасно):**
|
||||||
|
- Staging (8501) — отдельная БД (`./data/staging`), отдельный реестр (`ORCH_PROJECTS_JSON` = только sandbox). Прод-проекты не видит.
|
||||||
|
- Репозитории разделены, изоляция веток через git worktree (ORCH-2).
|
||||||
|
|
||||||
|
**Страховки:**
|
||||||
|
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.
|
||||||
|
|
||||||
|
**Правила для агентов при задачах ORCH:**
|
||||||
|
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.
|
||||||
|
2. Все проверки деплоя — на staging (8501), боевой 8500 не трогать.
|
||||||
|
3. Деплой self — только через хук с health-check + авто-rollback (`DEPLOY_HOOK.md`).
|
||||||
|
|
||||||
|
## Эксплуатация (быстрые команды)
|
||||||
|
```bash
|
||||||
|
# статус
|
||||||
|
docker ps --filter name=orchestrator
|
||||||
|
curl -s http://localhost:8500/health
|
||||||
|
curl -s http://localhost:8500/status # активные задачи
|
||||||
|
curl -s http://localhost:8500/queue # очередь
|
||||||
|
|
||||||
|
# поднять staging-песочницу
|
||||||
|
docker compose --profile staging up -d orchestrator-staging
|
||||||
|
curl -s http://localhost:8501/health
|
||||||
|
|
||||||
|
# логи
|
||||||
|
docker logs --tail 100 orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
*RUNBOOK 2026-06-05. Обновлять при изменении топологии/портов/переменных. См. CONTRIBUTING.md §8.*
|
||||||
85
docs/operations/STAGING.md
Normal file
85
docs/operations/STAGING.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Staging Environment (ORCH-31)
|
||||||
|
|
||||||
|
Orchestrator supports a permanent **staging instance** running on port **8501** with a
|
||||||
|
fully-isolated SQLite database. The staging instance shares the same codebase and
|
||||||
|
Dockerfile as production but is started under the `staging` Docker Compose profile so it
|
||||||
|
**never starts accidentally** during a normal `docker compose up -d`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| | Production | Staging |
|
||||||
|
|---|---|---|
|
||||||
|
| Port | 8500 | 8501 |
|
||||||
|
| Container name | `orchestrator` | `orchestrator-staging` |
|
||||||
|
| DB (host path) | `./data/orchestrator.db` | `./data/staging/orchestrator.db` |
|
||||||
|
| DB (container path) | `/app/data/orchestrator.db` | `/app/data/orchestrator.db` |
|
||||||
|
| env file | `.env` | `.env.staging` |
|
||||||
|
| Compose profile | *(default)* | `staging` |
|
||||||
|
|
||||||
|
DB isolation is achieved via a separate volume mount (`./data/staging:/app/data`), not by
|
||||||
|
changing `ORCH_DB_PATH` — the container path stays identical while the host path is a
|
||||||
|
different directory.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **`.env.staging`** — create from the template (see below). This file is **not committed**
|
||||||
|
to the repo (it contains secrets). Copy and fill in values before first start.
|
||||||
|
2. **`./data/staging/`** directory — created automatically on first container start.
|
||||||
|
|
||||||
|
### Create `.env.staging`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/slin/repos/orchestrator
|
||||||
|
cp .env.staging.example .env.staging
|
||||||
|
# Edit .env.staging — fill in real tokens / secrets.
|
||||||
|
# At Stage 1 (ORCH-31) you can reuse prod values; sandbox Plane project
|
||||||
|
# and isolated Gitea webhook will be wired in ORCH-32.
|
||||||
|
nano .env.staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starting Staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/slin/repos/orchestrator
|
||||||
|
docker compose --profile staging up -d orchestrator-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Check it is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps | grep orchestrator-staging
|
||||||
|
curl -s http://localhost:8501/health | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stopping Staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile staging stop orchestrator-staging
|
||||||
|
# or remove the container entirely:
|
||||||
|
docker compose --profile staging down orchestrator-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Normal `up -d` does NOT start staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This starts ONLY the prod orchestrator (port 8500). Staging is NOT affected.
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The `profiles: [staging]` directive in `docker-compose.yml` ensures staging is
|
||||||
|
completely invisible to commands that do not pass `--profile staging`.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f orchestrator-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
| Task | Description |
|
||||||
|
|---|---|
|
||||||
|
| **ORCH-31** *(this PR)* | Infra: compose service, .env template, gitignore, docs |
|
||||||
|
| **ORCH-32** | Sandbox: isolated Plane project + Gitea repo for staging |
|
||||||
|
| **ORCH-33** | Test suite running against staging endpoint |
|
||||||
|
| **ORCH-34** | Deploy hook: promote `orchestrator:candidate` image to staging |
|
||||||
136
docs/operations/STAGING_CHECK.md
Normal file
136
docs/operations/STAGING_CHECK.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# STAGING_CHECK.md — Инструкция по запуску staging check suite (ORCH-33)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
`scripts/staging_check.py` — самостоятельный скрипт проверки **живого** staging-стенда orchestrator (порт 8501). Не unit-тесты — реальные HTTP-вызовы против работающих сервисов.
|
||||||
|
|
||||||
|
Три блока проверок:
|
||||||
|
|
||||||
|
| Блок | Название | Что проверяет |
|
||||||
|
|------|----------|---------------|
|
||||||
|
| A | SMOKE | `/health`, `/queue`, `ORCH_STAGING=true` |
|
||||||
|
| B | ACCESS | Plane sandbox (R), Gitea sandbox (R+push), реестр проектов |
|
||||||
|
| C | E2E | Создать задачу → триггер конвейера → ветка + коммент → cleanup |
|
||||||
|
|
||||||
|
Exit code: **0** = все PASS, **non-zero** = есть FAIL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Требования к окружению
|
||||||
|
|
||||||
|
Скрипт читает токены/URL из env (те же переменные, что использует orchestrator):
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| `ORCH_STAGING` | Должна быть `true` — защита от случайного запуска на проде |
|
||||||
|
| `ORCH_PLANE_API_TOKEN` | Plane API token (`X-API-Key`) |
|
||||||
|
| `ORCH_PLANE_API_URL` | Plane base URL **без** `/api/v1` (скрипт добавляет сам) |
|
||||||
|
| `ORCH_PLANE_WORKSPACE_SLUG` | Workspace slug (`ag_proj`) |
|
||||||
|
| `ORCH_GITEA_TOKEN` | Gitea token (`Authorization: token …`) |
|
||||||
|
| `ORCH_GITEA_URL` | Gitea base URL (`http://localhost:3000`) |
|
||||||
|
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-секрет для подписи `/webhook/plane` (если пустой — без подписи) |
|
||||||
|
|
||||||
|
Все эти переменные **уже есть** внутри контейнера `orchestrator-staging`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Способы запуска
|
||||||
|
|
||||||
|
### 1. Внутри контейнера (рекомендуемый)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec orchestrator-staging \
|
||||||
|
python3 /repos/orchestrator/scripts/staging_check.py --mode stub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. С хоста (если есть токены в env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ORCH_STAGING=true
|
||||||
|
export ORCH_PLANE_API_TOKEN=...
|
||||||
|
# ... остальные переменные ...
|
||||||
|
|
||||||
|
python3 scripts/staging_check.py \
|
||||||
|
--base-url http://localhost:8501 \
|
||||||
|
--mode stub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Из docker exec с передачей URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec orchestrator-staging \
|
||||||
|
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||||
|
--base-url http://localhost:8501 \
|
||||||
|
--mode stub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Режимы (`--mode`)
|
||||||
|
|
||||||
|
| Режим | Описание | Скорость |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| `stub` (дефолт) | Проверяет **ранние артефакты** конвейера: ветка + QG-0-коммент. Создаются ДО запуска Claude CLI → быстро, детерминированно, без расхода LLM-кредитов. | ~30-90 сек |
|
||||||
|
| `full-real` | Дополнительно ждёт реального завершения аналитика. Долго, расходует LLM-кредиты. | 5-15+ мин |
|
||||||
|
|
||||||
|
**Текущий дефолт: `stub`** — достаточен для проверки работоспособности стенда.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что проверяет блок C (E2E) и почему это безопасно
|
||||||
|
|
||||||
|
Порядок `start_pipeline` в коде orchestrator:
|
||||||
|
1. Resolve проекта из реестра
|
||||||
|
2. Получить name/description из Plane API (если в webhook пустые)
|
||||||
|
3. **QG-0 гейт** (name ≥ 5 симв, description ≥ 20 симв)
|
||||||
|
4. **Создать work_item_id + ветку в Gitea + начальные доки**
|
||||||
|
5. **Записать строку задачи в БД**
|
||||||
|
6. Поставить аналитика в очередь (вот тут Claude CLI)
|
||||||
|
|
||||||
|
Блок C проверяет **шаги 4-5**, аналитика (шаг 6) **не ждёт**.
|
||||||
|
Тест-задача создаётся ТОЛЬКО в **SANDBOX** (`project_id 8c5a3025-...`),
|
||||||
|
ветка создаётся ТОЛЬКО в **orchestrator-sandbox**.
|
||||||
|
|
||||||
|
### CLEANUP (обязателен)
|
||||||
|
|
||||||
|
`try/finally` гарантирует удаление тестовых артефактов:
|
||||||
|
- Удаляет ветку из `orchestrator-sandbox`
|
||||||
|
- Удаляет задачу из Plane SANDBOX
|
||||||
|
|
||||||
|
Cleanup отрабатывает даже при падении e2e.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Принцип HMAC-подписи
|
||||||
|
|
||||||
|
Скрипт читает `ORCH_PLANE_WEBHOOK_SECRET` из env и формирует подпись:
|
||||||
|
```python
|
||||||
|
hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
```
|
||||||
|
Передаёт как заголовок `X-Plane-Signature`. Алгоритм совпадает с `verify_plane_signature` в `src/webhooks/plane.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изолированность от прода
|
||||||
|
|
||||||
|
| Проверка | Гарантия |
|
||||||
|
|---------|---------|
|
||||||
|
| A3 `ORCH_STAGING=true` | При false — abort до деструктивных блоков |
|
||||||
|
| B6 Реестр без боевых | ET/ORCH project_id absent в `known_plane_project_ids()` |
|
||||||
|
| C: only SANDBOX project_id | Webhook payload указывает только `8c5a3025-...` |
|
||||||
|
| C: only orchestrator-sandbox repo | Gitea operations на `admin/orchestrator-sandbox` |
|
||||||
|
| C: cleanup в finally | Артефакты удаляются даже при ошибке |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Добавление в деплой-хук
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В deploy.sh, после docker-compose up -d orchestrator-staging
|
||||||
|
docker exec orchestrator-staging \
|
||||||
|
python3 /repos/orchestrator/scripts/staging_check.py --mode stub
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Staging check FAILED — rolling back"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
176
scripts/orchestrator-deploy-hook.sh
Executable file
176
scripts/orchestrator-deploy-hook.sh
Executable file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy hook for orchestrator
|
||||||
|
# Supports --deploy (default) and --rollback modes.
|
||||||
|
# Adds health-check loop + automatic rollback if new deploy is unhealthy.
|
||||||
|
#
|
||||||
|
# Parametrised via env vars (defaults are STAGING — never prod):
|
||||||
|
# TARGET_SERVICE - docker-compose service name (default: orchestrator-staging)
|
||||||
|
# TARGET_PORT - health check port (default: 8501)
|
||||||
|
# TARGET_IMAGE - image name for retag (default: orchestrator-orchestrator-staging)
|
||||||
|
# COMPOSE_PROFILE - docker compose profile (default: staging)
|
||||||
|
# PREV_IMAGE_FILE - path to prev-image snapshot (default: $REPO/.deploy-prev-image-staging)
|
||||||
|
# LOG - log file path (default: /var/log/orchestrator/deploy-hook.log)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default)
|
||||||
|
# ./orchestrator-deploy-hook.sh --rollback # manual rollback
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO=/home/slin/repos/orchestrator
|
||||||
|
|
||||||
|
# ---- Defaults (STAGING — safe) ---------------------------------------------
|
||||||
|
TARGET_SERVICE="${TARGET_SERVICE:-orchestrator-staging}"
|
||||||
|
TARGET_PORT="${TARGET_PORT:-8501}"
|
||||||
|
TARGET_IMAGE="${TARGET_IMAGE:-orchestrator-orchestrator-staging}"
|
||||||
|
COMPOSE_PROFILE="${COMPOSE_PROFILE:-staging}"
|
||||||
|
PREV_IMAGE_FILE="${PREV_IMAGE_FILE:-$REPO/.deploy-prev-image-staging}"
|
||||||
|
|
||||||
|
# ---- Log setup -------------------------------------------------------------
|
||||||
|
LOG_DIR=/var/log/orchestrator
|
||||||
|
if mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||||
|
LOG="${LOG:-$LOG_DIR/deploy-hook.log}"
|
||||||
|
else
|
||||||
|
LOG="${LOG:-$REPO/deploy-hook.log}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Deploy hook called: target=$TARGET_SERVICE port=$TARGET_PORT args=$*"
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HEALTH CHECK helper
|
||||||
|
# Args: max_attempts sleep_sec label
|
||||||
|
# Returns 0 if healthy within attempts, 1 otherwise
|
||||||
|
# ============================================================================
|
||||||
|
health_check() {
|
||||||
|
local max_attempts="$1"
|
||||||
|
local sleep_sec="$2"
|
||||||
|
local label="${3:-health-check}"
|
||||||
|
local attempt=0
|
||||||
|
while [[ $attempt -lt $max_attempts ]]; do
|
||||||
|
attempt=$(( attempt + 1 ))
|
||||||
|
log "$label: attempt $attempt/$max_attempts - GET http://localhost:$TARGET_PORT/health"
|
||||||
|
local http_code body
|
||||||
|
body=$(curl -s --max-time 5 "http://localhost:$TARGET_PORT/health" 2>/dev/null || true)
|
||||||
|
http_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://localhost:$TARGET_PORT/health" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$http_code" == "200" ]] && echo "$body" | grep -q '"status":"ok"'; then
|
||||||
|
log "$label: OK (HTTP $http_code, body=$body)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "$label: not ready yet (HTTP $http_code, body=$body)"
|
||||||
|
if [[ $attempt -lt $max_attempts ]]; then
|
||||||
|
sleep "$sleep_sec"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
log "$label: FAILED after $max_attempts attempts"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ROLLBACK helper (also called for auto-rollback after bad deploy)
|
||||||
|
# ============================================================================
|
||||||
|
do_rollback() {
|
||||||
|
log "ROLLBACK: checking $PREV_IMAGE_FILE"
|
||||||
|
if [[ ! -s "$PREV_IMAGE_FILE" ]]; then
|
||||||
|
log "ROLLBACK: no previous image recorded - rollback skipped (exit 1)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local prev_img
|
||||||
|
prev_img=$(cat "$PREV_IMAGE_FILE")
|
||||||
|
if [[ -z "$prev_img" ]]; then
|
||||||
|
log "ROLLBACK: PREV_IMAGE_FILE is empty - rollback skipped (exit 1)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! docker image inspect "$prev_img" >/dev/null 2>&1; then
|
||||||
|
log "ROLLBACK: recorded image '$prev_img' not found locally - rollback skipped (exit 1)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "ROLLBACK: retagging $prev_img -> $TARGET_IMAGE"
|
||||||
|
docker tag "$prev_img" "$TARGET_IMAGE" >> "$LOG" 2>&1
|
||||||
|
log "ROLLBACK: restarting $TARGET_SERVICE on previous image"
|
||||||
|
if [[ -n "$COMPOSE_PROFILE" ]]; then
|
||||||
|
docker compose --profile "$COMPOSE_PROFILE" up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
|
||||||
|
else
|
||||||
|
docker compose up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
|
||||||
|
fi
|
||||||
|
log "ROLLBACK: container restarted, running post-rollback health check (5x3s)"
|
||||||
|
if health_check 5 3 "ROLLBACK-health"; then
|
||||||
|
log "ROLLBACK: service is healthy on previous image ($prev_img)"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "ROLLBACK: ROLLBACK ALSO FAILED - service still unhealthy after restoring $prev_img"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MANUAL --rollback mode
|
||||||
|
# ============================================================================
|
||||||
|
if [[ "${1:-}" == "--rollback" ]]; then
|
||||||
|
log "Manual ROLLBACK requested"
|
||||||
|
if do_rollback; then
|
||||||
|
log "Manual ROLLBACK succeeded"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log "Manual ROLLBACK failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NORMAL DEPLOY mode (--deploy or no argument)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 1. Capture currently running image BEFORE restart (best-effort)
|
||||||
|
PREV_IMG=""
|
||||||
|
SVC_CID=$(docker compose --profile "$COMPOSE_PROFILE" ps -q "$TARGET_SERVICE" 2>/dev/null || true)
|
||||||
|
if [[ -n "$SVC_CID" ]]; then
|
||||||
|
PREV_IMG=$(docker inspect --format '{{.Image}}' "$SVC_CID" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -n "$PREV_IMG" ]]; then
|
||||||
|
echo "$PREV_IMG" > "$PREV_IMAGE_FILE"
|
||||||
|
log "Saved previous image: $PREV_IMG -> $PREV_IMAGE_FILE"
|
||||||
|
else
|
||||||
|
log "No previous image captured (first deploy or service not running?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Pull latest code
|
||||||
|
log "git pull origin main"
|
||||||
|
git pull origin main >> "$LOG" 2>&1
|
||||||
|
|
||||||
|
# 3. Restart service
|
||||||
|
log "Starting $TARGET_SERVICE (profile=$COMPOSE_PROFILE)"
|
||||||
|
if [[ -n "$COMPOSE_PROFILE" ]]; then
|
||||||
|
docker compose --profile "$COMPOSE_PROFILE" up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
|
||||||
|
else
|
||||||
|
docker compose up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
|
||||||
|
fi
|
||||||
|
log "$TARGET_SERVICE restarted"
|
||||||
|
|
||||||
|
# 4. Health-check loop: 10 attempts x 6 seconds = up to 60s
|
||||||
|
log "Starting health-check: 10 attempts x 6s (max 60s)"
|
||||||
|
if health_check 10 6 "deploy-health"; then
|
||||||
|
log "Deploy SUCCESS: $TARGET_SERVICE healthy on port $TARGET_PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Health failed -> AUTO ROLLBACK
|
||||||
|
log "deploy FAILED: health not ok after 60s - initiating AUTO ROLLBACK"
|
||||||
|
rollback_rc=0
|
||||||
|
do_rollback || rollback_rc=$?
|
||||||
|
|
||||||
|
if [[ $rollback_rc -eq 0 ]]; then
|
||||||
|
log "deploy FAILED, rolled back to previous image successfully - exit 1"
|
||||||
|
exit 1
|
||||||
|
elif [[ $rollback_rc -eq 2 ]]; then
|
||||||
|
log "deploy FAILED, ROLLBACK ALSO FAILED - service may be down - exit 2"
|
||||||
|
exit 2
|
||||||
|
else
|
||||||
|
log "deploy FAILED, rollback skipped (no previous image) - exit 1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
639
scripts/staging_check.py
Normal file
639
scripts/staging_check.py
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
staging_check.py — Live staging-stand health & e2e check suite (ORCH-33).
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
Block A — SMOKE (health/queue, correct env)
|
||||||
|
Block B — ACCESS (read-only calls to Plane sandbox + Gitea sandbox + registry)
|
||||||
|
Block C — E2E (create task in SANDBOX → trigger pipeline via /webhook/plane
|
||||||
|
→ verify branch + job enqueued → CLEANUP in finally)
|
||||||
|
|
||||||
|
Usage (inside the container or with correct env set):
|
||||||
|
python3 scripts/staging_check.py [--base-url http://localhost:8501] [--mode stub|full-real]
|
||||||
|
|
||||||
|
Exit code: 0 = all PASS, non-zero = at least one FAIL.
|
||||||
|
|
||||||
|
NOTE on modes:
|
||||||
|
stub — default; checks early pipeline artifacts (branch + analyst job
|
||||||
|
enqueued) created BEFORE Claude CLI is invoked.
|
||||||
|
Fast, deterministic, no LLM spend.
|
||||||
|
full-real — additionally waits for the analyst agent to finish (long, costs
|
||||||
|
credits). Not the default.
|
||||||
|
|
||||||
|
NOTE on Plane comments (403):
|
||||||
|
The orchestrator posts the "🔍 Analyst запущен" comment using per-agent bot
|
||||||
|
tokens (ORCH_PLANE_BOT_ANALYST). These bot accounts must be added as members
|
||||||
|
of every Plane project they comment on. In staging the sandbox project was
|
||||||
|
created after the bots were provisioned → the bots are not yet members of
|
||||||
|
SANDBOX → add_comment returns 403 Forbidden.
|
||||||
|
|
||||||
|
This is a known infrastructure limitation of the staging sandbox, NOT a bug
|
||||||
|
in the pipeline itself. C9b therefore verifies pipeline success via the
|
||||||
|
staging job queue (/queue → recent) instead of Plane comments: the analyst
|
||||||
|
job is enqueued BEFORE the add_comment call and its presence in the queue
|
||||||
|
proves the pipeline ran through correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_BOLD = "\033[1m"
|
||||||
|
_GREEN = "\033[32m"
|
||||||
|
_RED = "\033[31m"
|
||||||
|
_YELLOW = "\033[33m"
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(msg: str) -> str:
|
||||||
|
return f" {_GREEN}✓ PASS{_RESET} {msg}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(msg: str) -> str:
|
||||||
|
return f" {_RED}✗ FAIL{_RESET} {msg}"
|
||||||
|
|
||||||
|
|
||||||
|
def _info(msg: str) -> str:
|
||||||
|
return f" {_YELLOW}·{_RESET} {msg}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Low-level HTTP helpers (stdlib only — no requests/httpx in scripts/)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _http(method: str, url: str, headers: dict | None = None,
|
||||||
|
body: bytes | None = None, timeout: int = 15) -> tuple[int, bytes]:
|
||||||
|
"""Simple HTTP wrapper. Returns (status_code, response_body)."""
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read()
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"{method} {url} → {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, headers: dict | None = None, timeout: int = 15) -> tuple[int, dict]:
|
||||||
|
status, body = _http("GET", url, headers=headers, timeout=timeout)
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
data = {"_raw": body.decode(errors="replace")}
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
|
||||||
|
def _post(url: str, headers: dict | None = None, payload: dict | None = None,
|
||||||
|
raw_body: bytes | None = None, timeout: int = 15) -> tuple[int, dict]:
|
||||||
|
if raw_body is not None:
|
||||||
|
body = raw_body
|
||||||
|
h = dict(headers or {})
|
||||||
|
if "Content-Type" not in h:
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
else:
|
||||||
|
body = json.dumps(payload or {}).encode()
|
||||||
|
h = dict(headers or {})
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
status, resp_body = _http("POST", url, headers=h, body=body, timeout=timeout)
|
||||||
|
try:
|
||||||
|
data = json.loads(resp_body)
|
||||||
|
except Exception:
|
||||||
|
data = {"_raw": resp_body.decode(errors="replace")}
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(url: str, headers: dict | None = None, payload: dict | None = None,
|
||||||
|
timeout: int = 15) -> tuple[int, dict]:
|
||||||
|
body = json.dumps(payload or {}).encode()
|
||||||
|
h = dict(headers or {})
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
status, resp_body = _http("PATCH", url, headers=h, body=body, timeout=timeout)
|
||||||
|
try:
|
||||||
|
data = json.loads(resp_body)
|
||||||
|
except Exception:
|
||||||
|
data = {"_raw": resp_body.decode(errors="replace")}
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
|
||||||
|
def _delete(url: str, headers: dict | None = None, timeout: int = 15) -> int:
|
||||||
|
status, _ = _http("DELETE", url, headers=headers, timeout=timeout)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HMAC helper for /webhook/plane
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sign_payload(secret: str, body: bytes) -> str:
|
||||||
|
"""Compute HMAC-SHA256 signature — matches verify_plane_signature in plane.py."""
|
||||||
|
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Results:
|
||||||
|
def __init__(self):
|
||||||
|
self._items: list[tuple[str, bool, str]] = [] # (label, passed, detail)
|
||||||
|
|
||||||
|
def add(self, label: str, passed: bool, detail: str = ""):
|
||||||
|
self._items.append((label, passed, detail))
|
||||||
|
line = _ok(label) if passed else _fail(label)
|
||||||
|
if detail:
|
||||||
|
line += f" [{detail}]"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
def summary(self) -> bool:
|
||||||
|
passed = sum(1 for _, ok, _ in self._items if ok)
|
||||||
|
total = len(self._items)
|
||||||
|
all_ok = passed == total
|
||||||
|
colour = _GREEN if all_ok else _RED
|
||||||
|
print()
|
||||||
|
print(f"{_BOLD}{'='*60}{_RESET}")
|
||||||
|
print(f"{colour}{_BOLD} RESULT: {passed}/{total} checks PASS{_RESET}")
|
||||||
|
print(f"{_BOLD}{'='*60}{_RESET}")
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block A — SMOKE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def block_a(base: str, results: Results):
|
||||||
|
print(f"\n{_BOLD}[Block A] SMOKE{_RESET}")
|
||||||
|
|
||||||
|
# A1 — /health
|
||||||
|
try:
|
||||||
|
status, data = _get(f"{base}/health")
|
||||||
|
ok = status == 200 and data.get("status") == "ok"
|
||||||
|
results.add("A1 GET /health → 200 status=ok", ok,
|
||||||
|
f"HTTP {status}, body={data}")
|
||||||
|
except Exception as e:
|
||||||
|
results.add("A1 GET /health → 200 status=ok", False, str(e))
|
||||||
|
|
||||||
|
# A2 — /queue
|
||||||
|
try:
|
||||||
|
status, data = _get(f"{base}/queue")
|
||||||
|
ok = (status == 200
|
||||||
|
and "counts" in data
|
||||||
|
and "max_concurrency" in data
|
||||||
|
and "resilience" in data)
|
||||||
|
results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", ok,
|
||||||
|
f"HTTP {status}, keys={list(data.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", False, str(e))
|
||||||
|
|
||||||
|
# A3 — ORCH_STAGING=true in env (guard against hitting prod)
|
||||||
|
staging_flag = os.environ.get("ORCH_STAGING", "").lower()
|
||||||
|
ok = staging_flag == "true"
|
||||||
|
results.add("A3 ORCH_STAGING=true (not prod)", ok,
|
||||||
|
f"ORCH_STAGING={os.environ.get('ORCH_STAGING', '<unset>')}")
|
||||||
|
if not ok:
|
||||||
|
print(_fail(" ⛔ Safety abort: ORCH_STAGING is not 'true'. "
|
||||||
|
"This might be prod. Skipping destructive blocks B/C."))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block B — ACCESS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
|
||||||
|
PROD_ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||||
|
PROD_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||||
|
|
||||||
|
|
||||||
|
def block_b(results: Results):
|
||||||
|
print(f"\n{_BOLD}[Block B] ACCESS{_RESET}")
|
||||||
|
|
||||||
|
plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
|
||||||
|
plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
|
||||||
|
# env stores URL WITHOUT /api/v1 — add it ourselves
|
||||||
|
plane_base = plane_base_env.rstrip("/") + "/api/v1"
|
||||||
|
workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
|
||||||
|
gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
|
||||||
|
gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
plane_headers = {"X-API-Key": plane_token}
|
||||||
|
gitea_headers = {"Authorization": f"token {gitea_token}"}
|
||||||
|
|
||||||
|
# B4 — Plane: list projects, sandbox id present
|
||||||
|
try:
|
||||||
|
url = f"{plane_base}/workspaces/{workspace}/projects/"
|
||||||
|
status, data = _get(url, headers=plane_headers)
|
||||||
|
if status == 200:
|
||||||
|
# API may return a list or {"results": [...]}
|
||||||
|
projects = data.get("results", data) if isinstance(data, dict) else data
|
||||||
|
if isinstance(projects, list):
|
||||||
|
ids = {p.get("id", "") for p in projects}
|
||||||
|
else:
|
||||||
|
ids = set()
|
||||||
|
ok = SANDBOX_PROJECT_ID in ids
|
||||||
|
results.add("B4 Plane: sandbox project accessible", ok,
|
||||||
|
f"HTTP {status}, found {len(ids)} project(s), sandbox={'YES' if ok else 'NO'}")
|
||||||
|
else:
|
||||||
|
results.add("B4 Plane: sandbox project accessible", False,
|
||||||
|
f"HTTP {status}")
|
||||||
|
except Exception as e:
|
||||||
|
results.add("B4 Plane: sandbox project accessible", False, str(e))
|
||||||
|
|
||||||
|
# B5 — Gitea: sandbox repo accessible, push=true
|
||||||
|
try:
|
||||||
|
url = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
|
||||||
|
status, data = _get(url, headers=gitea_headers)
|
||||||
|
push_ok = data.get("permissions", {}).get("push", False) if status == 200 else False
|
||||||
|
ok = status == 200 and push_ok
|
||||||
|
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", ok,
|
||||||
|
f"HTTP {status}, permissions={data.get('permissions')}")
|
||||||
|
except Exception as e:
|
||||||
|
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", False, str(e))
|
||||||
|
|
||||||
|
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs
|
||||||
|
try:
|
||||||
|
# Import from inside the container (script runs in /repos/orchestrator context)
|
||||||
|
sys.path.insert(0, "/repos/orchestrator")
|
||||||
|
# Force reload to pick up container env
|
||||||
|
import importlib
|
||||||
|
if "src.projects" in sys.modules:
|
||||||
|
importlib.reload(sys.modules["src.projects"])
|
||||||
|
from src.projects import known_plane_project_ids
|
||||||
|
known = known_plane_project_ids()
|
||||||
|
sandbox_present = SANDBOX_PROJECT_ID in known
|
||||||
|
et_absent = PROD_ET_PROJECT_ID not in known
|
||||||
|
orch_absent = PROD_ORCH_PROJECT_ID not in known
|
||||||
|
ok = sandbox_present and et_absent and orch_absent
|
||||||
|
detail = (
|
||||||
|
f"sandbox={'YES' if sandbox_present else 'NO'}, "
|
||||||
|
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
|
||||||
|
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
|
||||||
|
)
|
||||||
|
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", ok, detail)
|
||||||
|
except Exception as e:
|
||||||
|
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block C — E2E
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IN_PROGRESS_STATE_ID = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||||
|
|
||||||
|
# Path to staging SQLite DB inside the container
|
||||||
|
STAGING_DB_PATH = os.environ.get("ORCH_DB_PATH", "/app/data/orchestrator.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_webhook_payload(issue_id: str, issue_name: str, issue_desc: str) -> dict:
|
||||||
|
"""Build the minimal webhook payload that triggers start_pipeline."""
|
||||||
|
return {
|
||||||
|
"event": "issue",
|
||||||
|
"action": "updated",
|
||||||
|
"data": {
|
||||||
|
"id": issue_id,
|
||||||
|
"name": issue_name,
|
||||||
|
"description_stripped": issue_desc,
|
||||||
|
"project": SANDBOX_PROJECT_ID,
|
||||||
|
"state": {
|
||||||
|
"id": IN_PROGRESS_STATE_ID,
|
||||||
|
"name": "In Progress",
|
||||||
|
"group": "started",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _poll(fn, timeout: int = 60, interval: int = 3, label: str = ""):
|
||||||
|
"""Poll fn() until it returns truthy or timeout expires."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
result = fn()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
if label:
|
||||||
|
print(_info(f" waiting... ({label})"))
|
||||||
|
time.sleep(interval)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_staging_db(plane_issue_id: str):
|
||||||
|
"""Delete the test task row from staging SQLite DB."""
|
||||||
|
if not plane_issue_id:
|
||||||
|
print(_info("CLEANUP DB: no issue_id to clean"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(STAGING_DB_PATH)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM tasks WHERE plane_id = ?", (plane_issue_id,)
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if deleted:
|
||||||
|
print(_ok(f"CLEANUP DB: deleted {deleted} task row(s) for plane_id={plane_issue_id}"))
|
||||||
|
else:
|
||||||
|
print(_info(f"CLEANUP DB: no task row found for plane_id={plane_issue_id}"))
|
||||||
|
except Exception as e:
|
||||||
|
print(_fail(f"CLEANUP DB: error: {e}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_staging_jobs(plane_issue_id: str):
|
||||||
|
"""Delete job queue rows for the test task from staging SQLite DB."""
|
||||||
|
if not plane_issue_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(STAGING_DB_PATH)
|
||||||
|
# Find task ids for this plane_id first
|
||||||
|
task_rows = conn.execute(
|
||||||
|
"SELECT id FROM tasks WHERE plane_id = ?", (plane_issue_id,)
|
||||||
|
).fetchall()
|
||||||
|
if task_rows:
|
||||||
|
task_ids = [r[0] for r in task_rows]
|
||||||
|
placeholders = ",".join("?" * len(task_ids))
|
||||||
|
cur = conn.execute(
|
||||||
|
f"DELETE FROM jobs WHERE task_id IN ({placeholders})", task_ids
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
if deleted:
|
||||||
|
print(_ok(f"CLEANUP DB: deleted {deleted} job row(s) for task_ids={task_ids}"))
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(_fail(f"CLEANUP DB jobs: error: {e}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_dedup(plane_issue_id: str, wh_body_sha: str | None = None):
|
||||||
|
"""Remove dedup event entries for the test webhook delivery."""
|
||||||
|
if not wh_body_sha:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(STAGING_DB_PATH)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM events_dedup WHERE delivery_id = ?", (wh_body_sha,)
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if deleted:
|
||||||
|
print(_ok(f"CLEANUP DB: removed {deleted} dedup entry"))
|
||||||
|
except Exception as e:
|
||||||
|
# dedup table might not exist or different schema — not critical
|
||||||
|
print(_info(f"CLEANUP DB dedup: {e}"))
|
||||||
|
|
||||||
|
|
||||||
|
def block_c(base: str, results: Results, mode: str):
|
||||||
|
print(f"\n{_BOLD}[Block C] E2E (mode={mode}){_RESET}")
|
||||||
|
|
||||||
|
plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
|
||||||
|
plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
|
||||||
|
plane_base = plane_base_env.rstrip("/") + "/api/v1"
|
||||||
|
workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
|
||||||
|
gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
|
||||||
|
gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")
|
||||||
|
webhook_secret = os.environ.get("ORCH_PLANE_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
plane_headers = {"X-API-Key": plane_token}
|
||||||
|
gitea_headers = {"Authorization": f"token {gitea_token}"}
|
||||||
|
|
||||||
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S")
|
||||||
|
issue_name = f"[staging-check] e2e {ts}"
|
||||||
|
issue_desc = (
|
||||||
|
"Automated e2e check created by staging_check.py. "
|
||||||
|
"This task tests the live staging pipeline end-to-end. "
|
||||||
|
"Safe to delete — cleanup runs in finally block."
|
||||||
|
)
|
||||||
|
|
||||||
|
issue_id = None
|
||||||
|
branch_name = None
|
||||||
|
wh_body_bytes = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# C7 — Create task in Plane SANDBOX
|
||||||
|
print(_info(f"C7: Creating issue in SANDBOX project..."))
|
||||||
|
url = f"{plane_base}/workspaces/{workspace}/projects/{SANDBOX_PROJECT_ID}/issues/"
|
||||||
|
status, data = _post(url, headers=plane_headers, payload={
|
||||||
|
"name": issue_name,
|
||||||
|
"description_html": f"<p>{issue_desc}</p>",
|
||||||
|
"description_stripped": issue_desc,
|
||||||
|
})
|
||||||
|
issue_id = data.get("id")
|
||||||
|
ok = status in (200, 201) and bool(issue_id)
|
||||||
|
results.add("C7 Create issue in Plane SANDBOX", ok,
|
||||||
|
f"HTTP {status}, issue_id={issue_id}")
|
||||||
|
if not ok:
|
||||||
|
print(_fail(f" Cannot continue C8-C9 without issue. body={data}"))
|
||||||
|
results.add("C8 Trigger pipeline via /webhook/plane", False, "skipped: C7 failed")
|
||||||
|
results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped")
|
||||||
|
results.add("C9b Analyst job enqueued in staging queue", False, "skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Small delay to let Plane finish persisting the issue
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# C8 — Trigger pipeline via direct POST to /webhook/plane
|
||||||
|
print(_info(f"C8: Triggering pipeline via POST /webhook/plane ..."))
|
||||||
|
wh_payload = _make_webhook_payload(issue_id, issue_name, issue_desc)
|
||||||
|
wh_body_bytes = json.dumps(wh_payload).encode()
|
||||||
|
|
||||||
|
wh_headers = {"Content-Type": "application/json"}
|
||||||
|
if webhook_secret:
|
||||||
|
sig = _sign_payload(webhook_secret, wh_body_bytes)
|
||||||
|
wh_headers["X-Plane-Signature"] = sig
|
||||||
|
print(_info(f" Using HMAC signature (secret len={len(webhook_secret)})"))
|
||||||
|
else:
|
||||||
|
print(_info(" No webhook secret configured, sending without signature"))
|
||||||
|
|
||||||
|
status, resp = _post(f"{base}/webhook/plane",
|
||||||
|
headers=wh_headers,
|
||||||
|
raw_body=wh_body_bytes)
|
||||||
|
ok = status == 200 and resp.get("status") in ("accepted",)
|
||||||
|
results.add("C8 Trigger pipeline via /webhook/plane", ok,
|
||||||
|
f"HTTP {status}, resp={resp}")
|
||||||
|
if not ok:
|
||||||
|
print(_fail(f" Pipeline trigger failed. Cannot verify C9."))
|
||||||
|
results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped: C8 failed")
|
||||||
|
results.add("C9b Analyst job enqueued in staging queue", False, "skipped: C8 failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# C9a — Poll for branch in Gitea orchestrator-sandbox
|
||||||
|
print(_info("C9a: Polling for branch in orchestrator-sandbox (up to 60s)..."))
|
||||||
|
|
||||||
|
def _check_branch():
|
||||||
|
try:
|
||||||
|
burl = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox/branches"
|
||||||
|
s, bdata = _get(burl, headers=gitea_headers)
|
||||||
|
if s != 200:
|
||||||
|
return None
|
||||||
|
branches = bdata if isinstance(bdata, list) else bdata.get("results", [])
|
||||||
|
for b in branches:
|
||||||
|
bname = b.get("name", "")
|
||||||
|
# Branch name: feature/SANDBOX-NNN-staging-check-...
|
||||||
|
if "feature/" in bname and "staging-check" in bname:
|
||||||
|
return bname
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
branch_name = _poll(_check_branch, timeout=60, interval=3,
|
||||||
|
label="waiting for branch")
|
||||||
|
ok = bool(branch_name)
|
||||||
|
results.add("C9a Branch appears in orchestrator-sandbox", ok,
|
||||||
|
f"branch={branch_name or 'not found'}")
|
||||||
|
|
||||||
|
# C9b — Verify analyst job was enqueued via staging /queue
|
||||||
|
# NOTE: The orchestrator posts a "🔍 Analyst запущен" comment to Plane using
|
||||||
|
# per-agent bot tokens (ORCH_PLANE_BOT_ANALYST). In staging, the sandbox
|
||||||
|
# project was created after the bot accounts were provisioned, so the bots are
|
||||||
|
# not yet members of the SANDBOX project → add_comment returns 403 Forbidden.
|
||||||
|
# This is a known staging infrastructure limitation (not a pipeline bug).
|
||||||
|
# We therefore verify pipeline success via /queue (recent jobs): the analyst
|
||||||
|
# job is enqueued BEFORE the add_comment call, so its presence in the queue
|
||||||
|
# confirms the pipeline ran through to job dispatch.
|
||||||
|
print(_info("C9b: Checking staging job queue for analyst job (up to 30s)..."))
|
||||||
|
print(_info(" (Plane comment check skipped: bot-tokens not added to SANDBOX project)"))
|
||||||
|
|
||||||
|
def _check_queue():
|
||||||
|
try:
|
||||||
|
s, qdata = _get(f"{base}/queue")
|
||||||
|
if s != 200:
|
||||||
|
return None
|
||||||
|
recent = qdata.get("recent", [])
|
||||||
|
for job in recent:
|
||||||
|
if (job.get("agent") == "analyst"
|
||||||
|
and job.get("repo") == "orchestrator-sandbox"
|
||||||
|
and issue_name in (job.get("task_content") or "")):
|
||||||
|
return job
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
analyst_job = _poll(_check_queue, timeout=30, interval=2,
|
||||||
|
label="waiting for analyst job in queue")
|
||||||
|
ok = bool(analyst_job)
|
||||||
|
detail = ""
|
||||||
|
if analyst_job:
|
||||||
|
detail = (f"job_id={analyst_job.get('id')}, "
|
||||||
|
f"status={analyst_job.get('status')}, "
|
||||||
|
f"agent={analyst_job.get('agent')}")
|
||||||
|
results.add("C9b Analyst job enqueued in staging queue", ok, detail)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# C10 — CLEANUP (always runs)
|
||||||
|
print(f"\n{_BOLD}[CLEANUP]{_RESET}")
|
||||||
|
_cleanup(
|
||||||
|
plane_base=plane_base,
|
||||||
|
workspace=workspace,
|
||||||
|
gitea_base=gitea_base,
|
||||||
|
plane_headers=plane_headers,
|
||||||
|
gitea_headers=gitea_headers,
|
||||||
|
issue_id=issue_id,
|
||||||
|
branch_name=branch_name,
|
||||||
|
wh_body_bytes=wh_body_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers,
|
||||||
|
issue_id, branch_name, wh_body_bytes=None):
|
||||||
|
"""Delete test branch in Gitea, test issue in Plane SANDBOX, and DB rows."""
|
||||||
|
|
||||||
|
# Delete branch in Gitea
|
||||||
|
if branch_name:
|
||||||
|
try:
|
||||||
|
burl = (f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
|
||||||
|
f"/branches/{urllib.parse.quote(branch_name, safe='')}")
|
||||||
|
s = _delete(burl, headers=gitea_headers)
|
||||||
|
if s in (200, 204, 404):
|
||||||
|
print(_ok(f"CLEANUP: deleted branch {branch_name!r} (HTTP {s})"))
|
||||||
|
else:
|
||||||
|
print(_fail(f"CLEANUP: delete branch returned HTTP {s}"))
|
||||||
|
except Exception as e:
|
||||||
|
print(_fail(f"CLEANUP: delete branch error: {e}"))
|
||||||
|
else:
|
||||||
|
print(_info("CLEANUP: no branch to delete"))
|
||||||
|
|
||||||
|
# Delete issue in Plane SANDBOX
|
||||||
|
if issue_id:
|
||||||
|
try:
|
||||||
|
iurl = (f"{plane_base}/workspaces/{workspace}/projects/"
|
||||||
|
f"{SANDBOX_PROJECT_ID}/issues/{issue_id}/")
|
||||||
|
s = _delete(iurl, headers=plane_headers)
|
||||||
|
if s in (200, 204, 404):
|
||||||
|
print(_ok(f"CLEANUP: deleted Plane issue {issue_id} (HTTP {s})"))
|
||||||
|
else:
|
||||||
|
print(_fail(f"CLEANUP: delete Plane issue returned HTTP {s}"))
|
||||||
|
except Exception as e:
|
||||||
|
print(_fail(f"CLEANUP: delete Plane issue error: {e}"))
|
||||||
|
else:
|
||||||
|
print(_info("CLEANUP: no issue to delete"))
|
||||||
|
|
||||||
|
# Delete task + jobs from staging DB
|
||||||
|
if issue_id:
|
||||||
|
_cleanup_staging_jobs(issue_id)
|
||||||
|
_cleanup_staging_db(issue_id)
|
||||||
|
|
||||||
|
# Remove dedup entry so future re-runs with same body don't get "duplicate"
|
||||||
|
if wh_body_bytes is not None:
|
||||||
|
import hashlib as _hl
|
||||||
|
dedup_id = "plane" + _hl.sha256(b"plane" + wh_body_bytes).hexdigest()
|
||||||
|
_cleanup_dedup(issue_id, dedup_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Live staging-stand check suite (ORCH-33)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
default="http://localhost:8501",
|
||||||
|
help="Base URL of the staging orchestrator (default: http://localhost:8501)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["stub", "full-real"],
|
||||||
|
default="stub",
|
||||||
|
help=(
|
||||||
|
"stub (default): check early pipeline artifacts only (branch+job), "
|
||||||
|
"no LLM spend. "
|
||||||
|
"full-real: also wait for the analyst agent (slow, costs credits)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = args.base_url.rstrip("/")
|
||||||
|
|
||||||
|
print(f"{_BOLD}{'='*60}{_RESET}")
|
||||||
|
print(f"{_BOLD} ORCH-33 Staging Check Suite{_RESET}")
|
||||||
|
print(f" base_url : {base}")
|
||||||
|
print(f" mode : {args.mode}")
|
||||||
|
print(f" utc_time : {datetime.datetime.now(datetime.timezone.utc).isoformat()}")
|
||||||
|
print(f"{_BOLD}{'='*60}{_RESET}")
|
||||||
|
|
||||||
|
results = Results()
|
||||||
|
|
||||||
|
block_a(base, results)
|
||||||
|
block_b(results)
|
||||||
|
block_c(base, results, args.mode)
|
||||||
|
|
||||||
|
all_ok = results.summary()
|
||||||
|
sys.exit(0 if all_ok else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
296
src/qg/checks.py
296
src/qg/checks.py
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
import httpx
|
import httpx
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
@@ -137,7 +138,16 @@ def check_review_approved(repo: str, pr_number: int) -> tuple[bool, str]:
|
|||||||
|
|
||||||
def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Check if test report exists and contains PASS indicator.
|
Gate the testing -> deploy transition on the tester's MACHINE-READABLE verdict
|
||||||
|
in 13-test-report.md frontmatter, NOT on a naive substring search of the body.
|
||||||
|
|
||||||
|
ET-013 fix: the previous implementation did `if "PASS" in content`, so a report
|
||||||
|
explicitly marked `verdict: BLOCKED` / `status: blocked` but whose prose mentioned
|
||||||
|
"23 passed" / "✅ PASS" / "All checks passed" was treated as a pass, and an
|
||||||
|
unfinished feature reached Done. This mirrors check_reviewer_verdict (S-5) and
|
||||||
|
check_deploy_status (БАГ 8): read ONLY the YAML frontmatter `verdict:` / `status:`
|
||||||
|
fields, never the body.
|
||||||
|
|
||||||
File: docs/work-items/<work_item_id>/13-test-report.md
|
File: docs/work-items/<work_item_id>/13-test-report.md
|
||||||
"""
|
"""
|
||||||
repo_path = _repo_path(repo, branch)
|
repo_path = _repo_path(repo, branch)
|
||||||
@@ -149,12 +159,67 @@ def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None)
|
|||||||
try:
|
try:
|
||||||
with open(report_path, "r") as f:
|
with open(report_path, "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if "PASS" in content or "All tests passed" in content:
|
|
||||||
return True, "Test report indicates PASS"
|
|
||||||
return False, "Test report exists but no PASS indicator found"
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return False, f"Error reading test report: {e}"
|
return False, f"Error reading test report: {e}"
|
||||||
|
|
||||||
|
return _parse_tests_verdict(content)
|
||||||
|
|
||||||
|
|
||||||
|
# Positive / negative verdict tokens, derived from REAL tester reports in
|
||||||
|
# enduro-trails (ET-001..ET-014). The tester is inconsistent: most write
|
||||||
|
# `verdict: PASS`, but ET-006 used `verdict: ready-to-deploy` (with `status: PASSED`),
|
||||||
|
# ET-007 `verdict: PASS — ready-to-deploy`, ET-008 `verdict: stage:ready-to-deploy`
|
||||||
|
# (with `status: pass`). ET-013 (the bug) used `verdict: BLOCKED` / `status: blocked`.
|
||||||
|
# We therefore match known positive/negative TOKENS inside the normalized
|
||||||
|
# verdict/status fields, and treat a negative token as authoritative (a BLOCKED/FAILED
|
||||||
|
# report never passes, even if another field looks positive).
|
||||||
|
_TESTS_NEGATIVE_TOKENS = ("BLOCKED", "FAILED", "FAIL", "REQUEST_CHANGES", "REJECT", "RED")
|
||||||
|
_TESTS_POSITIVE_TOKENS = ("PASSED", "PASS", "READY-TO-DEPLOY", "READY_TO_DEPLOY", "GREEN", "APPROVED")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tests_verdict(content: str) -> tuple[bool, str]:
|
||||||
|
"""Map a 13-test-report.md body to a quality-gate verdict by reading ONLY the
|
||||||
|
machine-readable `verdict:` (and corroborating `status:`) YAML frontmatter fields.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- No frontmatter / bad YAML / neither field present -> (False, reason).
|
||||||
|
- A negative token (BLOCKED/FAILED/...) in verdict OR status -> (False) and is
|
||||||
|
authoritative (ET-013 main case: verdict BLOCKED wins over any prose PASS).
|
||||||
|
- Otherwise a positive token (PASS/PASSED/READY-TO-DEPLOY/...) in verdict OR
|
||||||
|
status -> (True).
|
||||||
|
- Anything else (unrecognized / empty verdict) -> (False, reason).
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
if not content.startswith("---"):
|
||||||
|
return False, "No YAML frontmatter in test report (cannot read machine verdict)"
|
||||||
|
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return False, "Malformed YAML frontmatter in test report"
|
||||||
|
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(parts[1]) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
return False, f"Invalid YAML frontmatter in test report: {e}"
|
||||||
|
if not isinstance(fm, dict):
|
||||||
|
return False, "Malformed YAML frontmatter in test report (not a mapping)"
|
||||||
|
|
||||||
|
verdict = str(fm.get("verdict", "") or "").upper().strip()
|
||||||
|
status = str(fm.get("status", "") or "").upper().strip()
|
||||||
|
|
||||||
|
if not verdict and not status:
|
||||||
|
return False, "No machine-readable verdict/status in test report frontmatter"
|
||||||
|
|
||||||
|
fields = f"{verdict} {status}"
|
||||||
|
for neg in _TESTS_NEGATIVE_TOKENS:
|
||||||
|
if neg in fields:
|
||||||
|
return False, f"Test verdict: {verdict or status} ({neg})"
|
||||||
|
for pos in _TESTS_POSITIVE_TOKENS:
|
||||||
|
if pos in fields:
|
||||||
|
return True, f"Test verdict: {verdict or status} (PASS)"
|
||||||
|
|
||||||
|
return False, f"No recognized PASS verdict in frontmatter (verdict={verdict!r}, status={status!r})"
|
||||||
|
|
||||||
|
|
||||||
def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||||
@@ -281,6 +346,64 @@ def check_tests_local(repo: str, branch: str) -> tuple[bool, str]:
|
|||||||
return False, f"Local test run error: {e}"
|
return False, f"Local test run error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_deploy_status(content: str) -> tuple[bool, str]:
|
||||||
|
"""Parse a 14-deploy-log.md body and map its `deploy_status:` frontmatter to a
|
||||||
|
quality-gate verdict. Reads ONLY the machine-readable YAML field, never prose.
|
||||||
|
|
||||||
|
deploy_status: SUCCESS -> (True, "Deploy status: SUCCESS")
|
||||||
|
deploy_status: FAILED -> (False, "Deploy status: FAILED")
|
||||||
|
missing field / no frontmatter / bad YAML -> (False, <reason>)
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
status = None
|
||||||
|
if content.startswith("---"):
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(parts[1]) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
return False, f"Invalid YAML frontmatter in deploy log: {e}"
|
||||||
|
status = str(fm.get("deploy_status", "")).upper().strip()
|
||||||
|
if status == "SUCCESS":
|
||||||
|
return True, "Deploy status: SUCCESS"
|
||||||
|
if status == "FAILED":
|
||||||
|
return False, "Deploy status: FAILED"
|
||||||
|
return False, f"No machine-readable deploy_status in frontmatter (got: {status!r})"
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_log_from_main(repo: str, work_item_id: str) -> str | None:
|
||||||
|
"""Best-effort read of 14-deploy-log.md from origin/main on the shared clone.
|
||||||
|
|
||||||
|
The deployer writes 14-deploy-log.md and merges the deploy artifacts into main
|
||||||
|
via a separate PR (see ET-013), so the file lands in origin/main, NOT in the
|
||||||
|
feature branch worktree the gate normally reads. This recovers it from main.
|
||||||
|
|
||||||
|
Degrades gracefully: any git failure (no clone, network/fetch error, file
|
||||||
|
absent in main) returns None instead of raising, so the caller falls back to
|
||||||
|
the plain "not found" verdict. Never raises.
|
||||||
|
"""
|
||||||
|
repo_clone = os.path.join(settings.repos_dir, repo)
|
||||||
|
if not os.path.isdir(os.path.join(repo_clone, ".git")):
|
||||||
|
return None
|
||||||
|
rel = f"docs/work-items/{work_item_id}/14-deploy-log.md"
|
||||||
|
try:
|
||||||
|
# Refresh origin/main so we see freshly-merged deploy artifacts.
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", repo_clone, "fetch", "origin", "main"],
|
||||||
|
check=False, capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
show = subprocess.run(
|
||||||
|
["git", "-C", repo_clone, "show", f"origin/main:{rel}"],
|
||||||
|
check=False, capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
logger.warning("deploy-log origin/main lookup failed for %s/%s: %s", repo, work_item_id, e)
|
||||||
|
return None
|
||||||
|
if show.returncode != 0:
|
||||||
|
return None
|
||||||
|
return show.stdout
|
||||||
|
|
||||||
|
|
||||||
def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
БАГ 8 fix: gate the deploy -> done transition on the deployer's machine-readable
|
БАГ 8 fix: gate the deploy -> done transition on the deployer's machine-readable
|
||||||
@@ -291,32 +414,154 @@ def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None)
|
|||||||
frontmatter. Returns:
|
frontmatter. Returns:
|
||||||
(True, ...) -> deploy_status: SUCCESS
|
(True, ...) -> deploy_status: SUCCESS
|
||||||
(False, ...) -> deploy_status: FAILED, missing field, or no frontmatter
|
(False, ...) -> deploy_status: FAILED, missing field, or no frontmatter
|
||||||
|
|
||||||
|
ET-013 path-sync fix: the deployer writes 14-deploy-log.md and merges the deploy
|
||||||
|
artifacts into main via a SEPARATE PR, so the log lands in origin/main, not in
|
||||||
|
the feature-branch worktree this gate reads via _repo_path(repo, branch). If the
|
||||||
|
file is absent in the worktree we fall back to reading it from origin/main on the
|
||||||
|
shared clone. Lookup order: worktree -> origin/main -> not found.
|
||||||
"""
|
"""
|
||||||
import yaml
|
|
||||||
repo_path = _repo_path(repo, branch)
|
repo_path = _repo_path(repo, branch)
|
||||||
log_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/14-deploy-log.md")
|
log_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/14-deploy-log.md")
|
||||||
|
|
||||||
if not os.path.isfile(log_path):
|
if os.path.isfile(log_path):
|
||||||
return False, "Deploy log not found (14-deploy-log.md)"
|
try:
|
||||||
|
with open(log_path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"Error reading deploy log: {e}"
|
||||||
|
return _parse_deploy_status(content)
|
||||||
|
|
||||||
|
# Not in the feature worktree — the deployer may have merged it into main.
|
||||||
|
main_content = _deploy_log_from_main(repo, work_item_id)
|
||||||
|
if main_content is not None:
|
||||||
|
return _parse_deploy_status(main_content)
|
||||||
|
|
||||||
|
return False, "Deploy log not found (14-deploy-log.md)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Self-hosting detection: staging-infra (localhost:8501) exists ONLY for the
|
||||||
|
# orchestrator repo itself (self-hosting). Other repos have no staging instance
|
||||||
|
# and their deployer prompts know nothing about it -- the gate must be a no-op
|
||||||
|
# for them. The repo value is the plain gitea repo name (ProjectConfig.repo),
|
||||||
|
# matching what _run_qg/advance_stage pass in. See ORCH-35 / PR #31.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SELF_HOSTING_REPO = "orchestrator"
|
||||||
|
|
||||||
|
|
||||||
|
def is_self_hosting_repo(repo: str) -> bool:
|
||||||
|
"""Return True iff repo is the self-hosted orchestrator (has staging infra).
|
||||||
|
|
||||||
|
Comparison is case-insensitive and strips whitespace for safety, but in
|
||||||
|
practice repo comes from the gitea webhook payload .repository.name which
|
||||||
|
is always lowercase (confirmed via projects.py registry entry).
|
||||||
|
"""
|
||||||
|
return (repo or "").strip().lower() == SELF_HOSTING_REPO.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_staging_status(content: str) -> tuple[bool, str]:
|
||||||
|
"""Parse a 15-staging-log.md body and map its `staging_status:` frontmatter to a
|
||||||
|
quality-gate verdict. Reads ONLY the machine-readable YAML field, never prose.
|
||||||
|
|
||||||
|
staging_status: SUCCESS -> (True, "Staging status: SUCCESS")
|
||||||
|
staging_status: FAILED -> (False, "Staging status: FAILED")
|
||||||
|
missing field / no frontmatter / bad YAML -> (False, <reason>)
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
status = None
|
||||||
|
if content.startswith("---"):
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(parts[1]) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
return False, f"Invalid YAML frontmatter in staging log: {e}"
|
||||||
|
status = str(fm.get("staging_status", "")).upper().strip()
|
||||||
|
if status == "SUCCESS":
|
||||||
|
return True, "Staging status: SUCCESS"
|
||||||
|
if status == "FAILED":
|
||||||
|
return False, "Staging status: FAILED"
|
||||||
|
return False, f"No machine-readable staging_status in frontmatter (got: {status!r})"
|
||||||
|
|
||||||
|
|
||||||
|
def _staging_log_from_main(repo: str, work_item_id: str) -> str | None:
|
||||||
|
"""Best-effort read of 15-staging-log.md from origin/main on the shared clone.
|
||||||
|
|
||||||
|
The deployer writes 15-staging-log.md and merges the staging artifacts into main
|
||||||
|
via a separate PR (mirroring the deploy-log pattern), so the file lands in
|
||||||
|
origin/main, NOT in the feature branch worktree the gate normally reads.
|
||||||
|
This recovers it from main.
|
||||||
|
|
||||||
|
Degrades gracefully: any git failure (no clone, network/fetch error, file
|
||||||
|
absent in main) returns None instead of raising, so the caller falls back to
|
||||||
|
the plain "not found" verdict. Never raises.
|
||||||
|
"""
|
||||||
|
repo_clone = os.path.join(settings.repos_dir, repo)
|
||||||
|
if not os.path.isdir(os.path.join(repo_clone, ".git")):
|
||||||
|
return None
|
||||||
|
rel = f"docs/work-items/{work_item_id}/15-staging-log.md"
|
||||||
try:
|
try:
|
||||||
with open(log_path, "r") as f:
|
# Refresh origin/main so we see freshly-merged staging artifacts.
|
||||||
content = f.read()
|
subprocess.run(
|
||||||
status = None
|
["git", "-C", repo_clone, "fetch", "origin", "main"],
|
||||||
if content.startswith("---"):
|
check=False, capture_output=True, timeout=30,
|
||||||
parts = content.split("---", 2)
|
)
|
||||||
if len(parts) >= 3:
|
show = subprocess.run(
|
||||||
try:
|
["git", "-C", repo_clone, "show", f"origin/main:{rel}"],
|
||||||
fm = yaml.safe_load(parts[1]) or {}
|
check=False, capture_output=True, text=True, timeout=15,
|
||||||
except yaml.YAMLError as e:
|
)
|
||||||
return False, f"Invalid YAML frontmatter in deploy log: {e}"
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
status = str(fm.get("deploy_status", "")).upper().strip()
|
logger.warning("staging-log origin/main lookup failed for %s/%s: %s", repo, work_item_id, e)
|
||||||
if status == "SUCCESS":
|
return None
|
||||||
return True, "Deploy status: SUCCESS"
|
if show.returncode != 0:
|
||||||
if status == "FAILED":
|
return None
|
||||||
return False, "Deploy status: FAILED"
|
return show.stdout
|
||||||
return False, f"No machine-readable deploy_status in frontmatter (got: {status!r})"
|
|
||||||
except OSError as e:
|
|
||||||
return False, f"Error reading deploy log: {e}"
|
def check_staging_status(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Gate the deploy-staging -> deploy transition on the deployer's machine-readable
|
||||||
|
verdict in 15-staging-log.md frontmatter (staging_status: SUCCESS|FAILED).
|
||||||
|
|
||||||
|
ORCH-35 conditional gate (Variant A):
|
||||||
|
- Non-self-hosting repos (anything other than "orchestrator") have no staging
|
||||||
|
instance and no deployer knowledge of it -> gate is an immediate pass.
|
||||||
|
- Self-hosting repo ("orchestrator") -> real check: reads ONLY the machine-
|
||||||
|
readable staging_status: field from YAML frontmatter, never body prose.
|
||||||
|
|
||||||
|
Mirrors check_deploy_status (БАГ 8) for the self-hosting path.
|
||||||
|
|
||||||
|
Lookup order (self-hosting only): worktree -> origin/main -> not found.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(True, "Staging gate N/A for <repo>") -> non-self-hosting repo (instant pass)
|
||||||
|
(True, ...) -> staging_status: SUCCESS (self-hosting path)
|
||||||
|
(False, ...) -> staging_status: FAILED, missing field, or no frontmatter
|
||||||
|
"""
|
||||||
|
# Variant A: non-self-hosting repos have no staging infra -- skip entirely.
|
||||||
|
if not is_self_hosting_repo(repo):
|
||||||
|
return True, f"Staging gate N/A for {repo}"
|
||||||
|
|
||||||
|
# Self-hosting (orchestrator) path: real verdict check.
|
||||||
|
repo_path = _repo_path(repo, branch)
|
||||||
|
log_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/15-staging-log.md")
|
||||||
|
|
||||||
|
if os.path.isfile(log_path):
|
||||||
|
try:
|
||||||
|
with open(log_path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"Error reading staging log: {e}"
|
||||||
|
return _parse_staging_status(content)
|
||||||
|
|
||||||
|
# Not in the feature worktree -- the deployer may have merged it into main.
|
||||||
|
main_content = _staging_log_from_main(repo, work_item_id)
|
||||||
|
if main_content is not None:
|
||||||
|
return _parse_staging_status(main_content)
|
||||||
|
|
||||||
|
return False, "Staging log not found (15-staging-log.md)"
|
||||||
|
|
||||||
|
|
||||||
# Registry for dynamic lookup by name
|
# Registry for dynamic lookup by name
|
||||||
@@ -330,4 +575,5 @@ QG_CHECKS = {
|
|||||||
"check_reviewer_verdict": check_reviewer_verdict,
|
"check_reviewer_verdict": check_reviewer_verdict,
|
||||||
"check_tests_local": check_tests_local,
|
"check_tests_local": check_tests_local,
|
||||||
"check_deploy_status": check_deploy_status,
|
"check_deploy_status": check_deploy_status,
|
||||||
|
"check_staging_status": check_staging_status,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -517,6 +517,32 @@ def _handle_qg_failure_rollbacks(
|
|||||||
f"(job_id={new_job})"
|
f"(job_id={new_job})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ORCH-35: deployer staging verdict FAILED -> roll deploy-staging back to development.
|
||||||
|
# Staging-провал = код плох; откат на development по образцу БАГ-8 (deploy->development).
|
||||||
|
# НЕ трогает ветку check_deploy_status ниже.
|
||||||
|
if agent == "deployer" and qg_name == "check_staging_status":
|
||||||
|
update_task_stage(task_id, "development")
|
||||||
|
notify_stage_change(task_id, current_stage, "development")
|
||||||
|
plane_notify_stage(work_item_id, current_stage, "development")
|
||||||
|
result.rolled_back_to = "development"
|
||||||
|
set_issue_blocked(work_item_id)
|
||||||
|
notify_qg_failure(task_id, "deploy-staging", "check_staging_status", reason)
|
||||||
|
plane_add_comment(
|
||||||
|
work_item_id,
|
||||||
|
f"\u274c Staging gate FAILED ({reason}). Rolled back to development. "
|
||||||
|
f"Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
|
||||||
|
author="deployer",
|
||||||
|
)
|
||||||
|
send_telegram(
|
||||||
|
f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). "
|
||||||
|
f"Rolled back to development. Needs fix."
|
||||||
|
)
|
||||||
|
result.alerted = True
|
||||||
|
logger.error(
|
||||||
|
f"Task {task_id}: deployer staging verdict FAILED, rolled back deploy-staging -> "
|
||||||
|
f"development ({reason})"
|
||||||
|
)
|
||||||
|
|
||||||
# БАГ 8: deployer verdict FAILED -> roll deploy back to development.
|
# БАГ 8: deployer verdict FAILED -> roll deploy back to development.
|
||||||
# The launcher's exit_code-based guard (launcher.py:475) never fires because
|
# The launcher's exit_code-based guard (launcher.py:475) never fires because
|
||||||
# the LLM process exit code is always 0; this gate fires on the machine-readable
|
# the LLM process exit code is always 0; this gate fires on the machine-readable
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Stage machine for orchestrator pipeline.
|
"""Stage machine for orchestrator pipeline.
|
||||||
|
|
||||||
Stages:
|
Stages:
|
||||||
created → analysis → architecture → development → review → testing → deploy → done
|
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||||
|
|
||||||
Each stage defines:
|
Each stage defines:
|
||||||
- next: the stage to advance to
|
- next: the stage to advance to
|
||||||
@@ -15,8 +15,9 @@ STAGE_TRANSITIONS = {
|
|||||||
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
||||||
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
||||||
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
||||||
"testing": {"next": "deploy", "agent": "deployer", "qg": "check_tests_passed"},
|
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
||||||
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
||||||
|
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
||||||
"done": {"next": None, "agent": None, "qg": None},
|
"done": {"next": None, "agent": None, "qg": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,36 @@ def _no_telegram(monkeypatch):
|
|||||||
monkeypatch.setattr("src.agents.launcher.send_telegram", _noop, raising=False)
|
monkeypatch.setattr("src.agents.launcher.send_telegram", _noop, raising=False)
|
||||||
monkeypatch.setattr("src.queue_worker.send_telegram", _noop, raising=False)
|
monkeypatch.setattr("src.queue_worker.send_telegram", _noop, raising=False)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_webhook_secrets(monkeypatch):
|
||||||
|
"""Isolate settings singleton between test files (CI cross-file isolation).
|
||||||
|
|
||||||
|
settings is a process-wide Pydantic singleton read once at import. Different
|
||||||
|
test modules set env variables differently at import-time, so those values leak
|
||||||
|
across files when pytest collects them together (as CI does).
|
||||||
|
|
||||||
|
1. webhook secrets: reset to "" so HMAC is disabled by default. Tests that
|
||||||
|
intentionally test the 401 path (test_webhook_dedup.py:268,278) re-apply
|
||||||
|
their own monkeypatch AFTER this autouse fixture runs, which overrides the
|
||||||
|
reset for the duration of that one test only.
|
||||||
|
|
||||||
|
2. db_path: reset to the value from ORCH_DB_PATH env var (last written by the
|
||||||
|
last imported test module). Without this, test_webhook_dedup.py (imported
|
||||||
|
first, alphabetically) seeds settings.db_path = dedup.db, while
|
||||||
|
test_webhooks.py's setup_db fixture tries to remove test_orchestrator.db,
|
||||||
|
leaving the DB dirty across tests that share a branch name and causing
|
||||||
|
get_task_by_repo_branch() to return a stale row with the wrong stage.
|
||||||
|
Per-test monkeypatches in test_webhook_dedup.setup_db override this reset.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from src.webhooks import gitea as gitea_mod
|
||||||
|
from src.webhooks import plane as plane_mod
|
||||||
|
from src import db as db_mod
|
||||||
|
monkeypatch.setattr(gitea_mod.settings, "gitea_webhook_secret", "", raising=False)
|
||||||
|
monkeypatch.setattr(plane_mod.settings, "plane_webhook_secret", "", raising=False)
|
||||||
|
db_path_env = os.environ.get("ORCH_DB_PATH", "")
|
||||||
|
if db_path_env:
|
||||||
|
monkeypatch.setattr(db_mod.settings, "db_path", db_path_env, raising=False)
|
||||||
|
yield
|
||||||
|
|||||||
349
tests/test_qg.py
349
tests/test_qg.py
@@ -19,6 +19,7 @@ from src.qg.checks import (
|
|||||||
check_tests_passed,
|
check_tests_passed,
|
||||||
check_tests_local,
|
check_tests_local,
|
||||||
check_deploy_status,
|
check_deploy_status,
|
||||||
|
check_staging_status,
|
||||||
)
|
)
|
||||||
from src.stages import get_qg_for_stage
|
from src.stages import get_qg_for_stage
|
||||||
|
|
||||||
@@ -167,23 +168,110 @@ class TestCheckReviewApproved:
|
|||||||
|
|
||||||
|
|
||||||
class TestCheckTestsPassed:
|
class TestCheckTestsPassed:
|
||||||
def test_report_with_pass(self, setup_work_item_dir):
|
"""ET-013 fix: testing -> deploy gate reads the tester's MACHINE-READABLE verdict
|
||||||
repo_dir = setup_work_item_dir
|
in 13-test-report.md frontmatter (verdict:/status:), NOT a substring of the body.
|
||||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
Mirrors check_reviewer_verdict / check_deploy_status. The old `if "PASS" in content`
|
||||||
wi_dir.mkdir(parents=True)
|
let a `verdict: BLOCKED` report whose prose said "23 passed"/"✅ PASS" pass the gate,
|
||||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: PASS\n")
|
shipping an unfinished feature to Done."""
|
||||||
|
|
||||||
|
def _write(self, repo_dir, content, wi="ET-001"):
|
||||||
|
wi_dir = repo_dir / "docs" / "work-items" / wi
|
||||||
|
wi_dir.mkdir(parents=True)
|
||||||
|
(wi_dir / "13-test-report.md").write_text(content)
|
||||||
|
|
||||||
|
def test_verdict_pass_passes(self, setup_work_item_dir):
|
||||||
|
# Most common real form (ET-001/002/005/009/011/012/014).
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\ntype: test-report\nverdict: PASS\nstatus: pass\n---\n\n# Test Report\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is True
|
||||||
|
assert "PASS" in reason
|
||||||
|
|
||||||
|
def test_verdict_pass_ready_to_deploy_passes(self, setup_work_item_dir):
|
||||||
|
# ET-007 real form: "PASS — ready-to-deploy".
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\nverdict: PASS — ready-to-deploy\nstatus: PASS\n---\n\nbody\n",
|
||||||
|
)
|
||||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
assert passed is True
|
assert passed is True
|
||||||
|
|
||||||
def test_report_without_pass(self, setup_work_item_dir):
|
def test_verdict_ready_to_deploy_with_status_passed_passes(self, setup_work_item_dir):
|
||||||
repo_dir = setup_work_item_dir
|
# ET-006 real form: verdict has no PASS word, but status: PASSED.
|
||||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
self._write(
|
||||||
wi_dir.mkdir(parents=True)
|
setup_work_item_dir,
|
||||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: FAIL\n")
|
"---\nverdict: ready-to-deploy\nstatus: PASSED\n---\n\nbody\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is True
|
||||||
|
|
||||||
|
def test_verdict_stage_ready_to_deploy_with_status_pass_passes(self, setup_work_item_dir):
|
||||||
|
# ET-008 real form: verdict: stage:ready-to-deploy, status: pass.
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\nverdict: stage:ready-to-deploy\nstatus: pass\n---\n\nbody\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is True
|
||||||
|
|
||||||
|
def test_blocked_verdict_with_pass_in_body_fails(self, setup_work_item_dir):
|
||||||
|
# THE ET-013 BUG: verdict BLOCKED but body is full of "PASS"/"passed".
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\ntype: test-report\nstatus: blocked\nverdict: BLOCKED\n---\n\n"
|
||||||
|
"23 passed\n✅ PASS (часть AC-18)\nAll checks passed\n",
|
||||||
|
)
|
||||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
assert passed is False
|
assert passed is False
|
||||||
|
assert "BLOCKED" in reason
|
||||||
|
|
||||||
|
def test_failed_verdict_fails(self, setup_work_item_dir):
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\nverdict: FAILED\nstatus: failed\n---\n\nbody\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is False
|
||||||
|
assert "FAILED" in reason
|
||||||
|
|
||||||
|
def test_passed_count_in_body_but_blocked_verdict_fails(self, setup_work_item_dir):
|
||||||
|
# Body says "23 passed" but frontmatter verdict BLOCKED -> substring no longer fools.
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\nverdict: BLOCKED\n---\n\nTests: 23 passed, 0 failed.\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is False
|
||||||
|
|
||||||
|
def test_no_frontmatter_fails(self, setup_work_item_dir):
|
||||||
|
# Old format / prose only -> no machine verdict -> fail.
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"# Test Report\n\nResult: PASS\nAll tests passed.\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is False
|
||||||
|
|
||||||
|
def test_no_verdict_field_fails(self, setup_work_item_dir):
|
||||||
|
# Frontmatter present but neither verdict nor status -> fail.
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\ntype: test-report\nversion: 1\n---\n\nResult: PASS\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is False
|
||||||
|
|
||||||
|
def test_invalid_yaml_fails_no_exception(self, setup_work_item_dir):
|
||||||
|
# Broken YAML frontmatter -> False with reason, never raises.
|
||||||
|
self._write(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\nverdict: [unclosed\n : : :\n---\n\nbody PASS\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
|
assert passed is False
|
||||||
|
assert "YAML" in reason or "frontmatter" in reason.lower()
|
||||||
|
|
||||||
def test_no_report(self, setup_work_item_dir):
|
def test_no_report(self, setup_work_item_dir):
|
||||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||||
@@ -242,6 +330,65 @@ class TestCheckDeployStatus:
|
|||||||
passed, reason = check_deploy_status("enduro-trails", "ET-011")
|
passed, reason = check_deploy_status("enduro-trails", "ET-011")
|
||||||
assert passed is False
|
assert passed is False
|
||||||
|
|
||||||
|
# --- ET-013 path-sync fix: log written to origin/main via separate PR ---
|
||||||
|
|
||||||
|
def test_origin_main_success_passes_when_absent_in_worktree(self, monkeypatch):
|
||||||
|
# Deployer merged 14-deploy-log.md into main via a separate PR; it is NOT
|
||||||
|
# in the feature worktree. Gate must recover it from origin/main -> PASS.
|
||||||
|
# (This is the exact ET-013 regression.)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._deploy_log_from_main",
|
||||||
|
lambda repo, wi: "---\ndeploy_status: SUCCESS\nversion: v0.0.5\n---\n\nLive.\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_deploy_status("enduro-trails", "ET-013")
|
||||||
|
assert passed is True
|
||||||
|
assert "SUCCESS" in reason
|
||||||
|
|
||||||
|
def test_origin_main_failed_fails(self, monkeypatch):
|
||||||
|
# A genuine FAILED log in main must still fail.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._deploy_log_from_main",
|
||||||
|
lambda repo, wi: "---\ndeploy_status: FAILED\nversion: v0.0.5\n---\n\nboom.\n",
|
||||||
|
)
|
||||||
|
passed, reason = check_deploy_status("enduro-trails", "ET-013")
|
||||||
|
assert passed is False
|
||||||
|
assert "FAILED" in reason
|
||||||
|
|
||||||
|
def test_absent_everywhere_fails(self, monkeypatch):
|
||||||
|
# Not in worktree and origin/main lookup yields nothing -> not found.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._deploy_log_from_main", lambda repo, wi: None
|
||||||
|
)
|
||||||
|
passed, reason = check_deploy_status("enduro-trails", "ET-013")
|
||||||
|
assert passed is False
|
||||||
|
assert "not found" in reason.lower()
|
||||||
|
|
||||||
|
@patch("src.qg.checks.subprocess.run")
|
||||||
|
@patch("src.qg.checks.os.path.isdir", return_value=True)
|
||||||
|
def test_fetch_failure_degrades_no_exception(self, mock_isdir, mock_run):
|
||||||
|
# git fetch/show raising (e.g. network) must degrade to "not found",
|
||||||
|
# never propagate an exception out of the gate.
|
||||||
|
import subprocess as _sp
|
||||||
|
mock_run.side_effect = _sp.TimeoutExpired(cmd="git", timeout=30)
|
||||||
|
passed, reason = check_deploy_status("enduro-trails", "ET-013")
|
||||||
|
assert passed is False
|
||||||
|
assert "not found" in reason.lower()
|
||||||
|
|
||||||
|
def test_worktree_log_short_circuits_main_lookup(self, setup_work_item_dir, monkeypatch):
|
||||||
|
# If the log IS present in the worktree, origin/main must NOT be consulted.
|
||||||
|
self._write_log(
|
||||||
|
setup_work_item_dir,
|
||||||
|
"---\ndeploy_status: SUCCESS\nversion: v0.0.3\n---\n\nDeployed OK.\n",
|
||||||
|
)
|
||||||
|
called = {"n": 0}
|
||||||
|
def _boom(repo, wi):
|
||||||
|
called["n"] += 1
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr("src.qg.checks._deploy_log_from_main", _boom)
|
||||||
|
passed, reason = check_deploy_status("enduro-trails", "ET-011")
|
||||||
|
assert passed is True
|
||||||
|
assert called["n"] == 0
|
||||||
|
|
||||||
def test_deploy_stage_qg_is_check_deploy_status(self):
|
def test_deploy_stage_qg_is_check_deploy_status(self):
|
||||||
assert get_qg_for_stage("deploy") == "check_deploy_status"
|
assert get_qg_for_stage("deploy") == "check_deploy_status"
|
||||||
|
|
||||||
@@ -302,3 +449,185 @@ class TestCheckTestsLocal:
|
|||||||
assert "../../tests/" in cmd
|
assert "../../tests/" in cmd
|
||||||
assert kwargs["cwd"] == os.path.join(str(tmp_path), "src", "api")
|
assert kwargs["cwd"] == os.path.join(str(tmp_path), "src", "api")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckStagingStatus:
|
||||||
|
"""ORCH-35 conditional gate (Variant A): deploy-staging gate is active ONLY for
|
||||||
|
the self-hosting orchestrator repo (has staging infra on localhost:8501). All
|
||||||
|
other repos pass immediately with "Staging gate N/A for <repo>".
|
||||||
|
|
||||||
|
Self-hosting path: reads machine-readable staging_status: from 15-staging-log.md
|
||||||
|
frontmatter. Mirrors check_deploy_status pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def orch_dir(self, tmp_path, monkeypatch):
|
||||||
|
"""Temp orchestrator repo dir (self-hosting)."""
|
||||||
|
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||||
|
d = tmp_path / "orchestrator"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _write_log(self, repo_dir, content, wi="ORCH-035"):
|
||||||
|
wi_dir = repo_dir / "docs" / "work-items" / wi
|
||||||
|
wi_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(wi_dir / "15-staging-log.md").write_text(content)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Self-hosting (orchestrator) path -- real file check
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_success_verdict_passes(self, orch_dir):
|
||||||
|
self._write_log(
|
||||||
|
orch_dir,
|
||||||
|
"---\nstaging_status: SUCCESS\ntimestamp: 2026-06-05T00:00:00Z\n---\n\nAll staging tests passed.\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035")
|
||||||
|
assert passed is True
|
||||||
|
assert "SUCCESS" in reason
|
||||||
|
|
||||||
|
def test_failed_verdict_fails(self, orch_dir):
|
||||||
|
self._write_log(
|
||||||
|
orch_dir,
|
||||||
|
"---\nstaging_status: FAILED\ntimestamp: 2026-06-05T00:00:00Z\n---\n\n2 tests failed.\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035")
|
||||||
|
assert passed is False
|
||||||
|
assert "FAILED" in reason
|
||||||
|
|
||||||
|
def test_no_file_fails_for_self_hosting(self, orch_dir):
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035")
|
||||||
|
assert passed is False
|
||||||
|
assert "not found" in reason.lower()
|
||||||
|
|
||||||
|
def test_no_field_fails(self, orch_dir):
|
||||||
|
# Frontmatter present but no staging_status field -> must NOT pass.
|
||||||
|
self._write_log(
|
||||||
|
orch_dir,
|
||||||
|
"---\nversion: v0.0.3\n---\n\nStatus: all good (prose only).\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035")
|
||||||
|
assert passed is False
|
||||||
|
|
||||||
|
def test_prose_only_no_frontmatter_fails(self, orch_dir):
|
||||||
|
# Prose mentioning SUCCESS but no machine-readable frontmatter -> fail.
|
||||||
|
self._write_log(
|
||||||
|
orch_dir,
|
||||||
|
"# Staging Log\n\nStatus: SUCCESS (prose, not frontmatter).\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035")
|
||||||
|
assert passed is False
|
||||||
|
|
||||||
|
def test_origin_main_success_passes_when_absent_in_worktree(self, monkeypatch):
|
||||||
|
# Deployer merged 15-staging-log.md into main; not in worktree -> recover from main.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._staging_log_from_main",
|
||||||
|
lambda repo, wi: "---\nstaging_status: SUCCESS\n---\n\nAll good.\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035-main")
|
||||||
|
assert passed is True
|
||||||
|
assert "SUCCESS" in reason
|
||||||
|
|
||||||
|
def test_origin_main_failed_fails(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._staging_log_from_main",
|
||||||
|
lambda repo, wi: "---\nstaging_status: FAILED\n---\n\nboom.\n",
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035-main")
|
||||||
|
assert passed is False
|
||||||
|
assert "FAILED" in reason
|
||||||
|
|
||||||
|
def test_absent_everywhere_fails(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.qg.checks._staging_log_from_main", lambda repo, wi: None
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("orchestrator", "ORCH-035-absent")
|
||||||
|
assert passed is False
|
||||||
|
assert "not found" in reason.lower()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Non-self-hosting path -- instant pass, no file dependency
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_non_self_hosting_passes_immediately_no_file(self, tmp_path, monkeypatch):
|
||||||
|
"""Non-self-hosting repo: gate is N/A even without a staging log file."""
|
||||||
|
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("enduro-trails", "ET-035")
|
||||||
|
assert passed is True
|
||||||
|
assert "N/A" in reason
|
||||||
|
assert "enduro-trails" in reason
|
||||||
|
|
||||||
|
def test_non_self_hosting_passes_regardless_of_file_content(self, tmp_path, monkeypatch):
|
||||||
|
"""Even a FAILED staging log must not block a non-self-hosting repo."""
|
||||||
|
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||||
|
et_dir = tmp_path / "enduro-trails" / "docs" / "work-items" / "ET-035"
|
||||||
|
et_dir.mkdir(parents=True)
|
||||||
|
(et_dir / "15-staging-log.md").write_text(
|
||||||
|
"---\nstaging_status: FAILED\n---\nShould be ignored.\n"
|
||||||
|
)
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("enduro-trails", "ET-035")
|
||||||
|
assert passed is True
|
||||||
|
assert "N/A" in reason
|
||||||
|
|
||||||
|
def test_unknown_repo_also_passes_immediately(self, tmp_path, monkeypatch):
|
||||||
|
"""Any repo that is not orchestrator gets N/A gate."""
|
||||||
|
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
|
||||||
|
from src.qg.checks import check_staging_status
|
||||||
|
passed, reason = check_staging_status("some-other-project", "XY-001")
|
||||||
|
assert passed is True
|
||||||
|
assert "N/A" in reason
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# is_self_hosting_repo helper
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_is_self_hosting_true_for_orchestrator(self):
|
||||||
|
from src.qg.checks import is_self_hosting_repo
|
||||||
|
assert is_self_hosting_repo("orchestrator") is True
|
||||||
|
|
||||||
|
def test_is_self_hosting_case_insensitive(self):
|
||||||
|
from src.qg.checks import is_self_hosting_repo
|
||||||
|
assert is_self_hosting_repo("Orchestrator") is True
|
||||||
|
assert is_self_hosting_repo("ORCHESTRATOR") is True
|
||||||
|
|
||||||
|
def test_is_self_hosting_false_for_enduro_trails(self):
|
||||||
|
from src.qg.checks import is_self_hosting_repo
|
||||||
|
assert is_self_hosting_repo("enduro-trails") is False
|
||||||
|
|
||||||
|
def test_is_self_hosting_false_for_empty(self):
|
||||||
|
from src.qg.checks import is_self_hosting_repo
|
||||||
|
assert is_self_hosting_repo("") is False
|
||||||
|
assert is_self_hosting_repo(None) is False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Stage machinery (regression: must not be broken)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_deploy_staging_qg_is_check_staging_status(self):
|
||||||
|
assert get_qg_for_stage("deploy-staging") == "check_staging_status"
|
||||||
|
|
||||||
|
def test_registered_in_qg_checks(self):
|
||||||
|
from src.qg.checks import QG_CHECKS, check_staging_status
|
||||||
|
assert QG_CHECKS.get("check_staging_status") is check_staging_status
|
||||||
|
|
||||||
|
def test_deploy_stage_qg_still_check_deploy_status(self):
|
||||||
|
"""Regression: existing deploy QG must not be broken."""
|
||||||
|
assert get_qg_for_stage("deploy") == "check_deploy_status"
|
||||||
|
|
||||||
|
def test_stage_chain(self):
|
||||||
|
"""Full chain: testing->deploy-staging->deploy->done."""
|
||||||
|
from src.stages import get_next_stage
|
||||||
|
assert get_next_stage("testing") == "deploy-staging"
|
||||||
|
assert get_next_stage("deploy-staging") == "deploy"
|
||||||
|
assert get_next_stage("deploy") == "done"
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class TestHappyPathAgentSelection:
|
|||||||
("architecture", "development", "developer"),
|
("architecture", "development", "developer"),
|
||||||
("development", "review", "reviewer"),
|
("development", "review", "reviewer"),
|
||||||
("review", "testing", "tester"),
|
("review", "testing", "tester"),
|
||||||
("testing", "deploy", "deployer"),
|
("testing", "deploy-staging", "deployer"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_advance_launches_current_stage_agent(
|
def test_advance_launches_current_stage_agent(
|
||||||
@@ -507,6 +507,120 @@ class TestAnalysisApprovedFlow:
|
|||||||
flow.assert_called_once()
|
flow.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-35: deploy-staging gate — rollback on staging failure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestStagingGate:
|
||||||
|
"""deploy-staging -> deploy must be gated on check_staging_status.
|
||||||
|
FAILED verdict rolls back to development (same as deploy БАГ-8 pattern:
|
||||||
|
staging failure = code is bad, needs developer fix)."""
|
||||||
|
|
||||||
|
def test_staging_success_advances_to_deploy(self, monkeypatch):
|
||||||
|
"""Happy path: staging SUCCESS -> advance to deploy (no agent launched)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS, "check_staging_status": _pass},
|
||||||
|
)
|
||||||
|
task_id = _make_task("deploy-staging")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy-staging", "enduro-trails", "ET-035",
|
||||||
|
"feature/ET-035-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
assert res.advanced is True
|
||||||
|
assert res.to_stage == "deploy"
|
||||||
|
assert _stage(task_id) == "deploy"
|
||||||
|
# deploy-staging has agent=deployer, so deployer is enqueued for deploy stage
|
||||||
|
assert res.enqueued_agent == "deployer"
|
||||||
|
jobs = _jobs()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0]["agent"] == "deployer"
|
||||||
|
|
||||||
|
def test_staging_failed_rolls_back_to_development(self, monkeypatch):
|
||||||
|
"""ORCH-35: staging FAILED -> roll back to development, not to testing."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS,
|
||||||
|
"check_staging_status": _fail("Staging status: FAILED")},
|
||||||
|
)
|
||||||
|
task_id = _make_task("deploy-staging")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy-staging", "enduro-trails", "ET-035",
|
||||||
|
"feature/ET-035-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
assert res.advanced is False
|
||||||
|
assert res.rolled_back_to == "development"
|
||||||
|
assert _stage(task_id) == "development" # NOT deploy, NOT testing
|
||||||
|
assert res.alerted is True
|
||||||
|
assert stage_engine.set_issue_blocked.called
|
||||||
|
assert stage_engine.send_telegram.called
|
||||||
|
|
||||||
|
def test_staging_failed_does_not_reach_deploy(self, monkeypatch):
|
||||||
|
"""Prod deploy is unreachable if staging gate is not green."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS,
|
||||||
|
"check_staging_status": _fail("Staging log not found")},
|
||||||
|
)
|
||||||
|
task_id = _make_task("deploy-staging")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy-staging", "enduro-trails", "ET-035",
|
||||||
|
"feature/ET-035-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
assert res.advanced is False
|
||||||
|
# Task must NOT be in deploy stage
|
||||||
|
assert _stage(task_id) != "deploy"
|
||||||
|
|
||||||
|
def test_staging_missing_log_rolls_back(self, monkeypatch):
|
||||||
|
"""Missing 15-staging-log.md -> gate fails -> rollback to development."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS,
|
||||||
|
"check_staging_status": _fail("Staging log not found (15-staging-log.md)")},
|
||||||
|
)
|
||||||
|
task_id = _make_task("deploy-staging")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy-staging", "enduro-trails", "ET-035",
|
||||||
|
"feature/ET-035-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
assert res.advanced is False
|
||||||
|
assert _stage(task_id) == "development"
|
||||||
|
|
||||||
|
def test_testing_to_deploy_staging_advance(self, monkeypatch):
|
||||||
|
"""testing -> deploy-staging: deployer is enqueued (ORCH-35 chain check)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS, "check_tests_passed": _pass},
|
||||||
|
)
|
||||||
|
task_id = _make_task("testing")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "testing", "enduro-trails", "ET-035",
|
||||||
|
"feature/ET-035-x", finished_agent="tester",
|
||||||
|
)
|
||||||
|
assert res.advanced is True
|
||||||
|
assert res.to_stage == "deploy-staging"
|
||||||
|
assert _stage(task_id) == "deploy-staging"
|
||||||
|
assert res.enqueued_agent == "deployer"
|
||||||
|
|
||||||
|
def test_deploy_still_rolls_back_on_check_deploy_status_fail(self, monkeypatch):
|
||||||
|
"""Existing БАГ-8 rollback must still work for deploy stage (regression guard)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS,
|
||||||
|
"check_deploy_status": _fail("Deploy status: FAILED")},
|
||||||
|
)
|
||||||
|
task_id = _make_task("deploy")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy", "enduro-trails", "ET-011",
|
||||||
|
"feature/ET-011-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
assert res.advanced is False
|
||||||
|
assert res.rolled_back_to == "development"
|
||||||
|
assert _stage(task_id) == "development"
|
||||||
|
assert res.alerted is True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# launcher + plane both delegate to the engine
|
# launcher + plane both delegate to the engine
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -54,13 +54,19 @@ def test_status_endpoint():
|
|||||||
assert "active_tasks" in resp.json()
|
assert "active_tasks" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("src.plane_sync.add_comment")
|
||||||
|
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=None)
|
||||||
|
@patch("src.plane_sync.fetch_issue_fields", return_value=("Test task", "This is a detailed test description for the task"))
|
||||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||||
def test_plane_webhook_creates_task(mock_docs, mock_branch):
|
def test_plane_webhook_creates_task(mock_docs, mock_branch, mock_fetch_fields, mock_fetch_seq, mock_add_comment):
|
||||||
"""work_item.created → task in DB with stage=analysis."""
|
"""work_item.created (via In Progress status) → task in DB with stage=analysis."""
|
||||||
resp = client.post("/webhook/plane", json={
|
resp = client.post("/webhook/plane", json={
|
||||||
"event": "work_item.created",
|
"event": "issue", "action": "updated",
|
||||||
"data": {"id": "test-123", "name": "Test task", "project": "proj-1"}
|
"data": {
|
||||||
|
"id": "test-123", "name": "Test task", "project": "proj-1",
|
||||||
|
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress", "group": "started"},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["status"] == "accepted"
|
assert resp.json()["status"] == "accepted"
|
||||||
@@ -75,17 +81,37 @@ def test_plane_webhook_creates_task(mock_docs, mock_branch):
|
|||||||
assert "feature/" in task["branch"]
|
assert "feature/" in task["branch"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("src.plane_sync.add_comment")
|
||||||
|
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=None)
|
||||||
|
@patch("src.plane_sync.fetch_issue_fields",
|
||||||
|
side_effect=[
|
||||||
|
("First task", "This is a detailed description for the first task item"),
|
||||||
|
("Second task", "This is a detailed description for the second task item"),
|
||||||
|
])
|
||||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||||
def test_plane_webhook_generates_sequential_ids(mock_docs, mock_branch):
|
def test_plane_webhook_generates_sequential_ids(
|
||||||
"""Multiple work items get sequential IDs."""
|
mock_docs, mock_branch, mock_fetch_fields, mock_fetch_seq, mock_add_comment
|
||||||
|
):
|
||||||
|
"""Multiple In Progress transitions get sequential IDs (ET-001, ET-002)."""
|
||||||
|
in_progress_state = {
|
||||||
|
"id": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||||
|
"name": "In Progress",
|
||||||
|
"group": "started",
|
||||||
|
}
|
||||||
client.post("/webhook/plane", json={
|
client.post("/webhook/plane", json={
|
||||||
"event": "work_item.created",
|
"event": "issue", "action": "updated",
|
||||||
"data": {"id": "item-1", "name": "First task", "project": "proj-1"}
|
"data": {
|
||||||
|
"id": "item-1", "name": "First task", "project": "proj-1",
|
||||||
|
"state": in_progress_state,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
client.post("/webhook/plane", json={
|
client.post("/webhook/plane", json={
|
||||||
"event": "work_item.created",
|
"event": "issue", "action": "updated",
|
||||||
"data": {"id": "item-2", "name": "Second task", "project": "proj-1"}
|
"data": {
|
||||||
|
"id": "item-2", "name": "Second task", "project": "proj-1",
|
||||||
|
"state": in_progress_state,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -202,8 +228,9 @@ def test_gitea_webhook_push():
|
|||||||
assert resp.json()["status"] == "accepted"
|
assert resp.json()["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("src.webhooks.gitea.plane_notify_stage")
|
||||||
@patch("src.webhooks.gitea.launcher")
|
@patch("src.webhooks.gitea.launcher")
|
||||||
def test_gitea_push_with_adr_advances_stage(mock_launcher):
|
def test_gitea_push_with_adr_advances_stage(mock_launcher, mock_plane_notify):
|
||||||
"""Push with ADR files at architecture stage → advance to development."""
|
"""Push with ADR files at architecture stage → advance to development."""
|
||||||
mock_launcher.launch.return_value = 1
|
mock_launcher.launch.return_value = 1
|
||||||
|
|
||||||
@@ -235,7 +262,7 @@ def test_gitea_push_with_adr_advances_stage(mock_launcher):
|
|||||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'push-001'").fetchone()
|
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'push-001'").fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
assert task["stage"] == "development"
|
assert task["stage"] == "development"
|
||||||
mock_launcher.launch.assert_called_once()
|
mock_plane_notify.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch("src.webhooks.gitea.check_ci_green")
|
@patch("src.webhooks.gitea.check_ci_green")
|
||||||
|
|||||||
Reference in New Issue
Block a user