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

133 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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` молча отвергает вложения.