394 lines
36 KiB
Markdown
394 lines
36 KiB
Markdown
# hub-api Specification
|
||
|
||
## Purpose
|
||
HTTP API хаба `jira-hub serve`: маршруты агента (выдача команд, отчёты, загрузка состояния), маршруты интерфейсов (зеркало, заметки, пакеты, настройки), резервные копии, автообновление зеркала и реле общей папки для агента за VPN.
|
||
|
||
## Requirements
|
||
|
||
### Requirement: Процесс и доступ
|
||
`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
|
||
`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать
|
||
`Authorization: Bearer <token>`: агентский токен открывает только `/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` с ключом задачи и именем поля,
|
||
пачка не применяется.
|
||
- `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`.
|
||
|
||
#### 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` с фильтрами `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` из заметок;
|
||
сокращение до карточки — дело интерфейса.
|
||
- `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 команд по ней,
|
||
`comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем
|
||
`comment_id`, по убыванию).
|
||
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||
- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
|
||
параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с
|
||
одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы), а также
|
||
по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен).
|
||
- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого
|
||
(64 hex-символа, нижний регистр); см. «Хранилище файлов вложений».
|
||
- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды
|
||
пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены
|
||
агентом; остановить — отмена команды с `force`).
|
||
- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
|
||
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
|
||
- `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** `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, хаб SHALL скопировать файл базы в `backups/<дата-время>-<batch>.sqlite`
|
||
и хранить последние `backup_keep` копий.
|
||
|
||
#### Scenario: Ротация
|
||
- **WHEN** копий больше `backup_keep`
|
||
- **THEN** самые старые удаляются до `backup_keep`
|
||
|
||
### Requirement: Автообновление зеркала
|
||
Если `auto_refresh_minutes > 0` и агент на связи, хаб SHALL поставить пакет «Автообновление» из `refresh {jql: base_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** автообновление не ставится
|
||
|
||
### Requirement: Реле через общую папку
|
||
`jira-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` под клиентским токеном — согласованный снимок зеркала для доски связей, прочитанный
|
||
в одной транзакции:
|
||
- `generated_at` — момент снимка (UTC);
|
||
- `last_sync` — последний завершённый полный прогон (`refresh` по базовой JQL): `{started_at, finished_at, issues_count}`
|
||
или `null`, если такого не было;
|
||
- `issues` — все задачи зеркала, включая эпики и соседей: `key`, `summary`, `issue_type`, `status`, `priority`,
|
||
`assignee`, `sprint`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (список), `neighbor` (bool),
|
||
`fact_status`, `work_kind` (из заметок, `null` если заметки нет); порядок — по ключу;
|
||
- `links` — все связи зеркала: `{source, target, link_type}`, `link_type` — имя типа в Jira, направление как в
|
||
`issue_link` (`source` блокирует/ссылается на `target`).
|
||
Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело интерфейса; хаб отдаёт данные
|
||
зеркала без преобразований.
|
||
|
||
#### 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** в журнале синхронизаций полный прогон, завершённый в 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 записываться в `<JIRA_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<N>, sha256 <первые 12 символов>…, <size> байт»; при ошибке — сообщение и код 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<to> на v<from>: <error>» (`rolled_back`),
|
||
«v<to> отвергнута: <error>» (`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`; повтор команды
|
||
после уборки отвергается при постановке 422 «файл <sha> не загружен: POST /files». Повторная загрузка того же
|
||
содержимого SHALL обновлять время изменения файла.
|
||
|
||
#### 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
|