Andrey Epifancev fd1799ac4a docs(openspec): архив web-app (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:20:58 +04:00

27 KiB
Raw Blame History

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 необязательны и хабом не используются.
  • GET /agent/known-keys — ключи зеркала (для определения соседей).
  • GET /agent/release — опубликованная версия агента {version, sha256, content} (content — текст agent.py); нет публикации или файл не сходится с манифестом по sha256 — 404 no_release.

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

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

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

  • GET /issues с фильтрами sprint, assignee, work_kind, fact_status, status, label, epic, neighbor, q (подстрока темы), limit/offset — строки зеркала (все поля issue) с fact_status и work_kind из заметок; сокращение до карточки — дело интерфейса.
  • GET /issues/{key} — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней.
  • PUT /issues/{key}/note — заметки (частичное обновление, updated_by из тела).
  • POST /batches {title, author, commands: [...]} → {id, estimate_seconds}; 422 при неизвестном типе, неверных параметрах (в том числе неверного типа значения), временном ключе без ensure_issue, двух и более ensure_issue с одинаковой summary (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
  • 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).

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

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}; оба — через временный файл и переименование, манифест последним. Команда SHALL работать без запущенного serve и печатать «опубликована v, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, опубликованное не меняется. Хаб SHALL читать манифест при каждом claim, GET /agent/release и GET /agent; битый манифест означает «публикации нет» (предупреждение в лог). GET /agent SHALL дополняться полями release ({version, sha256, published_at} или null), update_state, launcher_version и update_hint: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент онлайн, 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; вывод «опубликована v6, sha256 …»; код 0

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

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

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

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

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

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

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