# hub-api Specification ## Purpose HTTP API хаба `jira-hub serve`: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN. ## Requirements ### Requirement: Процесс и доступ `jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в `~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать `Authorization: Bearer `: агентский токен открывает только `/agent/*`, клиентский — всё остальное. Исключение — раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или `Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки дополнительно отвечает 502. Методы, кроме GET, POST и PUT, отвечают 501 стандартной страницей `http.server`, не JSON. #### Scenario: Чужой токен - **WHEN** агентским токеном вызван `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` сборки; данных зеркала в нём нет ### 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/`, файл загружен - **THEN** 200 `{file: , size, content}`, sha256 от декодированного `content` равен ``; клиентским токеном — 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`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку `closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; сокращение до карточки — дело интерфейса. Без `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` из тела). - `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/<дата-время>-.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: Реле через общую папку `jira-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует — записать одно предупреждение в лог и проверять снова. Обмен: - запрос агента — файл `req/.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать; - реле SHALL брать запрос переименованием в `work/.json` и обрабатывать каждый взятый запрос в отдельном потоке; - запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до `deadline` осталось не меньше 35 с, иначе выбрасываться без ответа; - остальные запросы реле SHALL пересылать в свой HTTP на `:` процесса `serve` (`127.0.0.1`, если `serve` слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ `resp/.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и переименование; затем удалять `work/.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 <секунды>`. 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), `fact_status`, `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: Публикация версии агента `jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/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 читать манифест при каждом `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` 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, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` - **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6, sha256 …»; код 0 #### 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/` нет #### 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` ### Requirement: Раздача веб-приложения Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_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` в каталоге jira-hub; - `GET /` и `GET /links` — 302 с `Location: /app/board`. Сборка приложения не хранится в 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/board` #### 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//` (копии 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` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; `issues_count` — задачи зеркала с меткой проекта. - `POST /projects` `{label, name, board_id?, author?}` → 201 `{project, batch_id}`: в одной транзакции проект записывается и ставится пакет «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку; автор пакета — `author` (по умолчанию `api`). Неверные поля (см. `data-model`, «Проекты») — 422 с указанием поля; метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакета нет. #### Scenario: Новый проект - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` - **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = 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: "AWPCA", name: "…"}`, проект `AWPCA` есть - **THEN** 409, пакет не поставлен #### Scenario: Без названия - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}` - **THEN** 422 с указанием поля `name` ### 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: , size: 100}`, а файла `` в `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"]`