## Context Хаб `main` 68de173 (после #44), агент `main` 7d90c9b (v10). Агент на виртуалке банка ходит в хаб через общую папку UTM (`hub_url = folder:…`), HTTP-транспорт тоже поддерживается. Найдено при чтении кода (15.10.2026): - Реле папки (`relay.py`) отдаёт тело ответа строкой UTF-8 (`decode("utf-8", "replace")`), агент в HTTP-режиме понимает только JSON (`Http.call`). Двоичный ответ не пройдёт ни там, ни там; JSON со строкой base64 пройдёт обоими путями. Прецедент — `GET /agent/release` (`content` строкой, sha256 сверяет агент). - Предел тела запроса хаба `MAX_BODY = 16 МБ` (413), в реле предела нет; ответы не ограничены. - `command.params` (JSON) уходит в каждый `claim` и каждую довыдачу (до 3), копируется при retry, по нему ищутся команды задачи (`params LIKE '%"KEY"%'`), агент пишет `params[:200]` в лог — содержимому файла там не место. - `issue_types` в настройках — `{task, epic}`; `issue_type_ref` агента для неизвестного типа отдаёт `{"name": None}` — Jira ответит 400 без понятной причины. Зеркало хранит имя типа Jira (`issue.issue_type`), id типа не хранит; веб показывает тип как есть, «Ошибка» станет обычной карточкой без доработок. - Подстановка `new:`, зависимость от `ensure_issue` и хвостовой `refresh` работают для любого типа с полем `key` в `SPEC.keys`. Старый агент на неизвестный тип отвечает `failed` «неизвестный тип команды» и не падает. - Подтверждения по состоянию у новых типов нет по умолчанию (`check_confirmed` → `False`). ## Goals / Non-Goals **Goals:** одним пакетом из чата завести «Ошибку», поставить в спринт и приложить локальный файл; повтор пакета не создаёт второй задачи и второго вложения; транспорт и реле не меняются. **Non-Goals:** см. proposal (веб-загрузка, список вложений в зеркале, удаление вложений, поля по умолчанию, гейтинг по версии агента, файлы больше 10 МБ). ## Decisions ### Тип «Ошибка» `issue_types.bug = {name: "Ошибка", id: null}` ставится на живом хабе `settings_set`; id впишем, когда агент создаст первую «Ошибку» (Jira вернёт тип в задаче). Хаб проверяет наличие настройки при постановке (быстрая ошибка в чате), агент — при выполнении (настройку могли убрать между постановкой и выдачей). ### Хранилище файлов Модуль `files.py` хаба: `put(content_bytes) -> (sha, size)`, `path(sha)`, `exists(sha)`, `size(sha)`, `sweep(conn, now)`. Каталог `/files/`, имя — sha256, без имени вложения (одно содержимое может уходить под разными именами). Запись: временный `..tmp` в том же каталоге, fsync, `os.replace`. Уборка — из `tick` не чаще раза в сутки: файлы старше 30 дней по mtime; ссылки — `params` команд `ensure_attachment` в `queued`/`claimed` (поиск `json_extract(params, '$.file')`). Повторная загрузка того же файла обновляет mtime (`os.utime`), чтобы свежая постановка не потеряла файл в уборке. ### Команда и выдача `SPEC["ensure_attachment"] = {required: [key, file, filename], optional: [], keys: [key], hints: {file: "sha256 из POST /files; в MCP — path к локальному файлу", filename: "имя вложения"}}`. Проверки типа значения — общие; формат `file`, наличие файла и `filename` — в проверке постановки, которой нужен каталог файлов (не в чистой `validate`, а в `queue.create_batch` с доступом к хранилищу). `size` дописывается при выдаче (`claim`) — там же, где `substitute` подставляет временные ключи; в базе `params` без `size`, поэтому retry и `GET /agent/commands/{id}` (восстановление из журнала — тоже с `size`) работают одинаково. ### MCP `batch_create` до `POST /batches` обходит команды: у `ensure_attachment` с `path` читает файл (не больше 10 МБ, иначе ошибка до загрузки), `POST /files`, подставляет `file` и `filename` (если не задан — `Path(path).name`), удаляет `path`. Ошибки чтения — текст инструмента с путём, пакет не ставится. Загрузка без последующей постановки (422 из-за другой команды) оставляет файл в хранилище — его уберёт уборка. ### Агент v11 `do_ensure_attachment`: `GET /issue/{key}?fields=attachment` → совпадение по `filename` и `size` → `done` (`existed: true`); иначе `hub.file(sha)` → проверка sha256 и длины → `Jira.upload(key, filename, data)`: `multipart/form-data`, граница — `uuid4().hex`, часть `file` с `Content-Disposition: form-data; name="file"; filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: application/octet-stream`. Таймаут загрузки — `max(timeout, 120)` с. Ответ Jira — список вложений, берётся `id` первого. Содержимое файла в журнал и лог не пишется. Заодно M-1 и M-2 из aep/jira-agent#30 в `comments_of`/`comment_state`. ### Выкатка 1. Merge PR хаба, `git pull` на Mac mini, перезапуск хаба (launchd) и MCP; `make web` не нужен (фронт не меняется). 2. `settings_set issue_types` = `{task…, epic…, bug: {name: "Ошибка", id: null}}`. 3. Merge PR агента, `jira-hub agent publish …/agent.py` (v11), агент обновится сам; `min_agent_version = 11`. 4. Живая проверка: пакет «Ошибка в бэклог + вложение с кириллическим именем» на тестовой задаче, повтор пакета — без дублей; тестовую задачу владелец удаляет в Jira; id типа «Ошибка» вписать в `issue_types.bug.id`. ## Risks / Trade-offs - base64 даёт +33 % к размеру, файл 10 МБ — около 13,4 МБ JSON в одном файле общей папки; VirtioFS это переносит (круг папки < 1 с на малых файлах, на больших — секунды), таймаут вызова агента 60 с с запасом. - Имя файла с кириллицей в multipart: Jira Server (commons-fileupload) читает `filename` в кодировке запроса; отправляем UTF-8 и `filename*`. Проверяется только живой загрузкой (шаг 4 выкатки); если имя исказится — следующей версией агента. - Предел вложений Jira банка неизвестен (по умолчанию 10 МБ); больший предел банка не используется, меньший даст 413 от Jira — `failed` с текстом. - Идемпотентность по `filename` + `size`: другой файл того же размера и с тем же именем будет принят за уже приложенный. ## Decisions made without discussion 1. 15.10.2026 — предел файла 10 МБ (после декодирования). Обоснование: стандартный предел вложений Jira, запас под 16 МБ тела запроса хаба с base64. Цена ошибки: файл 10–16 МБ нельзя приложить через мост — поднять предел одной константой. 2. 15.10.2026 — хранение файлов 30 дней с уборкой, кроме файлов активных команд. Обоснование: как `backup_keep` (30); retry старой команды после уборки даёт понятный 422 «файл не загружен». Цена: повторная загрузка файла из чата. 3. 15.10.2026 — пустой файл отвергается (422 `empty_file`). Обоснование: пустое вложение — почти всегда ошибка пути или выгрузки. Цена: нельзя приложить пустой файл намеренно. 4. 15.10.2026 — вложение с тем же именем, но другим размером загружается вторым вложением, старое не удаляется. Обоснование: команда только добавляет недостающее, удаление в Jira — необратимая операция. Цена: два вложения с одним именем, лишнее владелец удаляет руками. 5. 15.10.2026 — `size` дописывается хабом при выдаче, а не хранится в `params`. Обоснование: retry копирует `params` и проходит обычную проверку; клиент не может прислать неверный размер. Цена: выдача читает размер файла с диска. 6. 15.10.2026 — наличие настройки `issue_types.bug` проверяют и хаб (при постановке), и агент (при выполнении). Обоснование: быстрая ошибка в чате; настройку могли изменить между постановкой и выдачей. Цена: две проверки одного. 7. 15.10.2026 — проверка наличия файла и формата `file` — в `queue.create_batch`, а не в чистой `commands.validate`. Обоснование: `validate` не знает каталог данных. Цена: два места проверки параметров одной команды. 8. 15.10.2026 — в change включены M-1 и M-2 из aep/jira-agent#30 и R-1 из aep/jira-hub#46 (по порядку, согласованному владельцем 15.10.2026). M-3, M-4 из jira-agent#30 остаются в follow-up. Цена: change немного шире двух issue. 9. 15.10.2026 — `issue_type` вне списка — 422 «команда N: issue_type — task, epic или bug»; `init` добавляет `bug`, если ключ `issue_type_bug` или `issue_type_bug_id` есть в конфиге даже со значением `null` (тогда постановка `bug` — 422, как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. 10. 15.10.2026 — функции `files` принимают каталог хранилища первым параметром (`put(root, data)`, `path(root, sha)`, `exists`, `size`, `read`, `sweep(conn, root, now)`), каталог — `Hub.files_dir` (`<каталог базы>/files`), а не `paths.home()`. Обоснование: тесты поднимают хаб во временном каталоге без `JIRA_HUB_HOME` (как `release_dir`). Цена: лишний параметр в вызовах из `queue` и `api`. 11. 15.10.2026 — временный файл записи — `tempfile.mkstemp` в `files/` (`..<случайное>.tmp`, права 0600), а не фиксированное `..tmp`. Обоснование: две одновременные загрузки одного содержимого (ThreadingHTTPServer) не пишут в один временный файл и не ломают друг другу `os.replace`. Цена: брошенные после сбоя временные файлы убирает уборка общим правилом 30 дней. 12. 15.10.2026 — `files.path/exists/size/read` принимают только sha из 64 символов `[0-9a-f]`, иначе «файла нет» (`path` — `ValueError`). Обоснование: APFS не различает регистр — `BA78…` нашёл бы `ba78…`; `..` и `/` не строят путь вне `files/`. Цена: нет. 13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого, агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). 14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — 400». Цена: строка с пробелами внутри тоже принимается. 15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1). 16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`. Обоснование: для агента это тот же случай «файла нет». Цена: нет.