diff --git a/openspec/changes/attachment-fetch/.openspec.yaml b/openspec/changes/attachment-fetch/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/attachment-fetch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/attachment-fetch/design.md b/openspec/changes/attachment-fetch/design.md new file mode 100644 index 0000000..87c7fb1 --- /dev/null +++ b/openspec/changes/attachment-fetch/design.md @@ -0,0 +1,150 @@ +## Context + +`main` da8f5c9, схема v5, агент v13. Устройство, на которое опираются правки: + +- **Зеркало** — `mirror.apply_state`: upsert `issue`, замена `issue_link` и `issue_comment` задачи присланными (поле + `comments` необязательно — без него прежние комментарии остаются). Поля зеркала агент берёт одним `search` + (`state_fields`), `attachment` в них нет. +- **Хранилище файлов** — `files.py`: `<данные>/files/`, `put` (атомарно, предел 10 МБ, повтор обновляет mtime), + `sweep` раз в сутки из `tick` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`). Маршруты + `POST /files` (клиент) и `GET /agent/files/{sha}` (агент, base64). Тело запроса — до 16 МБ (`MAX_BODY`): файл 10 МБ в + base64 ≈ 14 МБ проходит, в том числе через реле папки. +- **Очередь** — пакеты, одна команда в полёте, пауза по `pacing` типа, хвостовой `refresh` по ключам пакета (кроме + пакетов только из `refresh`), довыдача до 3 раз, подтверждение по состоянию. +- **Агент** — `Executor.do_<тип>`; неизвестный тип — `failed` «неизвестный тип команды …». `JiraClient` умеет JSON и + multipart-загрузку (`upload`), считает вызовы и повторяет 429/503. +- **MCP** — ходит в хаб только через `client`; из `files` берёт только `MAX_SIZE`; `HubClient.from_home` знает каталог + данных хаба (`JIRA_HUB_HOME`). +- **Веб** — карточка `IssueCard.tsx`, рендер вики-разметки `web/src/issues/wiki/` (`!картинка!` сейчас — текст как есть), + адрес Jira — константа `JIRA_URL` в `web/src/board/constants.ts`. + +## Goals / Non-Goals + +**Goals:** список вложений задачи в зеркале без содержимого; привязка вложений к описанию и комментариям по ссылкам в +тексте; скачивание одного вложения по запросу из MCP (путь к локальной копии с исходным именем) и из веба (сохранение +файлом); одна команда на вложение при параллельных запросах. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Список вложений — в `refresh`, отдельной таблицей `issue_attachment` (схема v6).** Поле `attachment` в `search` + приходит целиком (без постраничности, в отличие от комментариев), поэтому лишних вызовов Jira нет. Таблица — часть + зеркала: пишет только `apply_state`, присланное `attachments` заменяет список задачи, без поля — список остаётся + (агент ≤ v13). PK — `attachment_id` (id вложения в Jira глобален). `created_at` — UTC для сортировки, как у + комментариев. +2. **Скачанное — отдельная таблица `attachment_file`, не колонка зеркала.** Граница «зеркало пишет только + `apply_state`» сохраняется; скачанный файл переживает замену списка вложений. Строка без файла в хранилище (убран + уборкой) — «не скачано», без отдельной чистки таблицы. +3. **Состояние вложения вычисляется при чтении** (`ready` / `fetching` / `failed` / `none`) из `attachment_file`, + хранилища и последней `fetch_attachment` по этому id. Отдельного статуса нет — нечему рассинхронизироваться. +4. **Команда `fetch_attachment {key, attachment_id}` в общей очереди.** Скачивание — обычная команда: темп, одна + команда в полёте, довыдача, журнал агента и восстановление работают как есть. Пакет из одной команды, без хвостового + `refresh` (в Jira ничего не меняется) и без проекта (вложение соседа тоже можно скачать). Пауза по умолчанию 2 с, как у + `refresh` (только чтение). Подтверждения по состоянию нет: потерянный отчёт закрывает довыдача — повтор безопасен. Код, перебирающий типы + команд, SHALL знать новый тип: `commands.check_confirmed` (иначе `None` и 500 на отчёте после `refresh`), признак + «пакет с записями» в `queue.claim` (иначе копия базы на каждое скачивание), выборка команд задачи в карточке. + *Отвергнуто:* отдельный канал «срочных» запросов мимо очереди — второй цикл в агенте, второй источник темпа к Jira. +5. **Файл от агента к хабу — `POST /agent/files {content}` (base64), в результате команды — только sha.** Результат + команды повторяется каждые 10 с до подтверждения и пишется в журнал агента; 14 МБ base64 там не нужны. Маршрут — + зеркало `POST /files` под агентским токеном, с теми же проверками. Отчёт `done` хаб принимает, только если файл с таким + sha и размером лежит в хранилище. +6. **Адрес содержимого агент собирает сам** — `/secure/attachment//<имя>`, а не берёт `content` из ответа + Jira: Basic-авторизация уходит только на `base_url` (анти-цель). Перед скачиванием — `GET /issue/{key}?fields=attachment`: + проверка, что вложение ещё есть, и размер из Jira до скачивания (больше 10 МБ — не качать). +7. **Одна команда на вложение.** `POST /attachments/{id}/fetch` в одной транзакции записи проверяет `fetching`/`ready` и + ставит пакет. Веб и MCP могут жать сколько угодно — команда одна. +8. **Предел 10 МБ — в трёх местах:** хаб отказывает в постановке по размеру из зеркала (413, без пакета), агент — по + размеру из Jira (на случай, если файл заменили), хранилище — `files.put` (последний рубеж). +9. **Старый агент — 409 `agent_too_old` до постановки.** Агент v13 на `fetch_attachment` ответил бы «неизвестный тип + команды»; хаб проверяет `agent_status.agent_version` и сразу говорит, что нужен v14. Версия неизвестна (агент не + выходил на связь) — ставим: команда дождётся агента. `min_agent_version` не поднимается. +10. **Ссылки на вложения ищет хаб**, а не каждый интерфейс: MCP и веб получают готовые `attachment_ids`. Разбор — + регулярные выражения по описанию и тексту комментариев (`!имя!`, `!имя|…!`, `[^имя]`, `[текст|^имя]`), имя — точное + совпадение, при повторе имени — самое новое вложение (так делает Jira). Веб дополнительно превращает такие ссылки в + отрендеренном тексте в кнопки скачивания по тем же правилам (список вложений у него есть). +11. **MCP кладёт копию с исходным именем в `<данные хаба>/downloads//<имя>`.** Файл хранилища без имени и + расширения Claude не откроет (анти-цель). Каталог по id — нет конфликтов имён; уборка `downloads/` — тем же `tick` + хаба (30 дней). MCP читает файл через `GET /attachments/{id}/content` (граница «MCP — только через `client`»), а не + из `files/` напрямую. +12. **MCP `attachment` ждёт сам** (опрос раз в 2 с, по умолчанию до 120 с, не больше 600): обычно агент успевает за + секунды; не успел — понятный ответ, повторный вызов не ставит вторую команду. +13. **Веб скачивает через `Blob`.** Маршруты хаба требуют токен в заголовке, ссылка `` его не передаст; + `fetch` → `Blob` → временная ссылка `download`. Файл до 10 МБ — память браузера не проблема. Пока агент везёт файл — + опрос раз в 2 с, пока карточка открыта на этой задаче; файл сохраняется сам, без второго нажатия. +14. **Уборка.** Скачанные вложения от уборки не защищены (их можно скачать снова); выдача `GET …/content` обновляет + mtime, так что файлы, которые смотрят, живут дольше. +15. **Порядок выкатки — хаб первым.** Хаб v6 со старым агентом: списков вложений нет (поле не приходит), скачивание + отвечает 409. Агент v14 со старым хабом: поле `attachments` старый хаб игнорирует (`apply_state` берёт известные + колонки), `fetch_attachment` старый хаб не выдаёт. +16. **Скачивания не заслоняют записи.** Пакеты «Скачать вложение …» не делают копию базы (в Jira не пишут) и не входят + в «последние 10 команд по задаче» карточки и MCP `issue`: при разборе ошибки с десятком файлов там остаются правки + моста в Jira. В общем списке пакетов они видны, как есть. +17. **Перенаправления при скачивании.** `urllib` следует 3xx и переносит `Authorization`; агент SHALL не выполнять + перенаправление на другой хост (обработчик перенаправлений или проверка адреса), а на тот же — может. +18. **Неизвестный размер и пустые файлы.** Вложение без размера ставится, агент обрывает чтение после 10 МБ; пустое + вложение принимается от агента (`POST /agent/files`), хотя `POST /files` пустой файл по-прежнему отвергает. + +## Risks / Trade-offs + +- **Ожидание в очереди.** Скачивание стоит за командами, поставленными раньше (полный `refresh` идёт минуты). Принимаем: + MCP отвечает «ещё идёт», веб показывает «Загружается…». +- **Объём через общую папку.** Файл 10 МБ — запрос ≈ 14 МБ JSON через реле. В пределах `MAX_BODY`; если реле окажется + медленным на таких файлах — снижать предел, а не менять транспорт. +- **Имя с запрещёнными символами** (`:` и др. — Jira банка их хранит, см. #43) — в копии MCP заменяются на `_`; в вебе + браузер правит имя сам. +- **Ссылка по имени после переименования** в Jira не находит вложение — показывается исходником. Редко. +- **Удалённое в Jira вложение** остаётся в зеркале до ближайшего `refresh` задачи; скачивание ответит понятной ошибкой. + +## Migration Plan + +1. PR хаба: схема v6, `attachments` в `apply_state`, `POST /agent/files`, `fetch_attachment` в контракте и очереди, + маршруты `/attachments/…`, ссылки в `GET /issues/{key}`, MCP (`issue`, `comment`, `attachment`), веб. После merge — + `git pull`, `make web`, перезапуск хаба владельцем, `/mcp` → reconnect. +2. PR агента: v14 (`attachments` в состоянии, `fetch_attachment`). После merge — `jira-hub agent publish + ../jira-agent/src/agent.py`; `agent_status` показывает v14; `min_agent_version` не поднимать. +3. Живая проверка: полный `refresh` → у задач с вложениями список в `issue`; MCP `attachment` по скриншоту и по Excel + (кириллическое имя с пробелами) → путь, файл открывается; повторный вызов — без новой команды; кнопка «Скачать» в + карточке → файл сохранён; вложение > 10 МБ, если найдётся, → отказ без команды. + +Откат: агент — публикуется v15 без новых возможностей (только вверх); хаб — предыдущий коммит, таблицы v6 остаются и не +мешают, `schema_version = 6` старый хаб не понизит. + +## Open Questions + +Нет. + +## Decisions made without discussion + +1. 2026-10-22 — **привязка вложения к комментарию — только по ссылке в тексте** (`!имя!`, `[^имя]`), без догадок по + автору и времени. Обоснование: Jira сама пишет ссылку при перетаскивании файла в комментарий; эвристика даёт ложные + привязки. Цена ошибки: файл, приложенный без ссылки, виден только в общем списке — добавить эвристику позже. +2. 2026-10-22 — **скачивание — команда общей очереди (`fetch_attachment`), а не отдельный канал.** Обоснование: темп, + довыдача, журнал агента — без нового кода. Цена ошибки: ожидание за длинным `refresh`; если мешает — приоритет + `fetch_attachment` в выдаче. +3. 2026-10-22 — **файл от агента — отдельным маршрутом `POST /agent/files`, в отчёте только sha.** Обоснование: отчёт + повторяется и пишется в журнал агента. Цена ошибки: нет. +4. 2026-10-22 — **адрес содержимого собирает агент из `base_url`.** Обоснование: учётные данные не уходят на чужой адрес. + Цена ошибки: если в Jira банка адрес вложений отличается от `/secure/attachment//<имя>` — выяснится на живой + проверке, поправить сборку адреса. +5. 2026-10-22 — **копия для Claude — `<данные хаба>/downloads//<имя>`, уборка 30 дней.** Обоснование: исходное имя и + расширение нужны, чтобы Claude открыл файл; каталог хаба уже под `0700`. Цена ошибки: Claude в другом репозитории + может спросить разрешение на чтение вне проекта — один раз на сессию. +6. 2026-10-22 — **старый агент — 409 `agent_too_old` до постановки, `min_agent_version` не поднимается.** Обоснование: + понятная ошибка вместо «неизвестный тип команды», старый агент продолжает работать. Цена ошибки: нет. +7. 2026-10-22 — **MCP `attachment` ждёт по умолчанию 120 с, максимум 600.** Обоснование: обычно агент успевает за + секунды, а чат не должен висеть дольше. Цена ошибки: при занятой очереди — повторный вызов. +8. 2026-10-22 — **ссылки на вложения в тексте веба — кнопка со скрепкой, без картинки.** Обоснование: решение владельца + «превью не делаем»; ссылка в тексте ведёт к тому же скачиванию. Цена ошибки: нет. +9. 2026-10-22 — **пауза `fetch_attachment` по умолчанию 2 с.** Обоснование: только чтение, как `refresh`. Цена ошибки: + нет — меняется настройкой `pacing`. +10. 2026-10-22 — **пакеты скачивания не делают копию базы и не входят в «последние 10 команд по задаче».** Обоснование: + находка рецензента — иначе серия скачиваний вытесняет копии перед записями и команды-записи из карточки. Цена + ошибки: скачивания по задаче видны только в списке пакетов. +11. 2026-10-22 — **перенаправление на другой хост при скачивании — `failed`, без перехода.** Обоснование: находка + рецензента — `urllib` унёс бы `Authorization` на чужой хост. Цена ошибки: если Jira банка отдаёт вложения через + другой хост — скачивание не работает до решения владельца, видно на живой проверке. +12. 2026-10-22 — **вложение без размера ставится, предел 10 МБ проверяет агент по прочитанному.** Обоснование: Jira + может не отдать размер; отказывать в таком вложении — терять файл. Цена ошибки: агент читает до 10 МБ лишнего. +13. 2026-10-22 — **пустое вложение принимается от агента.** Обоснование: в Jira бывают пустые файлы; иначе вечный + `failed`. Цена ошибки: нет. diff --git a/openspec/changes/attachment-fetch/proposal.md b/openspec/changes/attachment-fetch/proposal.md new file mode 100644 index 0000000..433efc1 --- /dev/null +++ b/openspec/changes/attachment-fetch/proposal.md @@ -0,0 +1,49 @@ +## Why + +Тестировщики прикладывают к ошибкам AURORA файлы: скриншоты, выгрузки в Excel (ОСВ), иногда PDF и JSON. Владелец +разбирает ошибки в чате с Claude, и Claude должен сам открыть эти файлы. Сейчас мост файлы из Jira не забирает совсем: +зеркало не знает даже, что у задачи есть вложения (агент умеет только загружать файл в Jira — `ensure_attachment`, +change `bug-and-attachments`). + +Решения владельца 22.10.2026 (brainstorming, jira-hub#83, jira-agent#43): +- **при синхронизации — только список вложений** (имя, размер, автор, дата), сами файлы автоматически не тянутся; +- **файл — по запросу**: агент скачивает нужное вложение из Jira и отдаёт хабу; +- **где**: в MCP (Claude получает путь к локальному файлу и открывает его) и в веб-приложении (кнопка «Скачать»); +- **из комментария**: файлы, на которые ссылается комментарий тестировщика, видны и скачиваются прямо у комментария — + в карточке, в MCP `issue` и `comment`, а не только в общем списке задачи; +- **предел 10 МБ** на файл, как у загрузки; крупнее — скачивать в Jira руками; +- **превью скриншотов в карточке не делаем**: для него файлы пришлось бы качать заранее. + +## What Changes + +- **Список вложений в зеркале.** Агент v14 в `refresh` добавляет поле `attachment` к `search` и шлёт у каждой задачи + `attachments` (id, имя, размер, тип, автор, дата) без содержимого. Хаб хранит их в новой таблице `issue_attachment` + (схема v6), заменяя список задачи целиком, как комментарии. +- **Ссылки из текста.** Хаб находит в описании и комментариях вики-ссылки на вложения (`!имя!`, `!имя|…!`, `[^имя]`) и + отдаёт у комментария `attachment_ids`, у задачи — `description_attachment_ids`. +- **Команда `fetch_attachment`.** Хаб ставит её по запросу интерфейса отдельным пакетом без хвостового `refresh`; агент + читает вложение в Jira, скачивает содержимое и отдаёт хабу новым маршрутом `POST /agent/files`; хаб кладёт файл в + хранилище `files/` (то же, что для загрузки; уборка через 30 дней) и запоминает в `attachment_file`. +- **API.** `GET /attachments/{id}` (с состоянием `none` / `fetching` / `ready` / `failed`), `POST /attachments/{id}/fetch` + (одна команда на вложение, сколько бы раз ни просили), `GET /attachments/{id}/content` (файл с исходным именем). + `GET /issues/{key}` отдаёт вложения и ссылки на них. +- **MCP.** `issue` и `comment` показывают вложения; новый инструмент `attachment` скачивает файл (ждёт агента до + `wait_seconds`) и отвечает путём к локальной копии с исходным именем в `<данные хаба>/downloads//`. +- **Веб.** В карточке раздел «Вложения (N)», плашки файлов под комментарием, ссылки на вложения в отрендеренном тексте — + все скачивают файл одной кнопкой; «Загружается…», пока агент везёт файл. + +## Non-Goals + +- Превью картинок в карточке и показ `!картинка!` картинкой. +- Скачивание файлов больше 10 МБ — только ссылка на Jira. +- Автоматическая подкачка файлов (заранее, при синхронизации, по подписке). +- Удаление и переименование вложений в Jira из моста. +- Привязка файла к комментарию по автору и времени — только по ссылке в тексте. + +## Anti-Goals + +- Синхронизация стала тяжелее: при `refresh` агент качает содержимое вложений или делает лишние вызовы Jira на задачу. +- Учётные данные Jira уходят на адрес не из `base_url` агента (адрес `content` из ответа Jira). +- Повторные нажатия и параллельные запросы из веба и MCP ставят несколько одинаковых скачиваний. +- Claude получает файл без имени и расширения и не может его открыть. +- Старый агент (≤ v13) или старый хаб ломают синхронизацию. diff --git a/openspec/changes/attachment-fetch/specs/agent/spec.md b/openspec/changes/attachment-fetch/specs/agent/spec.md new file mode 100644 index 0000000..70dc869 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/agent/spec.md @@ -0,0 +1,71 @@ +## MODIFIED Requirements + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment`, `attachment` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». +Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый +`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`; +`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше +комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment` +постранично (`maxResults = 100`) и передать полный список. Комментарий без `id` или с пустым `id` агент SHALL отбросить +с записью в лог (ключ задачи), остальные передать. Если дочитывание комментариев задачи вернуло 403 или 404, агент SHALL +передать задачу без поля `comments` (хаб оставит прежние комментарии) с записью в лог; `refresh` продолжается. +Каждая задача в состоянии (с v14) SHALL нести `attachments` — все вложения задачи из поля `attachment` ответа `search` +в порядке Jira, каждое `{id, filename, size, mime_type, author, created}`: `id` — строка; `filename` — имя как есть; +`size` — целое (байт) или `null`; `mime_type` — `mimeType` Jira или `null`; `author` — как у комментария; `created` — +время Jira как есть. Содержимое вложений при `refresh` MUST NOT скачиваться. Вложение без `id` агент SHALL отбросить с +записью в лог (ключ задачи). Нет поля `attachment` в ответе (поле скрыто настройками Jira) — `attachments = []`. + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + +#### Scenario: Комментарии в состоянии +- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора + второго нет `displayName`, его `name` — `ivanov` +- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число + вызовов `search` не изменилось + +#### Scenario: Неполный список комментариев +- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130` +- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев + +#### Scenario: Комментарий без id +- **WHEN** у задачи два комментария, у второго `id = null` +- **THEN** в состоянии один комментарий; в логе строка с ключом задачи; пачка принята хабом + +#### Scenario: Нет доступа к комментариям +- **WHEN** `GET /issue/AURORA-1620/comment` при дочитывании вернул 403 +- **THEN** AURORA-1620 в состоянии без поля `comments`; остальные задачи пачки с комментариями; `refresh` — `done` + +#### Scenario: Задача без комментариев +- **WHEN** у задачи в Jira нет комментариев +- **THEN** в её состоянии `comments = []` + +#### Scenario: Вложения в состоянии +- **WHEN** у AURORA-1650 в Jira два вложения: «скрин 1.png» (`mimeType = image/png`, 240000 байт, автор + `{name: "potapova", displayName: "Потапова Анна"}`) и «ОСВ 60.xlsx» +- **THEN** в состоянии AURORA-1650 `attachments` из двух элементов в порядке Jira, у первого `filename = "скрин 1.png"`, + `size = 240000`, `mime_type = "image/png"`, `author = "Потапова Анна"`; число вызовов `search` не изменилось, обращений + к содержимому вложений нет + +## ADDED Requirements + +### Requirement: Скачивание вложения +Агент v14 SHALL выполнять `fetch_attachment` по контракту `commands` («Скачивание вложения по запросу»). Метод `JiraClient` +для содержимого SHALL отдавать байты ответа без разбора JSON, с тем же счётом вызовов, повтором 429/503 и учётом времени +последнего вызова, что и прочие вызовы Jira. Отправка хабу — `HubClient` (`POST /agent/files`), работает и через общую +папку. В журнал агента (`journal.jsonl`) и в лог содержимое файла MUST NOT попадать — только id, имя, размер и sha256. + +#### Scenario: Скачивание через общую папку +- **WHEN** агент с `hub_url = folder:…` выполняет `fetch_attachment` вложения 3 МБ +- **THEN** содержимое уходит хабу одним запросом `POST /agent/files` через папку (тело ≈ 4 МБ base64); отчёт `done` с + `file` и `size`; в `journal.jsonl` нет содержимого файла diff --git a/openspec/changes/attachment-fetch/specs/commands/spec.md b/openspec/changes/attachment-fetch/specs/commands/spec.md new file mode 100644 index 0000000..e3bf89c --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/commands/spec.md @@ -0,0 +1,241 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` | +| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал | +| `fetch_attachment` | `key`, `attachment_id` (id вложения Jira, строка из цифр) | `GET /issue/{key}?fields=attachment` (имя и размер вложения) | ничего в Jira; содержимое — `GET /secure/attachment/{id}/{имя}`, затем хабу `POST /agent/files`; результат `{attachment_id, file, size}` | + +Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в +зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` — +доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта +нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P +ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе +не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и +`kanban` в проекты с этой доской; `null` тип не меняет. + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +#### Scenario: Спринт задачи проекта +- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}` +- **THEN** пакет создан, в параметрах команды `board_id = 4216` + +#### Scenario: Спринт на канбане +- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}` +- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет» + +#### Scenario: Тип доски определён +- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}` +- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban` + +### Requirement: Пакет и порядок выполнения +Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка +и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh` и `fetch_attachment`). Хаб SHALL проверять +типы параметров: текстовые параметры и ключи — строки (`null` у необязательного параметра — «не задан»; агент +трактует его по контракту типа, для `ensure_fields` — очистка поля), `target` команды +`ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` — +логическое значение; нарушение — 422 с именем параметра. Пакет с двумя и более `ensure_issue` с одинаковой `summary` +(после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать команды в порядке +`(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed` команда MUST NOT +отменяться автоматически. + +#### Scenario: Оценка длительности +- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment` +- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh` + +#### Scenario: Две задачи с одной темой +- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}` +- **THEN** 422 с темой «Отчёт», пакет не создан + +#### Scenario: Параметр не того типа +- **WHEN** пакет содержит `ensure_fields {key: X, labels_add: "AWPCA"}` +- **THEN** 422 с указанием `labels_add`, пакет не создан + +### Requirement: Политика темпа и пауза на команде +Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и +`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30, `refresh` и `fetch_attachment` 2; тип, которого нет в +сохранённой настройке `pacing`, получает зазор по умолчанию). При выдаче команды +хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего +последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент +SHALL выдерживать зазор типа команды между записями. + +#### Scenario: Нет следующей команды — нет паузы +- **WHEN** очередь пуста +- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб +SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия +`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь +есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`, `ensure_attachment`, `fetch_attachment` и `refresh` по +состоянию не подтверждаются (зеркало не показывает ни загрузку вложения, ни скачанный файл): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Описание с CRLF в зеркале +- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"` +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ + +### Requirement: Пакет проекта +Пакет, поставленный с `project = P` (см. `hub-api`), SHALL проходить при постановке, до записи, проверки проекта; +любая неудача — 422 с ключом задачи и причиной, пакет не создаётся: +- `P` нет среди проектов — 422; +- `ensure_issue`: если в `labels` нет P, хаб SHALL дописать P в конец `labels` (в базе параметры хранятся уже с ней; + без `labels` — `labels = [P]`); метка, совпадающая с P без учёта регистра, но не точно, SHALL заменяться на P; метка + другого проекта в `labels` — 422; +- `ensure_fields`: P в `labels_remove` или метка другого проекта в `labels_add` — 422; +- `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным + ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; +- `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P; +- `refresh` и `fetch_attachment` — без проверок проекта. +«Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту +(метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит, +метки хаб в нём не дописывает. + +#### Scenario: Метка проекта дописана +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {temp_key: new:1, summary: "Сверка остатков", labels: ["kind-dev"]}` +- **THEN** пакет создан; в параметрах команды `labels = ["kind-dev", "STOCKS_DRRK"]` + +#### Scenario: Чужая метка проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {…, labels: ["awpca"]}`, есть проект `AWPCA` +- **THEN** 422 «метка другого проекта: awpca», пакет не создан + +#### Scenario: Задача другого проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_fields {key: AURORA-1538, duedate: …}`, у AURORA-1538 метка только `AWPCA` +- **THEN** 422 «AURORA-1538 не в проекте STOCKS_DRRK» + +#### Scenario: Связь с соседом +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_link {source: AURORA-1 (метка STOCKS_DRRK), target: AURORA-1538 (метка AWPCA), …}` +- **THEN** пакет создан + +## ADDED Requirements + +### Requirement: Скачивание вложения по запросу +`fetch_attachment {key, attachment_id}` SHALL ставиться только хабом по запросу интерфейса (`POST /attachments/{id}/fetch`, +см. `hub-api`) — отдельным пакетом из одной команды, без хвостового `refresh` и без проекта (`batch.project = NULL`), +заголовок «Скачать вложение ()»; через `POST /batches` её MAY поставить и клиент, с теми же проверками. +При постановке хаб SHALL проверять: `attachment_id` — непустая строка из цифр; вложение с этим id есть в зеркале у задачи +`key` (иначе 422 «у нет вложения # в зеркале — обновите задачу»); его размер в зеркале не больше 10 МБ +(10 485 760 байт) — иначе 413 `too_large` «файл больше 10 МБ (<размер>) — скачайте в Jira»; размер неизвестен (`NULL`) — +ставится, предел проверяет агент. Агент SHALL выполнять её так: +1. `GET /issue/{key}?fields=attachment`; вложения с этим id нет — `failed` «вложение # удалено из в Jira»; +2. размер по Jira больше 10 МБ — `failed` «файл больше 10 МБ (<размер>) — скачайте в Jira», содержимое не качается; + размер неизвестен — качается, но чтение ответа SHALL обрываться на 10 485 761-м байте с тем же `failed`; +3. содержимое — `GET` по адресу, собранному из `base_url` агента: `/secure/attachment//<имя в URL-кодировке>` + (адрес `content` из ответа Jira MUST NOT использоваться: учётные данные уходят только на `base_url`), таймаут не + меньше 120 с; ответ Jira 429/503 — повтор по политике агента; перенаправление (3xx) агент MUST NOT выполнять с + учётными данными на другой хост (схема, хост и порт не как у `base_url`) — `failed` «Jira перенаправила скачивание на + <хост>»; длина полученного не равна известному размеру из шага 1 — `failed` «файл пришёл не целиком (<получено> из + <размер>)»; пустое вложение (0 байт) — допустимо; +4. `POST /agent/files {content}` (base64) хабу; ответ `{file, size}`; ошибка хаба — с префиксом «хаб: », недоступность + хаба повторяется, как при отправке состояния; +5. результат `done` `{attachment_id, file, size}`. +По отчёту `done` хаб SHALL записать скачанный файл вложения (см. `data-model`, «Скачанные вложения»). + +#### Scenario: Скачивание скриншота +- **WHEN** у AURORA-1650 в зеркале вложение #31337 «скрин 1.png» 240 000 байт; поставлена `fetch_attachment {key: + AURORA-1650, attachment_id: "31337"}` +- **THEN** агент читает задачу, качает `/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`, + отправляет содержимое хабу; отчёт `done {attachment_id: "31337", file: , size: 240000}`; в хвосте пакета + `refresh` нет + +#### Scenario: Вложение удалили в Jira +- **WHEN** вложение #31337 в зеркале есть, в Jira его уже нет +- **THEN** команда `failed` «вложение #31337 удалено из AURORA-1650 в Jira»; хаб файл не записывает + +#### Scenario: Слишком большой файл +- **WHEN** в зеркале у вложения #40000 размер 25 000 000 байт; интерфейс просит его скачать +- **THEN** 413 `too_large` «файл больше 10 МБ (23,8 МБ) — скачайте в Jira», пакет не создан + +#### Scenario: Размер неизвестен +- **WHEN** у вложения #50 в зеркале `size = NULL`, в Jira размера тоже нет, содержимое — 12 МБ +- **THEN** постановка проходит; агент обрывает чтение после 10 МБ, команда `failed` «файл больше 10 МБ (больше 10,0 МБ) — + скачайте в Jira»; хабу ничего не отправлено + +#### Scenario: Перенаправление на другой хост +- **WHEN** Jira на `GET /secure/attachment/31337/…` ответила 302 на `https://files.example/…` +- **THEN** агент туда не ходит, команда `failed` «Jira перенаправила скачивание на files.example» diff --git a/openspec/changes/attachment-fetch/specs/data-model/spec.md b/openspec/changes/attachment-fetch/specs/data-model/spec.md new file mode 100644 index 0000000..f6349e8 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/data-model/spec.md @@ -0,0 +1,141 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`, +`attachment_file`, `issue_note`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 6). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени +Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и +`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не +разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. + +Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений +появляются с первым `refresh` агента v14. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version = 6` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 6` + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, + пакеты на месте; `schema_version = 6` + +#### Scenario: Миграция базы v4 +- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`, + в `sync_run` полный прогон +- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`, + `created` прежний; у прогона `history_at = NULL`; затем миграция v5 → v6; `schema_version = 6` + +#### Scenario: Миграция базы v5 +- **WHEN** хаб стартует с базой `schema_version = 5` +- **THEN** в `backups/` копия базы v5; есть пустые `issue_attachment` и `attachment_file`; прочие таблицы не тронуты; + `schema_version = 6` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Зеркало задачи +Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), +`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), +`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` +SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается +на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть: +`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Таблица `issue_attachment` SHALL +хранить список вложений задачи без содержимого: `attachment_id` (PK, id вложения Jira), `key`, `filename`, `size` (байт +или `NULL`), `mime_type`, `author`, `created` (время Jira как есть), `created_at` (UTC, приведённое из `created`; не +разобралось — `NULL`). Обновлять `issue`, `issue_link`, `issue_comment` и +`issue_attachment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные +комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии +SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без +поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не +обновлённые им, — только если прогон загрузил хотя бы одну задачу. + +#### Scenario: Загрузка состояния заменяет связи +- **WHEN** агент прислал состояние задачи X с двумя связями +- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные + со стороны X, не трогаются до загрузки их источника + +#### Scenario: Соседняя задача +- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL +- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` + +#### Scenario: Пустой полный прогон +- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач +- **THEN** признак `neighbor` задач зеркала не меняется + +#### Scenario: Комментарий удалён в Jira +- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]` +- **THEN** у X в `issue_comment` только 102 + +#### Scenario: Состояние от агента до v10 +- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments` +- **THEN** поля X обновлены, три комментария на месте + +#### Scenario: Вложения в состоянии +- **WHEN** у X в зеркале вложения #1 и #2, агент v14 прислал X с `attachments = [{id: "2", …}, {id: "3", …}]` +- **THEN** у X в `issue_attachment` #2 (поля из состояния) и #3; #1 удалено; `attachment_file` не тронута + +#### Scenario: Состояние от агента до v14 +- **WHEN** у X в зеркале два вложения, агент v13 прислал X без поля `attachments` +- **THEN** поля X обновлены, два вложения на месте + +## ADDED Requirements + +### Requirement: Скачанные вложения +Таблица `attachment_file` SHALL хранить, какие вложения уже скачаны в хранилище файлов хаба: `attachment_id` (PK), +`file` (sha256 содержимого), `size`, `fetched_at` (UTC). Строку SHALL записывать (вставка или замена) только отчёт +`done` по `fetch_attachment`; загрузка состояния её MUST NOT трогать, поэтому скачанный файл переживает обновление +списка вложений. Строка без файла в хранилище (файл убран уборкой) SHALL считаться «не скачано». + +Состояние вложения для интерфейсов (`state`) SHALL вычисляться при чтении: +- `ready` — есть строка `attachment_file` и файл `file` есть в хранилище; +- `fetching` — нет `ready`, и есть `fetch_attachment` с этим `attachment_id` в `queued` или `claimed`; +- `failed` — нет ни того, ни другого, а последняя (по `id`) `fetch_attachment` с этим `attachment_id` — `failed` или + `cancelled`; её `error` отдаётся вместе с состоянием; +- `none` — вложение ни разу не запрашивали (или запрос был до уборки файла). + +#### Scenario: Файл убран уборкой +- **WHEN** у вложения #31337 строка `attachment_file`, файла `file` в `files/` нет; команд по нему в очереди нет, последняя + `fetch_attachment` по нему — `done` +- **THEN** `state = none`; новый запрос на скачивание ставит новую `fetch_attachment` + +#### Scenario: Список вложений обновился +- **WHEN** вложение #31337 скачано (`state = ready`), затем пришло состояние задачи с тем же вложением +- **THEN** `state` по-прежнему `ready` diff --git a/openspec/changes/attachment-fetch/specs/hub-api/spec.md b/openspec/changes/attachment-fetch/specs/hub-api/spec.md new file mode 100644 index 0000000..912e836 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/hub-api/spec.md @@ -0,0 +1,291 @@ +## 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/`, файл загружен +- **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: Хранилище файлов вложений +Хаб 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: Резервные копии +Перед выдачей первой команды пакета, содержащего записи в Jira (любая команда, кроме `refresh` и `fetch_attachment`), хаб +SHALL скопировать файл базы в `backups/<дата-время>-.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: , 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"]` diff --git a/openspec/changes/attachment-fetch/specs/mcp/spec.md b/openspec/changes/attachment-fetch/specs/mcp/spec.md new file mode 100644 index 0000000..c680ec5 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/mcp/spec.md @@ -0,0 +1,144 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: # <имя>, …»; затем вложения (см. «Вложения в MCP») | +| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «# <имя> (<размер>)» | +| `attachment` | `key`, `attachment` (номер вложения «#» или «», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» + +#### Scenario: Комментарий целиком +- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}` +- **THEN** ответ — «AURORA-9001 #101 <автор>, создан » и полный текст комментария без обрезки, переносы строк сохранены + +#### Scenario: Нет такого комментария +- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999 +- **THEN** ответ «у AURORA-9001 нет комментария #999» + +## ADDED Requirements + +### Requirement: Вложения в MCP +`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «# <имя> +(<размер>) — <автор>, », дальше через запятую: «в описании», если на вложение ссылается описание, «в #» +для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов; +без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»); +неизвестный — «размер неизвестен». + +`attachment` SHALL: +1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в + ответ строка «у N вложений «<имя>», взято самое новое #»); не найдено — «у нет вложения «<значение>»; + список — issue », в хаб больше не ходить; +2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira: + задача , вложение «<имя>»»; 409 и прочие ошибки хаба — их текст; +3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался — + «скачивание ещё идёт (команда #, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не + поставится»; `failed` — «не удалось скачать: »; +4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads//<имя>`, где имя — + `filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-`); каталог с правами + `0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать; +5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), , задача ». + +#### Scenario: Скриншот из комментария +- **WHEN** `comment {key: "AURORA-1650", comment_id: "101"}` показал «вложения: #31337 скрин 1.png (234,4 КБ)»; вызван + `attachment {key: "AURORA-1650", attachment: "#31337"}`, агент онлайн, файла у хаба нет +- **THEN** поставлена `fetch_attachment`; через несколько секунд ответ — путь + `…/jira-hub/downloads/31337/скрин 1.png` и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»; файл по пути + читается + +#### Scenario: Повторный вызов +- **WHEN** тот же вызов второй раз, файл у хаба (`ready`) и в `downloads/31337/` +- **THEN** ответ сразу, команд в очереди не прибавилось + +#### Scenario: Агент не успел +- **WHEN** `attachment {…, wait_seconds: 30}`, агент занят долгим `refresh` +- **THEN** через 30 с ответ «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая + команда не поставится» + +#### Scenario: По имени +- **WHEN** `attachment {key: "AURORA-1650", attachment: "ОСВ 60.xlsx"}`, вложение с таким именем одно +- **THEN** скачивается оно; путь оканчивается на `/ОСВ 60.xlsx` + +#### Scenario: Вложения в карточке +- **WHEN** `issue {key: "AURORA-1650"}`, у задачи вложения #31337 «скрин 1.png» (на него ссылается комментарий 101, + скачано) и #31338 «запись.mp4» на 25 МБ +- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у + комментария 101 строка «файлы: #31337 скрин 1.png» diff --git a/openspec/changes/attachment-fetch/specs/web-app/spec.md b/openspec/changes/attachment-fetch/specs/web-app/spec.md new file mode 100644 index 0000000..a634744 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/web-app/spec.md @@ -0,0 +1,180 @@ +## MODIFIED Requirements + +### Requirement: Карточка задачи +Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с +которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры, +загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую +адрес (новая вкладка, закладка) SHALL +показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста, +отпущенное за краем окна, окно не закрывает) и +возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после +закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад» +браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не +прокручивается. + +Содержимое окна: +- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка + «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено», + если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой + (`epic_summary`), иначе ключом; +- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует + завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками), + «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»); + в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в + зеркале»; пустые группы не показываются, без связей — «связей нет»; +- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в + заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания + раздела нет; +- «Вложения (N)» — новые сверху: значок по типу файла (картинка, таблица, PDF, прочее), имя, размер, «автор · дата» и + кнопка скачивания (требование «Скачивание вложений в карточке»); без вложений раздела нет; +- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст, + отрендеренный по требованию «Разметка Jira в карточке», под текстом — вложения, на которые ссылается комментарий + (`attachment_ids`): строка «плашек» с именем и размером, щелчок — скачивание; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL + запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»; +- «Служебные заметки» (требование «Служебные заметки»); +- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям; +- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут + при открытии; если команд не было — текст «команд по задаче не было». +На ширине от 900 px «Связи», «Описание», «Вложения», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; +уже — одна колонка: заметки, связи, описание, вложения, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно +SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные +остаются. + +#### Scenario: Связанная задача +- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе» +- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу + меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620 + +#### Scenario: Входящий блокер +- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500 +- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от» + +#### Scenario: Задача без команд +- **WHEN** по AURORA-1620 не было команд +- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет + +#### Scenario: Закрытие после переходов +- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc +- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку + +#### Scenario: Прямой адрес +- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕ +- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку + +#### Scenario: Скрытые комментарии +- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка +- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев + +#### Scenario: Просрочка закрытой задачи +- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово» +- **THEN** в строке под темой «срок 01.10» без пометки «просрочено»; при статусе «В работе» — с пометкой + +### Requirement: Разметка Jira в карточке +Описание задачи и текст каждого комментария в карточке SHALL показываться отрендеренной вики-разметкой Jira (хаб +хранит их как есть, в wiki) так, как их показывает Jira. Разбираются: +- блоки: заголовки `h1.`–`h6.` в начале строки; списки — строки, начинающиеся (после необязательных пробелов) с + маркеров `*`, `#` и пробела (уровень вложенности — числом маркеров, `*` — маркированный, `#` — нумерованный, + смешанная вложенность `*#` — по последнему маркеру) или с одного `-` и пробела (маркированный, первый уровень); + `--`, `*-` и прочие сочетания с `-` — не список; таблицы — строки, начинающиеся с + `|`, ячейки `||заголовок||` и `|ячейка|`; `{code}` / `{code:язык}` … `{code}` и `{noformat}` … `{noformat}` — + моноширинный блок без разбора разметки внутри; `{quote}` … `{quote}` и строка `bq. ` — цитата; открывающий тег + блока — в начале строки, закрывающий — в любом месте той же или последующей строки, текст после закрывающего тега + разбирается дальше как обычные строки; тег не в начале строки — текст как есть; строка `----` — + горизонтальная черта; прочие строки — абзацы, разделённые пустыми строками, одиночный перенос внутри абзаца — + перенос строки; +- внутри строки: `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}` + (`{{…}}` — без разбора внутри); маркер выделения действует, только если открывающий стоит в начале текста или после + пробела либо знака препинания и за ним не пробел и не тот же символ (`**x**` — текст как есть), а закрывающий стоит после не-пробела и за ним не буква и не + цифра (`data_type`, `2026-10-15`, `счёт-фактура`, `a*b*c` — обычный текст); `\\` — перенос строки; `\` перед + символом разметки — сам символ; +- ссылки: `[текст|адрес]`, `[адрес]` и голые адреса `http://`, `https://` — ссылка в новой вкладке, только для схем + `http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида + `ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне + (`UTF-8`, `SHA-256` и ключи других проектов — текст); + `[~логин]` — «@логин»; +- ссылки на вложения задачи — `!имя!`, `!имя|параметры!`, `[^имя]`, `[текст|^имя]` с тем же разбором имени, что у хаба + (`hub-api`, «Ссылки на вложения в тексте»), имя есть среди вложений задачи: ссылка-кнопка со значком скрепки и текстом + — именем файла (у `[текст|^имя]` — текстом), щелчок — скачивание; картинка в тексте не показывается; имени нет среди + вложений — исходник как есть. +Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!` и `[^имя]` без такого вложения у задачи, незакрытые маркеры и блоки и прочее), SHALL +показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, +`dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер +блока завершился ошибкой, блок SHALL показываться исходником. + +Кнопка «исходник» у описания и у каждого комментария SHALL переключать этот блок на исходник — текст с сохранёнными +переносами строк, как без разбора разметки, — и обратно кнопкой «текст»; остальные блоки не переключаются. При каждом +открытии карточки (в том числе при переходе по связи) все блоки показываются отрендеренными, выбор не запоминается. + +#### Scenario: Описание со структурой +- **WHEN** описание AURORA-1620 — `h3. Контекст`, строка `Шаги:`, затем `# Загрузить ОСВ` и `# Открыть документ`, + затем пустая строка и `*Итог:* ошибка в {{data_type}}` +- **THEN** в «Описании» заголовок «Контекст», абзац «Шаги:», нумерованный список из двух пунктов и абзац, где «Итог:» — + жирным, «data_type» — моноширинным; символов `h3.`, `#`, `*`, `{{` не видно + +#### Scenario: Переключение на исходник +- **WHEN** в карточке AURORA-1620 у описания нажато «исходник» +- **THEN** описание показано исходником с переносами строк (видны `h3. Контекст` и `# Загрузить ОСВ`), кнопка — «текст»; + комментарии остаются отрендеренными; после «текст» описание снова отрендерено + +#### Scenario: Исходник у одного комментария +- **WHEN** у второго комментария нажато «исходник», затем открыта связанная задача и возврат «назад» +- **THEN** сразу после нажатия исходником показан только второй комментарий; после возврата все комментарии + отрендерены + +#### Scenario: Ключ задачи в тексте +- **WHEN** комментарий — `П. 1 ведётся в AURORA-1445; AURORA-1645 снята как дубль.` +- **THEN** «AURORA-1445» и «AURORA-1645» — ссылки; щелчок по первой меняет содержимое окна на AURORA-1445 + +#### Scenario: Неизвестная разметка и опасный адрес +- **WHEN** описание — `{color:red}важно{color}`, `!схема.png!` и `[нажми|javascript:alert(1)]` +- **THEN** все три показаны текстом исходника как есть, ссылки и картинки нет, скрипт не выполняется + +#### Scenario: Списки с отступом и пустыми строками +- **WHEN** описание — `Шаги воспроизведения:`, ` * Загрузить ОСВ.`, пустая строка, ` * Дождаться обработки.` +- **THEN** абзац «Шаги воспроизведения:» и маркированные пункты «Загрузить ОСВ.» и «Дождаться обработки.» без + звёздочек + +#### Scenario: Обычный текст не портится +- **WHEN** описание — `Поле data_type = null, дата 2026-10-15, оборот 18 803 903,65 - 9 351 102,89, a*b*c, UTF-8, + **важно**` +- **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок + +#### Scenario: Скриншот в тексте комментария +- **WHEN** комментарий — «см. !скрин 1.png|thumbnail!», у задачи есть вложение «скрин 1.png» +- **THEN** вместо `!скрин 1.png|thumbnail!` — кнопка со скрепкой «скрин 1.png»; под комментарием плашка «скрин 1.png · + 234,4 КБ»; щелчок по любой из них скачивает файл + +## ADDED Requirements + +### Requirement: Скачивание вложений в карточке +Кнопка скачивания вложения (в разделе «Вложения», плашка под комментарием, ссылка в тексте) SHALL работать одинаково: +1. `POST /attachments/{id}/fetch {author: "web"}`; +2. `state = ready` — `GET /attachments/{id}/content` с клиентским токеном, браузер сохраняет файл под именем `filename` + (через `Blob` и временную ссылку `download`; адрес с токеном в строке браузера MUST NOT появляться); +3. `state = fetching` — у всех кнопок этого вложения в карточке вместо «Скачать» — «Загружается…» (неактивна), опрос + `GET /attachments/{id}` раз в 2 с, пока окно открыто на этой задаче; `ready` — шаг 2 сам, без второго нажатия; закрыли + окно или ушли на другую задачу — опрос прекращается, команда остаётся в очереди (файл ляжет в хаб); +4. `failed` — под вложением в разделе «Вложения» текст ошибки и кнопка «Повторить» (новый запрос); 409 `agent_too_old` + и прочие ошибки хаба — их текст там же. +Вложение с `state = ready` в разделе «Вложения» помечается «у хаба» и скачивается сразу. Размер неизвестен — «размер неизвестен» и обычная кнопка. +Вложение больше 10 МБ — +вместо кнопки ссылка «Открыть в Jira» (`<адрес Jira>/secure/attachment//<имя в URL-кодировке>`, новая вкладка) с +подсказкой «больше 10 МБ — только в Jira»; плашка и ссылка в тексте ведут туда же. + +#### Scenario: Скачать скриншот +- **WHEN** в карточке AURORA-1650 нажато «Скачать» у «скрин 1.png», файла у хаба нет, агент онлайн +- **THEN** кнопка — «Загружается…», через несколько секунд браузер сохраняет «скрин 1.png», кнопка снова «Скачать», у + вложения отметка «у хаба» + +#### Scenario: Окно закрыли во время скачивания +- **WHEN** нажато «Скачать», затем окно карточки закрыто +- **THEN** опросов `GET /attachments/{id}` больше нет; при следующем открытии карточки, если файл уже у хаба, вложение с + отметкой «у хаба» + +#### Scenario: Большой файл +- **WHEN** у вложения «запись.mp4» размер 25 МБ +- **THEN** вместо «Скачать» — ссылка «Открыть в Jira» с подсказкой «больше 10 МБ — только в Jira» + +#### Scenario: Агент старой версии +- **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14» +- **THEN** под вложением этот текст и «Повторить»