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