From 70eaf5dc207e5458dafd418b507f1eeb5762f28c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 16:06:20 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=20OpenSpec:=20at?= =?UTF-8?q?tachment-fetch=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека agent: вложение без имени тоже отбрасывается (R-50 агента). Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 0 .../2026-10-22-attachment-fetch}/design.md | 0 .../2026-10-22-attachment-fetch}/proposal.md | 0 .../specs/agent/spec.md | 0 .../specs/commands/spec.md | 0 .../specs/data-model/spec.md | 0 .../specs/hub-api/spec.md | 0 .../specs/mcp/spec.md | 0 .../specs/web-app/spec.md | 0 openspec/specs/agent/spec.md | 25 ++++- openspec/specs/commands/spec.md | 60 ++++++++++-- openspec/specs/data-model/spec.md | 64 ++++++++++--- openspec/specs/hub-api/spec.md | 95 +++++++++++++++++-- openspec/specs/mcp/spec.md | 52 +++++++++- openspec/specs/web-app/spec.md | 55 ++++++++++- 15 files changed, 318 insertions(+), 33 deletions(-) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/.openspec.yaml (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/design.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/proposal.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/agent/spec.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/commands/spec.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/data-model/spec.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/hub-api/spec.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/mcp/spec.md (100%) rename openspec/changes/{attachment-fetch => archive/2026-10-22-attachment-fetch}/specs/web-app/spec.md (100%) diff --git a/openspec/changes/attachment-fetch/.openspec.yaml b/openspec/changes/archive/2026-10-22-attachment-fetch/.openspec.yaml similarity index 100% rename from openspec/changes/attachment-fetch/.openspec.yaml rename to openspec/changes/archive/2026-10-22-attachment-fetch/.openspec.yaml diff --git a/openspec/changes/attachment-fetch/design.md b/openspec/changes/archive/2026-10-22-attachment-fetch/design.md similarity index 100% rename from openspec/changes/attachment-fetch/design.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/design.md diff --git a/openspec/changes/attachment-fetch/proposal.md b/openspec/changes/archive/2026-10-22-attachment-fetch/proposal.md similarity index 100% rename from openspec/changes/attachment-fetch/proposal.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/proposal.md diff --git a/openspec/changes/attachment-fetch/specs/agent/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/agent/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/agent/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/agent/spec.md diff --git a/openspec/changes/attachment-fetch/specs/commands/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/commands/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/commands/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/commands/spec.md diff --git a/openspec/changes/attachment-fetch/specs/data-model/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/data-model/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/data-model/spec.md diff --git a/openspec/changes/attachment-fetch/specs/hub-api/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/hub-api/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/hub-api/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/hub-api/spec.md diff --git a/openspec/changes/attachment-fetch/specs/mcp/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/mcp/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/mcp/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/mcp/spec.md diff --git a/openspec/changes/attachment-fetch/specs/web-app/spec.md b/openspec/changes/archive/2026-10-22-attachment-fetch/specs/web-app/spec.md similarity index 100% rename from openspec/changes/attachment-fetch/specs/web-app/spec.md rename to openspec/changes/archive/2026-10-22-attachment-fetch/specs/web-app/spec.md diff --git a/openspec/specs/agent/spec.md b/openspec/specs/agent/spec.md index 9d356a9..e23cd78 100644 --- a/openspec/specs/agent/spec.md +++ b/openspec/specs/agent/spec.md @@ -108,7 +108,7 @@ Jira в текущем процессе, до него — `null`. На HTTP 429 - **THEN** `last_jira_call_at` в `claim` — `null`; после первого вызова Jira следующий `claim` несёт его время ### Requirement: Чтение состояния -`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment` и спринт, +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment`, `attachment` и спринт, `maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в @@ -120,6 +120,11 @@ Jira в текущем процессе, до него — `null`. На HTTP 429 постранично (`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 задачах @@ -152,6 +157,13 @@ Jira в текущем процессе, до него — `null`. На HTTP 429 - **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` не изменилось, обращений + к содержимому вложений нет + ### Requirement: Доставка и запуск README агента SHALL описывать: первую установку (копирование `agent.py`, `run.py` и `agent.example.json` через общую папку в локальный каталог виртуалки, заполнение `agent.json` с выбором транспорта в `hub_url`, `check`), запуск @@ -493,3 +505,14 @@ from_string, to, to_string}`: `history_id` — id записи строкой; ` #### Scenario: Хаб без журнала - **WHEN** `refresh {jql: base}` без `history` - **THEN** журнал не читается; в результате нет ключа `history` + +### 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/specs/commands/spec.md b/openspec/specs/commands/spec.md index c270f95..26fecef 100644 --- a/openspec/specs/commands/spec.md +++ b/openspec/specs/commands/spec.md @@ -8,7 +8,7 @@ ### Requirement: Команда — желаемый результат на одной задаче Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` -без записи. Типы команд и их контракт: +без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт: | Тип | Параметры | Что читает | Что пишет | |---|---|---|---| @@ -20,6 +20,7 @@ | `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` — @@ -108,7 +109,7 @@ ### Requirement: Пакет и порядок выполнения Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка -и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Хаб SHALL проверять +и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh` и `fetch_attachment`). Хаб SHALL проверять типы параметров: текстовые параметры и ключи — строки (`null` у необязательного параметра — «не задан»; агент трактует его по контракту типа, для `ensure_fields` — очистка поля), `target` команды `ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` — @@ -131,7 +132,8 @@ ### Requirement: Политика темпа и пауза на команде Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и -`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30, `refresh` 2). При выдаче команды +`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 выдерживать зазор типа команды между записями. @@ -157,8 +159,8 @@ SHALL выдерживать зазор типа команды между за **Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия `\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь -есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`, `ensure_attachment` и `refresh` по -состоянию не подтверждаются (вложений в зеркале нет): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`, `ensure_attachment`, `fetch_attachment` и `refresh` по +состоянию не подтверждаются (зеркало не показывает ни загрузку вложения, ни скачанный файл): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) или отменой человеком. #### Scenario: Отчёт потерян, факт на месте @@ -230,7 +232,7 @@ SHALL перевести её в `confirmed_by_state`: `ensure_fields` — вс - `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; - `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P; -- `refresh` — без проверок проекта. +- `refresh` и `fetch_attachment` — без проверок проекта. «Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту (метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит, метки хаб в нём не дописывает. @@ -292,3 +294,49 @@ SHALL перевести её в `confirmed_by_state`: `ensure_fields` — вс #### Scenario: Хвост пакета до первой отметки - **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, прогонов с `history_at` нет - **THEN** в выданных параметрах нет `history` + +### 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/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index a8f0681..f301515 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -6,15 +6,15 @@ ## Requirements ### Requirement: Схема базы хаба -База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_note`, -`batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при -старте по номеру версии в `setting.schema_version` (текущая — 5). Миграция существующей базы SHALL выполняться в одной +База хаба 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`) и значений полей журнала. +Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта @@ -29,6 +29,9 @@ Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) `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` @@ -36,24 +39,29 @@ Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) #### Scenario: Миграция базы v1 - **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` - **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая - `issue_comment`; данные на месте; `schema_version = 5` + `issue_comment`; данные на месте; `schema_version = 6` #### Scenario: Миграция базы v2 - **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки -- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 5` +- **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 = 5` + пакеты на месте; `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`; `schema_version = 5` + `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"}` @@ -74,10 +82,14 @@ Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) `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`, `issue_link` и -`issue_comment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные +`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 сохранять. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не +SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без +поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не обновлённые им, — только если прогон загрузил хотя бы одну задачу. #### Scenario: Загрузка состояния заменяет связи @@ -101,6 +113,14 @@ SHALL сохранять. Полный прогон (`refresh` по базово - **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 обновлены, два вложения на месте + ### Requirement: Служебные заметки Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), `fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала @@ -298,3 +318,25 @@ label, через `, `; без проектов базовой JQL #### Scenario: Старый агент - **WHEN** агент v12 завершил `refresh` `done` без `result.history` - **THEN** у его `sync_run` `history_at = NULL` + +### 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/specs/hub-api/spec.md b/openspec/specs/hub-api/spec.md index 49a9891..bc06039 100644 --- a/openspec/specs/hub-api/spec.md +++ b/openspec/specs/hub-api/spec.md @@ -47,12 +47,17 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача пачка применяется независимо; `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` @@ -108,15 +113,21 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача зеркала (вне базовой 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 команд по ней, - `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем - `comment_id`, по убыванию). + `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}` (с командами и @@ -198,13 +209,17 @@ HTTP API хаба `jira-hub serve`: маршруты агента (выдача - **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре ### Requirement: Резервные копии -Перед выдачей первой команды пакета, содержащего записи в Jira, хаб SHALL скопировать файл базы в `backups/<дата-время>-.sqlite` -и хранить последние `backup_keep` копий. +Перед выдачей первой команды пакета, содержащего записи в Jira (любая команда, кроме `refresh` и `fetch_attachment`), хаб +SHALL скопировать файл базы в `backups/<дата-время>-.sqlite` и хранить последние `backup_keep` копий. #### Scenario: Ротация - **WHEN** копий больше `backup_keep` - **THEN** самые старые удаляются до `backup_keep` +#### Scenario: Скачивание без копии +- **WHEN** выдаётся `fetch_attachment` пакета «Скачать вложение …» +- **THEN** копия базы не создаётся + ### Requirement: Автообновление зеркала Если `auto_refresh_minutes > 0`, агент на связи и есть хотя бы один проект, хаб SHALL поставить пакет «Автообновление» из `refresh {jql: <базовая JQL из проектов>}`, когда в очереди нет команд в `queued` и `claimed` и последний `sync_run` @@ -414,9 +429,11 @@ sha256 (без расширения и имени вложения: имя за - запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем же `file` и не создаёт второй копии. Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по -времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed`; повтор команды +времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed` (скачанные +вложения `attachment_file` от уборки не защищены: их можно скачать снова), и подкаталоги `downloads//` +(копии MCP, см. `mcp`) старше 30 дней по времени изменения; повтор команды после уборки отвергается при постановке 422 «файл не загружен: POST /files». Повторная загрузка того же -содержимого SHALL обновлять время изменения файла. +содержимого SHALL обновлять время изменения файла; выдача файла `GET /attachments/{id}/content` — тоже. #### Scenario: Загрузка файла - **WHEN** `POST /files {content: base64("abc")}` @@ -435,6 +452,11 @@ sha256 (без расширения и имени вложения: имя за `queued`, файл C загружен вчера - **THEN** после уборки удалён только A +#### Scenario: Скачанное вложение и уборка +- **WHEN** в `files/` файл D скачан агентом (`attachment_file`) 40 дней назад и с тех пор не выдавался; в `downloads/` + каталог `31337/` создан 40 дней назад +- **THEN** после уборки D и `downloads/31337/` удалены; `state` вложения — `none` + ### Requirement: Маршруты проектов Хаб SHALL предоставлять под клиентским токеном: - `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; @@ -539,3 +561,60 @@ from_string, to, to_string}`. До записи хаб SHALL проверить #### Scenario: Задача с историей - **WHEN** у X в журнале смена статуса вчера и правка описания сегодня; `GET /issues/X` - **THEN** `changes` из двух элементов: первым — правка описания (`kind = description`), вторым — смена статуса + +### Requirement: Скачивание вложений +Хаб SHALL предоставлять интерфейсам под клиентским токеном: +- `GET /attachments/{id}` — вложение из зеркала `{attachment_id, key, filename, size, mime_type, author, created, state, + error, command_id, batch_id}`, где `command_id`/`batch_id` — последней `fetch_attachment` по нему (или `null`); нет в + зеркале — 404 `no_attachment`. +- `POST /attachments/{id}/fetch` `{author?}` — запросить скачивание. Нет в зеркале — 404 `no_attachment`; `state = + ready` или `fetching` — ответ как у `GET /attachments/{id}`, новой команды нет; размер больше 10 МБ — 413 `too_large`; + последний известный хабу агент (`agent_status.agent_version`) старше v14 — 409 `agent_too_old` «агент v не умеет + скачивать вложения — нужен v14» (версия неизвестна — проверка не делается); иначе хаб SHALL поставить пакет из одной + `fetch_attachment` (см. `commands`, автор — из тела, иначе `api`) и ответить как `GET /attachments/{id}` с `state = + fetching`. Проверка «уже ставится» и постановка SHALL выполняться в одной транзакции записи: два одновременных запроса + ставят одну команду. +- `GET /attachments/{id}/content` — содержимое при `state = ready`: 200, тело — байты файла, `Content-Type` — `mime_type` + вложения (нет или не вида `тип/подтип` — `application/octet-stream`), `Content-Disposition: attachment; + filename*=UTF-8''<имя в URL-кодировке>`, `X-Content-Type-Options: nosniff`, `Content-Length`; иначе 404 `not_fetched`. +Отчёт `done` по `fetch_attachment` (`POST /agent/commands/{id}/result`) SHALL записывать `attachment_file` из +`result.file` и `result.size`, если файл `file` есть в хранилище и его размер равен `size`; иначе команда SHALL +становиться `failed` «хаб: файл не найден в хранилище» и `attachment_file` не меняется. + +#### Scenario: Первый запрос +- **WHEN** вложение #31337 в зеркале, `state = none`, агент v14; `POST /attachments/31337/fetch {author: "web"}` +- **THEN** создан пакет «Скачать вложение скрин 1.png (AURORA-1650)» из одной `fetch_attachment`, без хвостового + `refresh`; ответ `state = fetching` с `command_id` и `batch_id` + +#### Scenario: Повторный запрос во время скачивания +- **WHEN** по #31337 команда в `queued`; MCP и веб одновременно вызывают `POST /attachments/31337/fetch` +- **THEN** оба ответа — `state = fetching` с той же `command_id`; новых пакетов нет + +#### Scenario: Файл уже у хаба +- **WHEN** #31337 в `state = ready`; `GET /attachments/31337/content` +- **THEN** 200, `Content-Type: image/png`, `Content-Disposition: attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`, + тело — файл; время изменения файла в `files/` обновлено + +#### Scenario: Старый агент +- **WHEN** `agent_status.agent_version = 13`; `POST /attachments/31337/fetch` +- **THEN** 409 `agent_too_old` «агент v13 не умеет скачивать вложения — нужен v14», пакета нет + +#### Scenario: Отчёт без файла в хранилище +- **WHEN** агент отчитался `done {file: , size: 100}`, а файла `` в `files/` нет +- **THEN** команда `failed` «хаб: файл не найден в хранилище»; `state` вложения — `failed` + +### Requirement: Ссылки на вложения в тексте +Хаб SHALL находить в описании задачи и в тексте каждого комментария ссылки на вложения задачи по вики-разметке Jira: +`!<имя>!` и `!<имя>|<параметры>!` (картинка; имя без `!`, `|` и переводов строк, не содержит `://`), `[^<имя>]` и +`[<текст>|^<имя>]` (ссылка на вложение; имя без `]` и переводов строк). Имя SHALL сравниваться с `filename` вложений +задачи точно (с учётом регистра); несколько вложений с одним именем — ссылка указывает на самое новое по `created_at` +(как в Jira); имени нет среди вложений — ссылка не учитывается. Ссылки внутри `{code}`/`{noformat}` тоже учитываются. + +#### Scenario: Скриншот в комментарии +- **WHEN** у задачи вложения «скрин 1.png» (#31337) и «ОСВ 60.xlsx» (#31338); комментарий 101 — «см. + !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!»; описание ссылается на «старый.png», которого нет +- **THEN** у комментария 101 `attachment_ids = ["31337", "31338"]`; `description_attachment_ids = []` + +#### Scenario: Два вложения с одним именем +- **WHEN** у задачи два вложения «скрин.png» (#10 от 14.10, #20 от 20.10); комментарий — «!скрин.png!» +- **THEN** у комментария `attachment_ids = ["20"]` diff --git a/openspec/specs/mcp/spec.md b/openspec/specs/mcp/spec.md index b9fe6e4..00b2d9b 100644 --- a/openspec/specs/mcp/spec.md +++ b/openspec/specs/mcp/spec.md @@ -22,8 +22,9 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм | `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; без комментариев — «комментариев нет» | -| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #» | +| `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` | статусы команд, ошибки, оценка остатка | @@ -189,3 +190,50 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм #### Scenario: История в карточке - **WHEN** `issue {key: X}`, у X 7 элементов журнала - **THEN** ответ содержит «изменения (7):», 5 последних событий и «ещё 2» + +### 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/specs/web-app/spec.md b/openspec/specs/web-app/spec.md index 7732b98..940dc4b 100644 --- a/openspec/specs/web-app/spec.md +++ b/openspec/specs/web-app/spec.md @@ -293,15 +293,18 @@ SHALL быть по умолчанию (в адресе ничего), «Все - «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания раздела нет; +- «Вложения (N)» — новые сверху: значок по типу файла (картинка, таблица, PDF, прочее), имя, размер, «автор · дата» и + кнопка скачивания (требование «Скачивание вложений в карточке»); без вложений раздела нет; - «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст, - отрендеренный по требованию «Разметка Jira в карточке»; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL + отрендеренный по требованию «Разметка Jira в карточке», под текстом — вложения, на которые ссылается комментарий + (`attachment_ids`): строка «плашек» с именем и размером, щелчок — скачивание; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»; - «Служебные заметки» (требование «Служебные заметки»); - «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям; - «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут при открытии; если команд не было — текст «команд по задаче не было». -На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; -уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно +На ширине от 900 px «Связи», «Описание», «Вложения», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; +уже — одна колонка: заметки, связи, описание, вложения, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные остаются. @@ -400,8 +403,12 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха `http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида `ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне (`UTF-8`, `SHA-256` и ключи других проектов — текст); - `[~логин]` — «@логин». -Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!`, незакрытые маркеры и блоки и прочее), SHALL + `[~логин]` — «@логин»; +- ссылки на вложения задачи — `!имя!`, `!имя|параметры!`, `[^имя]`, `[текст|^имя]` с тем же разбором имени, что у хаба + (`hub-api`, «Ссылки на вложения в тексте»), имя есть среди вложений задачи: ссылка-кнопка со значком скрепки и текстом + — именем файла (у `[текст|^имя]` — текстом), щелчок — скачивание; картинка в тексте не показывается; имени нет среди + вложений — исходник как есть. +Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!` и `[^имя]` без такого вложения у задачи, незакрытые маркеры и блоки и прочее), SHALL показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, `dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер блока завершился ошибкой, блок SHALL показываться исходником. @@ -444,6 +451,11 @@ SHALL сообщать об этом с кнопкой «Закрыть»; ха **важно**` - **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок +#### Scenario: Скриншот в тексте комментария +- **WHEN** комментарий — «см. !скрин 1.png|thumbnail!», у задачи есть вложение «скрин 1.png» +- **THEN** вместо `!скрин 1.png|thumbnail!` — кнопка со скрепкой «скрин 1.png»; под комментарием плашка «скрин 1.png · + 234,4 КБ»; щелчок по любой из них скачивает файл + ### Requirement: Проект в приложении Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть @@ -613,3 +625,36 @@ Semibold 14/20, заголовок страницы Bold 20/28. Страницы #### Scenario: Карточка с историей - **WHEN** открыта карточка задачи с 12 событиями журнала - **THEN** блок «История (12)» показывает 10 последних событий и кнопку «Показать все (12)»; после нажатия — все 12 + +### 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** под вложением этот текст и «Повторить»