Перейти к основному содержимому

ADR 0005 — Идемпотентный и возобновляемый bootstrap Gitflow

Контекст​

Фаза 2, сессия 3 («сердце»): ProvisionRepositoryUseCase начинает, помимо создания репозитория, выполнять полный bootstrap Gitflow (ветки dev/qa/rc, защиты в соответствии с capabilities, шаблон PR, docs/branching-policy.md) как последовательность шагов, которая должна: всегда сходиться к одному и тому же состоянию при N запусках (идемпотентность), возобновляться с места остановки после сбоя посередине (сохранение прогресса) и описывать каждую мутацию как proposed_action auto_approved в event log сессии — и всё это без какого-либо прецедента в коде (первая функция «убить процесс посередине и возобновить» в этом репозитории).

Два решения были явно подтверждены с пользователем до реализации; остальное было решено в ходе реализации, по мере того как интеграция с существующим кодом (сессии, proposed_actions, GitProviderContract) выявляла реальные ограничения.

Решения​

1. getFileContent как 9-я операция GitProviderContract. Проверку «файл уже закоммичен с тем же содержимым» (идемпотентность шагов commit) невозможно сделать с 8 исходными операциями — ни одна не читает содержимое файла. Подтверждено с пользователем: добавить getFileContent(externalId, branch, path): Promise<string | null>, реализованную в 3 provider (LocalGitProvider через git show, GithubProvider через Octokit repos.getContent, GitlabProvider через Gitbeaker RepositoryFiles.show), с покрытием в едином наборе контрактных тестов. Фальшивым backend GitHub/GitLab (msw) пришлось начать по-настоящему отслеживать содержимое (раньше только метаданные ветки/PR) — граф blob→tree→commit для GitHub (отражает реальный API), карта путь→содержимое по ветке для GitLab (API commit отправляет полное состояние за раз).

2. Выделенная сессия, создаваемая один раз и повторно используемая при каждом возобновлении. proposed_actions/session_events требуют существующего sessionId — нет поддержки действия/события «без сессии». Подтверждено с пользователем: ProvisionRepositoryUseCase создаёт ОДНУ сессию на проект при первой попытке (тот же дешёвый механизм, что у CreateSessionUseCase — только INSERT + событие outbox, никогда не вызывает engine), сохраняет id в repo_bootstraps.session_id и ПОВТОРНО ИСПОЛЬЗУЕТ её при каждом возобновлении — история bootstrap (успех, сбой, возобновление, пропуски) описывается в непрерывной хронологии, а не раздроблена между сессиями. Сессия никогда не переходит в active через TransitionSessionUseCase (который вызвал бы engine через ApiToEngineClient.startSession — неправильно для сессии, которая никогда не выполняет ни одной команды): переход created→active делается напрямую через SessionRepository.updateStatus, под защитой assertTransition. По той же причине сессия НИКОГДА не переходит в closed_abnormally при сбое шага — это терминальное состояние (без исходящих переходов, session-state-machine.ts), и оно сломало бы повторное использование в будущих возобновлениях; она остаётся active на протяжении любого числа неудачных попыток/возобновлений, закрываясь (closing→closed, не трогая engine на этих двух шагах) только когда 6 шагов сходятся в одном и том же запуске.

3. Единый курсор на проект, полная перепроверка при каждом запуске. repo_bootstraps — одна строка на проект (а не журнал по шагам) — {project_id, session_id, step, status, attempts, last_error}. Идемпотентность НЕ достигается пропуском шагов на основе этого курсора: КАЖДЫЙ запуск (свежий или возобновлённый) проходит 6 шагов с начала, вызывая check() каждого против реального состояния в provider, — лишь когда check() сообщает «не выполнено», происходит настоящая мутация. Курсор — лишь диагностика (последний затронутый шаг + результат), никогда не gate. Это единственное правило бесплатно даёт: идемпотентность (запуск N раз = все check() возвращают «выполнено», ноль мутаций), возобновление после сбоя (уже сделанные шаги остаются выполненными, исполняется только недостающее) и возобновление после «убийства процесса посреди мутации» (строка остаётся со status=running; при следующем запуске check() обнаруживает, что мутация на самом деле уже произошла, и пропускает её) — 3 сценария критерия теста (пункт 6) — это ОДИН механизм, а не три отдельные реализации.

4. Без decide() на пути bootstrap — статус рождается зашитым как auto_approved. Запрос гласит «решение auto_approved для bootstrap». Настоящий вызов decide() (как делает ProposeActionUseCase для действий пользователя/агента) НЕ дал бы этого по умолчанию: fallback decide() без применимого правила в permissions.json — require_approval, никогда не auto_approve. То есть «bootstrap всегда автоодобрен» имеет смысл только как структурная категория, отдельная от дискреционного pipeline, а не как «типичный результат decide() для нового проекта». Структурно безопасно: auto_approved никогда не является целью перехода в action-state-machine.ts (только начальным состоянием), поэтому зашивать его при создании не нарушает машину состояний. Принятое следствие: deny в permissions.json, нацеленный на git_branch_create и т. п., не блокирует bootstrap — это инфраструктура нового проекта, а не дискреционное действие агента/пользователя.

5. Порядок ВЫПОЛНЕНИЯ шагов отличается от порядка, перечисленного в запросе. Запрос перечисляет «создать dev из main» первым, commits последними. Невозможно в таком виде: createRepo создаёт пустой bare-репозиторий без начального commit во всех 3 provider (auto_init: false — «подготовлен» должно означать одно и то же во всех), а ref без единого commit не может быть источником createBranch. Два commit в main (шаблон PR, branching-policy.md) должны идти ПЕРВЫМИ — именно они дают main её первый commit. Реальный порядок выполнения (BOOTSTRAP_STEP_SEQUENCE/BOOTSTRAP_STEPS, поддерживаемые синхронными): commit_pr_template → commit_branching_policy → create_dev_branch → create_qa_branch → create_rc_branch → protect_branches. Каскад веток — dev←main, qa←dev, rc←qa (моё значение по умолчанию — запрос не уточняет источник qa/rc; следует pipeline продвижения dev→qa→rc→main, описанному в CLAUDE.md). Enum bootstrap_step в схеме (порядок объявления) не пришлось менять — он никогда не сравнивается по порядку, только на равенство.

6. Без 409 при повторной подготовке уже сошедшегося проекта. Предыдущая версия ProvisionRepositoryUseCase бросала ConflictException, если для проекта уже был репозиторий. Сохранение этого guard сломало бы идемпотентность: «запуск сценария использования N раз сходится без ошибки» (пункт 2 запроса) несовместимо с «2-й вызов бросает 409». Решено: никакого guard конфликта в этом смысле — уже сошедшийся проект лишь заставляет все 6 check() сообщить «выполнено» (чистый пропуск), никогда не ошибку.

7. Учётные данные github/gitlab на пользователя (PAT), а не на проект (OAuth). Предыдущая версия получала токен через project_git_connections (OAuth-подключение на проект, используемое устаревшим createRepository). Новый контракт (createRepo и т. д.) ожидает PAT (token: в конструкторе Gitbeaker — PRIVATE-TOKEN), а не OAuth-токен (oauthToken: — Authorization: Bearer); они не взаимозаменяемы (docs/adr/0004). Решено: учётные данные теперь берутся из UserCredentialRepository (PAT, зарегистрированный пользователем, см. docs/adr/0004), расшифровываемый напрямую (голая строка, без JSON-обёртки {accessToken,refreshToken} потока OAuth).

8. Устаревший контракт GitProvider/createRepository выведен из употребления. Подтверждено через grep, что ProvisionRepositoryUseCase был единственным потребителем .createRepository() во всём src/ — GitProviderRegistry теперь возвращает GitProviderContract напрямую. Закрывает «явный долг», зафиксированный в docs/adr/0001.

Последствия​

  • Никакого столбца статуса в projects — provisioning|provisioned| provision_failed выводится исключительно из repo_bootstraps (repo-bootstrap-status.ts, та же framework-free философия, что у action-state-machine.ts), что избегает двух источников истины.
  • repo_bootstraps не использует SELECT ... FOR UPDATE — конкурентная подготовка для ОДНОГО И ТОГО ЖЕ проекта в этой сессии не сериализуется (то же неявное упрощение, которое уже делал старый guard findByProjectId). Если это станет реальной проблемой, будущее решение касается оптимистичной/пессимистичной блокировки строки, а не перепроектирования курсора.
  • Реальный риск, не реализованный в этой сессии: GithubProvider.protectBranch зашивает enforce_admins: true, что заблокировало бы commits в main, если бы порядок был обратным (защитить перед commit), — здесь это не так (commits идут первыми), но реальный GitHub с main, уже защищённой вне bootstrap, мог бы отклонить административный push позже. Вне рамок этой сессии (критерий приёмки проверен против LocalGitProvider, без понятия защиты).
  • apps/api/scripts/demo-repo-bootstrap.ts демонстрирует критерий приёмки от начала до конца: подготавливает с LocalGitProvider, внедряет сбой во 2-й вызов createBranch (наблюдаемый эквивалент убийства процесса на шаге 4 из 6), запускает снова, сходится и печатает полный event log, показывающий bootstrap.step_skipped шагов 1-3 при возобновлении.