Andrey Epifancev ec743d57e8 Архив OpenSpec: hub-backend-fixes (#129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:20:21 +04:00

79 KiB
Raw Permalink Blame History

hub-api Specification

Purpose

HTTP API хаба sdlc-hub serve: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN.

Requirements

Requirement: Процесс и доступ

sdlc-hub serve SHALL запускать один процесс: HTTP на 0.0.0.0:<port> (по умолчанию 8790), база и настройки в ~/.local/share/sdlc-hub/ (переопределяется SDLC_HUB_HOME). Все маршруты API SHALL требовать Authorization: Bearer <token>: токен агента модуля открывает только /agent/* (маршруты ядра — от имени этого агента, маршруты модуля со scope agent — только агенту своего модуля, см. modules, «Агенты модулей»), клиентский — всё остальное. sdlc-hub tokens SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение — раздача веб-приложения (GET /app, GET /app/…) и перенаправления GET /, GET /links: это статические файлы без данных, они отдаются без токена. Ответы API — JSON, ошибки — {error, detail} с кодами 400 (неверное тело или Content-Length), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки дополнительно отвечает 502. Методы, кроме GET, POST, PUT и PATCH, отвечают 501 стандартной страницей http.server, не JSON.

Scenario: Чужой токен

  • WHEN токеном агента jira вызван GET /issues
  • THEN 401

Scenario: Неподдерживаемый метод

  • WHEN DELETE /issues/AURORA-1
  • THEN 501, тело не JSON; база не меняется

Scenario: Страница без токена

  • WHEN GET /app/board без заголовка Authorization, сборка приложения есть
  • THEN 200, Content-Type: text/html; charset=utf-8, тело — index.html сборки; данных зеркала в нём нет

Scenario: Токены после миграции

  • WHEN база v6 с агентским токеном T мигрировала в v7, запущен sdlc-hub tokens
  • THEN вывод содержит клиентский токен, jira: T и новый токен wiki

Requirement: Маршруты агента

Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):

  • POST /agent/claim {agent_version, settings_version, last_jira_call_at, update_state, launcher_version} — долгий запрос до 30 с; ответ {command: {id, type, params, pause_seconds}, settings_version, agent_release} или {command: null, settings_version, agent_release}, где agent_release — {version, sha256} опубликованной версии агента или null; обновляет agent_status (last_jira_call_at = null не затирает прежнее значение). update_state (объект или null, error хранится до 500 символов) и launcher_version (целое или null) необязательны; хаб SHALL хранить последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая claim. Если есть команда в claimed, claim SHALL выдать её снова (повторная выдача, см. commands), а не следующую из очереди.
  • GET /agent/settings — настройки для агента (без токенов).
  • GET /agent/commands/{id} — команда по id (для восстановления из журнала), включая pause_seconds.
  • POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited} — идемпотентно: повторный отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта done с result.key по ensure_issue в cancelled или failed без результата (результат записывается, статус не меняется, заметка new:N переносится на ключ). Отчёт по команде в queued SHALL отвергаться 409 not_claimed. error хаб SHALL обрезать до 1500 символов.
  • POST /agent/state {command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page} — загрузка состояния пачками; neighbor_keys — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и sync_run. Каждая пачка применяется независимо; page и last_page необязательны и хабом не используются. Поле задачи comments необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, id — непустая строка, body — строка, author, created, updated — строка или null; нарушение — 422 bad_state с ключом задачи и именем поля, пачка не применяется. Поле задачи attachments (агент v14) необязательно и проверяется так же: список объектов, id — непустая строка, filename — непустая строка, size — неотрицательное целое или null, mime_type, author, created — строка или null.
  • GET /agent/known-keys — ключи зеркала (для определения соседей).
  • GET /agent/release — опубликованная версия агента {version, sha256, content} (content — текст agent.py); нет публикации или файл не сходится с манифестом по sha256 — 404 no_release.
  • GET /agent/files/{sha256} — файл вложения из хранилища хаба {file, size, content} (content — base64 содержимого); файла нет — 404 no_file.
  • POST /agent/files {content} — файл вложения, скачанный агентом из Jira, в хранилище хаба; ответ {file, size}; проверки и ошибки — как у POST /files (см. «Хранилище файлов вложений»), кроме пустого файла: от агента он принимается (в Jira бывают пустые вложения).

Scenario: Выдача с подстановкой ключа

  • WHEN следующая команда ссылается на new:60, а ensure_issue для new:60 завершилась с AURORA-1590
  • THEN claim отдаёт команду с AURORA-1590 в параметрах

Scenario: Повторный отчёт

  • WHEN агент дважды прислал result для команды 15
  • THEN второй ответ 200, статус и результат не изменились

Scenario: Отчёт по невыданной команде

  • WHEN POST /agent/commands/16/result с status = done, команда 16 в queued
  • THEN 409 not_claimed, команда осталась queued

Scenario: Длинная ошибка

  • WHEN агент прислал failed с error длиной 5000 символов
  • THEN в command.error первые 1500 символов

Scenario: Поздний отчёт после предела выдач

  • WHEN ensure_issue {temp_key: new:8, …} переведена хабом в failed «агент не отчитался после 3 выдач», затем агент прислал done с result.key = AURORA-1630
  • THEN команда failed, в result — AURORA-1630; заметка new:8 перенесена на AURORA-1630

Scenario: Публикация в ответе claim

  • WHEN опубликована v6, агент v5 отправил claim, команд нет
  • THEN ответ {command: null, settings_version, agent_release: {version: 6, sha256: …}}

Scenario: Состояние обновления от агента

  • WHEN агент прислал в claim update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"} и launcher_version = 1
  • THEN GET /agent отдаёт это update_state, launcher_version = 1 и update_hint «откат с v7 на v6: код 1 через 3 с»

Scenario: Скачивание версии

  • WHEN агентским токеном GET /agent/release, опубликована v6
  • THEN 200 {version: 6, sha256, content}, sha256 от content в UTF-8 равен sha256; клиентским токеном — 401; без публикации — 404 no_release

Scenario: Скачивание файла вложения

  • WHEN агентским токеном GET /agent/files/<sha>, файл загружен
  • THEN 200 {file: <sha>, size, content}, sha256 от декодированного content равен <sha>; клиентским токеном — 401; файла нет — 404 no_file

Scenario: Неверный комментарий в пачке

  • WHEN POST /agent/state с задачами X и Y, у Y комментарий {id: "7", body: 42}
  • THEN 422 bad_state с ключом Y и полем body; ни X, ни Y в зеркале не изменились

Requirement: Маршруты интерфейсов

Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном:

  • GET /issues с фильтрами project (разрез по проекту, см. data-model, «Проекты»: свои и соседи, neighbor относительно проекта; неизвестный проект — 404), sprint, assignee, work_kind, status, label, epic, neighbor, q (подстрока темы), hide_closed (1 / true — без задач, чей status входит в настройку closed_statuses; задача без статуса не закрыта), limit/offset — строки зеркала (все поля issue) с work_kind из заметок; параметр fact_status хаб игнорирует, как любой неизвестный; сокращение до карточки — дело интерфейса. Без project — всё зеркало, neighbor — признак зеркала (вне базовой JQL).
  • GET /issues/{key} — задача целиком: поля, epic_summary (тема эпика из зеркала или null), связи в обе стороны (links_out: [{target_key, link_type, summary, status}], links_in: [{source_key, link_type, summary, status}], где summary и status — связанной задачи из зеркала или null, если её там нет), заметки, последние 10 команд по ней (без fetch_attachment), comments: [{comment_id, author, created, updated, body, attachment_ids}] — все комментарии, новые сверху (по created, затем comment_id, по убыванию); attachments: [{attachment_id, filename, size, mime_type, author, created, state, error}] — все вложения задачи, новые сверху (по created_at, затем attachment_id, по убыванию), state и error — см. data-model, «Скачанные вложения» (error — только при failed, иначе null); description_attachment_ids и attachment_ids комментария — id вложений, на которые ссылается текст (см. «Ссылки на вложения в тексте»), в порядке первого упоминания, без повторов.
  • PUT /issues/{key}/note — заметки (частичное обновление, updated_by из тела); поле вне списка заметок (в том числе fact_status) — 422 «неизвестные поля заметки: …», заметка не меняется.
  • POST /batches {title, author, project?, commands: [...]} → {id, estimate_seconds}; 422 при неизвестном типе, неверных параметрах (в том числе неверного типа значения), временном ключе без ensure_issue, двух и более ensure_issue с одинаковой summary (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы), по проверкам постановки из commands (тип bug без настройки, файл вложения не загружен, доска для ensure_sprint), а также по проверкам пакета проекта (см. commands), если передан project.
  • GET /attachments/{id}, POST /attachments/{id}/fetch, GET /attachments/{id}/content — вложения задач (см. «Скачивание вложений»).
  • POST /files {content} (content — base64 содержимого файла) → {file, size}, где file — sha256 содержимого (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений».
  • GET /batches?limit=&project= (с project — только пакеты этого проекта), GET /batches/{id} (с командами и project), POST /batches/{id}/cancel — отменяет queued команды пакета; ответ — вид пакета с полем still_claimed: список {id, type} команд пакета в claimed (они будут выполнены агентом; остановить — отмена команды с force).
  • POST /commands/{id}/retry {author?, project?} — новая команда с теми же параметрами в новом пакете «Повтор команды N»; хаб снимает с параметров то, что дописывает сам (board_id у ensure_sprint), и проверяет команду заново. project передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не передан — новый пакет получает проект исходного и проходит его проверки.
  • POST /commands/{id}/cancel — только для queued; для claimed — 409, если не передан force: true.
  • POST /refresh {keys | jql | full: true} → пакет из одного refresh; full — по базовой JQL из проектов, без проектов — 422 «нет проектов».
  • GET /agent, GET /sync-runs?limit=, GET /settings, PUT /settings (частично; поднимает settings_version; ответ — все настройки после записи, как у GET /settings, включая settings_version).

Scenario: Фильтр по спринту

  • WHEN GET /issues?sprint=Спринт 2
  • THEN только задачи с таким sprint, отсортированы по сроку, затем по ключу

Scenario: Список проекта

  • WHEN свои задачи STOCKS_DRRK — AURORA-1 и AURORA-4, сосед — AURORA-2; GET /issues?project=STOCKS_DRRK
  • THEN три задачи, у AURORA-2 neighbor = true; с neighbor=1 — только AURORA-2; задач только с меткой AWPCA нет

Scenario: Неизвестный проект

  • WHEN GET /issues?project=NOPE
  • THEN 404

Scenario: Пакеты проекта

  • WHEN пакет 10 поставлен с project = STOCKS_DRRK, пакет 11 — без проекта; GET /batches?project=STOCKS_DRRK
  • THEN в ответе только пакет 10

Scenario: Повтор чужой команды

  • WHEN команда 40 из пакета проекта AWPCA; POST /commands/40/retry {project: "STOCKS_DRRK"}
  • THEN 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан

Scenario: Повтор ensure_sprint

  • WHEN ensure_sprint 41 failed, в её params board_id = 4216; POST /commands/41/retry
  • THEN новый пакет создан, board_id в новой команде вычислен хабом заново

Scenario: Полный прогон без проектов

  • WHEN проектов нет, POST /refresh {full: true}
  • THEN 422 «нет проектов», пакет не поставлен

Scenario: Отмена взятой команды

  • WHEN POST /commands/15/cancel без force, команда claimed
  • THEN 409 с claimed_at; с force: true — cancelled, следующий отчёт агента по ней принимается, статус не меняется

Scenario: Поздний отчёт по отменённому созданию

  • WHEN ensure_issue {temp_key: new:7, …} отменена с force: true, затем агент прислал done с result.key = AURORA-1620
  • THEN команда cancelled, в result — AURORA-1620; заметка new:7 перенесена на AURORA-1620

Scenario: Дубль темы в пакете

  • WHEN POST /batches с двумя ensure_issue, у обеих summary = "Шахматка: комментарии"
  • THEN 422 с темой «Шахматка: комментарии», пакет не создан

Scenario: Тема не строка

  • WHEN POST /batches с ensure_issue {temp_key: new:1, summary: 42, …}
  • THEN 422 с указанием параметра summary, пакет не создан

Scenario: Отмена пакета с командой у агента

  • WHEN в пакете 9 команда 30 claimed, команды 31 и 32 queued; POST /batches/9/cancel
  • THEN 31 и 32 cancelled; в ответе still_claimed = [{id: 30, type: …}]; 30 остаётся claimed

Scenario: Ответ на запись настроек

  • WHEN PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}
  • THEN 200; ответ содержит relay_dir с записанным значением, новую settings_version и остальные настройки; токенов в ответе нет

Scenario: Карточка с комментариями и связями

  • WHEN у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик»
  • THEN GET /issues/AURORA-9001 отдаёт comments с комментарием от 15.10 первым, у связи на AURORA-9002 — summary = "API свода", status = "Новая", у связи на AURORA-1 — summary = null, status = null; epic_summary = "Тестовый эпик"

Scenario: Без закрытых

  • WHEN closed_statuses = ["Готово", "Снято"], в зеркале задачи в статусах «В работе», «Готово», «Снято» и без статуса; GET /issues?hide_closed=1
  • THEN в ответе задачи «В работе» и без статуса; без hide_closed — все четыре

Requirement: Резервные копии

Перед выдачей первой команды пакета, содержащего записи в Jira (любая команда, кроме refresh и fetch_attachment), хаб SHALL скопировать файл базы в backups/<дата-время>-<batch>.sqlite и хранить последние backup_keep копий.

Scenario: Ротация

  • WHEN копий больше backup_keep
  • THEN самые старые удаляются до backup_keep

Scenario: Скачивание без копии

  • WHEN выдаётся fetch_attachment пакета «Скачать вложение …»
  • THEN копия базы не создаётся

Requirement: Автообновление зеркала

Если auto_refresh_minutes > 0, агент на связи и есть хотя бы один проект, хаб SHALL поставить пакет «Автообновление» из refresh {jql: <базовая JQL из проектов>}, когда в очереди нет команд в queued и claimed и последний sync_run начат раньше, чем auto_refresh_minutes назад (или sync_run ещё нет).

Scenario: Очередь занята

  • WHEN интервал прошёл, но в очереди есть queued команды
  • THEN автообновление не ставится до опустошения очереди

Scenario: Есть выданная команда без отчёта

  • WHEN интервал прошёл, queued нет, одна команда в claimed
  • THEN автообновление не ставится; команда довыдаётся при следующем claim

Scenario: Недавнее обновление

  • WHEN очередь пуста, auto_refresh_minutes = 60, последний sync_run начат 20 минут назад
  • THEN автообновление не ставится

Scenario: Два проекта

  • WHEN интервал прошёл, очередь пуста, проекты AWPCA и STOCKS_DRRK
  • THEN поставлен один refresh с JQL project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")

Requirement: Реле через общую папку

sdlc-hub serve SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка relay_dir пуста (null), реле бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если каталог relay_dir существует, реле SHALL создать в нём подкаталоги req/, resp/, work/; если не существует — записать одно предупреждение в лог и проверять снова.

Обмен:

  • запрос агента — файл req/<id>.json: {id, method, path, body, authorization, deadline}, где deadline — момент UTC (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
  • реле SHALL брать запрос переименованием в work/<id>.json и обрабатывать каждый взятый запрос в отдельном потоке;
  • запрос с истёкшим deadline SHALL выбрасываться без ответа; POST /agent/claim SHALL пересылаться, только если до deadline осталось не меньше 35 с, иначе выбрасываться без ответа;
  • остальные запросы реле SHALL пересылать в свой HTTP на <host>:<port> процесса serve (127.0.0.1, если serve слушает 0.0.0.0) тем же методом, путём и телом, с заголовком Authorization из файла, и записывать ответ resp/<id>.json = {id, status, body} (тело — строка ответа HTTP) через временный файл с именем на точку и переименование; затем удалять work/<id>.json;
  • если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь status = 502 и текст ошибки в body;
  • нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.

При первой подготовке каждой папки после старта реле (в том числе новой папки после смены relay_dir) реле SHALL удалить файлы из work/ с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL только создавать недостающие подкаталоги, не трогая work/. Раз в минуту реле SHALL удалять ответы в resp/ старше 10 мин, запросы в req/ с deadline старше 60 с и файлы с именем на точку старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку relay <METHOD> <path> <status> <секунды>. HTTP API, обработчики и проверка токенов при этом не меняются.

Scenario: Запрос через папку

  • WHEN в req/ появился a1.json с GET /agent/settings, агентским токеном и deadline через 60 с
  • THEN в resp/ появляется a1.json со status = 200 и телом настроек; work/a1.json удалён

Scenario: Чужой токен через папку

  • WHEN в файле запроса GET /issues с агентским токеном
  • THEN ответ со status = 401, как по HTTP

Scenario: Просроченный claim

  • WHEN реле взяло POST /agent/claim, до deadline которого осталось 20 с
  • THEN запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась queued

Scenario: Долгий claim не задерживает другие запросы

  • WHEN реле пересылает claim, который ждёт команду 30 с, и в это время в req/ появляется GET /agent/settings
  • THEN ответ на settings пишется, не дожидаясь claim

Scenario: Папка не подключена

  • WHEN relay_dir задан, но каталога нет; через минуту каталог появился
  • THEN в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы

Scenario: Сбой протокола своего HTTP

  • WHEN при пересылке запроса свой HTTP оборвал ответ (http.client.HTTPException)
  • THEN в resp/ ответ со status = 502 и текстом ошибки; реле продолжает работу

Scenario: Повторная подготовка не трогает взятые запросы

  • WHEN реле работает, в work/ лежит взятый запрос, и пропал подкаталог resp/
  • THEN реле создаёт resp/ заново; файл в work/ не удалён, в логе нет «удалён незавершённый запрос»

Requirement: Снимок доски

Хаб SHALL предоставлять GET /board?project= под клиентским токеном — согласованный снимок зеркала для доски связей, прочитанный в одной транзакции:

  • generated_at — момент снимка (UTC);
  • last_sync — последний завершённый полный прогон (refresh по текущей базовой JQL из проектов): {started_at, finished_at, issues_count} или null, если такого не было; общий для всех проектов;
  • issues — с project — свои задачи проекта и его соседи (см. data-model, «Проекты»), neighbor относительно проекта; без project — все задачи зеркала, включая эпики и соседей, neighbor — признак зеркала. Поля: key, summary, issue_type, status, priority, assignee, sprint, duedate, planned_start, planned_end, epic_key, labels (список), neighbor (bool), work_kind (из заметки, null если заметки нет); порядок — по ключу;
  • links — с project — связи, у которых хотя бы один конец — своя задача проекта; без project — все связи зеркала: {source, target, link_type}, link_type — имя типа в Jira, направление как в issue_link (source блокирует/ссылается на target). Неизвестный проект — 404. Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело интерфейса; хаб отдаёт данные зеркала без преобразований.

Scenario: Снимок с задачами и связью

  • WHEN в зеркале эпик AURORA-1351, задачи AURORA-1620 (метка kind-dev, эпик AURORA-1351) и AURORA-1621, связь AURORA-1620 → AURORA-1621 типа ASUPR_Блокер_Начала, заметка work_kind = "разработка" у AURORA-1620
  • THEN GET /board отдаёт три задачи по ключу, у AURORA-1620 labels = ["kind-dev"], epic_key = "AURORA-1351", work_kind = "разработка"; в links — {source: "AURORA-1620", target: "AURORA-1621", link_type: "ASUPR_Блокер_Начала"}

Scenario: Снимок проекта

  • WHEN AURORA-1 (метка STOCKS_DRRK) связана с AURORA-2 (метка AWPCA), AURORA-2 связана с AURORA-3 (метка AWPCA); GET /board?project=STOCKS_DRRK
  • THEN issues — AURORA-1 (neighbor = false) и AURORA-2 (neighbor = true); links — только AURORA-1 ↔ AURORA-2

Scenario: Последний полный прогон

  • WHEN в журнале синхронизаций полный прогон, завершённый в 10:00, и после него обновление по ключам в 11:00
  • THEN last_sync.finished_at — 10:00

Scenario: Без токена

  • WHEN GET /board без токена или с агентским токеном
  • THEN 401

Requirement: Публикация версии агента

sdlc-hub agent publish [--module <модуль>] PATH SHALL публиковать файл агента модуля (по умолчанию jira; неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ, ровно одна строка AGENT_VERSION = <целое>, версия больше опубликованной для этого модуля. Файл SHALL записываться в <SDLC_HUB_HOME>/release/<модуль>/agent.py (каталоги 0700), затем manifest.json {version, sha256, size, published_at}; оба — через временный файл с fsync и переименование, манифест последним. Прежний agent.py на время публикации SHALL переименовываться в agent.py.prev и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (OSError), хаб SHALL вернуть agent.py.prev на место agent.py, удалить временные файлы и завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного serve и печатать «опубликована v, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести прежнюю публикацию release/agent.py и release/manifest.json в release/jira/, если release/jira/ ещё нет; ошибка переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом claim, GET /agent/release и GET /agent; манифест годен, только если это объект, version — целое число (не логическое) не меньше 1, sha256 — строка из 64 символов [0-9a-f]; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый текст ошибки. Файл agent.py, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет» (GET /agent/release — 404 no_release). GET /agent?module=<модуль> (без параметра — jira) и каждый элемент GET /agents SHALL дополняться полями release ({version, sha256, published_at} или null), update_state, launcher_version, agent_reported (был ли claim этого агента с момента запуска хаба) и update_hint: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент онлайн, agent_reported = true, launcher_version = null), «откат с v на v: » (rolled_back), «v отвергнута: » (rejected), иначе null.

Scenario: Публикация новой версии

  • WHEN опубликована v5, sdlc-hub agent publish jira-agent/src/agent.py с AGENT_VERSION = 6
  • THEN в release/jira/ — новый agent.py и манифест с version = 6, agent.py.prev нет; вывод «опубликована v6, sha256 …»; код 0

Scenario: Публикация eva-agent

  • WHEN sdlc-hub agent publish --module wiki eva-agent/src/agent.py с AGENT_VERSION = 1, опубликована jira v14
  • THEN в release/wiki/ — v1; claim агента wiki получает agent_release {version: 1, …}, claim агента jira — {version: 14, …}

Scenario: Неизвестный модуль

  • WHEN sdlc-hub agent publish --module nope agent.py
  • THEN код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; release/ не изменён

Scenario: Версия не выше опубликованной

  • WHEN опубликована v6, публикуется файл с AGENT_VERSION = 6
  • THEN «опубликована v6, новая должна быть больше», код 1; release/ не изменён

Scenario: Нет строки версии

  • WHEN публикуется файл без строки AGENT_VERSION = <целое> или с двумя такими строками
  • THEN сообщение об ошибке, код 1; release/ не изменён

Scenario: Диск полон при записи манифеста

  • WHEN опубликована v8, публикуется v9, запись manifest.json завершилась OSError
  • THEN «не удалось записать публикацию: …», код 1, без трейсбека; agent.py и манифест — прежние v8, GET /agent/release отдаёт v8; временных файлов и agent.py.prev в release/jira/ нет

Scenario: Негодный манифест

  • WHEN в manifest.json version = 6.9 (или true, или sha256 короче 64 символов), хаб получает 100 claim
  • THEN в claim agent_release = null, GET /agent/release — 404; в логе одно предупреждение о манифесте

Scenario: Агент без запускателя

  • WHEN опубликована v6, агент v5 онлайн, в последнем claim launcher_version = null
  • THEN GET /agent → agent_reported = true, update_hint = «агент запущен без run.py — обновится только вручную»

Scenario: Хаб перезапущен, агент ещё не выходил на связь

  • WHEN опубликована v10, агент v9 под запускателем, хаб перезапущен, claim после запуска ещё не было
  • THEN GET /agent → agent_reported = false, launcher_version = null, update_hint = null; после первого claim — agent_reported = true, launcher_version из claim

Scenario: Перенос прежней публикации

  • WHEN хаб с release/agent.py v14 и манифестом без release/jira/ запущен на новом коде
  • THEN публикация v14 лежит в release/jira/, claim агента jira получает agent_release {version: 14, …}

Requirement: Раздача веб-приложения

Хаб SHALL отдавать собранное веб-приложение (спека web-app) из каталога пакета src/sdlc_hub/web/app/, читая файлы с диска на каждый запрос:

  • GET /app/assets/<путь> — файл из assets/ сборки; путь, который после разрешения выходит за assets/, не существует или содержит нулевой байт, — 404; Content-Type по расширению (.js — text/javascript; charset=utf-8, .css — text/css; charset=utf-8, .svg — image/svg+xml, .png — image/png, .ico — image/x-icon, .woff2 — font/woff2, .map — application/json, прочее — application/octet-stream); Cache-Control: public, max-age=31536000, immutable;
  • любой другой GET /app или GET /app/… — index.html сборки, text/html; charset=utf-8, Cache-Control: no-cache;
  • если index.html сборки нет — на любой GET /app… 503 text/html; charset=utf-8 с текстом, что приложение не собрано и нужно выполнить make web в каталоге sdlc-hub;
  • GET / и GET /links — 302 с Location: /app/ (главная страница приложения — «Обзор»). Сборка приложения не хранится в git; её создаёт make web.

Scenario: Файл сборки

  • WHEN в сборке есть assets/index-abc123.js, запрошен GET /app/assets/index-abc123.js
  • THEN 200, Content-Type: text/javascript; charset=utf-8, Cache-Control с immutable, тело — содержимое файла

Scenario: Путь приложения

  • WHEN запрошен GET /app/issues/AURORA-1620
  • THEN 200, тело — index.html сборки, Cache-Control: no-cache

Scenario: Выход за каталог сборки

  • WHEN запрошен GET /app/assets/../../api.py (в том числе в виде %2e%2e)
  • THEN 404, содержимое файлов вне assets/ не отдаётся

Scenario: Приложение не собрано

  • WHEN каталога сборки нет, запрошен GET /app/board
  • THEN 503, тело — HTML с подсказкой make web

Scenario: Старый адрес доски

  • WHEN запрошен GET /links или GET /
  • THEN 302, Location: /app/

Scenario: Нулевой байт в пути

  • WHEN запрошен GET /app/assets/%00 или GET /app/assets/a%00.js
  • THEN 404 not_found, а не 500

Requirement: Хранилище файлов вложений

Хаб SHALL хранить файлы вложений в каталоге files/ внутри каталога данных, по одному файлу на содержимое с именем sha256 (без расширения и имени вложения: имя задаёт команда). Каталог SHALL иметь права 0700 (выставляются при создании и при каждом старте, как у backups/). POST /files:

  • content не строка или не base64 — 400 bad_request; пустой файл — 422 empty_file;
  • файл больше 10 МБ (10 485 760 байт) после декодирования — 413 too_large (тело запроса до 16 МБ проходит);
  • запись атомарная: временный файл в files/, затем переименование; повторная загрузка того же содержимого отвечает тем же file и не создаёт второй копии. Файлы в бэкапы базы MUST NOT входить. Фоновый tick хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по времени изменения), на которые не ссылается ни одна команда ensure_attachment в queued или claimed (скачанные вложения attachment_file от уборки не защищены: их можно скачать снова), и подкаталоги downloads/<attachment_id>/ (копии MCP, см. mcp) старше 30 дней по времени изменения; повтор команды после уборки отвергается при постановке 422 «файл не загружен: POST /files». Повторная загрузка того же содержимого SHALL обновлять время изменения файла; выдача файла GET /attachments/{id}/content — тоже.

Scenario: Загрузка файла

  • WHEN POST /files {content: base64("abc")}
  • THEN 200 {file: "ba7816bf…", size: 3}; в files/ файл ba7816bf… с содержимым abc

Scenario: Повторная загрузка

  • WHEN тот же POST /files второй раз
  • THEN 200 с тем же file; в files/ по-прежнему один файл

Scenario: Слишком большой файл

  • WHEN POST /files с файлом 11 МБ
  • THEN 413 too_large, в files/ ничего не появилось

Scenario: Уборка старых файлов

  • WHEN в files/ файл A старше 30 дней без ссылок, файл B старше 30 дней, на него ссылается ensure_attachment в queued, файл C загружен вчера
  • THEN после уборки удалён только A

Scenario: Скачанное вложение и уборка

  • WHEN в files/ файл D скачан агентом (attachment_file) 40 дней назад и с тех пор не выдавался; в downloads/ каталог 31337/ создан 40 дней назад
  • THEN после уборки D и downloads/31337/ удалены; state вложения — none

Requirement: Маршруты проектов

Хаб SHALL предоставлять под клиентским токеном:

  • GET /projects → список проектов по created_at, затем label; каждый — плоский объект из полей ядра (label, name, created_at) и полей всех модулей (modules, «Поля проекта от модулей»). Модуль jira даёт board_id, board_type, issues_count (задачи зеркала с меткой проекта), jira_synced_at (время завершения последнего полного обновления зеркала, общее для всех проектов; null, если его не было), модуль wiki — wiki_root, wiki_root_title, wiki_pages, wiki_synced_at, wiki_sync_error.
  • POST /projects {label, name, author?, <поля модулей>} → 201 {project, batch_id, batches}: в одной транзакции проект записывается, модули записывают свои поля и ставят свои пакеты; batches — список {module, id} всех поставленных пакетов в порядке модулей; batch_id — пакет первого модуля, вернувшего пакет (у jira — «Новый проект <метка>» из одного refresh по базовой JQL, уже включающей новую метку). Автор пакетов — author (по умолчанию api). Неизвестное поле, неверные поля ядра (см. data-model, «Проекты») или модуля — 422 с указанием поля; метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакетов нет.
  • PATCH /projects/{label} {<изменяемые поля модулей>, author?} → 200 {project, batch_ids, batches}: модули правят свои поля и ставят пакеты в одной транзакции; batches — список {module, id}. Неизменяемое поле (метка, название, board_id) — 422 «<поле> после создания не меняется»; неизвестное поле или неверное значение — 422 с указанием поля; проекта нет — 404.

Scenario: Новый проект

  • WHEN POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}, есть проект AWPCA
  • THEN 201; GET /projects отдаёт два проекта, у STOCKS_DRRK board_type = null, wiki_root = null; в очереди пакет автора viewer с refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}

Scenario: Проект без доски

  • WHEN POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}
  • THEN 201, board_id = null

Scenario: Проект с корнем вики

  • WHEN POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234"}
  • THEN 201; в очереди пакет refresh и пакет wiki_sync {label: "STOCKS_DRRK", root: "DOC-001234"}; batch_id — пакет refresh; batches — [{module: "jira", id: <refresh>}, {module: "wiki", id: <wiki_sync>}]

Scenario: Повтор метки

  • WHEN POST /projects {label: "AWPCA", name: "…"}, проект AWPCA есть
  • THEN 409, пакет не поставлен

Scenario: Без названия

  • WHEN POST /projects {label: "STOCKS_DRRK", name: " "}
  • THEN 422 с указанием поля name

Scenario: Неизвестное поле

  • WHEN POST /projects {label: "X", name: "X", board: 5}
  • THEN 422 с указанием поля board, проекта нет

Scenario: Корень вики у существующего проекта

  • WHEN PATCH /projects/AWPCA {wiki_root: "DOC-000777"}
  • THEN 200, batch_ids — один пакет wiki_sync, batches — [{module: "wiki", id: <он же>}]; GET /projects отдаёт AWPCA с wiki_root = DOC-000777

Scenario: Доску не меняют

  • WHEN PATCH /projects/AWPCA {board_id: 1}
  • THEN 422 «board_id после создания не меняется», доска прежняя

Scenario: Свежесть Jira в проекте

  • WHEN последний полный пакет refresh завершён в 11:07, GET /projects
  • THEN у каждого проекта jira_synced_at — это время; до первого полного обновления — null

Requirement: Приём журнала изменений

Хаб SHALL принимать от агента журнал изменений задач маршрутом POST /agent/history (агентский токен) с телом {command_id, changes: [...]}, элемент — {history_id, seq, key, created, author, field_id, field_name, from, from_string, to, to_string}. До записи хаб SHALL проверить форму всей пачки: changes — список объектов; history_id, key, created, field_name — непустые строки; seq — целое ≥ 0; author, field_id, from, from_string, to, to_string — строка или null. Нарушение — 422 bad_history с номером элемента и именем поля, пачка не применяется. Принятая пачка записывается в issue_change в одной транзакции (см. data-model, «Журнал изменений задач»); ответ — {accepted, duplicates} (записано новых строк, отброшено повторов). Задача элемента может отсутствовать в зеркале — элемент всё равно записывается.

Scenario: Пачка журнала

  • WHEN POST /agent/history с тремя элементами, один из них уже есть в issue_change
  • THEN 200 {accepted: 2, duplicates: 1}

Scenario: Неверный элемент

  • WHEN POST /agent/history с двумя элементами, у второго seq = "0"
  • THEN 422 bad_history с номером элемента 1 и полем seq; ни один элемент не записан

Scenario: Клиентский токен

  • WHEN POST /agent/history клиентским токеном
  • THEN 401

Requirement: Лента изменений

Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом GET /changes с параметрами:

  • since, until — ISO-8601 со смещением или Z (время без смещения — 422); без until — текущее время; без since — until минус 24 часа;
  • project — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404;
  • key — события одной задачи (без ограничения проектом);
  • kinds — через запятую из status, description, dates, comment, other, или all; по умолчанию — status,description,dates,comment;
  • limit — по умолчанию 500, не больше 2000.

События: строки issue_change с at в [since, until] — вид status (поле status), description (поле description), dates (поля duedate, planned_start, planned_end), other (прочие поля); и комментарии issue_comment с created_at в [since, until] — вид comment. Ответ — {since, until, truncated, changes: [...]}, элемент — {at, key, summary, status, kind, field, author, from, to, from_value, to_value}: summary и status — текущие из зеркала (null, если задачи в зеркале нет); from/to — from_string/to_string строки журнала, а если строка null — from_value/to_value; from_value/to_value — сырые значения строки журнала; у комментария field = "comment", from = null, to — текст комментария, from_value = to_value = null. Порядок — по at по убыванию, затем key, history_id, seq (комментарии — comment_id). Событий больше limit — отдаются первые limit по этому порядку и truncated = true. Неверное время, since позже until, неизвестный вид, limit вне 1..2000 — 422 bad_request с именем параметра.

Scenario: Что закрылось за два часа

  • WHEN в 12:00 UTC GET /changes?project=AWPCA&since=2026-10-22T10:00:00Z&kinds=status; в журнале — X (метка AWPCA) «В работе» → «Готово» в 11:15 UTC и Y (метка STOCKS_DRRK) в 11:20 UTC
  • THEN одно событие: X, kind = status, from = "В работе", to = "Готово", автор и at из журнала

Scenario: Комментарий в ленте

  • WHEN у X комментарий с created = "2026-10-22T13:30:00.000+0300" (10:30 UTC), запрос с since 10:00 UTC
  • THEN в ленте событие kind = comment, at = "2026-10-22T10:30:00+00:00", to — текст комментария

Scenario: Прочие поля по запросу

  • WHEN в журнале X сменился исполнитель; запрос без kinds, затем с kinds=all
  • THEN в первом ответе события нет; во втором — kind = other, field = "assignee"

Scenario: Сосед проекта не в ленте

  • WHEN Z без метки AWPCA связана с задачей AWPCA; в журнале Z сменился статус; GET /changes?project=AWPCA
  • THEN события Z нет; GET /changes?key=Z его отдаёт

Scenario: Неверный период

  • WHEN GET /changes?since=вчера
  • THEN 422 bad_request с параметром since

Requirement: Журнал в журнале синхронизаций

GET /sync-runs SHALL отдавать у каждого прогона также history_at (null — журнал по прогону не принят).

Scenario: Прогон с журналом

  • WHEN полный прогон завершился done с принятым журналом; GET /sync-runs
  • THEN у прогона history_at — время приёма; у хвостового refresh {keys} — null

Requirement: История задачи в карточке

GET /issues/{key} SHALL отдавать также changes — все строки issue_change задачи (все виды, включая other) в формате элемента ленты без summary и status, по at по убыванию (строки с at = NULL — в конце); журнала нет — changes = [].

Scenario: Задача с историей

  • WHEN у X в журнале смена статуса вчера и правка описания сегодня; GET /issues/X
  • THEN changes из двух элементов: первым — правка описания (kind = description), вторым — смена статуса

Requirement: Скачивание вложений

Хаб SHALL предоставлять интерфейсам под клиентским токеном:

  • GET /attachments/{id} — вложение из зеркала {attachment_id, key, filename, size, mime_type, author, created, state, error, command_id, batch_id}, где command_id/batch_id — последней fetch_attachment по нему (или null); нет в зеркале — 404 no_attachment.
  • POST /attachments/{id}/fetch {author?} — запросить скачивание. Нет в зеркале — 404 no_attachment; state = ready или fetching — ответ как у GET /attachments/{id}, новой команды нет; размер больше 10 МБ — 413 too_large; последний известный хабу агент (agent_status.agent_version) старше v14 — 409 agent_too_old «агент v не умеет скачивать вложения — нужен v14» (версия неизвестна — проверка не делается); иначе хаб SHALL поставить пакет из одной fetch_attachment (см. commands, автор — из тела, иначе api) и ответить как GET /attachments/{id} с state = fetching. Проверка «уже ставится» и постановка SHALL выполняться в одной транзакции записи: два одновременных запроса ставят одну команду.
  • GET /attachments/{id}/content — содержимое при state = ready: 200, тело — байты файла, Content-Type — mime_type вложения (нет или не вида тип/подтип — application/octet-stream), Content-Disposition: attachment; filename*=UTF-8''<имя в URL-кодировке>, X-Content-Type-Options: nosniff, Content-Length; иначе 404 not_fetched. Отчёт done по fetch_attachment (POST /agent/commands/{id}/result) SHALL записывать attachment_file из result.file и result.size, если файл file есть в хранилище и его размер равен size; иначе команда SHALL становиться failed «хаб: файл не найден в хранилище» и attachment_file не меняется.

Scenario: Первый запрос

  • WHEN вложение #31337 в зеркале, state = none, агент v14; POST /attachments/31337/fetch {author: "web"}
  • THEN создан пакет «Скачать вложение скрин 1.png (AURORA-1650)» из одной fetch_attachment, без хвостового refresh; ответ state = fetching с command_id и batch_id

Scenario: Повторный запрос во время скачивания

  • WHEN по #31337 команда в queued; MCP и веб одновременно вызывают POST /attachments/31337/fetch
  • THEN оба ответа — state = fetching с той же command_id; новых пакетов нет

Scenario: Файл уже у хаба

  • WHEN #31337 в state = ready; GET /attachments/31337/content
  • THEN 200, Content-Type: image/png, Content-Disposition: attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png, тело — файл; время изменения файла в files/ обновлено

Scenario: Старый агент

  • WHEN agent_status.agent_version = 13; POST /attachments/31337/fetch
  • THEN 409 agent_too_old «агент v13 не умеет скачивать вложения — нужен v14», пакета нет

Scenario: Отчёт без файла в хранилище

  • WHEN агент отчитался done {file: <sha>, size: 100}, а файла <sha> в files/ нет
  • THEN команда failed «хаб: файл не найден в хранилище»; state вложения — failed

Requirement: Ссылки на вложения в тексте

Хаб SHALL находить в описании задачи и в тексте каждого комментария ссылки на вложения задачи по вики-разметке Jira: !<имя>! и !<имя>|<параметры>! (картинка; имя без !, | и переводов строк, не содержит ://), [^<имя>] и [<текст>|^<имя>] (ссылка на вложение; имя без ] и переводов строк). Имя SHALL сравниваться с filename вложений задачи точно (с учётом регистра); несколько вложений с одним именем — ссылка указывает на самое новое по created_at (как в Jira); имени нет среди вложений — ссылка не учитывается. Ссылки внутри {code}/{noformat} тоже учитываются.

Scenario: Скриншот в комментарии

  • WHEN у задачи вложения «скрин 1.png» (#31337) и «ОСВ 60.xlsx» (#31338); комментарий 101 — «см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!»; описание ссылается на «старый.png», которого нет
  • THEN у комментария 101 attachment_ids = ["31337", "31338"]; description_attachment_ids = []

Scenario: Два вложения с одним именем

  • WHEN у задачи два вложения «скрин.png» (#10 от 14.10, #20 от 20.10); комментарий — «!скрин.png!»
  • THEN у комментария attachment_ids = ["20"]

Requirement: Каталог данных прежнего имени

Если SDLC_HUB_HOME не задан, ~/.local/share/sdlc-hub/ не существует, а ~/.local/share/jira-hub/ существует, любая команда sdlc-hub, которой нужен каталог данных (serve, mcp, tokens, init, import, agent publish), SHALL завершиться с кодом 2 и текстом в stderr: каталог данных лежит под прежним именем, перенести его — scripts/cutover.sh (или mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub при остановленном хабе). Команда MUST NOT создавать новый каталог, переносить или менять прежний. Если существуют оба каталога, используется новый, в stderr — предупреждение, что прежний можно удалить после проверки.

Scenario: Код обновлён, каталог не перенесён

  • WHEN есть только ~/.local/share/jira-hub/, запущен sdlc-hub serve
  • THEN код выхода 2, в stderr — подсказка о переносе; ~/.local/share/sdlc-hub/ не создан, прежний каталог не тронут

Scenario: Задан SDLC_HUB_HOME

  • WHEN SDLC_HUB_HOME=/tmp/x, есть ~/.local/share/jira-hub/
  • THEN команда работает с /tmp/x, проверка прежнего каталога не выполняется

Requirement: Переключение живого хаба

scripts/cutover.sh SHALL переводить живую установку Mac mini со старого имени на новое. Без --apply скрипт SHALL только печатать шаги и найденные проблемы, ничего не меняя. С --apply — по порядку, с остановкой на первой ошибке:

  1. проверки: основной checkout jira-bridge/jira-hub чистый, на main и совпадает с origin/main (владелец сделал git pull --ff-only прямо перед запуском: скрипт приходит этим pull; неотслеживаемая прежняя сборка src/jira_hub/ без tracked-файлов не считается грязью); в Gitea доступен aep/sdlc-hub (владелец переименовал репозиторий; адрес выводится из origin заменой /aep/jira-hub на /aep/sdlc-hub, протокол сохраняется, CUTOVER_REMOTE переопределяет); есть uv, make, node, npm, claude, launchctl; ~/.local/share/sdlc-hub/ и jira-bridge/sdlc-hub не существуют;
  2. остановить launchd ru.aepif.jira-hub, дождаться закрытия порта и переименовать его plist в *.pre-sdlc;
  3. перенести каталог данных ~/.local/share/jira-hub → ~/.local/share/sdlc-hub;
  4. перенести checkout jira-bridge/jira-hub → jira-bridge/sdlc-hub, удалить неотслеживаемый src/jira_hub, git worktree repair, origin → aep/sdlc-hub, пересоздать .venv (uv sync), make web;
  5. поставить и запустить launchd ru.aepif.sdlc-hub (uv run --project <checkout> sdlc-hub serve, лог ~/Library/Logs/sdlc-hub.log) и дождаться ответа хаба на порту из настроек;
  6. в проектах-потребителях (по умолчанию ~/projects/awp-ca, ~/projects/stocks_drrk и каждый ~/projects/*/.mcp.json, где есть jira-hub, например sfm_foxpro; сухой прогон печатает полный список): в .mcp.json заменить записи jira-hub → sdlc-jira (sdlc-hub mcp jira) и jira-hub-wiki → sdlc-wiki (sdlc-hub mcp wiki) с новым путём; в .claude/settings*.json — имена серверов (enabledMcpjsonServers и т. п.) и разрешения mcp__jira-hub__ → mcp__sdlc-jira__, mcp__jira-hub-wiki__ → mcp__sdlc-wiki__; в CLAUDE.md и .claude/skills/** — те же имена серверов и инструментов. Прочие файлы проектов (заметки, отчёты) не трогаются. Перед правкой файл копируется рядом с суффиксом .pre-sdlc; коммиты скрипт не делает;
  7. в user scope Claude Code заменить jira-hub на sdlc-jira (claude mcp remove/add). Каждый выполненный шаг SHALL печататься; в конце — сводка, что осталось владельцу (/mcp reconnect в открытых сессиях, codegraph init в checkout и worktree, удалить копии .pre-sdlc после проверки). Шаги откатываются в обратном порядке по инструкции из README.

Scenario: Сухой прогон

  • WHEN bash scripts/cutover.sh без аргументов
  • THEN напечатаны шаги и проверки, файловая система, launchd и конфигурации Claude не изменены

Scenario: Репозиторий в Gitea не переименован

  • WHEN bash scripts/cutover.sh --apply, aep/sdlc-hub в Gitea не найден
  • THEN скрипт останавливается на шаге 1 с подсказкой, launchd и каталоги не тронуты

Requirement: Модуль пакета

GET /batches и GET /batches/{id} SHALL отдавать у пакета поле module — имя модуля, которому принадлежит тип первой команды пакета (по описаниям модулей в реестре); пакет без команд или с типом, которого не знает ни один модуль, — module = null. Фильтр GET /batches?module=<имя> SHALL отдавать только пакеты модуля; неизвестный модуль — 404.

Scenario: Модуль у пакетов

  • WHEN в очереди пакет «Автообновление» (refresh, автор hub) и пакет wiki_sync проекта AWPCA; GET /batches
  • THEN у первого module = "jira", у второго module = "wiki"

Scenario: Фильтр по модулю

  • WHEN GET /batches?module=wiki&project=AWPCA
  • THEN только пакеты модуля wiki проекта AWPCA

Scenario: Неизвестный модуль

  • WHEN GET /batches?module=nope
  • THEN 404

Requirement: Приём досок и спринтов

POST /agent/state (агент v15) SHALL принимать необязательные поля:

  • у задачи — rank (строка или null), story_points (число или null), sprint_ids (список целых); присланные rank и story_points записываются в issue; присланный sprint_ids заменяет все строки issue_sprint задачи; задача без поля свои значения сохраняет;
  • в пачке — boards: список {id, type, columns, kanban_backlog, sprints}; columns — список {name, statuses} или null, kanban_backlog — true/false/null, sprints — список {id, name, state, start_date, end_date, complete_date, goal}. Доска из boards SHALL заменять свою строку jira_board и все строки jira_sprint этой доски; доски, которых нет в boards, не меняются. Форму хаб SHALL проверять до записи, как comments: нарушение — 422 bad_state с ключом задачи или номером доски и именем поля, пачка не применяется. state спринта вне active/future/closed, sprint_ids не список целых (в том числе null) — нарушение; rank: null и story_points: null — очистить поле. Тип доски из boards (scrum / kanban) хаб SHALL записывать и в jira_project.board_type проектов этой доски; null тип не меняет. Пачка может быть пустой (issues: []) и нести только boards.

Scenario: Пачка с досками

  • WHEN пачка refresh несёт boards: [{id: 4216, type: "scrum", columns: […], kanban_backlog: null, sprints: [101 closed, 102 active, 103 future]}], раньше у доски были спринты 101 и 102
  • THEN у доски 4216 строки jira_sprint 101, 102, 103 с присланными полями; колонки заменены

Scenario: Агент v14

  • WHEN пачка без boards, задачи без rank, story_points, sprint_ids
  • THEN данные досок и эти поля задач не меняются

Requirement: Маршрут планирования

GET /agile?project=<метка> (клиентский токен) SHALL отдавать для проекта (неизвестный — 404; без project — 422): {board, sprints, backlog, board_issues}.

  • board — {id, type, columns, kanban_backlog, fetched_at} доски проекта; доски нет — null (остальные поля — []/null); доска есть, а её данных от агента нет — {id, type, columns: null, kanban_backlog: null, fetched_at: null}.
  • sprints — все спринты доски: {id, name, state, start_date, end_date, complete_date, goal, count, count_done, unestimated, points, points_done, issues}; issues — свои задачи проекта в этом спринте (по issue_sprint); count/count_done — их число и число выполненных, points/points_done — сумма story_points всех и выполненных, unestimated — число без оценки. Порядок: active по start_date, future по id, closed по complete_date, новые сверху. У kanban-доски — [].
  • backlog — scrum: свои задачи не в активном и не в будущем спринте и не выполненные; kanban: свои задачи в статусах первой колонки, если kanban_backlog = true, иначе null.
  • board_issues — только kanban: свои задачи в статусах остальных колонок (при kanban_backlog не true — и первой) и без колонки; выполненные — только с jira_updated не старше 14 дней; scrum — null. Строка задачи — {key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}; списки — по rank (строки сравниваются как есть; null — в конце), затем по key.

Scenario: Спринт и бэклог

  • WHEN у AWPCA активный спринт 102 с задачами A (3 SP, в последней колонке), B (без оценки), в бэклоге C и выполненная D без спринта
  • THEN спринт 102: count = 2, count_done = 1, points = 3, points_done = 3, unestimated = 1; backlog — только C

Scenario: Проект без доски

  • WHEN GET /agile?project=STOCKS_DRRK, у проекта нет доски
  • THEN 200, board = null, sprints = [], backlog = null, board_issues = null

Requirement: Форма тела запроса

На тело неверной формы хаб SHALL отвечать 422, а не 500. В POST /agent/state поле issues SHALL быть списком, каждый его элемент — объектом с непустым строковым key. Иначе 422 bad_state с номером элемента, и пачка не применяется. В POST /wiki/sync поле project в теле, если задано, SHALL быть строкой. Иначе 422 bad_request «project — строка».

Scenario: Элемент состояния не объект

  • WHEN агент шлёт POST /agent/state {issues: ["A-1"]}
  • THEN 422 bad_state «issues[0] — объект задачи с key», зеркало не изменилось

Scenario: Проект вики не строкой

  • WHEN POST /wiki/sync {project: 5}
  • THEN 422 bad_request «project — строка», пакет не создан

Requirement: Ошибка доступа к файлу хранилища

Если файл хранилища вложений не читается (PermissionError, IsADirectoryError или другая ошибка ОС), хаб SHALL обрабатывать его как отсутствующий: при claim ensure_attachment получает size = null, GET /files/<sha> и скачивание вложения отвечают 404, как для удалённого файла. В лог хаба SHALL писаться предупреждение с путём и ошибкой. FileNotFoundError в лог не пишется.

Scenario: Нет прав на файл

  • WHEN у файла files/<sha> сняты права на чтение, агент берёт ensure_attachment с этим file
  • THEN claim отвечает 200, у команды size = null; в логе — предупреждение с путём

Requirement: Пустой SDLC_HUB_HOME

Переменная SDLC_HUB_HOME, заданная пустой строкой, SHALL трактоваться как незаданная: каталог данных — ~/.local/share/sdlc-hub/, проверка прежнего каталога выполняется. Хаб MUST NOT писать данные в рабочий каталог процесса из-за пустой переменной.

Scenario: Пустая переменная

  • WHEN SDLC_HUB_HOME= sdlc-hub tokens
  • THEN база открыта из ~/.local/share/sdlc-hub/hub.sqlite; в рабочем каталоге не появился hub.sqlite

Requirement: Восстановление прерванной публикации агента

Перед публикацией версии агента модуля хаб SHALL приводить release/<модуль>/ к опубликованному состоянию по manifest.json. Если agent.py.prev есть, а agent.py нет или не совпадает по sha256 с манифестом, тогда как agent.py.prev совпадает, agent.py.prev SHALL возвращаться на место agent.py. Если agent.py совпадает с манифестом, оставшийся agent.py.prev удаляется. В других сочетаниях файлы не трогаются, в лог пишется предупреждение. Годный agent.py.prev, совпадающий с манифестом, MUST NOT перезаписываться другим содержимым.

Scenario: Сбой между переименованием и записью

  • WHEN опубликована v5, прошлая публикация упала после переименования: agent.py нет, agent.py.prev — v5, манифест — v5; новая публикация v6 снова падает при записи
  • THEN в release/jira/ — agent.py v5 и манифест v5, опубликованной остаётся v5

Scenario: Сбой поверх неудачного возврата

  • WHEN agent.py — недописанный v6, agent.py.prev — v5, манифест — v5; запущена публикация v6
  • THEN перед записью agent.py восстановлен из .prev (v5); при сбое записи опубликованной остаётся v5, а .prev не затёрт недописанным файлом