From d327a0488de2839f05b537fbe9ffbef3d5c0856a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 23:42:21 +0400 Subject: [PATCH 1/9] =?UTF-8?q?docs(openspec):=20change=20bug-and-attachme?= =?UTF-8?q?nts=20=E2=80=94=20=D1=82=D0=B8=D0=BF=20=C2=AB=D0=9E=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=C2=BB=20=D0=B8=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дельты commands, hub-api, mcp, data-model, agent: issue_type bug с настройкой issue_types.bug, хранилище файлов хаба (POST /files, GET /agent/files/{sha256}), команда ensure_attachment, path в MCP batch_create, агент v11; заодно R-1 (#46) и M-1/M-2 из aep/jira-agent#30. Co-Authored-By: Claude Opus 5.5 --- .../bug-and-attachments/.openspec.yaml | 2 + .../changes/bug-and-attachments/design.md | 99 +++++++++++ .../changes/bug-and-attachments/proposal.md | 61 +++++++ .../bug-and-attachments/specs/agent/spec.md | 85 +++++++++ .../specs/commands/spec.md | 154 ++++++++++++++++ .../specs/data-model/spec.md | 24 +++ .../bug-and-attachments/specs/hub-api/spec.md | 167 ++++++++++++++++++ .../bug-and-attachments/specs/mcp/spec.md | 71 ++++++++ 8 files changed, 663 insertions(+) create mode 100644 openspec/changes/bug-and-attachments/.openspec.yaml create mode 100644 openspec/changes/bug-and-attachments/design.md create mode 100644 openspec/changes/bug-and-attachments/proposal.md create mode 100644 openspec/changes/bug-and-attachments/specs/agent/spec.md create mode 100644 openspec/changes/bug-and-attachments/specs/commands/spec.md create mode 100644 openspec/changes/bug-and-attachments/specs/data-model/spec.md create mode 100644 openspec/changes/bug-and-attachments/specs/hub-api/spec.md create mode 100644 openspec/changes/bug-and-attachments/specs/mcp/spec.md diff --git a/openspec/changes/bug-and-attachments/.openspec.yaml b/openspec/changes/bug-and-attachments/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/bug-and-attachments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md new file mode 100644 index 0000000..f72eb2e --- /dev/null +++ b/openspec/changes/bug-and-attachments/design.md @@ -0,0 +1,99 @@ +## 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. diff --git a/openspec/changes/bug-and-attachments/proposal.md b/openspec/changes/bug-and-attachments/proposal.md new file mode 100644 index 0000000..28a453b --- /dev/null +++ b/openspec/changes/bug-and-attachments/proposal.md @@ -0,0 +1,61 @@ +## Why + +15.10.2026 при заведении AURORA-1639 (замечания тестирования и файл ОСВ, не прошедший распознавание) мост не справился +с двумя вещами, и владелец доделывал руками в Jira: задачу нельзя завести типом «Ошибка» (`ensure_issue` принимает только +`task`/`epic`), и к задаче нельзя приложить файл — такой команды нет. Баги из тестирования в AURORA заводятся типом +«Ошибка», и к ним почти всегда прикладываются файлы (ОСВ, скриншоты). Issues: aep/jira-hub#43, aep/jira-agent#31. + +Решения владельца 15.10.2026 (brainstorming): файл приходит только из MCP (локальный путь в чате), веб-морда загрузку +пока не умеет; обязательные поля у «Ошибки» те же, что у «Задачи»; файл хранится в хабе, агент забирает его отдельным +запросом (вариант A), а не через общую папку напрямую и не base64 в параметрах команды. + +## What Changes + +- **Тип `bug`** в `ensure_issue`: настройка `issue_types.bug = {name, id}` (`id` необязателен — агент шлёт `{name}`), + ключи конфига `issue_type_bug`/`issue_type_bug_id` в `init`; без настройки хаб отвергает пакет 422, агент — ошибкой + конфигурации вместо `{"name": null}`. +- **Хранилище файлов** в хабе: `POST /files {content: base64}` → `{file: sha256, size}`, каталог `files/` (0700), предел + 10 МБ (413), атомарная запись, одна копия на содержимое, уборка файлов старше 30 дней без ссылок активных команд. + `GET /agent/files/{sha256}` — файл агенту (base64 в JSON: проходит и через реле папки, и по HTTP). +- **Команда `ensure_attachment {key, file, filename}`**: `key` — ключ или `new:`; хаб проверяет, что файл загружен, + при выдаче дописывает `size`; агент ищет вложение с тем же именем и размером, иначе скачивает файл у хаба, сверяет sha256 + и загружает `multipart/form-data`. Пауза 30 с; по состоянию зеркала не подтверждается. +- **MCP `batch_create`**: у `ensure_attachment` вместо `file` можно `path` — MCP сам загружает файл и подставляет `file` + и `filename`. Одним пакетом: создать «Ошибку» → поставить в спринт → приложить файл. +- **Описание параметров и ошибки 422** (R-1 из aep/jira-hub#46): закрепить в спеке поведение, сделанное в #44 — + описание `batch_create` из описания параметров в коде, «ожидаются …» в ошибках валидации. +- **Агент v11** (`AGENT_VERSION = 11`, самообновлением): тип из настроек с ошибкой конфигурации, `do_ensure_attachment`, + multipart на stdlib; заодно из aep/jira-agent#30 — комментарий без `id` отбрасывается с логом (M-1), 403/404 при + дочитывании комментариев не роняет `refresh` (M-2). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `commands`: тип `bug`; команда `ensure_attachment` (таблица, темп, подтверждение); проверки постановки; описание + параметров и текст ошибок валидации. +- `hub-api`: `POST /files`, `GET /agent/files/{sha256}`, хранилище файлов и его уборка, 422 у `POST /batches`. +- `mcp`: описание `batch_create` из кода; `path` у `ensure_attachment`. +- `data-model`: `issue_types.bug` и ключи конфига. +- `agent`: тип из настроек, выполнение `ensure_attachment`, устойчивость дочитывания комментариев, v11. + +## Non-Goals + +- загрузка файла из веб-морды и список вложений в зеркале/карточке — отдельно, когда понадобится; +- удаление и замена вложений в Jira (команда только добавляет недостающее); +- поля по умолчанию для типа «Ошибка» (обязательные поля как у задачи); +- привязка выдачи команд к версии агента: старый агент отвечает `failed` «неизвестный тип команды», хватает + `min_agent_version = 11` (предупреждение); +- вложения больше 10 МБ. + +## Impact + +- jira-hub: `commands.py` (SPEC, проверки), `queue.py` (дописывание `size` при выдаче), `api.py` (два маршрута, уборка + в `tick`), новый модуль хранилища файлов, `settings.py` (`init`), `mcp_server.py` (`path`), тесты, спеки, README, + ARCHITECTURE.md. Схема БД не меняется. +- jira-agent (aep/jira-agent#31): `src/agent.py` v11, тесты и фейки (вложения в `fake_jira`, файлы в `fake_hub`), + README, ARCHITECTURE.md. `run.py` не меняется. +- Выкатка: merge хаба → перезапуск хаба и MCP → `settings_set issue_types` с `bug` → `jira-hub agent publish` v11 → + `min_agent_version = 11` → живая проверка на тестовой задаче (удаляет владелец). diff --git a/openspec/changes/bug-and-attachments/specs/agent/spec.md b/openspec/changes/bug-and-attachments/specs/agent/spec.md new file mode 100644 index 0000000..b7949e5 --- /dev/null +++ b/openspec/changes/bug-and-attachments/specs/agent/spec.md @@ -0,0 +1,85 @@ +## MODIFIED Requirements + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». +Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый +`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`; +`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше +комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment` +постранично (`maxResults = 100`) и передать полный список. Комментарий без `id` или с пустым `id` агент SHALL отбросить +с записью в лог (ключ задачи), остальные передать. Если дочитывание комментариев задачи вернуло 403 или 404, агент SHALL +передать задачу без поля `comments` (хаб оставит прежние комментарии) с записью в лог; `refresh` продолжается. + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + +#### Scenario: Комментарии в состоянии +- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора + второго нет `displayName`, его `name` — `ivanov` +- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число + вызовов `search` не изменилось + +#### Scenario: Неполный список комментариев +- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130` +- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев + +#### Scenario: Комментарий без id +- **WHEN** у задачи два комментария, у второго `id = null` +- **THEN** в состоянии один комментарий; в логе строка с ключом задачи; пачка принята хабом + +#### Scenario: Нет доступа к комментариям +- **WHEN** `GET /issue/AURORA-1620/comment` при дочитывании вернул 403 +- **THEN** AURORA-1620 в состоянии без поля `comments`; остальные задачи пачки с комментариями; `refresh` — `done` + +#### Scenario: Задача без комментариев +- **WHEN** у задачи в Jira нет комментариев +- **THEN** в её состоянии `comments = []` + +## ADDED Requirements + +### Requirement: Тип задачи и вложения +Агент (с v11) SHALL брать тип для `ensure_issue` из `issue_types.`: `{id}`, если `id` задан, иначе `{name}`; +нет записи или в ней нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.», `failed` +без вызова Jira. + +`ensure_attachment {key, file, filename, size}` (`size` дописан хабом при выдаче) агент SHALL выполнять так: +0. `size = null` — `failed` «файл удалён с хаба, загрузите заново» без вызова Jira. +1. `GET /rest/api/2/issue/{key}?fields=attachment`; вложение с тем же `filename` и тем же `size` есть — `done` с его + `attachment_id`, файл у хаба не запрашивается. +2. Файл — `GET /agent/files/{file}`; sha256 и длина декодированного `content` SHALL совпасть с `file` и `size`, иначе — + временный сбой (повтор, как обрыв связи с хабом); 404 — `failed` «файл удалён с хаба, загрузите заново». +3. `POST /rest/api/2/issue/{key}/attachments`, `multipart/form-data` с одной частью `file`, имя файла — `filename` в UTF-8 + (`filename="…"` и `filename*=UTF-8''…`), заголовок `X-Atlassian-Token: no-check`; результат `attachment_id` из ответа. + Ответ Jira 4xx (в том числе 413) — `failed` с кодом и текстом Jira. +Содержимое файла MUST NOT попадать в журнал и лог (только `file`, `filename`, размер). `AGENT_VERSION` SHALL быть 11. + +#### Scenario: Ошибка по имени типа +- **WHEN** `ensure_issue {issue_type: bug, …}`, `issue_types.bug = {name: "Ошибка"}` +- **THEN** в `POST /issue` `issuetype = {name: "Ошибка"}` + +#### Scenario: Нет настройки типа +- **WHEN** `ensure_issue {issue_type: bug, …}`, в `issue_types` нет `bug` +- **THEN** `failed` «нет настройки issue_types.bug», вызовов Jira нет + +#### Scenario: Загрузка вложения с кириллическим именем +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ сч. 60 за 2 кв. 2026.xlsx"}`, у X вложений нет +- **THEN** один `GET /issue/X`, один `GET /agent/files/`, один `POST /issue/X/attachments`; в Jira вложение с этим + именем и размером файла; отчёт `done` с `attachment_id` + +#### Scenario: Файл повреждён в пути +- **WHEN** sha256 полученного `content` не равен `file` +- **THEN** вложение в Jira не загружается; команда повторяется как при обрыве связи с хабом + +#### Scenario: Старый агент +- **WHEN** агент v10 получил `ensure_attachment` +- **THEN** `failed` «неизвестный тип команды», агент работает дальше (поведение v10, без изменений) diff --git a/openspec/changes/bug-and-attachments/specs/commands/spec.md b/openspec/changes/bug-and-attachments/specs/commands/spec.md new file mode 100644 index 0000000..c9ffc3f --- /dev/null +++ b/openspec/changes/bug-and-attachments/specs/commands/spec.md @@ -0,0 +1,154 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `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` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +### Requirement: Политика темпа и пауза на команде +Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и +`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30, `refresh` 2). При выдаче команды +хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего +последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент +SHALL выдерживать зазор типа команды между записями. + +#### Scenario: Нет следующей команды — нет паузы +- **WHEN** очередь пуста +- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `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` по +состоянию не подтверждаются (вложений в зеркале нет): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Описание с CRLF в зеркале +- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"` +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ + +## ADDED Requirements + +### Requirement: Описание параметров и ошибки валидации +Описание параметров каждого типа команды (обязательные, необязательные, формат значения, условие на набор) SHALL +храниться в коде хаба в одном месте с правилами валидации; описание инструмента MCP `batch_create` SHALL собираться из +него (см. `mcp`). Ошибка валидации «нет <параметры>» и «лишние параметры <параметры>» SHALL заканчиваться ожидаемым +набором параметров типа: «ожидаются <обязательные>» и, если у типа есть необязательные, «необязательные <список>». + +#### Scenario: Неверное имя параметра +- **WHEN** `POST /batches` с `ensure_status {key: X, status: "Готово"}` +- **THEN** 422 «команда 1 (ensure_status): нет target; ожидаются key, target» + +#### Scenario: Лишний параметр у типа с необязательными +- **WHEN** `POST /batches` с `ensure_link {source: X, target: Y, link_type: "связанные", force: true}` +- **THEN** 422 «команда 1 (ensure_link): лишние параметры force; ожидаются source, target, link_type; необязательные replace» + +### Requirement: Проверки ensure_issue с типом bug и ensure_attachment при постановке +При постановке пакета хаб SHALL отвергать 422 без создания пакета: `ensure_issue` с `issue_type = bug`, если в настройках +нет `issue_types.bug` с непустым `name` или `id` («нет настройки issue_types.bug»); `ensure_attachment`, если `file` — не +64 hex-символа в нижнем регистре, файла с таким sha256 нет в хранилище хаба («файл не загружен: POST /files»), +`filename` пустой или содержит `/` или `\`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту +(`claim`, в том числе повторной) хаб SHALL добавить к параметрам `ensure_attachment` `size` — текущий размер файла в +хранилище, или `size = null`, если файла уже нет; в базе параметры команды остаются без `size`, поэтому повтор команды +(`POST /commands/{id}/retry`) проходит проверку как новая. + +#### Scenario: Ошибка без настройки типа +- **WHEN** `POST /batches` с `ensure_issue {issue_type: bug, …}`, в `issue_types` только `task` и `epic` +- **THEN** 422 «команда 1: нет настройки issue_types.bug», пакет не создан + +#### Scenario: Вложение без загруженного файла +- **WHEN** `POST /batches` с `ensure_attachment {key: X, file: <64 hex>, filename: "a.xlsx"}`, файла нет в хранилище +- **THEN** 422 «команда 1: файл не загружен: POST /files», пакет не создан + +#### Scenario: Ошибка с вложением одним пакетом +- **WHEN** пакет `[ensure_issue {temp_key: new:1, issue_type: bug, …}, ensure_sprint {key: new:1, sprint: "Спринт 2"}, + ensure_attachment {key: new:1, file: , filename: "ОСВ 60.xlsx"}]`, файл загружен +- **THEN** пакет создан; `ensure_attachment` выдаётся после завершения `ensure_issue` с подставленным ключом и `size` файла diff --git a/openspec/changes/bug-and-attachments/specs/data-model/spec.md b/openspec/changes/bug-and-attachments/specs/data-model/spec.md new file mode 100644 index 0000000..fdd4936 --- /dev/null +++ b/openspec/changes/bug-and-attachments/specs/data-model/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся). Спринты агент читает с доски `board_id`, +отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился diff --git a/openspec/changes/bug-and-attachments/specs/hub-api/spec.md b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md new file mode 100644 index 0000000..b9931ba --- /dev/null +++ b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md @@ -0,0 +1,167 @@ +## MODIFIED Requirements + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command: + null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или + `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или + `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить + последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в + `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`. +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по + `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N` + переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до + 1500 символов. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая + пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments` + необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` — + строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля, + пачка не применяется. +- `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`. + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +#### Scenario: Отчёт по невыданной команде +- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` +- **THEN** 409 `not_claimed`, команда осталась `queued` + +#### Scenario: Длинная ошибка +- **WHEN** агент прислал `failed` с `error` длиной 5000 символов +- **THEN** в `command.error` первые 1500 символов + +#### Scenario: Поздний отчёт после предела выдач +- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент + прислал `done` с `result.key = AURORA-1630` +- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` + +#### Scenario: Публикация в ответе claim +- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет +- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}` + +#### Scenario: Состояние обновления от агента +- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}` + и `launcher_version = 1` +- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с» + +#### Scenario: Скачивание версии +- **WHEN** агентским токеном `GET /agent/release`, опубликована v6 +- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401; + без публикации — 404 `no_release` + +#### Scenario: Скачивание файла вложения +- **WHEN** агентским токеном `GET /agent/files/`, файл загружен +- **THEN** 200 `{file: , size, content}`, sha256 от декодированного `content` равен ``; клиентским токеном — 401; + файла нет — 404 `no_file` + +#### Scenario: Неверный комментарий в пачке +- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}` +- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `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`, по убыванию). +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с + одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы), а также + по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен). +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N». +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`. +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +## ADDED Requirements + +### Requirement: Хранилище файлов вложений +Хаб SHALL хранить файлы вложений в каталоге `files/` внутри каталога данных, по одному файлу на содержимое с именем +sha256 (без расширения и имени вложения: имя задаёт команда). Каталог SHALL иметь права `0700` (выставляются при создании +и при каждом старте, как у `backups/`). `POST /files`: +- `content` не строка или не base64 — 400 `bad_request`; пустой файл — 422 `empty_file`; +- файл больше 10 МБ (10 485 760 байт) после декодирования — 413 `too_large` (тело запроса до 16 МБ проходит); +- запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем + же `file` и не создаёт второй копии. +Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки удалять файлы старше 30 дней (по +времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed`; повтор команды +после уборки отвергается при постановке 422 «файл не загружен: POST /files». Повторная загрузка того же +содержимого SHALL обновлять время изменения файла. + +#### Scenario: Загрузка файла +- **WHEN** `POST /files {content: base64("abc")}` +- **THEN** 200 `{file: "ba7816bf…", size: 3}`; в `files/` файл `ba7816bf…` с содержимым `abc` + +#### Scenario: Повторная загрузка +- **WHEN** тот же `POST /files` второй раз +- **THEN** 200 с тем же `file`; в `files/` по-прежнему один файл + +#### Scenario: Слишком большой файл +- **WHEN** `POST /files` с файлом 11 МБ +- **THEN** 413 `too_large`, в `files/` ничего не появилось + +#### Scenario: Уборка старых файлов +- **WHEN** в `files/` файл A старше 30 дней без ссылок, файл B старше 30 дней, на него ссылается `ensure_attachment` в + `queued`, файл C загружен вчера +- **THEN** после уборки удалён только A diff --git a/openspec/changes/bug-and-attachments/specs/mcp/spec.md b/openspec/changes/bug-and-attachments/specs/mcp/spec.md new file mode 100644 index 0000000..126ba0f --- /dev/null +++ b/openspec/changes/bug-and-attachments/specs/mcp/spec.md @@ -0,0 +1,71 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» From a8672cf7c02c1311ee4b805c2a1c03026ea8c60c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 00:06:42 +0400 Subject: [PATCH 2/9] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=85=D0=B0=D0=B1=D0=B0=20bug-and-attachments=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-15-bug-and-attachments-hub.md | 1746 +++++++++++++++++ 1 file changed, 1746 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md diff --git a/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md b/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md new file mode 100644 index 0000000..11231f3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md @@ -0,0 +1,1746 @@ +# bug-and-attachments — тип «Ошибка» и вложения — план хаба + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** из чата одним пакетом завести задачу типом «Ошибка», поставить её в спринт и приложить локальный файл: хаб +принимает `issue_type = bug` (при настройке `issue_types.bug`), хранит файлы вложений (`POST /files`, `files/`), +ставит и выдаёт агенту команду `ensure_attachment` (с `size` при выдаче), отдаёт файл агенту (`GET /agent/files/{sha256}`), +убирает старые файлы; MCP `batch_create` принимает `path` вместо `file` и загружает файл сам. + +**Architecture:** новый модуль `files.py` — хранилище без HTTP и без окружения: каталог передаётся параметром +(`Hub.files_dir = <каталог базы>/files`, 0700 при каждом старте). `commands.SPEC` получает `ensure_attachment` и `bug` в +подсказке; проверки постановки, которым нужны настройки и хранилище (`bug` без настройки, формат и наличие файла, +`filename`), — `queue._check_setup` внутри `queue.create_batch`; `size` дописывается только в ответ агенту +(`queue.deliver_params` из `claim` и `GET /agent/commands/{id}`), в базе `params` без `size`. `api` — маршруты +`POST /files`, `GET /agent/files/{sha256}` и уборка в `tick` раз в сутки. `mcp_server.batch_create` до `POST /batches` +читает все `path`, загружает их `POST /files` и подставляет `file`/`filename`. + +**Tech Stack:** Python 3.13 (uv), stdlib (`http.server`, `sqlite3`, `base64`, `hashlib`, `tempfile`), pytest 9, пакет `mcp` +(без изменений); фронт (`web/`) не меняется, только `make check`. + +**Spec:** `openspec/changes/bug-and-attachments/` — `proposal.md`, `design.md` (решения 1–8; этот план дописывает 9–19), +`specs/commands/spec.md`, `specs/hub-api/spec.md`, `specs/mcp/spec.md`, `specs/data-model/spec.md`. `specs/agent/spec.md` +— для jira-agent (aep/jira-agent#31, отдельный план); хаб от агента не зависит. + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach`, ветка `feat/43-bug-and-attachments`, +issue aep/jira-hub#43. + +## Global Constraints + +- Все команды — с путями worktree: git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach …`, + pytest — `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest …`. Не делать `cd` + в `/Users/andrey.epifancev/projects/awp-ca/jira-hub` (основной checkout на main, хук guard-main). Живой хаб :8790, + `~/.local/share/jira-hub` и живые настройки (`settings_set issue_types` — шаг выкатки после merge) не трогать. +- codegraph: в `codegraph_explore` передавать `projectPath` = `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach`. +- Схема БД не меняется (`db.SCHEMA_VERSION = 3`). Новых зависимостей нет (ни Python, ни npm). Фронт не меняется. +- Значения из спеки — дословно: + - предел файла — `10 485 760` байт после декодирования (`files.MAX_SIZE = 10 * 1024 * 1024`); больше — 413 `too_large`; + - пустой файл — 422 `empty_file`; `content` не строка или не base64 — 400 `bad_request`; + - файла нет у `GET /agent/files/{sha256}` — 404 `no_file`; ответ — `{file, size, content}`, `content` — base64; + - `POST /files` → 200 `{file, size}`, `file` — sha256 содержимого, 64 hex-символа в нижнем регистре; + - каталог `files/` — права `0700` при создании и при каждом старте; файлы в бэкапы базы не входят; + - уборка — из `tick` не чаще раза в сутки, файлы старше 30 дней по mtime, кроме файлов команд `ensure_attachment` + в `queued`/`claimed` (`json_extract(params, '$.file')`); повторная загрузка обновляет mtime; + - 422 «команда : нет настройки issue_types.bug»; 422 «команда : файл не загружен: POST /files»; + - `issue_types.bug` годна, если это объект с непустым `name` или `id`; + - `init` добавляет `bug` в `issue_types`, только если в конфиге есть ключ `issue_type_bug` или `issue_type_bug_id`; + - `SPEC["ensure_attachment"] = {required: [key, file, filename], optional: [], keys: [key], hints: {file: "sha256 из + POST /files; в MCP — path к локальному файлу", filename: "имя вложения"}}`; подсказка `issue_type` — `task|epic|bug`; + - пауза `ensure_attachment` — 30 с (`DEFAULT_PACING`, `pacing_from_config`); по состоянию зеркала не подтверждается; + - `size` — в ответе агенту (`claim`, повторная выдача, `GET /agent/commands/{id}`); файла нет — `size = null`; + в базе `params` без `size`; клиентский `size` — 422 «лишние параметры size»; + - MCP: «файл не найден: », «у ensure_attachment либо path, либо file»; `filename` по умолчанию — + `Path(path).name`; больше 10 МБ — ошибка до загрузки. +- Комментарии, docstring, тексты ошибок и коммиты — по-русски; идентификаторы — английские. Сообщение коммита + заканчивается `(#43)`, последний абзац — `Co-Authored-By: Claude Opus 5.5 `. Не пушить. +- Решения, принятые планом без обсуждения, дописываются в `openspec/changes/bug-and-attachments/design.md` в конец + раздела `## Decisions made without discussion` (номера 9–19, по задачам) — в той задаче, которая их реализует. +- Зелёным считается только весь набор: `uv run --directory … pytest -q -p no:cacheprovider` (до плана — 338 passed); + незакрытое соединение SQLite — ошибка набора (`filterwarnings`), в тестах — только фикстуры `open_db`/`conn` или + явный `close()`. Перед последним коммитом — `make -C … check`. + +## Review Focus + +1. `content` base64 с переводами строк (GNU `base64`, библиотеки с переносом по 76 символов) — хаб принимает и считает + sha256 от декодированного — Task 3, `test_files_base64_with_line_breaks`. +2. Файл ровно 10 485 760 байт принимается, на байт больше — отказ, в `files/` ничего не остаётся — Task 2, + `test_put_limits`; через HTTP 11 МБ — 413 — Task 3, `test_files_too_large_is_413`. +3. Один файл в двух командах (одна `done`, вторая `queued`) и файл команды в `claimed` (агент его как раз скачивает) — + уборка их не трогает — Task 2, `test_sweep_keeps_claimed_and_shared_file`. +4. id файла не из 64 hex нижнего регистра (`..`, `%2E%2E`, тот же sha в верхнем регистре — APFS не различает регистр) — + 404 `no_file`, путь вне `files/` не строится — Task 2, `test_bad_sha_never_builds_path`; Task 3, + `test_agent_file_bad_id_is_no_file`. +5. `path` из чата с `~`, относительный, второй файл пакета отсутствует — понятный текст с путём, в хаб ничего не + загружено — Task 5, `test_batch_create_explicit_filename_and_tilde`, `test_batch_create_bad_path`, + `test_batch_create_second_file_missing_uploads_nothing`. + +## Карта файлов + +| Файл | Задача | Что меняется | +|---|---|---| +| `src/jira_hub/commands.py` | 1, 4 | подсказка и проверка `issue_type` (`bug`); `SPEC["ensure_attachment"]`; `check_confirmed` | +| `src/jira_hub/settings.py` | 1, 4 | `_issue_types` в `init_from_config`; `ensure_attachment` в `DEFAULT_PACING` и `pacing_from_config` | +| `src/jira_hub/queue.py` | 1, 4 | `_check_setup` (bug; вложение), `create_batch(files_dir=)`, `deliver_params`, `claim(files_dir=)`, `retry_command(files_dir=)` | +| `src/jira_hub/files.py` (новый) | 2 | хранилище: `put`, `path`, `exists`, `size`, `read`, `is_sha`, `sweep` | +| `src/jira_hub/api.py` | 3, 4 | `Hub.files_dir`, `last_sweep`, `sweep_files`, `tick`; `h_files_post`, `h_agent_file`; `files_dir` в `h_claim`, `h_agent_command`, `h_batches_post`, `h_command_retry` | +| `src/jira_hub/mcp_server.py` | 5 | `_read_local`, `_attach_files`, `batch_create`, описание | +| `tests/test_commands.py`, `test_settings.py`, `test_queue.py`, `test_api.py`, `test_mcp.py`, `test_files.py` (новый) | 1–5 | тесты | +| `README.md`, `ARCHITECTURE.md`, `CLAUDE.md` | 1–5 | контракт команд, маршруты, каталог `files/`, MCP `path` | +| `openspec/changes/bug-and-attachments/design.md` | 1–5 | решения 9–19 | + +`src/jira_hub/client.py` не меняется: `HubClient.post("/files", {...})` хватает. `relay.py` не меняется (design: ответ +JSON со строкой base64 проходит реле как есть). + +--- + +### Task 1: Тип `bug` — валидация, проверка настройки при постановке, `init` (commands, data-model) + +**Модель исполнителя:** haiku (весь код готов, три модуля по несколько строк). + +**Files:** +- Modify: `src/jira_hub/commands.py` (`SPEC["ensure_issue"]["hints"]`, `validate`) +- Modify: `src/jira_hub/settings.py` (новая `_issue_types`, `init_from_config`) +- Modify: `src/jira_hub/queue.py` (новая `_check_setup`, `create_batch`) +- Modify: `README.md` (контракт команд), `openspec/changes/bug-and-attachments/design.md` (решение 9) +- Test: `tests/test_commands.py`, `tests/test_settings.py`, `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: — +- Produces: `queue._check_setup(conn, cmds: list[dict]) -> None` (бросает `commands.CommandError`; в Task 4 получает + третий параметр `files_dir`); хелпер тестов `bug(temp="new:1", summary="ОСВ 60 не распознана") -> dict` в + `tests/test_queue.py` (Task 4 его использует); `settings._issue_types(cfg: dict) -> dict`. + +- [ ] **Step 1: Окружение** + +```bash +uv --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach sync +npm --prefix /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/web ci +``` + +Expected: `uv` без ошибок; `npm ci` ставит `web/node_modules` (нужен для `make check` в Task 5). + +- [ ] **Step 2: Падающие тесты валидации** + +В `tests/test_commands.py` в параметрах `test_validate_errors` заменить строку + +```python + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), +``` + +на + +```python + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}], + "task, epic или bug"), +``` + +и после `test_unknown_param_error_names_expected` добавить: + +```python +def test_validate_accepts_bug_issue_type(): + """bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue).""" + cmds = commands.validate([{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]) + assert cmds[0]["params"]["issue_type"] == "bug" + assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug" +``` + +- [ ] **Step 3: Падающие тесты постановки** + +В `tests/test_queue.py` после хелпера `fields` добавить хелпер и тесты в конец файла: + +```python +def bug(temp="new:1", summary="ОСВ 60 не распознана"): + return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary, + "labels": ["AWPCA"]}} +``` + +```python +def test_bug_without_setting_rejected(conn): + """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан.""" + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()]) + assert str(e.value) == "команда 2: нет настройки issue_types.bug" + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None]) +def test_bug_with_empty_setting_rejected(conn, value): + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value}) + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [bug()]) + + +@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}]) +def test_bug_with_setting_accepted(conn, value): + settings.set_(conn, "issue_types", {"bug": value}) + b = queue.create_batch(conn, "ошибка", "claude", [bug()]) + assert b["count"] == 2 # ensure_issue + хвостовой refresh +``` + +- [ ] **Step 4: Падающие тесты `init` и API** + +В `tests/test_settings.py` заменить импорты на + +```python +import json +from pathlib import Path + +import pytest + +from jira_hub import queue, settings +from jira_hub.commands import CommandError +``` + +и в конец файла добавить: + +```python +def test_init_without_bug_keys_rejects_bug(conn): + """Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422.""" + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + settings.init_from_config(conn, cfg) + assert set(settings.get(conn, "issue_types")) == {"task", "epic"} + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "x", "labels": ["AWPCA"]}}]) + + +@pytest.mark.parametrize("extra, expected", [ + ({"issue_type_bug": "Ошибка"}, {"name": "Ошибка", "id": None}), + ({"issue_type_bug_id": "10102"}, {"name": None, "id": "10102"}), + ({"issue_type_bug": "Ошибка", "issue_type_bug_id": "10102"}, {"name": "Ошибка", "id": "10102"}), +]) +def test_init_bug_from_config(conn, extra, expected): + cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra} + settings.init_from_config(conn, cfg) + types = settings.get(conn, "issue_types") + assert types["bug"] == expected + assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} +``` + +В конец `tests/test_api.py` добавить: + +```python +def test_bug_without_setting_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "ошибка", "commands": [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) + assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" + assert hub["client"].get("/batches")["batches"] == [] +``` + +- [ ] **Step 5: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_commands.py tests/test_queue.py tests/test_settings.py tests/test_api.py -k "bug or validate_errors"` +Expected: FAIL — `test_validate_errors[...story...]` (сообщение «task или epic»), `test_validate_accepts_bug_issue_type` +(«issue_type — task или epic»), `test_bug_with_setting_accepted` (тот же отказ), `test_bug_without_setting_rejected` +(текст «issue_type — task или epic»), `test_init_bug_from_config` (`KeyError: 'bug'`), `test_bug_without_setting_is_422`. + +- [ ] **Step 6: `commands` — `bug` в подсказке и проверке** + +В `src/jira_hub/commands.py` в `SPEC["ensure_issue"]["hints"]` заменить `"issue_type": "task|epic"` на +`"issue_type": "task|epic|bug"`. В `validate` заменить + +```python + if params["issue_type"] not in ("task", "epic"): + raise CommandError(f"команда {i}: issue_type — task или epic") +``` + +на + +```python + if params["issue_type"] not in ("task", "epic", "bug"): + raise CommandError(f"команда {i}: issue_type — task, epic или bug") +``` + +- [ ] **Step 7: `settings` — `bug` из конфига** + +В `src/jira_hub/settings.py` перед `init_from_config` добавить: + +```python +def _issue_types(cfg: dict) -> dict: + """issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть + issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»).""" + out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")} + if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg: + out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")} + return out +``` + +и в `init_from_config` заменить + +```python + "issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, + "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, +``` + +на + +```python + "issue_types": _issue_types(cfg), +``` + +- [ ] **Step 8: `queue` — проверка постановки** + +В `src/jira_hub/queue.py` перед `create_batch` добавить: + +```python +def _check_setup(conn, cmds) -> None: + """Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета. + Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok: + raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") +``` + +и в `create_batch` сразу после `cmds = C.validate(cmds)` добавить строку `_check_setup(conn, cmds)`. + +- [ ] **Step 9: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (338 + новые). + +- [ ] **Step 10: README и решение 9** + +В `README.md` в таблице «Контракт команд» заменить строку `ensure_issue` + +```markdown +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +``` + +на + +```markdown +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +``` + +и сразу после таблицы (перед абзацем «Ключ в параметре может быть временным…») вставить абзац: + +```markdown +Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть +`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из +ключей конфига `issue_type_bug`/`issue_type_bug_id`, если они есть; на живом хабе — `settings_set issue_types` с объектом +целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`. +``` + +В `openspec/changes/bug-and-attachments/design.md` в конец раздела `## Decisions made without discussion` дописать: + +```markdown +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, + как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. +``` + +- [ ] **Step 11: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/commands.py src/jira_hub/settings.py src/jira_hub/queue.py tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py README.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(commands): тип bug в ensure_issue — проверка настройки issue_types.bug при постановке (#43)" -m "validate принимает task|epic|bug; queue._check_setup отвергает bug без issue_types.bug с name или id (422 «команда N: нет настройки issue_types.bug»); init берёт bug из issue_type_bug/issue_type_bug_id, только если ключи есть (решение 9)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Хранилище файлов `files.py` (hub-api «Хранилище файлов вложений») + +**Модель исполнителя:** haiku (новый модуль целиком и его тесты, код готов). + +**Files:** +- Create: `src/jira_hub/files.py` +- Create: `tests/test_files.py` +- Modify: `ARCHITECTURE.md` (строка модуля, цепочки), `openspec/changes/bug-and-attachments/design.md` (решения 10–13) + +**Interfaces:** +- Consumes: `paths.secure_dir(path) -> None`; таблица `command` (`type`, `params`, `status`). +- Produces (модуль `jira_hub.files`): + - `MAX_SIZE: int = 10_485_760`, `KEEP_DAYS: int = 30`, `SHA_RE`; + - `class EmptyFile(ValueError)`, `class TooLarge(ValueError)`; + - `is_sha(value) -> bool` — строка из 64 символов `[0-9a-f]`; + - `path(root, sha) -> Path` (негодный sha — `ValueError`); + - `exists(root, sha) -> bool`, `size(root, sha) -> int | None`, `read(root, sha) -> bytes | None` (негодный sha или + файла нет — `False`/`None`); + - `put(root, data: bytes) -> tuple[str, int]` — `(sha256, размер)`; `EmptyFile`, `TooLarge`; `OSError` записи + пробрасывается, временный файл удалён; + - `sweep(conn, root, now: float | None = None) -> list[str]` — имена удалённых файлов (по алфавиту). + +- [ ] **Step 1: Падающие тесты** + +Создать `tests/test_files.py`: + +```python +"""Хранилище файлов вложений (bug-and-attachments, hub-api «Хранилище файлов вложений»).""" +import hashlib +import json +import os +import stat +import time + +import pytest + +from jira_hub import db, files, settings + +ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… +DAY = 86400 + + +@pytest.fixture +def root(tmp_path): + return tmp_path / "files" + + +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c + + +def test_put_creates_closed_dir_and_file(root): + assert files.put(root, b"abc") == (ABC, 3) + assert ABC.startswith("ba7816bf") + assert (root / ABC).read_bytes() == b"abc" + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + assert [p.name for p in root.iterdir()] == [ABC] # временных файлов не осталось + + +def test_put_again_keeps_one_copy_and_touches_mtime(root): + files.put(root, b"abc") + old = time.time() - 40 * DAY + os.utime(root / ABC, (old, old)) + assert files.put(root, b"abc") == (ABC, 3) + assert [p.name for p in root.iterdir()] == [ABC] + assert (root / ABC).stat().st_mtime > time.time() - 60 # свежая постановка не теряет файл в уборке + + +def test_put_rewrites_truncated_copy(root): + files.put(root, b"abc") + (root / ABC).write_bytes(b"ab") # оборванная копия + files.put(root, b"abc") + assert (root / ABC).read_bytes() == b"abc" + + +def test_put_limits(root): + with pytest.raises(files.EmptyFile): + files.put(root, b"") + with pytest.raises(files.TooLarge): + files.put(root, b"\0" * (files.MAX_SIZE + 1)) + assert not root.exists() or list(root.iterdir()) == [] + sha, size = files.put(root, b"\0" * files.MAX_SIZE) # ровно 10 485 760 байт — принимается + assert files.MAX_SIZE == 10_485_760 and size == files.MAX_SIZE and files.size(root, sha) == files.MAX_SIZE + + +def test_put_failure_leaves_no_temp(root, monkeypatch): + def broken(*args): + raise OSError("диск полон") + monkeypatch.setattr(files.os, "replace", broken) + with pytest.raises(OSError, match="диск полон"): + files.put(root, b"abc") + assert list(root.iterdir()) == [] + + +def test_put_after_dir_removed(root): + files.put(root, b"abc") + (root / ABC).unlink() + root.rmdir() + assert files.put(root, b"abc") == (ABC, 3) and files.exists(root, ABC) + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + + +def test_lookup(root): + files.put(root, b"abc") + assert files.exists(root, ABC) and files.size(root, ABC) == 3 and files.read(root, ABC) == b"abc" + assert files.path(root, ABC) == root / ABC + missing = "0" * 64 + assert not files.exists(root, missing) and files.size(root, missing) is None and files.read(root, missing) is None + + +@pytest.mark.parametrize("bad", [ABC.upper(), ABC[:63], ABC + "0", "..", "../hub.sqlite", ABC + "\n", "", None, 5]) +def test_bad_sha_never_builds_path(root, bad): + """APFS не различает регистр: ABC.upper() нашёл бы файл ABC, если бы путь строился без проверки.""" + files.put(root, b"abc") + assert files.is_sha(bad) is False + assert not files.exists(root, bad) and files.size(root, bad) is None and files.read(root, bad) is None + with pytest.raises(ValueError): + files.path(root, bad) + + +def _file(root, content, age_days): + sha, _ = files.put(root, content) + t = time.time() - age_days * DAY + os.utime(root / sha, (t, t)) + return sha + + +def _command(conn, sha, status): + if conn.execute("SELECT 1 FROM batch WHERE id = 1").fetchone() is None: + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 'x', 'claude', ?)", (db.now(),)) + conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (1, 0, 'ensure_attachment', ?, ?)", + (json.dumps({"key": "A-1", "file": sha, "filename": "a.xlsx"}), status)) + + +def test_sweep_spec_scenario(root, conn): + """Спека hub-api «Уборка старых файлов»: A старше 30 дней без ссылок, B старше 30 дней в queued, C вчерашний.""" + a = _file(root, b"A", 31) + b = _file(root, b"B", 31) + c = _file(root, b"C", 1) + _command(conn, b, "queued") + assert files.sweep(conn, root) == [a] + assert sorted(p.name for p in root.iterdir()) == sorted([b, c]) + + +def test_sweep_keeps_claimed_and_shared_file(root, conn): + shared = _file(root, b"S", 31) + claimed = _file(root, b"K", 31) + gone = _file(root, b"G", 31) + _command(conn, shared, "done") + _command(conn, shared, "queued") # тот же файл под другим именем в другой команде + _command(conn, claimed, "claimed") + for status in ("done", "cancelled", "failed", "skipped", "confirmed_by_state"): + _command(conn, gone, status) + assert files.sweep(conn, root) == [gone] + assert files.exists(root, shared) and files.exists(root, claimed) + + +def test_sweep_removes_stale_temp_and_handles_missing_dir(root, conn, tmp_path): + assert files.sweep(conn, tmp_path / "нет") == [] + files.put(root, b"abc") + tmp = root / f".{ABC}.x1y2.tmp" # брошен сбоем посреди записи + tmp.write_bytes(b"ab") + t = time.time() - 31 * DAY + os.utime(tmp, (t, t)) + assert files.sweep(conn, root) == [tmp.name] + assert files.exists(root, ABC) + + +def test_sweep_uses_now(root, conn): + sha = _file(root, b"N", 0) + assert files.sweep(conn, root) == [] + assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_files.py` +Expected: FAIL — `ImportError: cannot import name 'files' from 'jira_hub'`. + +- [ ] **Step 3: Модуль `files.py`** + +Создать `src/jira_hub/files.py`: + +```python +"""Хранилище файлов вложений: <каталог данных>/files/, одна копия на содержимое, без имени вложения — имя задаёт +команда ensure_attachment (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: +каталог передаётся параметром (Hub.files_dir, решение 10).""" +import hashlib +import os +import re +import tempfile +import time +from pathlib import Path + +from .paths import secure_dir + +MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) +KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) +SHA_RE = re.compile(r"[0-9a-f]{64}") + + +class EmptyFile(ValueError): + """Пустое содержимое — API отвечает 422 empty_file (решение 3).""" + + +class TooLarge(ValueError): + """Больше MAX_SIZE — API отвечает 413 too_large.""" + + +def is_sha(value) -> bool: + """sha256 в хранилище — ровно 64 символа [0-9a-f]: верхний регистр, «..», «/» путь не строят (решение 12).""" + return isinstance(value, str) and SHA_RE.fullmatch(value) is not None + + +def path(root, sha) -> Path: + if not is_sha(sha): + raise ValueError(f"не sha256: {sha!r}") + return Path(root) / sha + + +def exists(root, sha) -> bool: + return is_sha(sha) and path(root, sha).is_file() + + +def size(root, sha) -> int | None: + """Размер файла в байтах; None — файла нет или sha негодный.""" + if not is_sha(sha): + return None + try: + return path(root, sha).stat().st_size + except FileNotFoundError: + return None + + +def read(root, sha) -> bytes | None: + if not is_sha(sha): + return None + try: + return path(root, sha).read_bytes() + except FileNotFoundError: + return None + + +def put(root, data: bytes) -> tuple[str, int]: + """Сохранить содержимое, вернуть (sha256, размер). Копия того же размера уже есть — только обновить mtime (уборка + считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись + атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется + (решение 11).""" + if not data: + raise EmptyFile("пустой файл") + if len(data) > MAX_SIZE: + raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") + root = Path(root) + secure_dir(root) + sha = hashlib.sha256(data).hexdigest() + target = root / sha + if size(root, sha) == len(data): + try: + os.utime(target) + return sha, len(data) + except FileNotFoundError: + pass # убран между проверкой и utime — пишем заново + fd, tmp = tempfile.mkstemp(dir=root, prefix=f".{sha}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return sha, len(data) + + +def sweep(conn, root, now: float | None = None) -> list[str]: + """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или + claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят + тем же путём. Каталога нет — пусто.""" + root = Path(root) + if not root.is_dir(): + return [] + cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 + active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " + "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} + removed = [] + for p in sorted(root.iterdir()): + if p.name in active or not p.is_file(): + continue + try: + if p.stat().st_mtime < cutoff: + p.unlink() + removed.append(p.name) + except FileNotFoundError: + continue + return removed +``` + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS. + +- [ ] **Step 5: ARCHITECTURE и решения 10–13** + +В `ARCHITECTURE.md` в таблице модулей после строки `backup` вставить: + +```markdown +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | +``` + +и в строке «Цепочки:» после `` `db → backup → paths`, `db → paths`; `` вставить `` `files → paths`; ``. + +В `openspec/changes/bug-and-attachments/design.md` в конец раздела `## Decisions made without discussion` дописать: + +```markdown +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 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). +``` + +- [ ] **Step 6: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/files.py tests/test_files.py ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(files): хранилище файлов вложений files/ с уборкой (#43)" -m "put — атомарно (mkstemp, fsync, os.replace), одна копия на содержимое, mtime при повторе, предел 10 485 760 байт, пустой — отказ; path/exists/size/read только для sha из 64 hex; sweep — старше 30 дней без ensure_attachment в queued/claimed (решения 10–13)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: API файлов — `POST /files`, `GET /agent/files/{sha256}`, каталог при старте, уборка в `tick` (hub-api) + +**Модель исполнителя:** haiku (код готов; два обработчика, конструктор и `tick` в одном модуле). + +**Files:** +- Modify: `src/jira_hub/api.py` (импорты, `SWEEP_EVERY`, `Hub.__init__`, `Hub.tick`, новый `Hub.sweep_files`, `h_files_post`, + `h_agent_file`, `ROUTES`) +- Modify: `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/bug-and-attachments/design.md` (решения 14–16) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes (Task 2): `files.put(root, data) -> (sha, size)`, `files.read(root, sha) -> bytes | None`, + `files.sweep(conn, root, now) -> list[str]`, `files.EmptyFile`, `files.TooLarge`, `files.MAX_SIZE`. +- Produces: `api.Hub(db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None)`, атрибуты + `Hub.files_dir: Path` (по умолчанию `db_path.parent / "files"`), `Hub.last_sweep: float | None`; + `Hub.sweep_files(conn, now: float | None = None) -> list[str] | None` (`None` — ещё рано); `api.SWEEP_EVERY = 86400`; + хелпер тестов `_b64(data: bytes) -> str` в `tests/test_api.py` (Task 4 его использует). + +- [ ] **Step 1: Падающие тесты** + +В `tests/test_api.py` заменить импорты в начале файла на + +```python +import base64 +import hashlib +import json +import os +import stat +import threading +import time +import urllib.error +import urllib.request +from datetime import timedelta + +import pytest + +from jira_hub import api, db, files, queue, release, settings +from jira_hub.client import HubClient, HubError +``` + +и в конец файла добавить: + +```python +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_files_upload_and_agent_download(hub, tmp_path): + """Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения».""" + c, a = hub["client"], hub["agent"] + sha = hashlib.sha256(b"abc").hexdigest() + out = c.post("/files", {"content": _b64(b"abc")}) + assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf") + assert (tmp_path / "files" / sha).read_bytes() == b"abc" + assert c.post("/files", {"content": _b64(b"abc")}) == out + assert [p.name for p in (tmp_path / "files").iterdir()] == [sha] + got = a.get(f"/agent/files/{sha}") + assert got["file"] == sha and got["size"] == 3 + assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha + with pytest.raises(HubError) as e: + c.get(f"/agent/files/{sha}") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.post("/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.get("/agent/files/" + "0" * 64) + assert (e.value.status, e.value.error) == (404, "no_file") + + +@pytest.mark.parametrize("body, status, error", [ + ({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"), + ({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"), + ({"content": ""}, 422, "empty_file"), +]) +def test_files_bad_upload(hub, tmp_path, body, status, error): + with pytest.raises(HubError) as e: + hub["client"].post("/files", body) + assert (e.value.status, e.value.error) == (status, error) + assert list((tmp_path / "files").iterdir()) == [] + + +def test_files_base64_with_line_breaks(hub): + data = bytes(range(256)) * 4 + s = _b64(data) + wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64 + assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024} + + +def test_files_too_large_is_413(hub, tmp_path): + """Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит.""" + with pytest.raises(HubError) as e: + hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert (e.value.status, e.value.error) == (413, "too_large") + assert list((tmp_path / "files").iterdir()) == [] + + +@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64]) +def test_agent_file_bad_id_is_no_file(hub, bad): + hub["client"].post("/files", {"content": _b64(b"abc")}) + with pytest.raises(HubError) as e: + hub["agent"].get(f"/agent/files/{bad}") + assert (e.value.status, e.value.error) == (404, "no_file") + + +def test_files_dir_closed_on_every_start(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + d = tmp_path / "files" + assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700 + os.chmod(d, 0o755) + api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert stat.S_IMODE(d.stat().st_mode) == 0o700 + + +def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): + h = hub["hub"] + sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"] + old = time.time() - 31 * 86400 + os.utime(tmp_path / "files" / sha, (old, old)) + calls = [] + real = files.sweep + monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k)) + h.tick() + assert len(calls) == 1 and not (tmp_path / "files" / sha).exists() + h.tick() + assert len(calls) == 1 # второй тик в те же сутки — без уборки + h.last_sweep -= api.SWEEP_EVERY + h.tick() + assert len(calls) == 2 + + +def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog): + def broken(*args, **kwargs): + raise OSError("диск недоступен") + monkeypatch.setattr(api.files, "sweep", broken) + hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление + hub["hub"].tick() # не бросает + assert "уборка файлов: сбой" in caplog.text + assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_api.py -k "files or tick or no_file"` +Expected: FAIL — `/files` и `/agent/files/…` отвечают 404 `not_found`, `Hub` без `files_dir`, `api.files` нет +(`AttributeError`). + +- [ ] **Step 3: Импорты и конструктор `Hub`** + +В `src/jira_hub/api.py`: в импорты stdlib добавить `import base64`; строку `from . import backup, db, mirror, queue, relay, release, settings` +заменить на `from . import backup, db, files, mirror, queue, relay, release, settings`; после `SOCKET_TIMEOUT = …` добавить + +```python +SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») +``` + +В `Hub.__init__` заменить сигнатуру и начало: + +```python + def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None): + self.db_path = Path(db_path) + self.web_dir = Path(web_dir) if web_dir else WEB_APP + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" + self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) +``` + +и после строки `secure_dir(self.backups_dir)` добавить + +```python + secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ +``` + +- [ ] **Step 4: `tick` и `sweep_files`** + +Заменить `Hub.tick` целиком на: + +```python + def tick(self): + """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой + логируется и не мешает ни другой, ни следующему тику.""" + conn = None + try: + conn = self.conn() + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + log.exception("автообновление: сбой тика") + try: + if conn is not None: + self.sweep_files(conn) + except Exception: # noqa: BLE001 + log.exception("уборка файлов: сбой") + finally: + if conn is not None: + conn.close() + + def sweep_files(self, conn, now=None): + """Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после + перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано.""" + now = time.time() if now is None else now + if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY: + return None + self.last_sweep = now + removed = files.sweep(conn, self.files_dir, now) + if removed: + log.info("уборка файлов: удалено %d", len(removed)) + return removed +``` + +- [ ] **Step 5: Обработчики и маршруты** + +После `h_agent_release` добавить: + +```python +def h_agent_file(hub, conn, m, q, body): + """Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex + нижнего регистра — тот же 404 no_file (решение 16).""" + data = files.read(hub.files_dir, m.group(1)) + if data is None: + raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба") + return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} +``` + +После `h_batch_cancel` добавить: + +```python +def h_files_post(hub, conn, m, q, body): + """Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) + отбрасываются до строгой проверки (решение 14).""" + content = body.get("content") + if not isinstance(content, str): + raise ApiError(400, "bad_request", "content — строка base64") + try: + data = base64.b64decode("".join(content.split()), validate=True) + except ValueError: # binascii.Error — подкласс ValueError + raise ApiError(400, "bad_request", "content — не base64") from None + try: + sha, size = files.put(hub.files_dir, data) + except files.EmptyFile: + raise ApiError(422, "empty_file", "пустой файл") from None + except files.TooLarge: + raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None + return 200, {"file": sha, "size": size} +``` + +В `ROUTES` после строки `("GET", r"^/agent/release$", h_agent_release, "agent"),` добавить + +```python + ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), +``` + +и после строки `("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),` добавить + +```python + ("POST", r"^/files$", h_files_post, "client"), +``` + +- [ ] **Step 6: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (в том числе прежние `test_tick_survives_connection_failure` и `test_tick_creates_auto_refresh_once`). + +- [ ] **Step 7: Документация и решения 14–16** + +`README.md`: +- строки «Данные лежат…» заменить: + +```markdown +Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): +`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. +``` + +- `ошибки `{error, detail}` (400/401/404/409/422).` → `ошибки `{error, detail}` (400/401/404/409/413/422).`; +- в таблице маршрутов после строки `GET /agent/release` вставить + +```markdown +| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | +``` + + и после строки `POST /refresh` вставить + +```markdown +| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | +``` + +- в «Эксплуатация» после пункта «Резервные копии…» вставить пункт: + +```markdown +- Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый + раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в + очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается + заново. +``` + +`CLAUDE.md`: строку «- Данные: …» заменить на + +```markdown +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +``` + +и в разделе «Архитектура» после строки, начинающейся с «- `db` (схема, миграции) ←», добавить строку + +```markdown +- `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. +``` + +`ARCHITECTURE.md`: строку модуля `api` заменить на + +```markdown +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | +``` + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. + Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — + 400». Цена: строка с пробелами внутри тоже принимается. +15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после + старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких + перезапусках хаба в сутки уборок больше одной (вопрос к формулировке спеки «не чаще раза в сутки» — в отчёте плана). +16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`. + Обоснование: для агента это тот же случай «файла нет». Цена: нет. +``` + +- [ ] **Step 8: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/api.py tests/test_api.py README.md CLAUDE.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(api): POST /files и GET /agent/files/{sha256}, files/ 0700 при старте, уборка в tick раз в сутки (#43)" -m "Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Команда `ensure_attachment` — контракт, проверки постановки, `size` при выдаче (commands, hub-api) + +**Модель исполнителя:** sonnet (интеграция: меняются сигнатуры `queue.create_batch`/`claim`/`retry_command` и их вызовы +в `api`; четыре модуля). + +**Files:** +- Modify: `src/jira_hub/commands.py` (`SPEC`, `check_confirmed`) +- Modify: `src/jira_hub/settings.py` (`DEFAULT_PACING`, `pacing_from_config`) +- Modify: `src/jira_hub/queue.py` (импорт, `_check_setup`, `create_batch`, новая `deliver_params`, `claim`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_claim`, `h_agent_command`, `h_batches_post`, `h_command_retry`) +- Modify: `README.md`, `ARCHITECTURE.md`, `openspec/changes/bug-and-attachments/design.md` (решения 17–18) +- Test: `tests/test_commands.py`, `tests/test_settings.py`, `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `files.put(root, data)`, `files.is_sha(value)`, `files.exists(root, sha)`, `files.size(root, sha)` (Task 2); + `Hub.files_dir` (Task 3); хелперы тестов `bug()` в `tests/test_queue.py` (Task 1) и `_b64()` в `tests/test_api.py` + (Task 3); `queue._check_setup(conn, cmds)` (Task 1) — меняется сигнатура. +- Produces: + - `commands.SPEC["ensure_attachment"]` (см. Global Constraints), `"ensure_attachment" in commands.WRITE_TYPES`; + - `settings.DEFAULT_PACING["ensure_attachment"] == 30`; + - `queue._check_setup(conn, cmds, files_dir) -> None`; + - `queue.create_batch(conn, title, author, cmds, files_dir=None) -> dict`; + - `queue.deliver_params(cmd: dict, files_dir) -> dict` — параметры для агента (`size` у `ensure_attachment`); + - `queue.claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None`; + - `queue.retry_command(conn, command_id, author, files_dir=None) -> dict`. + `files_dir=None` — хранилища нет: вложение не ставится («файл … не загружен»), при выдаче `size = null`. + +- [ ] **Step 1: Падающие тесты контракта и темпа** + +В `tests/test_commands.py` в параметры `test_check_confirmed` перед строкой с `"refresh"` добавить + +```python + ({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False), +``` + +и после `test_validate_accepts_bug_issue_type` добавить: + +```python +ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc") + + +def test_ensure_attachment_contract(): + ok = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}]) + assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"] + assert "ensure_attachment" in commands.WRITE_TYPES + assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), " + "filename (имя вложения)") in commands.describe().splitlines() + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}]) + assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}]) + assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename" + with pytest.raises(CommandError, match="file — строка"): + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}]) +``` + +В конец `tests/test_settings.py`: + +```python +def test_attachment_pacing(conn): + assert settings.DEFAULT_PACING["ensure_attachment"] == 30 + assert settings.pacing(conn)["ensure_attachment"] == 30 + assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 + settings.validate("pacing", {"ensure_attachment": 10}) +``` + +- [ ] **Step 2: Падающие тесты очереди** + +В `tests/test_queue.py` заменить импорты на + +```python +import hashlib + +import pytest + +from jira_hub import db, files, mirror, queue, settings +from jira_hub.commands import CommandError +from jira_hub.queue import ClaimedError +``` + +и после хелпера `bug` добавить: + +```python +SHA = hashlib.sha256(b"abc").hexdigest() + + +@pytest.fixture +def store(tmp_path): + """Каталог хранилища с файлом «abc».""" + root = tmp_path / "files" + files.put(root, b"abc") + return root + + +def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"): + return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}} +``` + +В конец файла: + +```python +def test_attachment_without_file_rejected(conn, store): + """Спека commands «Вложение без загруженного файла»: 422, пакет не создан.""" + missing = "0" * 64 + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store) + assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files" + with pytest.raises(CommandError, match=f"файл {SHA} не загружен"): + queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("params, msg", [ + ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"filename": " "}, "команда 1: filename пустой"), + ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), +]) +def test_attachment_params_rejected(conn, store, params, msg): + cmd = attach() + cmd["params"].update(params) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store) + assert str(e.value) == msg + + +def test_bug_with_attachment_one_batch(conn, store): + """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}}) + b = queue.create_batch(conn, "ошибка", "claude", [ + bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")], + files_dir=store) + assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2 + c1 = queue.claim(conn, files_dir=store) + assert c1["type"] == "ensure_issue" + queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"}) + c2 = queue.claim(conn, files_dir=store) + assert c2["type"] == "ensure_sprint" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn, files_dir=store) + assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30 + assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3} + again = queue.claim(conn, files_dir=store) # довыдача — тоже с size + assert again["id"] == c3["id"] and again["params"]["size"] == 3 + stored = queue.get_command(conn, c3["id"])["params"] + assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size + + +def test_attachment_size_null_when_file_gone(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + (store / SHA).unlink() + c = queue.claim(conn, files_dir=store) + assert c["type"] == "ensure_attachment" and c["params"]["size"] is None + assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение + assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None + + +def test_attachment_retry_passes_validation(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + queue.report_result(conn, c["id"], "failed", error="HTTP 413") + r = queue.retry_command(conn, c["id"], "claude", files_dir=store) + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"} + (store / SHA).unlink() # убран уборкой + with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"): + queue.retry_command(conn, c["id"], "claude", files_dir=store) + + +def test_attachment_not_confirmed_by_state(conn, store): + mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], []) + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed" +``` + +- [ ] **Step 3: Падающий тест API** + +В конец `tests/test_api.py`: + +```python +def test_attachment_flow_over_api(hub): + """POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size.""" + c, a = hub["client"], hub["agent"] + sha = c.post("/files", {"content": _b64(b"abc")})["file"] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]}) + assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files" + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]}) + cmd = a.post("/agent/claim", {})["command"] + assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3} + assert cmd["pause_seconds"] == 30 + assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3 + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} +``` + +- [ ] **Step 4: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py -k "attachment or check_confirmed"` +Expected: FAIL — «неизвестный тип «ensure_attachment»», `KeyError: 'ensure_attachment'` в темпе, `TypeError: create_batch() +got an unexpected keyword argument 'files_dir'`, `AttributeError: … 'deliver_params'`. + +- [ ] **Step 5: `commands` и `settings`** + +В `src/jira_hub/commands.py` в `SPEC` после записи `"ensure_sprint": {…},` (перед `"refresh"`) вставить: + +```python + "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"], + "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу", + "filename": "имя вложения"}}, +``` + +В `check_confirmed` заменить docstring и первую проверку: + +```python +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не + подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля + (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в + зеркале нет (bug-and-attachments).""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"): + return False, None +``` + +В `src/jira_hub/settings.py` заменить `DEFAULT_PACING` на + +```python +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} +``` + +и в `pacing_from_config` строку возврата на + +```python + return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2} +``` + +- [ ] **Step 6: `queue` — проверки вложения, `size` при выдаче, `files_dir`** + +В `src/jira_hub/queue.py` строку `from . import db, mirror, settings` заменить на `from . import db, files, mirror, settings`. +`_check_setup` из Task 1 заменить целиком: + +```python +def _check_setup(conn, cmds, files_dir) -> None: + """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 + до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). + files_dir=None — хранилища нет: любой файл «не загружен».""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + p = c["params"] + if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: + raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") + if c["type"] == "ensure_attachment": + if not files.is_sha(p["file"]): + raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") + if files_dir is None or not files.exists(files_dir, p["file"]): + raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") + if not p["filename"].strip(): + raise C.CommandError(f"команда {i}: filename пустой") + if "/" in p["filename"] or "\\" in p["filename"]: + raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") +``` + +В `create_batch` заменить сигнатуру и вызов: + +```python +def create_batch(conn, title, author, cmds, files_dir=None) -> dict: + cmds = C.validate(cmds) + _check_setup(conn, cmds, files_dir) +``` + +Перед `claim` добавить: + +```python +def deliver_params(cmd, files_dir) -> dict: + """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, + если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + if cmd["type"] != "ensure_attachment": + return cmd["params"] + size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None + return {**cmd["params"], "size": size} +``` + +Сигнатуру `claim` заменить на + +```python +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: +``` + +и последнюю строку `claim` на + +```python + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause, + "batch_id": cmd["batch_id"]} +``` + +`retry_command` заменить на + +```python +def retry_command(conn, command_id, author, files_dir=None) -> dict: + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], + files_dir=files_dir) +``` + +- [ ] **Step 7: `api` — передать `files_dir`** + +В `src/jira_hub/api.py`: +- в `h_claim` вызов `queue.claim(...)` заменить на + +```python + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup, files_dir=hub.files_dir) +``` + +- `h_agent_command` заменить на + +```python +def h_agent_command(hub, conn, m, q, body): + """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" + cmd = _not_found(queue.get_command(conn, _int(m)), "command") + return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)} +``` + +- в `h_batches_post` вызов на + `return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir)`; +- в `h_command_retry` вызов на + `return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir)`. + +`h_refresh` и `queue.maybe_auto_refresh` не меняются (в них только `refresh`). + +- [ ] **Step 8: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (в том числе `test_batch_create_description_lists_params_from_spec` в `tests/test_mcp.py` — +строка `ensure_attachment` в описании собирается из `SPEC`). + +- [ ] **Step 9: Документация и решения 17–18** + +`README.md`, таблица «Контракт команд» — после строки `ensure_sprint` вставить + +```markdown +| `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/` и `\`); `size` хаб дописывает при выдаче агенту | +``` + +и абзац про зазоры заменить на + +```markdown +Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). + +`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не +загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба +(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет). +``` + +`ARCHITECTURE.md`: строку модуля `queue` заменить на + +```markdown +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | +``` + +и в «Потоки данных» после строки «Запись в Jira: …» добавить + +```markdown +- Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. +``` + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +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` молча отвергает вложения. +``` + +- [ ] **Step 10: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/commands.py src/jira_hub/settings.py src/jira_hub/queue.py src/jira_hub/api.py tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py README.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)" -m "SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл не загружен: POST /files»), filename без / и \\; claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: MCP `batch_create` — `path` у `ensure_attachment` (mcp) и полная проверка + +**Модель исполнителя:** haiku (код готов, один модуль и его тесты). + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` (импорты, `_read_local`, `_attach_files`, `batch_create`, описание) +- Modify: `README.md` (раздел MCP), `ARCHITECTURE.md` (`mcp_server`, «Границы»), `openspec/changes/bug-and-attachments/design.md` (решение 19) +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `files.MAX_SIZE` (Task 2); `POST /files` → `{file, size}` (Task 3); `ensure_attachment` и 422 постановки + (Tasks 1, 4); `HubClient.post(path, body) -> dict` (без изменений). +- Produces: `mcp_server._read_local(path) -> bytes | str` (содержимое или текст причины с путём); + `mcp_server._attach_files(client, commands) -> list | str` (команды с `file`/`filename` вместо `path` или текст ошибки); + `mcp_server.BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"`. + +- [ ] **Step 1: Падающие тесты** + +В `tests/test_mcp.py` заменить импорты на + +```python +import asyncio +import hashlib +import json +import os +import re +import threading +from types import SimpleNamespace + +import pytest + +from jira_hub import api, commands, files, mcp_server, settings +from jira_hub.client import HubClient +``` + +и в конец файла добавить: + +```python +BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", + "labels": ["AWPCA"]}} + + +def _batch_commands(out): + batch_id = int(out.split("#")[1].split()[0]) + return mcp_server._client().get(f"/batches/{batch_id}")["commands"] + + +def _write(path, data): + path.write_bytes(data) + return str(path) + + +def test_batch_create_bug_with_local_file(live, tmp_path): + """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" + mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, + "bug": {"name": "Ошибка", "id": None}})) + f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx" + f.write_bytes(b"PK\x03\x04 xlsx") + out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", + "params": {"key": "new:1", "path": str(f)}}]) + assert "3 команд" in out + cmds = _batch_commands(out) + sha = hashlib.sha256(f.read_bytes()).hexdigest() + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"] + assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"} + assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes() + + +def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "скрин.png").write_bytes(b"png") + out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) + assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(), + "filename": "Экран 1.png"} + + +@pytest.mark.parametrize("make, expected", [ + (lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"), + (lambda d: str(d), "не файл: {p}"), + (lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"), + (lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"), + (lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)), + "файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"), +]) +def test_batch_create_bad_path(live, tmp_path, make, expected): + """Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено.""" + src = tmp_path / "src" + src.mkdir() + p = make(src) + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) + assert out == expected.format(p=p) + assert mcp_server._client().get("/batches")["batches"] == [] + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_unreadable_file(live, tmp_path): + f = tmp_path / "закрыт.xlsx" + f.write_bytes(b"x") + f.chmod(0) + try: + if os.access(f, os.R_OK): + pytest.skip("файл читается и без прав (root)") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) + assert out == f"нет прав на чтение: {f}" + finally: + f.chmod(0o600) + + +def test_batch_create_path_and_file(live, tmp_path): + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": str(f), "file": "0" * 64}}]) + assert out == "у ensure_attachment либо path, либо file" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path): + good = tmp_path / "a.xlsx" + good.write_bytes(b"a") + missing = tmp_path / "нет.xlsx" + out = mcp_server.batch_create("x", [ + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}}, + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}]) + assert out == f"файл не найден: {missing}" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_hub_422_after_upload(live, tmp_path): + """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) + assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug" + assert mcp_server._client().get("/batches")["batches"] == [] + + +def test_batch_create_description_mentions_path(): + desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description + assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc + assert "issue_type (task|epic|bug)" in desc + assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_mcp.py -k "batch_create"` +Expected: FAIL — хаб отвечает «ошибка хаба 422 bad_batch: команда 1 (ensure_attachment): нет file, filename; …» или +«лишние параметры path», нет текста «вместо file передайте path». + +- [ ] **Step 3: Реализация** + +В `src/jira_hub/mcp_server.py` импорты заменить на + +```python +import asyncio +import base64 +import functools +import inspect +import json +import stat +import time +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from .client import HubClient, HubError, HubUnavailable +from .commands import describe +from .files import MAX_SIZE +``` + +после `AUTHOR = "claude"` добавить + +```python +BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" +``` + +перед `def _doc` добавить: + +```python +def _read_local(path) -> bytes | str: + """Содержимое локального файла для ensure_attachment.path или текст причины с путём (spec mcp, решение 19).""" + if not isinstance(path, str) or not path: + return "path — строка с абсолютным путём к файлу" + p = Path(path).expanduser() + if not p.is_absolute(): + return f"нужен абсолютный путь: {path}" + try: + st = p.stat() + if not stat.S_ISREG(st.st_mode): + return f"не файл: {path}" + if st.st_size > MAX_SIZE: + return f"файл больше 10 МБ ({st.st_size} байт): {path}" + data = p.read_bytes() + except FileNotFoundError: + return f"файл не найден: {path}" + except PermissionError: + return f"нет прав на чтение: {path}" + except OSError as e: + return f"файл не прочитан ({e.strerror or e}): {path}" + if not data: + return f"пустой файл: {path}" + if len(data) > MAX_SIZE: # вырос между stat и чтением + return f"файл больше 10 МБ ({len(data)} байт): {path}" + return data + + +def _attach_files(client, commands): + """ensure_attachment с path: сначала прочитать все файлы пакета, затем загрузить их POST /files и подставить file и + filename (по умолчанию — имя файла из path). Ошибка любого файла — текст, в хаб ничего не загружено. Остальные + команды и форму пакета проверяет хаб.""" + if not isinstance(commands, list): + return commands + read = {} + for i, cmd in enumerate(commands): + params = cmd.get("params") if isinstance(cmd, dict) and cmd.get("type") == "ensure_attachment" else None + if not isinstance(params, dict) or "path" not in params: + continue + if params.get("file") not in (None, ""): + return BOTH_FILE_AND_PATH + data = _read_local(params["path"]) + if isinstance(data, str): + return data + read[i] = data + out = [] + for i, cmd in enumerate(commands): + if i not in read: + out.append(cmd) + continue + params = {k: v for k, v in cmd["params"].items() if k != "path"} + up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) + params["file"] = up["file"] + params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name + out.append({**cmd, "params": params}) + return out +``` + +Описание и тело `batch_create` заменить: + +```python +@mcp.tool() +@guard +@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. +Параметры по типам (необязательные — в [ ]): +""" + describe() + """ +ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, +задача без неё выпадет из базовой JQL и из зеркала. +ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит +файл в хаб и подставит file, filename по умолчанию — имя файла.""") +def batch_create(title: str, commands: list[dict]) -> str: + client = _client() + prepared = _attach_files(client, commands) + if isinstance(prepared, str): + return prepared + b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared}) + return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" +``` + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS. + +- [ ] **Step 5: Документация и решение 19** + +`README.md`, таблица инструментов MCP — строку `batch_create` заменить на + +```markdown +| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | +``` + +и после строки «Автор команд из MCP: `claude`; из API без автора: `api`.» добавить: + +````markdown +«Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, +загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, +нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится. + +```json +[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}, + {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] +``` +```` + +`ARCHITECTURE.md`: строку модуля `mcp_server` заменить на + +```markdown +| `mcp_server` | инструменты MCP по stdio; `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) | +``` + +и в «Границы» строку «`mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.» на +«`mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел +`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).» + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +19. 15.10.2026 — MCP `batch_create` с `path`: `~` раскрывается; относительный путь — «нужен абсолютный путь: »; + пустой файл отвергается до загрузки — «пустой файл: »; прочие тексты — «не файл: », «нет прав на + чтение: », «файл больше 10 МБ ( байт): », «файл не прочитан (<причина>): »; сначала читаются + все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: cwd сервера MCP + неочевиден; спека дословно задаёт только «файл не найден» и «либо path, либо file». Цена: пакет с N файлами держит + их в памяти MCP до загрузки (≤ N × 10 МБ). +``` + +- [ ] **Step 6: Полная проверка** + +Run: `make -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach check` +Expected: pytest — все PASS (без `ResourceWarning`), `npm run lint` — без ошибок, `npm test` (Vitest) — все PASS. Если +`web/node_modules` нет — сначала `npm --prefix /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/web ci`. + +- [ ] **Step 7: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/mcp_server.py tests/test_mcp.py README.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(mcp): batch_create принимает path у ensure_attachment и сам загружает файл (#43)" -m "Все файлы пакета читаются до загрузки: «файл не найден: », «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19)." -m "Co-Authored-By: Claude Opus 5.5 " +``` From 3b4cf66b778775839452e0a6a3616a96f654ef92 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:35:56 +0400 Subject: [PATCH 3/9] =?UTF-8?q?feat(commands):=20=D1=82=D0=B8=D0=BF=20bug?= =?UTF-8?q?=20=D0=B2=20ensure=5Fissue=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20issue=5Ftypes.bug=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B5=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate принимает task|epic|bug; queue._check_setup отвергает bug без issue_types.bug с name или id (422 «команда N: нет настройки issue_types.bug»); init берёт bug из issue_type_bug/issue_type_bug_id, только если ключи есть (решение 9). Спека hub-api: уборка файлов раз в сутки в пределах процесса (R-1). Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++- .../changes/bug-and-attachments/design.md | 3 ++ .../bug-and-attachments/specs/hub-api/spec.md | 2 +- src/jira_hub/commands.py | 6 ++-- src/jira_hub/queue.py | 11 ++++++++ src/jira_hub/settings.py | 12 ++++++-- tests/test_api.py | 8 ++++++ tests/test_commands.py | 11 +++++++- tests/test_queue.py | 28 +++++++++++++++++++ tests/test_settings.py | 26 ++++++++++++++++- 10 files changed, 105 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7b32379..4fd2dce 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | Тип | Параметры | |---|---| -| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | | `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | | `ensure_comment` | `key`, `marker`, `body` | | `ensure_status` | `key`, `target` (имя статуса или список-путь) | @@ -194,6 +194,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | | `refresh` | `keys` или `jql` | +Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть +`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из +ключей конфига `issue_type_bug`/`issue_type_bug_id`, если они есть; на живом хабе — `settings_set issue_types` с объектом +целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`. + Ключ в параметре может быть временным `new:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`): diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index f72eb2e..a0baf5f 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -97,3 +97,6 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a Обоснование: `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, + как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. diff --git a/openspec/changes/bug-and-attachments/specs/hub-api/spec.md b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md index b9931ba..f470721 100644 --- a/openspec/changes/bug-and-attachments/specs/hub-api/spec.md +++ b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md @@ -144,7 +144,7 @@ sha256 (без расширения и имени вложения: имя за - файл больше 10 МБ (10 485 760 байт) после декодирования — 413 `too_large` (тело запроса до 16 МБ проходит); - запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем же `file` и не создаёт второй копии. -Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки удалять файлы старше 30 дней (по +Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed`; повтор команды после уборки отвергается при постановке 422 «файл не загружен: POST /files». Повторная загрузка того же содержимого SHALL обновлять время изменения файла. diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index baf1b6c..bc81aac 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -18,7 +18,7 @@ SPEC = { "optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels", "epic_key", "assignee"], "keys": ["temp_key", "epic_key"], - "hints": {"temp_key": "new:", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}}, + "hints": {"temp_key": "new:", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}}, "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], "keys": ["key", "epic_key"], "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"}, @@ -139,8 +139,8 @@ def validate(commands) -> list[dict]: raise CommandError(f"команда {i}: temp_key должен быть new:") if params["temp_key"] in defined: raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен") - if params["issue_type"] not in ("task", "epic"): - raise CommandError(f"команда {i}: issue_type — task или epic") + if params["issue_type"] not in ("task", "epic", "bug"): + raise CommandError(f"команда {i}: issue_type — task, epic или bug") for key in temp_keys_used({"type": ctype, "params": params}): if key not in defined: raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue") diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3faead8..3bcad31 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -45,8 +45,19 @@ def _dump(value) -> str: # --- пакеты ----------------------------------------------------------------- +def _check_setup(conn, cmds) -> None: + """Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета. + Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok: + raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") + + def create_batch(conn, title, author, cmds) -> dict: cmds = C.validate(cmds) + _check_setup(conn, cmds) keys = [] for c in cmds: keys += [k for k in C.referenced_keys(c) if k not in keys] diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index fc91e75..f2fc533 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -135,6 +135,15 @@ def pacing_from_config(p: dict) -> dict: "ensure_link": comment, "ensure_sprint": comment, "refresh": 2} +def _issue_types(cfg: dict) -> dict: + """issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть + issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»).""" + out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")} + if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg: + out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")} + return out + + def init_from_config(conn, cfg: dict, force=False) -> list[str]: """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" @@ -144,8 +153,7 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]: values = { "project_key": project, "base_jql": f"project = {project} AND labels = AWPCA", - "issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, - "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, + "issue_types": _issue_types(cfg), "epic_name_field_id": cfg.get("epic_name_field_id"), "field_ids": cfg.get("fields", {}), "epic_link": cfg.get("epic_link", {}), diff --git a/tests/test_api.py b/tests/test_api.py index e11d7c0..dc3bdf9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -598,3 +598,11 @@ def test_issue_detail_with_comments(hub): got = hub["client"].get("/issues/A-1") assert [x["body"] for x in got["comments"]] == ["новый", "старый"] assert got["epic_summary"] == "Эпик" + + +def test_bug_without_setting_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "ошибка", "commands": [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) + assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" + assert hub["client"].get("/batches")["batches"] == [] diff --git a/tests/test_commands.py b/tests/test_commands.py index 45f0783..fdcc4d3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -28,7 +28,8 @@ def test_validate_ok_and_tail_keys(): ([{"type": "refresh", "params": {}}], "без keys и jql"), ([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"), ([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:"), - ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}], + "task, epic или bug"), ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}}, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"), ]) @@ -55,6 +56,14 @@ def test_unknown_param_error_names_expected(): "ожидаются source, target, link_type; необязательные replace") +def test_validate_accepts_bug_issue_type(): + """bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue).""" + cmds = commands.validate([{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]) + assert cmds[0]["params"]["issue_type"] == "bug" + assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug" + + def test_spec_hints_name_known_params(): for ctype, spec in commands.SPEC.items(): assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype diff --git a/tests/test_queue.py b/tests/test_queue.py index a6732ae..750c1d4 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -515,3 +515,31 @@ def test_delivery_limit_of_write_command_keeps_open_sync_run(conn): assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh assert queue.get_command(conn, c["id"])["status"] == "failed" assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None + + +def bug(temp="new:1", summary="ОСВ 60 не распознана"): + return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary, + "labels": ["AWPCA"]}} + + +def test_bug_without_setting_rejected(conn): + """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан.""" + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()]) + assert str(e.value) == "команда 2: нет настройки issue_types.bug" + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None]) +def test_bug_with_empty_setting_rejected(conn, value): + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value}) + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [bug()]) + + +@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}]) +def test_bug_with_setting_accepted(conn, value): + settings.set_(conn, "issue_types", {"bug": value}) + b = queue.create_batch(conn, "ошибка", "claude", [bug()]) + assert b["count"] == 2 # ensure_issue + хвостовой refresh diff --git a/tests/test_settings.py b/tests/test_settings.py index 4926338..7803ab4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,7 +3,8 @@ from pathlib import Path import pytest -from jira_hub import settings +from jira_hub import queue, settings +from jira_hub.commands import CommandError FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -63,3 +64,26 @@ def test_relay_dir_default_and_validation(tmp_path, open_db): with pytest.raises(ValueError): settings.validate("relay_dir", bad) assert "relay_dir" not in settings.AGENT_KEYS + + +def test_init_without_bug_keys_rejects_bug(conn): + """Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422.""" + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + settings.init_from_config(conn, cfg) + assert set(settings.get(conn, "issue_types")) == {"task", "epic"} + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "x", "labels": ["AWPCA"]}}]) + + +@pytest.mark.parametrize("extra, expected", [ + ({"issue_type_bug": "Ошибка"}, {"name": "Ошибка", "id": None}), + ({"issue_type_bug_id": "10102"}, {"name": None, "id": "10102"}), + ({"issue_type_bug": "Ошибка", "issue_type_bug_id": "10102"}, {"name": "Ошибка", "id": "10102"}), +]) +def test_init_bug_from_config(conn, extra, expected): + cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra} + settings.init_from_config(conn, cfg) + types = settings.get(conn, "issue_types") + assert types["bug"] == expected + assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} From 19bba60cacc7f45c9828c98f5e83ac13e155d387 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:38:31 +0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(files):=20=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20fil?= =?UTF-8?q?es/=20=D1=81=20=D1=83=D0=B1=D0=BE=D1=80=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit put — атомарно (mkstemp, fsync, os.replace), одна копия на содержимое, mtime при повторе, предел 10 485 760 байт, пустой — отказ; path/exists/size/read только для sha из 64 hex; sweep — старше 30 дней без ensure_attachment в queued/claimed (решения 10–13). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 3 +- .../changes/bug-and-attachments/design.md | 14 ++ src/jira_hub/files.py | 112 +++++++++++++ tests/test_files.py | 149 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/files.py create mode 100644 tests/test_files.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5dfab78..b61da5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,7 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | @@ -29,7 +30,7 @@ перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`. -Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index a0baf5f..1161912 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -100,3 +100,17 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 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 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py new file mode 100644 index 0000000..edbea42 --- /dev/null +++ b/src/jira_hub/files.py @@ -0,0 +1,112 @@ +"""Хранилище файлов вложений: <каталог данных>/files/, одна копия на содержимое, без имени вложения — имя задаёт +команда ensure_attachment (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: +каталог передаётся параметром (Hub.files_dir, решение 10).""" +import hashlib +import os +import re +import tempfile +import time +from pathlib import Path + +from .paths import secure_dir + +MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) +KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) +SHA_RE = re.compile(r"[0-9a-f]{64}") + + +class EmptyFile(ValueError): + """Пустое содержимое — API отвечает 422 empty_file (решение 3).""" + + +class TooLarge(ValueError): + """Больше MAX_SIZE — API отвечает 413 too_large.""" + + +def is_sha(value) -> bool: + """sha256 в хранилище — ровно 64 символа [0-9a-f]: верхний регистр, «..», «/» путь не строят (решение 12).""" + return isinstance(value, str) and SHA_RE.fullmatch(value) is not None + + +def path(root, sha) -> Path: + if not is_sha(sha): + raise ValueError(f"не sha256: {sha!r}") + return Path(root) / sha + + +def exists(root, sha) -> bool: + return is_sha(sha) and path(root, sha).is_file() + + +def size(root, sha) -> int | None: + """Размер файла в байтах; None — файла нет или sha негодный.""" + if not is_sha(sha): + return None + try: + return path(root, sha).stat().st_size + except FileNotFoundError: + return None + + +def read(root, sha) -> bytes | None: + if not is_sha(sha): + return None + try: + return path(root, sha).read_bytes() + except FileNotFoundError: + return None + + +def put(root, data: bytes) -> tuple[str, int]: + """Сохранить содержимое, вернуть (sha256, размер). Копия того же размера уже есть — только обновить mtime (уборка + считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись + атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется + (решение 11).""" + if not data: + raise EmptyFile("пустой файл") + if len(data) > MAX_SIZE: + raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") + root = Path(root) + secure_dir(root) + sha = hashlib.sha256(data).hexdigest() + target = root / sha + if size(root, sha) == len(data): + try: + os.utime(target) + return sha, len(data) + except FileNotFoundError: + pass # убран между проверкой и utime — пишем заново + fd, tmp = tempfile.mkstemp(dir=root, prefix=f".{sha}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return sha, len(data) + + +def sweep(conn, root, now: float | None = None) -> list[str]: + """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или + claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят + тем же путём. Каталога нет — пусто.""" + root = Path(root) + if not root.is_dir(): + return [] + cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 + active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " + "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} + removed = [] + for p in sorted(root.iterdir()): + if p.name in active or not p.is_file(): + continue + try: + if p.stat().st_mtime < cutoff: + p.unlink() + removed.append(p.name) + except FileNotFoundError: + continue + return removed diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..1ebf899 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,149 @@ +"""Хранилище файлов вложений (bug-and-attachments, hub-api «Хранилище файлов вложений»).""" +import hashlib +import json +import os +import stat +import time + +import pytest + +from jira_hub import db, files, settings + +ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… +DAY = 86400 + + +@pytest.fixture +def root(tmp_path): + return tmp_path / "files" + + +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c + + +def test_put_creates_closed_dir_and_file(root): + assert files.put(root, b"abc") == (ABC, 3) + assert ABC.startswith("ba7816bf") + assert (root / ABC).read_bytes() == b"abc" + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + assert [p.name for p in root.iterdir()] == [ABC] # временных файлов не осталось + + +def test_put_again_keeps_one_copy_and_touches_mtime(root): + files.put(root, b"abc") + old = time.time() - 40 * DAY + os.utime(root / ABC, (old, old)) + assert files.put(root, b"abc") == (ABC, 3) + assert [p.name for p in root.iterdir()] == [ABC] + assert (root / ABC).stat().st_mtime > time.time() - 60 # свежая постановка не теряет файл в уборке + + +def test_put_rewrites_truncated_copy(root): + files.put(root, b"abc") + (root / ABC).write_bytes(b"ab") # оборванная копия + files.put(root, b"abc") + assert (root / ABC).read_bytes() == b"abc" + + +def test_put_limits(root): + with pytest.raises(files.EmptyFile): + files.put(root, b"") + with pytest.raises(files.TooLarge): + files.put(root, b"\0" * (files.MAX_SIZE + 1)) + assert not root.exists() or list(root.iterdir()) == [] + sha, size = files.put(root, b"\0" * files.MAX_SIZE) # ровно 10 485 760 байт — принимается + assert files.MAX_SIZE == 10_485_760 and size == files.MAX_SIZE and files.size(root, sha) == files.MAX_SIZE + + +def test_put_failure_leaves_no_temp(root, monkeypatch): + def broken(*args): + raise OSError("диск полон") + monkeypatch.setattr(files.os, "replace", broken) + with pytest.raises(OSError, match="диск полон"): + files.put(root, b"abc") + assert list(root.iterdir()) == [] + + +def test_put_after_dir_removed(root): + files.put(root, b"abc") + (root / ABC).unlink() + root.rmdir() + assert files.put(root, b"abc") == (ABC, 3) and files.exists(root, ABC) + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + + +def test_lookup(root): + files.put(root, b"abc") + assert files.exists(root, ABC) and files.size(root, ABC) == 3 and files.read(root, ABC) == b"abc" + assert files.path(root, ABC) == root / ABC + missing = "0" * 64 + assert not files.exists(root, missing) and files.size(root, missing) is None and files.read(root, missing) is None + + +@pytest.mark.parametrize("bad", [ABC.upper(), ABC[:63], ABC + "0", "..", "../hub.sqlite", ABC + "\n", "", None, 5]) +def test_bad_sha_never_builds_path(root, bad): + """APFS не различает регистр: ABC.upper() нашёл бы файл ABC, если бы путь строился без проверки.""" + files.put(root, b"abc") + assert files.is_sha(bad) is False + assert not files.exists(root, bad) and files.size(root, bad) is None and files.read(root, bad) is None + with pytest.raises(ValueError): + files.path(root, bad) + + +def _file(root, content, age_days): + sha, _ = files.put(root, content) + t = time.time() - age_days * DAY + os.utime(root / sha, (t, t)) + return sha + + +def _command(conn, sha, status): + if conn.execute("SELECT 1 FROM batch WHERE id = 1").fetchone() is None: + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 'x', 'claude', ?)", (db.now(),)) + conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (1, 0, 'ensure_attachment', ?, ?)", + (json.dumps({"key": "A-1", "file": sha, "filename": "a.xlsx"}), status)) + + +def test_sweep_spec_scenario(root, conn): + """Спека hub-api «Уборка старых файлов»: A старше 30 дней без ссылок, B старше 30 дней в queued, C вчерашний.""" + a = _file(root, b"A", 31) + b = _file(root, b"B", 31) + c = _file(root, b"C", 1) + _command(conn, b, "queued") + assert files.sweep(conn, root) == [a] + assert sorted(p.name for p in root.iterdir()) == sorted([b, c]) + + +def test_sweep_keeps_claimed_and_shared_file(root, conn): + shared = _file(root, b"S", 31) + claimed = _file(root, b"K", 31) + gone = _file(root, b"G", 31) + _command(conn, shared, "done") + _command(conn, shared, "queued") # тот же файл под другим именем в другой команде + _command(conn, claimed, "claimed") + for status in ("done", "cancelled", "failed", "skipped", "confirmed_by_state"): + _command(conn, gone, status) + assert files.sweep(conn, root) == [gone] + assert files.exists(root, shared) and files.exists(root, claimed) + + +def test_sweep_removes_stale_temp_and_handles_missing_dir(root, conn, tmp_path): + assert files.sweep(conn, tmp_path / "нет") == [] + files.put(root, b"abc") + tmp = root / f".{ABC}.x1y2.tmp" # брошен сбоем посреди записи + tmp.write_bytes(b"ab") + t = time.time() - 31 * DAY + os.utime(tmp, (t, t)) + assert files.sweep(conn, root) == [tmp.name] + assert files.exists(root, ABC) + + +def test_sweep_uses_now(root, conn): + sha = _file(root, b"N", 0) + assert files.sweep(conn, root) == [] + assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha] From e8707d47620c300935ed3bae726adf4e0d2909e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:41:54 +0400 Subject: [PATCH 5/9] =?UTF-8?q?feat(api):=20POST=20/files=20=D0=B8=20GET?= =?UTF-8?q?=20/agent/files/{sha256},=20files/=200700=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5,=20=D1=83=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=20tick=20=D1=80=D0=B0=D0=B7=20?= =?UTF-8?q?=D0=B2=20=D1=81=D1=83=D1=82=D0=BA=D0=B8=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 3 +- README.md | 10 +- .../changes/bug-and-attachments/design.md | 8 ++ src/jira_hub/api.py | 61 +++++++++- tests/test_api.py | 104 +++++++++++++++++- 6 files changed, 179 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b61da5d..54e232f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,7 +14,7 @@ | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | diff --git a/CLAUDE.md b/CLAUDE.md index 39d4356..e3be21b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,13 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента); каталог данных, `backups/` и `release/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. - `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. +- `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы). - Подробная карта модулей и границ — `ARCHITECTURE.md`. diff --git a/README.md b/README.md index 4fd2dce..5c846b6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`. Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): -`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. +`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. ## Первый запуск @@ -128,7 +128,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py ## HTTP API Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, -ошибки `{error, detail}` (400/401/404/409/422). +ошибки `{error, detail}` (400/401/404/409/413/422). | Маршрут | Токен | Назначение | |---|---|---| @@ -139,6 +139,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /agent/state` | агент | загрузка состояния задач пачками | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | +| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | | `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | @@ -148,6 +149,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | +| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | @@ -223,6 +225,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). +- Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый + раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в + очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается + заново. - Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала, хаб ставит пакет «Автообновление». - Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`. diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 1161912..680b93b 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -114,3 +114,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 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`. + Обоснование: для агента это тот же случай «файла нет». Цена: нет. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1d5efce..ef6419e 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -1,6 +1,7 @@ """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).""" +import base64 import hmac import json import logging @@ -13,7 +14,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, mirror, queue, relay, release, settings +from . import backup, db, files, mirror, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -29,6 +30,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит +SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») class Raw(NamedTuple): @@ -45,16 +47,19 @@ class ApiError(Exception): class Hub: - def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None): + def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None): self.db_path = Path(db_path) self.web_dir = Path(web_dir) if web_dir else WEB_APP self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" + self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # неизвестен, а не null (hub-hygiene-d, решение 7) self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии + secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ conn = self.conn() settings.ensure_defaults(conn) conn.close() @@ -63,17 +68,35 @@ class Hub: return db.connect(self.db_path) def tick(self): - """Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" + """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой + логируется и не мешает ни другой, ни следующему тику.""" conn = None try: conn = self.conn() queue.maybe_auto_refresh(conn) except Exception: # noqa: BLE001 log.exception("автообновление: сбой тика") + try: + if conn is not None: + self.sweep_files(conn) + except Exception: # noqa: BLE001 + log.exception("уборка файлов: сбой") finally: - if conn: + if conn is not None: conn.close() + def sweep_files(self, conn, now=None): + """Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после + перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано.""" + now = time.time() if now is None else now + if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY: + return None + self.last_sweep = now + removed = files.sweep(conn, self.files_dir, now) + if removed: + log.info("уборка файлов: удалено %d", len(removed)) + return removed + def note_agent_update(self, body): """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" @@ -141,6 +164,15 @@ def h_agent_release(hub, conn, m, q, body): return 200, rel +def h_agent_file(hub, conn, m, q, body): + """Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex + нижнего регистра — тот же 404 no_file (решение 16).""" + data = files.read(hub.files_dir, m.group(1)) + if data is None: + raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба") + return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} + + def h_agent_settings(hub, conn, m, q, body): return 200, settings.for_agent(conn) @@ -258,6 +290,25 @@ def h_batch_cancel(hub, conn, m, q, body): raise ApiError(404, "not_found", "batch") +def h_files_post(hub, conn, m, q, body): + """Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) + отбрасываются до строгой проверки (решение 14).""" + content = body.get("content") + if not isinstance(content, str): + raise ApiError(400, "bad_request", "content — строка base64") + try: + data = base64.b64decode("".join(content.split()), validate=True) + except ValueError: # binascii.Error — подкласс ValueError + raise ApiError(400, "bad_request", "content — не base64") from None + try: + sha, size = files.put(hub.files_dir, data) + except files.EmptyFile: + raise ApiError(422, "empty_file", "пустой файл") from None + except files.TooLarge: + raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None + return 200, {"file": sha, "size": size} + + def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") @@ -344,6 +395,7 @@ ROUTES = [ ("POST", r"^/agent/state$", h_agent_state, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), + ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), @@ -352,6 +404,7 @@ ROUTES = [ ("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), + ("POST", r"^/files$", h_files_post, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/refresh$", h_refresh, "client"), diff --git a/tests/test_api.py b/tests/test_api.py index dc3bdf9..c4cddd5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,13 +1,17 @@ +import base64 +import hashlib import json import os +import stat import threading +import time import urllib.error import urllib.request from datetime import timedelta import pytest -from jira_hub import api, db, queue, release, settings +from jira_hub import api, db, files, queue, release, settings from jira_hub.client import HubClient, HubError @@ -606,3 +610,101 @@ def test_bug_without_setting_is_422(hub): "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" assert hub["client"].get("/batches")["batches"] == [] + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_files_upload_and_agent_download(hub, tmp_path): + """Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения».""" + c, a = hub["client"], hub["agent"] + sha = hashlib.sha256(b"abc").hexdigest() + out = c.post("/files", {"content": _b64(b"abc")}) + assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf") + assert (tmp_path / "files" / sha).read_bytes() == b"abc" + assert c.post("/files", {"content": _b64(b"abc")}) == out + assert [p.name for p in (tmp_path / "files").iterdir()] == [sha] + got = a.get(f"/agent/files/{sha}") + assert got["file"] == sha and got["size"] == 3 + assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha + with pytest.raises(HubError) as e: + c.get(f"/agent/files/{sha}") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.post("/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.get("/agent/files/" + "0" * 64) + assert (e.value.status, e.value.error) == (404, "no_file") + + +@pytest.mark.parametrize("body, status, error", [ + ({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"), + ({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"), + ({"content": ""}, 422, "empty_file"), +]) +def test_files_bad_upload(hub, tmp_path, body, status, error): + with pytest.raises(HubError) as e: + hub["client"].post("/files", body) + assert (e.value.status, e.value.error) == (status, error) + assert list((tmp_path / "files").iterdir()) == [] + + +def test_files_base64_with_line_breaks(hub): + data = bytes(range(256)) * 4 + s = _b64(data) + wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64 + assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024} + + +def test_files_too_large_is_413(hub, tmp_path): + """Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит.""" + with pytest.raises(HubError) as e: + hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert (e.value.status, e.value.error) == (413, "too_large") + assert list((tmp_path / "files").iterdir()) == [] + + +@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64]) +def test_agent_file_bad_id_is_no_file(hub, bad): + hub["client"].post("/files", {"content": _b64(b"abc")}) + with pytest.raises(HubError) as e: + hub["agent"].get(f"/agent/files/{bad}") + assert (e.value.status, e.value.error) == (404, "no_file") + + +def test_files_dir_closed_on_every_start(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + d = tmp_path / "files" + assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700 + os.chmod(d, 0o755) + api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert stat.S_IMODE(d.stat().st_mode) == 0o700 + + +def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): + h = hub["hub"] + sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"] + old = time.time() - 31 * 86400 + os.utime(tmp_path / "files" / sha, (old, old)) + calls = [] + real = files.sweep + monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k)) + h.tick() + assert len(calls) == 1 and not (tmp_path / "files" / sha).exists() + h.tick() + assert len(calls) == 1 # второй тик в те же сутки — без уборки + h.last_sweep -= api.SWEEP_EVERY + h.tick() + assert len(calls) == 2 + + +def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog): + def broken(*args, **kwargs): + raise OSError("диск недоступен") + monkeypatch.setattr(api.files, "sweep", broken) + hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление + hub["hub"].tick() # не бросает + assert "уборка файлов: сбой" in caplog.text + assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"] From 9b21060d60175e0ace754800c641cd0d9f4e9639 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:46:14 +0400 Subject: [PATCH 6/9] =?UTF-8?q?feat(commands):=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=20ensure=5Fattachment=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B5,=20size=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B5=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 3 +- README.md | 7 +- .../changes/bug-and-attachments/design.md | 8 ++ .../specs/commands/spec.md | 3 +- src/jira_hub/api.py | 10 +- src/jira_hub/commands.py | 12 ++- src/jira_hub/queue.py | 47 ++++++--- src/jira_hub/settings.py | 4 +- tests/test_api.py | 19 ++++ tests/test_commands.py | 22 +++++ tests/test_queue.py | 98 ++++++++++++++++++- tests/test_settings.py | 7 ++ 12 files changed, 215 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54e232f..36c2ce4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | -| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | @@ -34,6 +34,7 @@ ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. +- Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. diff --git a/README.md b/README.md index 5c846b6..4ee25c4 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | | `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | +| `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | | `refresh` | `keys` или `jql` | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть @@ -213,7 +214,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub ``` Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, -`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`). +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). + +`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не +загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба +(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет). ## Пример сценария diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 680b93b..6f324ca 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -122,3 +122,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг 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` молча отвергает вложения. diff --git a/openspec/changes/bug-and-attachments/specs/commands/spec.md b/openspec/changes/bug-and-attachments/specs/commands/spec.md index c9ffc3f..329361c 100644 --- a/openspec/changes/bug-and-attachments/specs/commands/spec.md +++ b/openspec/changes/bug-and-attachments/specs/commands/spec.md @@ -135,7 +135,8 @@ SHALL перевести её в `confirmed_by_state`: `ensure_fields` — вс При постановке пакета хаб SHALL отвергать 422 без создания пакета: `ensure_issue` с `issue_type = bug`, если в настройках нет `issue_types.bug` с непустым `name` или `id` («нет настройки issue_types.bug»); `ensure_attachment`, если `file` — не 64 hex-символа в нижнем регистре, файла с таким sha256 нет в хранилище хаба («файл не загружен: POST /files»), -`filename` пустой или содержит `/` или `\`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту +`filename` пустой (или из одних пробелов) или содержит `/`, `\`, `"` или управляющие символы (код < 32 и 127, +в том числе CR и LF): такие имена ломают `multipart/form-data`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту (`claim`, в том числе повторной) хаб SHALL добавить к параметрам `ensure_attachment` `size` — текущий размер файла в хранилище, или `size = null`, если файла уже нет; в базе параметры команды остаются без `size`, поэтому повтор команды (`POST /commands/{id}/retry`) проходит проверку как новая. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ef6419e..a1f71d4 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -144,7 +144,7 @@ def h_claim(hub, conn, m, q, body): deadline = time.monotonic() + hub.claim_wait while True: cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), - backup=hub.make_backup) + backup=hub.make_backup, files_dir=hub.files_dir) if cmd or time.monotonic() >= deadline: break time.sleep(1) @@ -178,7 +178,9 @@ def h_agent_settings(hub, conn, m, q, body): def h_agent_command(hub, conn, m, q, body): - return 200, _not_found(queue.get_command(conn, _int(m)), "command") + """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" + cmd = _not_found(queue.get_command(conn, _int(m)), "command") + return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): @@ -270,7 +272,7 @@ def h_batches_post(hub, conn, m, q, body): if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") try: - return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -313,7 +315,7 @@ def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") try: - return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir) except KeyError: raise ApiError(404, "not_found", "command") except CommandError as e: diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index bc81aac..f864ce7 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -31,6 +31,9 @@ SPEC = { "hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}}, "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"], "hints": {"sprint": "имя спринта или backlog"}}, + "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"], + "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу", + "filename": "имя вложения"}}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, "note": "keys или jql"}, } @@ -182,11 +185,12 @@ def _norm_text(value): def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: - """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: - потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, - решение 4); совпадение темы с задачей неизвестного автора не доказательство.""" + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не + подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля + (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в + зеркале нет (bug-and-attachments).""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_issue", "ensure_comment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"): return False, None issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3bcad31..66d2dcc 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -7,7 +7,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C -from . import db, mirror, settings +from . import db, files, mirror, settings log = logging.getLogger(__name__) @@ -45,19 +45,33 @@ def _dump(value) -> str: # --- пакеты ----------------------------------------------------------------- -def _check_setup(conn, cmds) -> None: - """Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета. - Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).""" +def _check_setup(conn, cmds, files_dir) -> None: + """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 + до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). + files_dir=None — хранилища нет: любой файл «не загружен».""" bug = (settings.get(conn, "issue_types") or {}).get("bug") bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) for i, c in enumerate(cmds, 1): - if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok: + p = c["params"] + if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") + if c["type"] == "ensure_attachment": + if not files.is_sha(p["file"]): + raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") + if files_dir is None or not files.exists(files_dir, p["file"]): + raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") + if not p["filename"].strip(): + raise C.CommandError(f"команда {i}: filename пустой") + if "/" in p["filename"] or "\\" in p["filename"]: + raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") + if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]): + # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) + raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") -def create_batch(conn, title, author, cmds) -> dict: +def create_batch(conn, title, author, cmds, files_dir=None) -> dict: cmds = C.validate(cmds) - _check_setup(conn, cmds) + _check_setup(conn, cmds, files_dir) keys = [] for c in cmds: keys += [k for k in C.referenced_keys(c) if k not in keys] @@ -172,7 +186,16 @@ def _redeliver(conn, now) -> dict | None: log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) -def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: +def deliver_params(cmd, files_dir) -> dict: + """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, + если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + if cmd["type"] != "ensure_attachment": + return cmd["params"] + size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None + return {**cmd["params"], "size": size} + + +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: now = db.now() to_backup = None with db.transaction(conn): @@ -214,7 +237,8 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non backup(to_backup) except Exception: # сбой копии не должен держать очередь: команда уже взята log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True) - return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]} + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause, + "batch_id": cmd["batch_id"]} # --- отчёты ----------------------------------------------------------------- @@ -325,11 +349,12 @@ def cancel_command(conn, command_id, force=False) -> dict: return get_command(conn, command_id) -def retry_command(conn, command_id, author) -> dict: +def retry_command(conn, command_id, author, files_dir=None) -> dict: cmd = get_command(conn, command_id) if cmd is None: raise KeyError(command_id) - return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}]) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], + files_dir=files_dir) # --- агент и автообновление ---------------------------------------------------- diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index f2fc533..23fcf88 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -5,7 +5,7 @@ import os import secrets DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "refresh": 2} + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} DEFAULTS = { "base_jql": "project = AURORA AND labels = AWPCA", @@ -132,7 +132,7 @@ def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "refresh": 2} + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2} def _issue_types(cfg: dict) -> dict: diff --git a/tests/test_api.py b/tests/test_api.py index c4cddd5..9554794 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -708,3 +708,22 @@ def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, cap hub["hub"].tick() # не бросает assert "уборка файлов: сбой" in caplog.text assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"] + + +def test_attachment_flow_over_api(hub): + """POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size.""" + c, a = hub["client"], hub["agent"] + sha = c.post("/files", {"content": _b64(b"abc")})["file"] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]}) + assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files" + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]}) + cmd = a.post("/agent/claim", {})["command"] + assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3} + assert cmd["pause_seconds"] == 30 + assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3 + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} diff --git a/tests/test_commands.py b/tests/test_commands.py index fdcc4d3..9d52d63 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -64,6 +64,27 @@ def test_validate_accepts_bug_issue_type(): assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug" +ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc") + + +def test_ensure_attachment_contract(): + ok = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}]) + assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"] + assert "ensure_attachment" in commands.WRITE_TYPES + assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), " + "filename (имя вложения)") in commands.describe().splitlines() + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}]) + assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}]) + assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename" + with pytest.raises(CommandError, match="file — строка"): + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}]) + + def test_spec_hints_name_known_params(): for ctype, spec in commands.SPEC.items(): assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype @@ -109,6 +130,7 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True), ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False), ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False), + ({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False), ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), ]) diff --git a/tests/test_queue.py b/tests/test_queue.py index 750c1d4..aea73c9 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,6 +1,8 @@ +import hashlib + import pytest -from jira_hub import db, mirror, queue, settings +from jira_hub import db, files, mirror, queue, settings from jira_hub.commands import CommandError from jira_hub.queue import ClaimedError @@ -522,6 +524,21 @@ def bug(temp="new:1", summary="ОСВ 60 не распознана"): "labels": ["AWPCA"]}} +SHA = hashlib.sha256(b"abc").hexdigest() + + +@pytest.fixture +def store(tmp_path): + """Каталог хранилища с файлом «abc».""" + root = tmp_path / "files" + files.put(root, b"abc") + return root + + +def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"): + return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}} + + def test_bug_without_setting_rejected(conn): """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан.""" settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}) @@ -543,3 +560,82 @@ def test_bug_with_setting_accepted(conn, value): settings.set_(conn, "issue_types", {"bug": value}) b = queue.create_batch(conn, "ошибка", "claude", [bug()]) assert b["count"] == 2 # ensure_issue + хвостовой refresh + + +def test_attachment_without_file_rejected(conn, store): + """Спека commands «Вложение без загруженного файла»: 422, пакет не создан.""" + missing = "0" * 64 + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store) + assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files" + with pytest.raises(CommandError, match=f"файл {SHA} не загружен"): + queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("params, msg", [ + ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"filename": " "}, "команда 1: filename пустой"), + ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\n60.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\r.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\x7f.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), +]) +def test_attachment_params_rejected(conn, store, params, msg): + cmd = attach() + cmd["params"].update(params) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store) + assert str(e.value) == msg + + +def test_bug_with_attachment_one_batch(conn, store): + """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}}) + b = queue.create_batch(conn, "ошибка", "claude", [ + bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")], + files_dir=store) + assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2 + c1 = queue.claim(conn, files_dir=store) + assert c1["type"] == "ensure_issue" + queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"}) + c2 = queue.claim(conn, files_dir=store) + assert c2["type"] == "ensure_sprint" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn, files_dir=store) + assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30 + assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3} + again = queue.claim(conn, files_dir=store) # довыдача — тоже с size + assert again["id"] == c3["id"] and again["params"]["size"] == 3 + stored = queue.get_command(conn, c3["id"])["params"] + assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size + + +def test_attachment_size_null_when_file_gone(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + (store / SHA).unlink() + c = queue.claim(conn, files_dir=store) + assert c["type"] == "ensure_attachment" and c["params"]["size"] is None + assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение + assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None + + +def test_attachment_retry_passes_validation(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + queue.report_result(conn, c["id"], "failed", error="HTTP 413") + r = queue.retry_command(conn, c["id"], "claude", files_dir=store) + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"} + (store / SHA).unlink() # убран уборкой + with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"): + queue.retry_command(conn, c["id"], "claude", files_dir=store) + + +def test_attachment_not_confirmed_by_state(conn, store): + mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], []) + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed" diff --git a/tests/test_settings.py b/tests/test_settings.py index 7803ab4..9043c62 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -87,3 +87,10 @@ def test_init_bug_from_config(conn, extra, expected): types = settings.get(conn, "issue_types") assert types["bug"] == expected assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} + + +def test_attachment_pacing(conn): + assert settings.DEFAULT_PACING["ensure_attachment"] == 30 + assert settings.pacing(conn)["ensure_attachment"] == 30 + assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 + settings.validate("pacing", {"ensure_attachment": 10}) From 4e397b563de735aacdd818d7263103057bded333 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:51:44 +0400 Subject: [PATCH 7/9] =?UTF-8?q?feat(mcp):=20batch=5Fcreate=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20path=20=D1=83=20en?= =?UTF-8?q?sure=5Fattachment=20=D0=B8=20=D1=81=D0=B0=D0=BC=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B6=D0=B0=D0=B5=D1=82=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все файлы пакета читаются до загрузки: «файл не найден: », «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19). design.md: решение 20 (R-2). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 +- README.md | 12 +- .../changes/bug-and-attachments/design.md | 10 ++ src/jira_hub/mcp_server.py | 72 +++++++++++- tests/test_mcp.py | 111 +++++++++++++++++- 5 files changed, 204 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 36c2ce4..3abb3f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio | `client` | +| `mcp_server` | инструменты MCP по stdio; `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | @@ -44,7 +44,8 @@ - Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). -- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`. +- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел + `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит. diff --git a/README.md b/README.md index 4ee25c4..669fd5b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ uv run jira-hub import ../admin/jira-sync | `issues` | фильтры как у `GET /issues`, `limit` | таблица задач | | `issue` | `key` | поля, связи, заметки, последние команды | | `notes_set` | `key`, поля заметок | обновлённые заметки | -| `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд | +| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | | `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту; ожидание асинхронное, сервер не блокируется | | `batches` | `limit` | последние пакеты | @@ -76,6 +76,16 @@ uv run jira-hub import ../admin/jira-sync Автор команд из MCP: `claude`; из API без автора: `api`. +«Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, +загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, +нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится. + +```json +[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}, + {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] +``` + ## Транспорт через общую папку Если сеть между виртуалкой и Mac mini закрыта (VPN «Континент ZTN» на виртуалке режет локальную сеть), агент ходит diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 6f324ca..ced6e53 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -130,3 +130,13 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a умолчанию `None`: вложение не ставится — «файл не загружен», при выдаче `size = null`); `api` передаёт `Hub.files_dir`; `GET /agent/commands/{id}` дописывает `size` через `queue.deliver_params`. Обоснование: `queue` остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без `files_dir` молча отвергает вложения. +19. 15.10.2026 — MCP `batch_create` с `path`: `~` раскрывается; относительный путь — «нужен абсолютный путь: »; + пустой файл отвергается до загрузки — «пустой файл: »; прочие тексты — «не файл: », «нет прав на + чтение: », «файл больше 10 МБ ( байт): », «файл не прочитан (<причина>): »; сначала читаются + все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: cwd сервера MCP + неочевиден; спека дословно задаёт только «файл не найден» и «либо path, либо file». Цена: пакет с N файлами держит + их в памяти MCP до загрузки (≤ N × 10 МБ). +20. 16.10.2026 — хаб при постановке отвергает `filename` с `"` и управляющими символами (код < 32 и 127, в том числе CR + и LF) — «команда N: filename без кавычек и управляющих символов (CR, LF и др.)» (рулинг R-2). Обоснование: такие + имена ломают заголовок части `multipart/form-data` у агента; кодирование имени в агенте остаётся запасной защитой. + Цена: файл с кавычкой в имени прикладывается под другим именем (`filename` в команде). diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 548a788..157c8bb 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -1,19 +1,24 @@ """MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" import asyncio +import base64 import functools import inspect import json +import stat import time +from pathlib import Path from mcp.server.fastmcp import FastMCP from .client import HubClient, HubError, HubUnavailable from .commands import describe +from .files import MAX_SIZE OFFLINE = "хаб не запущен: jira-hub serve" POLL_SECONDS = 5 AUTHOR = "claude" +BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" mcp = FastMCP("jira-hub") @@ -160,6 +165,63 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) +def _read_local(path) -> bytes | str: + """Содержимое локального файла для ensure_attachment.path или текст причины с путём (spec mcp, решение 19).""" + if not isinstance(path, str) or not path: + return "path — строка с абсолютным путём к файлу" + p = Path(path).expanduser() + if not p.is_absolute(): + return f"нужен абсолютный путь: {path}" + try: + st = p.stat() + if not stat.S_ISREG(st.st_mode): + return f"не файл: {path}" + if st.st_size > MAX_SIZE: + return f"файл больше 10 МБ ({st.st_size} байт): {path}" + data = p.read_bytes() + except FileNotFoundError: + return f"файл не найден: {path}" + except PermissionError: + return f"нет прав на чтение: {path}" + except OSError as e: + return f"файл не прочитан ({e.strerror or e}): {path}" + if not data: + return f"пустой файл: {path}" + if len(data) > MAX_SIZE: # вырос между stat и чтением + return f"файл больше 10 МБ ({len(data)} байт): {path}" + return data + + +def _attach_files(client, commands): + """ensure_attachment с path: сначала прочитать все файлы пакета, затем загрузить их POST /files и подставить file и + filename (по умолчанию — имя файла из path). Ошибка любого файла — текст, в хаб ничего не загружено. Остальные + команды и форму пакета проверяет хаб.""" + if not isinstance(commands, list): + return commands + read = {} + for i, cmd in enumerate(commands): + params = cmd.get("params") if isinstance(cmd, dict) and cmd.get("type") == "ensure_attachment" else None + if not isinstance(params, dict) or "path" not in params: + continue + if params.get("file") not in (None, ""): + return BOTH_FILE_AND_PATH + data = _read_local(params["path"]) + if isinstance(data, str): + return data + read[i] = data + out = [] + for i, cmd in enumerate(commands): + if i not in read: + out.append(cmd) + continue + params = {k: v for k, v in cmd["params"].items() if k != "path"} + up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) + params["file"] = up["file"] + params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name + out.append({**cmd, "params": params}) + return out + + def _doc(text: str): """Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации.""" def deco(fn): @@ -174,9 +236,15 @@ def _doc(text: str): Параметры по типам (необязательные — в [ ]): """ + describe() + """ ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, -задача без неё выпадет из базовой JQL и из зеркала.""") +задача без неё выпадет из базовой JQL и из зеркала. +ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит +файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: - b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands}) + client = _client() + prepared = _attach_files(client, commands) + if isinstance(prepared, str): + return prepared + b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared}) return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f365f4b..3568442 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,12 +1,14 @@ import asyncio +import hashlib import json +import os import re import threading from types import SimpleNamespace import pytest -from jira_hub import api, commands, mcp_server, settings +from jira_hub import api, commands, files, mcp_server, settings from jira_hub.client import HubClient @@ -275,3 +277,110 @@ def test_fmt_issue_comments(): no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, "updated": None, "body": ""}])) assert "комментарии (1):" in no_author and "ещё" not in no_author + + +BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", + "labels": ["AWPCA"]}} + + +def _batch_commands(out): + batch_id = int(out.split("#")[1].split()[0]) + return mcp_server._client().get(f"/batches/{batch_id}")["commands"] + + +def _write(path, data): + path.write_bytes(data) + return str(path) + + +def test_batch_create_bug_with_local_file(live, tmp_path): + """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" + mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, + "bug": {"name": "Ошибка", "id": None}})) + f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx" + f.write_bytes(b"PK\x03\x04 xlsx") + out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", + "params": {"key": "new:1", "path": str(f)}}]) + assert "3 команд" in out + cmds = _batch_commands(out) + sha = hashlib.sha256(f.read_bytes()).hexdigest() + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"] + assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"} + assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes() + + +def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "скрин.png").write_bytes(b"png") + out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) + assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(), + "filename": "Экран 1.png"} + + +@pytest.mark.parametrize("make, expected", [ + (lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"), + (lambda d: str(d), "не файл: {p}"), + (lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"), + (lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"), + (lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)), + "файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"), +]) +def test_batch_create_bad_path(live, tmp_path, make, expected): + """Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено.""" + src = tmp_path / "src" + src.mkdir() + p = make(src) + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) + assert out == expected.format(p=p) + assert mcp_server._client().get("/batches")["batches"] == [] + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_unreadable_file(live, tmp_path): + f = tmp_path / "закрыт.xlsx" + f.write_bytes(b"x") + f.chmod(0) + try: + if os.access(f, os.R_OK): + pytest.skip("файл читается и без прав (root)") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) + assert out == f"нет прав на чтение: {f}" + finally: + f.chmod(0o600) + + +def test_batch_create_path_and_file(live, tmp_path): + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": str(f), "file": "0" * 64}}]) + assert out == "у ensure_attachment либо path, либо file" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path): + good = tmp_path / "a.xlsx" + good.write_bytes(b"a") + missing = tmp_path / "нет.xlsx" + out = mcp_server.batch_create("x", [ + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}}, + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}]) + assert out == f"файл не найден: {missing}" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_hub_422_after_upload(live, tmp_path): + """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) + assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug" + assert mcp_server._client().get("/batches")["batches"] == [] + + +def test_batch_create_description_mentions_path(): + desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description + assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc + assert "issue_type (task|epic|bug)" in desc + assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc From 2e98ce6062baa2e44677c02fa7bc531691747369 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 02:05:45 +0400 Subject: [PATCH 8/9] =?UTF-8?q?fix(files):=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BB=D0=B5=D1=87=D0=B8=D1=82=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D1=80=D1=87=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=8E=20=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B6=D0=B5=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=D0=B0=20(M-?= =?UTF-8?q?2,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit put сверяет sha256 копии на диске при совпадении размера и переписывает испорченную; design.md: решение 13 уточнено, рулинги R-3…R-5 — решения 21–23 (M-5 финального ревью). Co-Authored-By: Claude Opus 5.5 --- openspec/changes/bug-and-attachments/design.md | 15 ++++++++++++--- src/jira_hub/files.py | 14 +++++++++++--- tests/test_files.py | 8 ++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index ced6e53..00ff319 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -111,9 +111,10 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 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 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). +13. 15.10.2026 — повторная загрузка: целая копия (тот же размер и sha256 содержимого на диске) — только `os.utime`; + оборванная (другой размер) или испорченная (тот же размер, другой sha256 — M-2 финального ревью) — переписывается. + Обоснование: имя — sha256 содержимого; повторная загрузка из чата должна лечить испорченную копию. Цена: чтение + копии (до 10 МБ) при каждой повторной загрузке. 14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — 400». Цена: строка с пробелами внутри тоже принимается. @@ -140,3 +141,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a и LF) — «команда N: filename без кавычек и управляющих символов (CR, LF и др.)» (рулинг R-2). Обоснование: такие имена ломают заголовок части `multipart/form-data` у агента; кодирование имени в агенте остаётся запасной защитой. Цена: файл с кавычкой в имени прикладывается под другим именем (`filename` в команде). +21. 16.10.2026 — текст отчёта агента при повреждённом в пути файле («результат неизвестен (обрыв связи с хабом)…») + не меняется (рулинг R-3): общий путь повтора `Runner.execute`. Цена: неточная формулировка в редком случае — в Jira + при этом ничего не записано. +22. 16.10.2026 — агент без записи `issue_types.<тип>` (в том числе `task` и `epic`) отвечает `failed` «нет настройки + issue_types.<тип>» без вызова Jira вместо `{"name": null}` (рулинг R-4, следует из спеки agent). Цена: нет — на + живом хабе настройки `task` и `epic` есть; отмечено в PR. +23. 16.10.2026 — план агента (5 задач) исполнен параллельно фоновым исполнителем на sonnet по executing-plans, план + хаба — в сессии; финальное ревью обеих веток — opus (рулинг R-5). Цена: два контекста вместо одного. diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py index edbea42..abb81c7 100644 --- a/src/jira_hub/files.py +++ b/src/jira_hub/files.py @@ -57,9 +57,17 @@ def read(root, sha) -> bytes | None: return None +def _intact(target, sha) -> bool: + """Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью).""" + try: + return hashlib.sha256(target.read_bytes()).hexdigest() == sha + except FileNotFoundError: + return False + + def put(root, data: bytes) -> tuple[str, int]: - """Сохранить содержимое, вернуть (sha256, размер). Копия того же размера уже есть — только обновить mtime (уборка - считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись + """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить + mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется (решение 11).""" if not data: @@ -70,7 +78,7 @@ def put(root, data: bytes) -> tuple[str, int]: secure_dir(root) sha = hashlib.sha256(data).hexdigest() target = root / sha - if size(root, sha) == len(data): + if size(root, sha) == len(data) and _intact(target, sha): try: os.utime(target) return sha, len(data) diff --git a/tests/test_files.py b/tests/test_files.py index 1ebf899..470412d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -147,3 +147,11 @@ def test_sweep_uses_now(root, conn): sha = _file(root, b"N", 0) assert files.sweep(conn, root) == [] assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha] + + +def test_put_rewrites_corrupted_copy_of_same_size(root): + """Финальное ревью M-2: испорченная копия того же размера лечится повторной загрузкой.""" + files.put(root, b"abc") + (root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое + assert files.put(root, b"abc") == (ABC, 3) + assert (root / ABC).read_bytes() == b"abc" From 87d28a9f24940309f7f163705a607214cad2f5df Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 02:07:00 +0400 Subject: [PATCH 9/9] =?UTF-8?q?docs(openspec):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20?= =?UTF-8?q?=E2=80=94=20filename=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=B0=D0=B2?= =?UTF-8?q?=D1=8B=D1=87=D0=B5=D0=BA=20=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D1=8E=D1=89=D0=B8=D1=85=20=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2=20(R-2,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/bug-and-attachments/specs/commands/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/bug-and-attachments/specs/commands/spec.md b/openspec/changes/bug-and-attachments/specs/commands/spec.md index 329361c..ecc540d 100644 --- a/openspec/changes/bug-and-attachments/specs/commands/spec.md +++ b/openspec/changes/bug-and-attachments/specs/commands/spec.md @@ -13,7 +13,7 @@ | `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | | `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | | `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `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` | +| `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` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в