Andrey Epifancev 54d87225bd OpenSpec: change attachment-fetch — вложения из Jira по запросу (#83)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:59:37 +04:00

28 KiB
Raw Blame History

MODIFIED Requirements

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, 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: Хранилище файлов вложений

Хаб 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: Резервные копии

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

Scenario: Ротация

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

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

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

ADDED Requirements

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"]