Andrey Epifancev 9b21060d60 feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)
SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:46:14 +04:00

16 KiB
Raw Blame History

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:<n>, зависимость от 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). Каталог <home>/files/, имя — sha256, без имени вложения (одно содержимое может уходить под разными именами). Запись: временный .<sha>.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''<percent-encoded>, 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/ (.<sha>.<случайное>.tmp, права 0600), а не фиксированное .<sha>.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. Обоснование: для агента это тот же случай «файла нет». Цена: нет.
  17. 15.10.2026 — тексты 422 без формулировки в спеке: «команда N: file — sha256 из POST /files (64 символа 0-9a-f)», «команда N: filename пустой» (только пробелы; пустая строка — общее «нет filename; ожидаются …»), «команда N: filename без / и \ — имя вложения, а не путь». Порядок проверок: формат file, наличие файла, filename. Цена: формулировки правятся одной строкой.
  18. 15.10.2026 — queue.create_batch, queue.retry_command и queue.claim получают files_dir параметром (по умолчанию None: вложение не ставится — «файл не загружен», при выдаче size = null); api передаёт Hub.files_dir; GET /agent/commands/{id} дописывает size через queue.deliver_params. Обоснование: queue остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без files_dir молча отвергает вложения.