Merge pull request 'Тип «Ошибка» и вложения: хранилище файлов, ensure_attachment, path в MCP (#43)' (#47) from feat/43-bug-and-attachments into main

This commit is contained in:
aep 2026-10-16 09:10:12 +03:00
commit ee1e240396
24 changed files with 3407 additions and 43 deletions

View File

@ -11,13 +11,14 @@
| `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` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `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` |
| `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` |
@ -29,10 +30,11 @@
перевод в модель доски — в `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`.
- Вложение: файл → `POST /files` → `files/<sha256>` → `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`.
@ -42,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` и своих копий не держит.

View File

@ -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/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
- Подробная карта модулей и границ — `ARCHITECTURE.md`.

View File

@ -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.
## Первый запуск
@ -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» на виртуалке режет локальную сеть), агент ходит
@ -128,7 +138,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 +149,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 +159,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) |
@ -186,14 +198,20 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
| Тип | Параметры |
|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
| `ensure_issue` | `temp_key` (`new:<n>`), `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` (имя статуса или список-путь) |
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) |
| `ensure_attachment` | `key` (ключ или `new:<n>`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту |
| `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:<n>`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):
@ -206,7 +224,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: файл <sha> не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет).
## Пример сценария
@ -218,6 +240,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30).
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в
очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается
заново.
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
хаб ставит пакет «Автообновление».
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15

View File

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

View File

@ -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:<n>`; хаб проверяет, что файл загружен,
при выдаче дописывает `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` → живая проверка на тестовой задаче (удаляет владелец).

View File

@ -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.<issue_type>`: `{id}`, если `id` задан, иначе `{name}`;
нет записи или в ней нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.<issue_type>», `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: <sha>, filename: "ОСВ сч. 60 за 2 кв. 2026.xlsx"}`, у X вложений нет
- **THEN** один `GET /issue/X`, один `GET /agent/files/<sha>`, один `POST /issue/X/attachments`; в Jira вложение с этим
именем и размером файла; отчёт `done` с `attachment_id`
#### Scenario: Файл повреждён в пути
- **WHEN** sha256 полученного `content` не равен `file`
- **THEN** вложение в Jira не загружается; команда повторяется как при обрыве связи с хабом
#### Scenario: Старый агент
- **WHEN** агент v10 получил `ensure_attachment`
- **THEN** `failed` «неизвестный тип команды», агент работает дальше (поведение v10, без изменений)

View File

@ -0,0 +1,155 @@
## MODIFIED Requirements
### Requirement: Команда — желаемый результат на одной задаче
Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить →
записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done`
без записи. Типы команд и их контракт:
| Тип | Параметры | Что читает | Что пишет |
|---|---|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = <proj> AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.<issue_type>` (для эпика — с `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: <sha>, 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 нет в хранилище хаба («файл <sha> не загружен: POST /files»),
`filename` пустой (или из одних пробелов) или содержит `/`, `\`, `"` или управляющие символы (код < 32 и 127,
в том числе CR и LF): такие имена ломают `multipart/form-data`. Клиент `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: файл <sha> не загружен: 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: <sha>, filename: "ОСВ 60.xlsx"}]`, файл загружен
- **THEN** пакет создан; `ensure_attachment` выдаётся после завершения `ensure_issue` с подставленным ключом и `size` файла

View File

@ -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` не изменился

View File

@ -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/<sha>`, файл загружен
- **THEN** 200 `{file: <sha>, size, content}`, sha256 от декодированного `content` равен `<sha>`; клиентским токеном — 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 «файл <sha> не загружен: 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

View File

@ -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<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `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: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### 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»

View File

@ -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 = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").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)."""
@ -121,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)
@ -141,12 +164,23 @@ 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)
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):
@ -238,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))
@ -258,11 +292,30 @@ 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 — строка")
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:
@ -344,6 +397,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 +406,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"),

View File

@ -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:<n>", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}},
"hints": {"temp_key": "new:<n>", "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": "список"},
@ -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"},
}
@ -139,8 +142,8 @@ def validate(commands) -> list[dict]:
raise CommandError(f"команда {i}: temp_key должен быть new:<n>")
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")
@ -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:

120
src/jira_hub/files.py Normal file
View File

@ -0,0 +1,120 @@
"""Хранилище файлов вложений: <каталог данных>/files/<sha256>, одна копия на содержимое, без имени вложения — имя задаёт
команда 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 _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, размер). Целая копия уже есть (тот же 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) and _intact(target, sha):
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

View File

@ -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)} мин"

View File

@ -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,8 +45,33 @@ def _dump(value) -> str:
# --- пакеты -----------------------------------------------------------------
def create_batch(conn, title, author, cmds) -> dict:
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 без / и \\ — имя вложения, а не путь")
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, files_dir=None) -> dict:
cmds = C.validate(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]
@ -161,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):
@ -203,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"]}
# --- отчёты -----------------------------------------------------------------
@ -314,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)
# --- агент и автообновление ----------------------------------------------------

View File

@ -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,16 @@ 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:
"""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]:
@ -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", {}),

View File

@ -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
@ -598,3 +602,128 @@ 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"] == []
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"]] == ["Автообновление"]
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"}

View File

@ -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:<n>"),
([{"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,35 @@ 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"
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
@ -100,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),
])

157
tests/test_files.py Normal file
View File

@ -0,0 +1,157 @@
"""Хранилище файлов вложений (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]
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"

View File

@ -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

View File

@ -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
@ -515,3 +517,125 @@ 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"]}}
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"}})
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
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"

View File

@ -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,33 @@ 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"}
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})