From a8672cf7c02c1311ee4b805c2a1c03026ea8c60c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 00:06:42 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=20bug-and-attachments=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-15-bug-and-attachments-hub.md | 1746 +++++++++++++++++ 1 file changed, 1746 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md diff --git a/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md b/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md new file mode 100644 index 0000000..11231f3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md @@ -0,0 +1,1746 @@ +# bug-and-attachments — тип «Ошибка» и вложения — план хаба + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** из чата одним пакетом завести задачу типом «Ошибка», поставить её в спринт и приложить локальный файл: хаб +принимает `issue_type = bug` (при настройке `issue_types.bug`), хранит файлы вложений (`POST /files`, `files/`), +ставит и выдаёт агенту команду `ensure_attachment` (с `size` при выдаче), отдаёт файл агенту (`GET /agent/files/{sha256}`), +убирает старые файлы; MCP `batch_create` принимает `path` вместо `file` и загружает файл сам. + +**Architecture:** новый модуль `files.py` — хранилище без HTTP и без окружения: каталог передаётся параметром +(`Hub.files_dir = <каталог базы>/files`, 0700 при каждом старте). `commands.SPEC` получает `ensure_attachment` и `bug` в +подсказке; проверки постановки, которым нужны настройки и хранилище (`bug` без настройки, формат и наличие файла, +`filename`), — `queue._check_setup` внутри `queue.create_batch`; `size` дописывается только в ответ агенту +(`queue.deliver_params` из `claim` и `GET /agent/commands/{id}`), в базе `params` без `size`. `api` — маршруты +`POST /files`, `GET /agent/files/{sha256}` и уборка в `tick` раз в сутки. `mcp_server.batch_create` до `POST /batches` +читает все `path`, загружает их `POST /files` и подставляет `file`/`filename`. + +**Tech Stack:** Python 3.13 (uv), stdlib (`http.server`, `sqlite3`, `base64`, `hashlib`, `tempfile`), pytest 9, пакет `mcp` +(без изменений); фронт (`web/`) не меняется, только `make check`. + +**Spec:** `openspec/changes/bug-and-attachments/` — `proposal.md`, `design.md` (решения 1–8; этот план дописывает 9–19), +`specs/commands/spec.md`, `specs/hub-api/spec.md`, `specs/mcp/spec.md`, `specs/data-model/spec.md`. `specs/agent/spec.md` +— для jira-agent (aep/jira-agent#31, отдельный план); хаб от агента не зависит. + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach`, ветка `feat/43-bug-and-attachments`, +issue aep/jira-hub#43. + +## Global Constraints + +- Все команды — с путями worktree: git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach …`, + pytest — `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest …`. Не делать `cd` + в `/Users/andrey.epifancev/projects/awp-ca/jira-hub` (основной checkout на main, хук guard-main). Живой хаб :8790, + `~/.local/share/jira-hub` и живые настройки (`settings_set issue_types` — шаг выкатки после merge) не трогать. +- codegraph: в `codegraph_explore` передавать `projectPath` = `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach`. +- Схема БД не меняется (`db.SCHEMA_VERSION = 3`). Новых зависимостей нет (ни Python, ни npm). Фронт не меняется. +- Значения из спеки — дословно: + - предел файла — `10 485 760` байт после декодирования (`files.MAX_SIZE = 10 * 1024 * 1024`); больше — 413 `too_large`; + - пустой файл — 422 `empty_file`; `content` не строка или не base64 — 400 `bad_request`; + - файла нет у `GET /agent/files/{sha256}` — 404 `no_file`; ответ — `{file, size, content}`, `content` — base64; + - `POST /files` → 200 `{file, size}`, `file` — sha256 содержимого, 64 hex-символа в нижнем регистре; + - каталог `files/` — права `0700` при создании и при каждом старте; файлы в бэкапы базы не входят; + - уборка — из `tick` не чаще раза в сутки, файлы старше 30 дней по mtime, кроме файлов команд `ensure_attachment` + в `queued`/`claimed` (`json_extract(params, '$.file')`); повторная загрузка обновляет mtime; + - 422 «команда : нет настройки issue_types.bug»; 422 «команда : файл не загружен: POST /files»; + - `issue_types.bug` годна, если это объект с непустым `name` или `id`; + - `init` добавляет `bug` в `issue_types`, только если в конфиге есть ключ `issue_type_bug` или `issue_type_bug_id`; + - `SPEC["ensure_attachment"] = {required: [key, file, filename], optional: [], keys: [key], hints: {file: "sha256 из + POST /files; в MCP — path к локальному файлу", filename: "имя вложения"}}`; подсказка `issue_type` — `task|epic|bug`; + - пауза `ensure_attachment` — 30 с (`DEFAULT_PACING`, `pacing_from_config`); по состоянию зеркала не подтверждается; + - `size` — в ответе агенту (`claim`, повторная выдача, `GET /agent/commands/{id}`); файла нет — `size = null`; + в базе `params` без `size`; клиентский `size` — 422 «лишние параметры size»; + - MCP: «файл не найден: », «у ensure_attachment либо path, либо file»; `filename` по умолчанию — + `Path(path).name`; больше 10 МБ — ошибка до загрузки. +- Комментарии, docstring, тексты ошибок и коммиты — по-русски; идентификаторы — английские. Сообщение коммита + заканчивается `(#43)`, последний абзац — `Co-Authored-By: Claude Opus 5.5 `. Не пушить. +- Решения, принятые планом без обсуждения, дописываются в `openspec/changes/bug-and-attachments/design.md` в конец + раздела `## Decisions made without discussion` (номера 9–19, по задачам) — в той задаче, которая их реализует. +- Зелёным считается только весь набор: `uv run --directory … pytest -q -p no:cacheprovider` (до плана — 338 passed); + незакрытое соединение SQLite — ошибка набора (`filterwarnings`), в тестах — только фикстуры `open_db`/`conn` или + явный `close()`. Перед последним коммитом — `make -C … check`. + +## Review Focus + +1. `content` base64 с переводами строк (GNU `base64`, библиотеки с переносом по 76 символов) — хаб принимает и считает + sha256 от декодированного — Task 3, `test_files_base64_with_line_breaks`. +2. Файл ровно 10 485 760 байт принимается, на байт больше — отказ, в `files/` ничего не остаётся — Task 2, + `test_put_limits`; через HTTP 11 МБ — 413 — Task 3, `test_files_too_large_is_413`. +3. Один файл в двух командах (одна `done`, вторая `queued`) и файл команды в `claimed` (агент его как раз скачивает) — + уборка их не трогает — Task 2, `test_sweep_keeps_claimed_and_shared_file`. +4. id файла не из 64 hex нижнего регистра (`..`, `%2E%2E`, тот же sha в верхнем регистре — APFS не различает регистр) — + 404 `no_file`, путь вне `files/` не строится — Task 2, `test_bad_sha_never_builds_path`; Task 3, + `test_agent_file_bad_id_is_no_file`. +5. `path` из чата с `~`, относительный, второй файл пакета отсутствует — понятный текст с путём, в хаб ничего не + загружено — Task 5, `test_batch_create_explicit_filename_and_tilde`, `test_batch_create_bad_path`, + `test_batch_create_second_file_missing_uploads_nothing`. + +## Карта файлов + +| Файл | Задача | Что меняется | +|---|---|---| +| `src/jira_hub/commands.py` | 1, 4 | подсказка и проверка `issue_type` (`bug`); `SPEC["ensure_attachment"]`; `check_confirmed` | +| `src/jira_hub/settings.py` | 1, 4 | `_issue_types` в `init_from_config`; `ensure_attachment` в `DEFAULT_PACING` и `pacing_from_config` | +| `src/jira_hub/queue.py` | 1, 4 | `_check_setup` (bug; вложение), `create_batch(files_dir=)`, `deliver_params`, `claim(files_dir=)`, `retry_command(files_dir=)` | +| `src/jira_hub/files.py` (новый) | 2 | хранилище: `put`, `path`, `exists`, `size`, `read`, `is_sha`, `sweep` | +| `src/jira_hub/api.py` | 3, 4 | `Hub.files_dir`, `last_sweep`, `sweep_files`, `tick`; `h_files_post`, `h_agent_file`; `files_dir` в `h_claim`, `h_agent_command`, `h_batches_post`, `h_command_retry` | +| `src/jira_hub/mcp_server.py` | 5 | `_read_local`, `_attach_files`, `batch_create`, описание | +| `tests/test_commands.py`, `test_settings.py`, `test_queue.py`, `test_api.py`, `test_mcp.py`, `test_files.py` (новый) | 1–5 | тесты | +| `README.md`, `ARCHITECTURE.md`, `CLAUDE.md` | 1–5 | контракт команд, маршруты, каталог `files/`, MCP `path` | +| `openspec/changes/bug-and-attachments/design.md` | 1–5 | решения 9–19 | + +`src/jira_hub/client.py` не меняется: `HubClient.post("/files", {...})` хватает. `relay.py` не меняется (design: ответ +JSON со строкой base64 проходит реле как есть). + +--- + +### Task 1: Тип `bug` — валидация, проверка настройки при постановке, `init` (commands, data-model) + +**Модель исполнителя:** haiku (весь код готов, три модуля по несколько строк). + +**Files:** +- Modify: `src/jira_hub/commands.py` (`SPEC["ensure_issue"]["hints"]`, `validate`) +- Modify: `src/jira_hub/settings.py` (новая `_issue_types`, `init_from_config`) +- Modify: `src/jira_hub/queue.py` (новая `_check_setup`, `create_batch`) +- Modify: `README.md` (контракт команд), `openspec/changes/bug-and-attachments/design.md` (решение 9) +- Test: `tests/test_commands.py`, `tests/test_settings.py`, `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: — +- Produces: `queue._check_setup(conn, cmds: list[dict]) -> None` (бросает `commands.CommandError`; в Task 4 получает + третий параметр `files_dir`); хелпер тестов `bug(temp="new:1", summary="ОСВ 60 не распознана") -> dict` в + `tests/test_queue.py` (Task 4 его использует); `settings._issue_types(cfg: dict) -> dict`. + +- [ ] **Step 1: Окружение** + +```bash +uv --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach sync +npm --prefix /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/web ci +``` + +Expected: `uv` без ошибок; `npm ci` ставит `web/node_modules` (нужен для `make check` в Task 5). + +- [ ] **Step 2: Падающие тесты валидации** + +В `tests/test_commands.py` в параметрах `test_validate_errors` заменить строку + +```python + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), +``` + +на + +```python + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}], + "task, epic или bug"), +``` + +и после `test_unknown_param_error_names_expected` добавить: + +```python +def test_validate_accepts_bug_issue_type(): + """bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue).""" + cmds = commands.validate([{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]) + assert cmds[0]["params"]["issue_type"] == "bug" + assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug" +``` + +- [ ] **Step 3: Падающие тесты постановки** + +В `tests/test_queue.py` после хелпера `fields` добавить хелпер и тесты в конец файла: + +```python +def bug(temp="new:1", summary="ОСВ 60 не распознана"): + return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary, + "labels": ["AWPCA"]}} +``` + +```python +def test_bug_without_setting_rejected(conn): + """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан.""" + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()]) + assert str(e.value) == "команда 2: нет настройки issue_types.bug" + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None]) +def test_bug_with_empty_setting_rejected(conn, value): + settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value}) + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [bug()]) + + +@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}]) +def test_bug_with_setting_accepted(conn, value): + settings.set_(conn, "issue_types", {"bug": value}) + b = queue.create_batch(conn, "ошибка", "claude", [bug()]) + assert b["count"] == 2 # ensure_issue + хвостовой refresh +``` + +- [ ] **Step 4: Падающие тесты `init` и API** + +В `tests/test_settings.py` заменить импорты на + +```python +import json +from pathlib import Path + +import pytest + +from jira_hub import queue, settings +from jira_hub.commands import CommandError +``` + +и в конец файла добавить: + +```python +def test_init_without_bug_keys_rejects_bug(conn): + """Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422.""" + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + settings.init_from_config(conn, cfg) + assert set(settings.get(conn, "issue_types")) == {"task", "epic"} + with pytest.raises(CommandError, match="нет настройки issue_types.bug"): + queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "x", "labels": ["AWPCA"]}}]) + + +@pytest.mark.parametrize("extra, expected", [ + ({"issue_type_bug": "Ошибка"}, {"name": "Ошибка", "id": None}), + ({"issue_type_bug_id": "10102"}, {"name": None, "id": "10102"}), + ({"issue_type_bug": "Ошибка", "issue_type_bug_id": "10102"}, {"name": "Ошибка", "id": "10102"}), +]) +def test_init_bug_from_config(conn, extra, expected): + cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra} + settings.init_from_config(conn, cfg) + types = settings.get(conn, "issue_types") + assert types["bug"] == expected + assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} +``` + +В конец `tests/test_api.py` добавить: + +```python +def test_bug_without_setting_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "ошибка", "commands": [{"type": "ensure_issue", "params": { + "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) + assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" + assert hub["client"].get("/batches")["batches"] == [] +``` + +- [ ] **Step 5: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_commands.py tests/test_queue.py tests/test_settings.py tests/test_api.py -k "bug or validate_errors"` +Expected: FAIL — `test_validate_errors[...story...]` (сообщение «task или epic»), `test_validate_accepts_bug_issue_type` +(«issue_type — task или epic»), `test_bug_with_setting_accepted` (тот же отказ), `test_bug_without_setting_rejected` +(текст «issue_type — task или epic»), `test_init_bug_from_config` (`KeyError: 'bug'`), `test_bug_without_setting_is_422`. + +- [ ] **Step 6: `commands` — `bug` в подсказке и проверке** + +В `src/jira_hub/commands.py` в `SPEC["ensure_issue"]["hints"]` заменить `"issue_type": "task|epic"` на +`"issue_type": "task|epic|bug"`. В `validate` заменить + +```python + if params["issue_type"] not in ("task", "epic"): + raise CommandError(f"команда {i}: issue_type — task или epic") +``` + +на + +```python + if params["issue_type"] not in ("task", "epic", "bug"): + raise CommandError(f"команда {i}: issue_type — task, epic или bug") +``` + +- [ ] **Step 7: `settings` — `bug` из конфига** + +В `src/jira_hub/settings.py` перед `init_from_config` добавить: + +```python +def _issue_types(cfg: dict) -> dict: + """issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть + issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»).""" + out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")} + if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg: + out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")} + return out +``` + +и в `init_from_config` заменить + +```python + "issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, + "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, +``` + +на + +```python + "issue_types": _issue_types(cfg), +``` + +- [ ] **Step 8: `queue` — проверка постановки** + +В `src/jira_hub/queue.py` перед `create_batch` добавить: + +```python +def _check_setup(conn, cmds) -> None: + """Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета. + Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok: + raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") +``` + +и в `create_batch` сразу после `cmds = C.validate(cmds)` добавить строку `_check_setup(conn, cmds)`. + +- [ ] **Step 9: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (338 + новые). + +- [ ] **Step 10: README и решение 9** + +В `README.md` в таблице «Контракт команд» заменить строку `ensure_issue` + +```markdown +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +``` + +на + +```markdown +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +``` + +и сразу после таблицы (перед абзацем «Ключ в параметре может быть временным…») вставить абзац: + +```markdown +Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть +`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из +ключей конфига `issue_type_bug`/`issue_type_bug_id`, если они есть; на живом хабе — `settings_set issue_types` с объектом +целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`. +``` + +В `openspec/changes/bug-and-attachments/design.md` в конец раздела `## Decisions made without discussion` дописать: + +```markdown +9. 15.10.2026 — `issue_type` вне списка — 422 «команда N: issue_type — task, epic или bug»; `init` добавляет `bug`, если + ключ `issue_type_bug` или `issue_type_bug_id` есть в конфиге даже со значением `null` (тогда постановка `bug` — 422, + как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. +``` + +- [ ] **Step 11: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/commands.py src/jira_hub/settings.py src/jira_hub/queue.py tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py README.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(commands): тип bug в ensure_issue — проверка настройки issue_types.bug при постановке (#43)" -m "validate принимает task|epic|bug; queue._check_setup отвергает bug без issue_types.bug с name или id (422 «команда N: нет настройки issue_types.bug»); init берёт bug из issue_type_bug/issue_type_bug_id, только если ключи есть (решение 9)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Хранилище файлов `files.py` (hub-api «Хранилище файлов вложений») + +**Модель исполнителя:** haiku (новый модуль целиком и его тесты, код готов). + +**Files:** +- Create: `src/jira_hub/files.py` +- Create: `tests/test_files.py` +- Modify: `ARCHITECTURE.md` (строка модуля, цепочки), `openspec/changes/bug-and-attachments/design.md` (решения 10–13) + +**Interfaces:** +- Consumes: `paths.secure_dir(path) -> None`; таблица `command` (`type`, `params`, `status`). +- Produces (модуль `jira_hub.files`): + - `MAX_SIZE: int = 10_485_760`, `KEEP_DAYS: int = 30`, `SHA_RE`; + - `class EmptyFile(ValueError)`, `class TooLarge(ValueError)`; + - `is_sha(value) -> bool` — строка из 64 символов `[0-9a-f]`; + - `path(root, sha) -> Path` (негодный sha — `ValueError`); + - `exists(root, sha) -> bool`, `size(root, sha) -> int | None`, `read(root, sha) -> bytes | None` (негодный sha или + файла нет — `False`/`None`); + - `put(root, data: bytes) -> tuple[str, int]` — `(sha256, размер)`; `EmptyFile`, `TooLarge`; `OSError` записи + пробрасывается, временный файл удалён; + - `sweep(conn, root, now: float | None = None) -> list[str]` — имена удалённых файлов (по алфавиту). + +- [ ] **Step 1: Падающие тесты** + +Создать `tests/test_files.py`: + +```python +"""Хранилище файлов вложений (bug-and-attachments, hub-api «Хранилище файлов вложений»).""" +import hashlib +import json +import os +import stat +import time + +import pytest + +from jira_hub import db, files, settings + +ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… +DAY = 86400 + + +@pytest.fixture +def root(tmp_path): + return tmp_path / "files" + + +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c + + +def test_put_creates_closed_dir_and_file(root): + assert files.put(root, b"abc") == (ABC, 3) + assert ABC.startswith("ba7816bf") + assert (root / ABC).read_bytes() == b"abc" + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + assert [p.name for p in root.iterdir()] == [ABC] # временных файлов не осталось + + +def test_put_again_keeps_one_copy_and_touches_mtime(root): + files.put(root, b"abc") + old = time.time() - 40 * DAY + os.utime(root / ABC, (old, old)) + assert files.put(root, b"abc") == (ABC, 3) + assert [p.name for p in root.iterdir()] == [ABC] + assert (root / ABC).stat().st_mtime > time.time() - 60 # свежая постановка не теряет файл в уборке + + +def test_put_rewrites_truncated_copy(root): + files.put(root, b"abc") + (root / ABC).write_bytes(b"ab") # оборванная копия + files.put(root, b"abc") + assert (root / ABC).read_bytes() == b"abc" + + +def test_put_limits(root): + with pytest.raises(files.EmptyFile): + files.put(root, b"") + with pytest.raises(files.TooLarge): + files.put(root, b"\0" * (files.MAX_SIZE + 1)) + assert not root.exists() or list(root.iterdir()) == [] + sha, size = files.put(root, b"\0" * files.MAX_SIZE) # ровно 10 485 760 байт — принимается + assert files.MAX_SIZE == 10_485_760 and size == files.MAX_SIZE and files.size(root, sha) == files.MAX_SIZE + + +def test_put_failure_leaves_no_temp(root, monkeypatch): + def broken(*args): + raise OSError("диск полон") + monkeypatch.setattr(files.os, "replace", broken) + with pytest.raises(OSError, match="диск полон"): + files.put(root, b"abc") + assert list(root.iterdir()) == [] + + +def test_put_after_dir_removed(root): + files.put(root, b"abc") + (root / ABC).unlink() + root.rmdir() + assert files.put(root, b"abc") == (ABC, 3) and files.exists(root, ABC) + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + + +def test_lookup(root): + files.put(root, b"abc") + assert files.exists(root, ABC) and files.size(root, ABC) == 3 and files.read(root, ABC) == b"abc" + assert files.path(root, ABC) == root / ABC + missing = "0" * 64 + assert not files.exists(root, missing) and files.size(root, missing) is None and files.read(root, missing) is None + + +@pytest.mark.parametrize("bad", [ABC.upper(), ABC[:63], ABC + "0", "..", "../hub.sqlite", ABC + "\n", "", None, 5]) +def test_bad_sha_never_builds_path(root, bad): + """APFS не различает регистр: ABC.upper() нашёл бы файл ABC, если бы путь строился без проверки.""" + files.put(root, b"abc") + assert files.is_sha(bad) is False + assert not files.exists(root, bad) and files.size(root, bad) is None and files.read(root, bad) is None + with pytest.raises(ValueError): + files.path(root, bad) + + +def _file(root, content, age_days): + sha, _ = files.put(root, content) + t = time.time() - age_days * DAY + os.utime(root / sha, (t, t)) + return sha + + +def _command(conn, sha, status): + if conn.execute("SELECT 1 FROM batch WHERE id = 1").fetchone() is None: + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 'x', 'claude', ?)", (db.now(),)) + conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (1, 0, 'ensure_attachment', ?, ?)", + (json.dumps({"key": "A-1", "file": sha, "filename": "a.xlsx"}), status)) + + +def test_sweep_spec_scenario(root, conn): + """Спека hub-api «Уборка старых файлов»: A старше 30 дней без ссылок, B старше 30 дней в queued, C вчерашний.""" + a = _file(root, b"A", 31) + b = _file(root, b"B", 31) + c = _file(root, b"C", 1) + _command(conn, b, "queued") + assert files.sweep(conn, root) == [a] + assert sorted(p.name for p in root.iterdir()) == sorted([b, c]) + + +def test_sweep_keeps_claimed_and_shared_file(root, conn): + shared = _file(root, b"S", 31) + claimed = _file(root, b"K", 31) + gone = _file(root, b"G", 31) + _command(conn, shared, "done") + _command(conn, shared, "queued") # тот же файл под другим именем в другой команде + _command(conn, claimed, "claimed") + for status in ("done", "cancelled", "failed", "skipped", "confirmed_by_state"): + _command(conn, gone, status) + assert files.sweep(conn, root) == [gone] + assert files.exists(root, shared) and files.exists(root, claimed) + + +def test_sweep_removes_stale_temp_and_handles_missing_dir(root, conn, tmp_path): + assert files.sweep(conn, tmp_path / "нет") == [] + files.put(root, b"abc") + tmp = root / f".{ABC}.x1y2.tmp" # брошен сбоем посреди записи + tmp.write_bytes(b"ab") + t = time.time() - 31 * DAY + os.utime(tmp, (t, t)) + assert files.sweep(conn, root) == [tmp.name] + assert files.exists(root, ABC) + + +def test_sweep_uses_now(root, conn): + sha = _file(root, b"N", 0) + assert files.sweep(conn, root) == [] + assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_files.py` +Expected: FAIL — `ImportError: cannot import name 'files' from 'jira_hub'`. + +- [ ] **Step 3: Модуль `files.py`** + +Создать `src/jira_hub/files.py`: + +```python +"""Хранилище файлов вложений: <каталог данных>/files/, одна копия на содержимое, без имени вложения — имя задаёт +команда ensure_attachment (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: +каталог передаётся параметром (Hub.files_dir, решение 10).""" +import hashlib +import os +import re +import tempfile +import time +from pathlib import Path + +from .paths import secure_dir + +MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) +KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) +SHA_RE = re.compile(r"[0-9a-f]{64}") + + +class EmptyFile(ValueError): + """Пустое содержимое — API отвечает 422 empty_file (решение 3).""" + + +class TooLarge(ValueError): + """Больше MAX_SIZE — API отвечает 413 too_large.""" + + +def is_sha(value) -> bool: + """sha256 в хранилище — ровно 64 символа [0-9a-f]: верхний регистр, «..», «/» путь не строят (решение 12).""" + return isinstance(value, str) and SHA_RE.fullmatch(value) is not None + + +def path(root, sha) -> Path: + if not is_sha(sha): + raise ValueError(f"не sha256: {sha!r}") + return Path(root) / sha + + +def exists(root, sha) -> bool: + return is_sha(sha) and path(root, sha).is_file() + + +def size(root, sha) -> int | None: + """Размер файла в байтах; None — файла нет или sha негодный.""" + if not is_sha(sha): + return None + try: + return path(root, sha).stat().st_size + except FileNotFoundError: + return None + + +def read(root, sha) -> bytes | None: + if not is_sha(sha): + return None + try: + return path(root, sha).read_bytes() + except FileNotFoundError: + return None + + +def put(root, data: bytes) -> tuple[str, int]: + """Сохранить содержимое, вернуть (sha256, размер). Копия того же размера уже есть — только обновить mtime (уборка + считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись + атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется + (решение 11).""" + if not data: + raise EmptyFile("пустой файл") + if len(data) > MAX_SIZE: + raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") + root = Path(root) + secure_dir(root) + sha = hashlib.sha256(data).hexdigest() + target = root / sha + if size(root, sha) == len(data): + try: + os.utime(target) + return sha, len(data) + except FileNotFoundError: + pass # убран между проверкой и utime — пишем заново + fd, tmp = tempfile.mkstemp(dir=root, prefix=f".{sha}.", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return sha, len(data) + + +def sweep(conn, root, now: float | None = None) -> list[str]: + """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или + claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят + тем же путём. Каталога нет — пусто.""" + root = Path(root) + if not root.is_dir(): + return [] + cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 + active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " + "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} + removed = [] + for p in sorted(root.iterdir()): + if p.name in active or not p.is_file(): + continue + try: + if p.stat().st_mtime < cutoff: + p.unlink() + removed.append(p.name) + except FileNotFoundError: + continue + return removed +``` + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS. + +- [ ] **Step 5: ARCHITECTURE и решения 10–13** + +В `ARCHITECTURE.md` в таблице модулей после строки `backup` вставить: + +```markdown +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | +``` + +и в строке «Цепочки:» после `` `db → backup → paths`, `db → paths`; `` вставить `` `files → paths`; ``. + +В `openspec/changes/bug-and-attachments/design.md` в конец раздела `## Decisions made without discussion` дописать: + +```markdown +10. 15.10.2026 — функции `files` принимают каталог хранилища первым параметром (`put(root, data)`, `path(root, sha)`, + `exists`, `size`, `read`, `sweep(conn, root, now)`), каталог — `Hub.files_dir` (`<каталог базы>/files`), а не + `paths.home()`. Обоснование: тесты поднимают хаб во временном каталоге без `JIRA_HUB_HOME` (как `release_dir`). + Цена: лишний параметр в вызовах из `queue` и `api`. +11. 15.10.2026 — временный файл записи — `tempfile.mkstemp` в `files/` (`..<случайное>.tmp`, права 0600), а не + фиксированное `..tmp`. Обоснование: две одновременные загрузки одного содержимого (ThreadingHTTPServer) не + пишут в один временный файл и не ломают друг другу `os.replace`. Цена: брошенные после сбоя временные файлы + убирает уборка общим правилом 30 дней. +12. 15.10.2026 — `files.path/exists/size/read` принимают только sha из 64 символов `[0-9a-f]`, иначе «файла нет» + (`path` — `ValueError`). Обоснование: APFS не различает регистр — `BA78…` нашёл бы `ba78…`; `..` и `/` не строят + путь вне `files/`. Цена: нет. +13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная + запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого, + агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). +``` + +- [ ] **Step 6: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/files.py tests/test_files.py ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(files): хранилище файлов вложений files/ с уборкой (#43)" -m "put — атомарно (mkstemp, fsync, os.replace), одна копия на содержимое, mtime при повторе, предел 10 485 760 байт, пустой — отказ; path/exists/size/read только для sha из 64 hex; sweep — старше 30 дней без ensure_attachment в queued/claimed (решения 10–13)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: API файлов — `POST /files`, `GET /agent/files/{sha256}`, каталог при старте, уборка в `tick` (hub-api) + +**Модель исполнителя:** haiku (код готов; два обработчика, конструктор и `tick` в одном модуле). + +**Files:** +- Modify: `src/jira_hub/api.py` (импорты, `SWEEP_EVERY`, `Hub.__init__`, `Hub.tick`, новый `Hub.sweep_files`, `h_files_post`, + `h_agent_file`, `ROUTES`) +- Modify: `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/bug-and-attachments/design.md` (решения 14–16) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes (Task 2): `files.put(root, data) -> (sha, size)`, `files.read(root, sha) -> bytes | None`, + `files.sweep(conn, root, now) -> list[str]`, `files.EmptyFile`, `files.TooLarge`, `files.MAX_SIZE`. +- Produces: `api.Hub(db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None)`, атрибуты + `Hub.files_dir: Path` (по умолчанию `db_path.parent / "files"`), `Hub.last_sweep: float | None`; + `Hub.sweep_files(conn, now: float | None = None) -> list[str] | None` (`None` — ещё рано); `api.SWEEP_EVERY = 86400`; + хелпер тестов `_b64(data: bytes) -> str` в `tests/test_api.py` (Task 4 его использует). + +- [ ] **Step 1: Падающие тесты** + +В `tests/test_api.py` заменить импорты в начале файла на + +```python +import base64 +import hashlib +import json +import os +import stat +import threading +import time +import urllib.error +import urllib.request +from datetime import timedelta + +import pytest + +from jira_hub import api, db, files, queue, release, settings +from jira_hub.client import HubClient, HubError +``` + +и в конец файла добавить: + +```python +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_files_upload_and_agent_download(hub, tmp_path): + """Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения».""" + c, a = hub["client"], hub["agent"] + sha = hashlib.sha256(b"abc").hexdigest() + out = c.post("/files", {"content": _b64(b"abc")}) + assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf") + assert (tmp_path / "files" / sha).read_bytes() == b"abc" + assert c.post("/files", {"content": _b64(b"abc")}) == out + assert [p.name for p in (tmp_path / "files").iterdir()] == [sha] + got = a.get(f"/agent/files/{sha}") + assert got["file"] == sha and got["size"] == 3 + assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha + with pytest.raises(HubError) as e: + c.get(f"/agent/files/{sha}") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.post("/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.get("/agent/files/" + "0" * 64) + assert (e.value.status, e.value.error) == (404, "no_file") + + +@pytest.mark.parametrize("body, status, error", [ + ({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"), + ({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"), + ({"content": ""}, 422, "empty_file"), +]) +def test_files_bad_upload(hub, tmp_path, body, status, error): + with pytest.raises(HubError) as e: + hub["client"].post("/files", body) + assert (e.value.status, e.value.error) == (status, error) + assert list((tmp_path / "files").iterdir()) == [] + + +def test_files_base64_with_line_breaks(hub): + data = bytes(range(256)) * 4 + s = _b64(data) + wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64 + assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024} + + +def test_files_too_large_is_413(hub, tmp_path): + """Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит.""" + with pytest.raises(HubError) as e: + hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert (e.value.status, e.value.error) == (413, "too_large") + assert list((tmp_path / "files").iterdir()) == [] + + +@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64]) +def test_agent_file_bad_id_is_no_file(hub, bad): + hub["client"].post("/files", {"content": _b64(b"abc")}) + with pytest.raises(HubError) as e: + hub["agent"].get(f"/agent/files/{bad}") + assert (e.value.status, e.value.error) == (404, "no_file") + + +def test_files_dir_closed_on_every_start(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + d = tmp_path / "files" + assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700 + os.chmod(d, 0o755) + api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert stat.S_IMODE(d.stat().st_mode) == 0o700 + + +def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): + h = hub["hub"] + sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"] + old = time.time() - 31 * 86400 + os.utime(tmp_path / "files" / sha, (old, old)) + calls = [] + real = files.sweep + monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k)) + h.tick() + assert len(calls) == 1 and not (tmp_path / "files" / sha).exists() + h.tick() + assert len(calls) == 1 # второй тик в те же сутки — без уборки + h.last_sweep -= api.SWEEP_EVERY + h.tick() + assert len(calls) == 2 + + +def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog): + def broken(*args, **kwargs): + raise OSError("диск недоступен") + monkeypatch.setattr(api.files, "sweep", broken) + hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление + hub["hub"].tick() # не бросает + assert "уборка файлов: сбой" in caplog.text + assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"] +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_api.py -k "files or tick or no_file"` +Expected: FAIL — `/files` и `/agent/files/…` отвечают 404 `not_found`, `Hub` без `files_dir`, `api.files` нет +(`AttributeError`). + +- [ ] **Step 3: Импорты и конструктор `Hub`** + +В `src/jira_hub/api.py`: в импорты stdlib добавить `import base64`; строку `from . import backup, db, mirror, queue, relay, release, settings` +заменить на `from . import backup, db, files, mirror, queue, relay, release, settings`; после `SOCKET_TIMEOUT = …` добавить + +```python +SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») +``` + +В `Hub.__init__` заменить сигнатуру и начало: + +```python + def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None): + self.db_path = Path(db_path) + self.web_dir = Path(web_dir) if web_dir else WEB_APP + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" + self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) +``` + +и после строки `secure_dir(self.backups_dir)` добавить + +```python + secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ +``` + +- [ ] **Step 4: `tick` и `sweep_files`** + +Заменить `Hub.tick` целиком на: + +```python + def tick(self): + """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой + логируется и не мешает ни другой, ни следующему тику.""" + conn = None + try: + conn = self.conn() + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + log.exception("автообновление: сбой тика") + try: + if conn is not None: + self.sweep_files(conn) + except Exception: # noqa: BLE001 + log.exception("уборка файлов: сбой") + finally: + if conn is not None: + conn.close() + + def sweep_files(self, conn, now=None): + """Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после + перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано.""" + now = time.time() if now is None else now + if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY: + return None + self.last_sweep = now + removed = files.sweep(conn, self.files_dir, now) + if removed: + log.info("уборка файлов: удалено %d", len(removed)) + return removed +``` + +- [ ] **Step 5: Обработчики и маршруты** + +После `h_agent_release` добавить: + +```python +def h_agent_file(hub, conn, m, q, body): + """Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex + нижнего регистра — тот же 404 no_file (решение 16).""" + data = files.read(hub.files_dir, m.group(1)) + if data is None: + raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба") + return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} +``` + +После `h_batch_cancel` добавить: + +```python +def h_files_post(hub, conn, m, q, body): + """Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) + отбрасываются до строгой проверки (решение 14).""" + content = body.get("content") + if not isinstance(content, str): + raise ApiError(400, "bad_request", "content — строка base64") + try: + data = base64.b64decode("".join(content.split()), validate=True) + except ValueError: # binascii.Error — подкласс ValueError + raise ApiError(400, "bad_request", "content — не base64") from None + try: + sha, size = files.put(hub.files_dir, data) + except files.EmptyFile: + raise ApiError(422, "empty_file", "пустой файл") from None + except files.TooLarge: + raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None + return 200, {"file": sha, "size": size} +``` + +В `ROUTES` после строки `("GET", r"^/agent/release$", h_agent_release, "agent"),` добавить + +```python + ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), +``` + +и после строки `("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),` добавить + +```python + ("POST", r"^/files$", h_files_post, "client"), +``` + +- [ ] **Step 6: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (в том числе прежние `test_tick_survives_connection_failure` и `test_tick_creates_auto_refresh_once`). + +- [ ] **Step 7: Документация и решения 14–16** + +`README.md`: +- строки «Данные лежат…» заменить: + +```markdown +Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): +`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. +``` + +- `ошибки `{error, detail}` (400/401/404/409/422).` → `ошибки `{error, detail}` (400/401/404/409/413/422).`; +- в таблице маршрутов после строки `GET /agent/release` вставить + +```markdown +| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | +``` + + и после строки `POST /refresh` вставить + +```markdown +| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | +``` + +- в «Эксплуатация» после пункта «Резервные копии…» вставить пункт: + +```markdown +- Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый + раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в + очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается + заново. +``` + +`CLAUDE.md`: строку «- Данные: …» заменить на + +```markdown +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +``` + +и в разделе «Архитектура» после строки, начинающейся с «- `db` (схема, миграции) ←», добавить строку + +```markdown +- `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. +``` + +`ARCHITECTURE.md`: строку модуля `api` заменить на + +```markdown +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | +``` + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. + Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — + 400». Цена: строка с пробелами внутри тоже принимается. +15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после + старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких + перезапусках хаба в сутки уборок больше одной (вопрос к формулировке спеки «не чаще раза в сутки» — в отчёте плана). +16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`. + Обоснование: для агента это тот же случай «файла нет». Цена: нет. +``` + +- [ ] **Step 8: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/api.py tests/test_api.py README.md CLAUDE.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(api): POST /files и GET /agent/files/{sha256}, files/ 0700 при старте, уборка в tick раз в сутки (#43)" -m "Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Команда `ensure_attachment` — контракт, проверки постановки, `size` при выдаче (commands, hub-api) + +**Модель исполнителя:** sonnet (интеграция: меняются сигнатуры `queue.create_batch`/`claim`/`retry_command` и их вызовы +в `api`; четыре модуля). + +**Files:** +- Modify: `src/jira_hub/commands.py` (`SPEC`, `check_confirmed`) +- Modify: `src/jira_hub/settings.py` (`DEFAULT_PACING`, `pacing_from_config`) +- Modify: `src/jira_hub/queue.py` (импорт, `_check_setup`, `create_batch`, новая `deliver_params`, `claim`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_claim`, `h_agent_command`, `h_batches_post`, `h_command_retry`) +- Modify: `README.md`, `ARCHITECTURE.md`, `openspec/changes/bug-and-attachments/design.md` (решения 17–18) +- Test: `tests/test_commands.py`, `tests/test_settings.py`, `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `files.put(root, data)`, `files.is_sha(value)`, `files.exists(root, sha)`, `files.size(root, sha)` (Task 2); + `Hub.files_dir` (Task 3); хелперы тестов `bug()` в `tests/test_queue.py` (Task 1) и `_b64()` в `tests/test_api.py` + (Task 3); `queue._check_setup(conn, cmds)` (Task 1) — меняется сигнатура. +- Produces: + - `commands.SPEC["ensure_attachment"]` (см. Global Constraints), `"ensure_attachment" in commands.WRITE_TYPES`; + - `settings.DEFAULT_PACING["ensure_attachment"] == 30`; + - `queue._check_setup(conn, cmds, files_dir) -> None`; + - `queue.create_batch(conn, title, author, cmds, files_dir=None) -> dict`; + - `queue.deliver_params(cmd: dict, files_dir) -> dict` — параметры для агента (`size` у `ensure_attachment`); + - `queue.claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None`; + - `queue.retry_command(conn, command_id, author, files_dir=None) -> dict`. + `files_dir=None` — хранилища нет: вложение не ставится («файл … не загружен»), при выдаче `size = null`. + +- [ ] **Step 1: Падающие тесты контракта и темпа** + +В `tests/test_commands.py` в параметры `test_check_confirmed` перед строкой с `"refresh"` добавить + +```python + ({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False), +``` + +и после `test_validate_accepts_bug_issue_type` добавить: + +```python +ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc") + + +def test_ensure_attachment_contract(): + ok = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}]) + assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"] + assert "ensure_attachment" in commands.WRITE_TYPES + assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), " + "filename (имя вложения)") in commands.describe().splitlines() + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}]) + assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}]) + assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename" + with pytest.raises(CommandError, match="file — строка"): + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}]) +``` + +В конец `tests/test_settings.py`: + +```python +def test_attachment_pacing(conn): + assert settings.DEFAULT_PACING["ensure_attachment"] == 30 + assert settings.pacing(conn)["ensure_attachment"] == 30 + assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 + settings.validate("pacing", {"ensure_attachment": 10}) +``` + +- [ ] **Step 2: Падающие тесты очереди** + +В `tests/test_queue.py` заменить импорты на + +```python +import hashlib + +import pytest + +from jira_hub import db, files, mirror, queue, settings +from jira_hub.commands import CommandError +from jira_hub.queue import ClaimedError +``` + +и после хелпера `bug` добавить: + +```python +SHA = hashlib.sha256(b"abc").hexdigest() + + +@pytest.fixture +def store(tmp_path): + """Каталог хранилища с файлом «abc».""" + root = tmp_path / "files" + files.put(root, b"abc") + return root + + +def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"): + return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}} +``` + +В конец файла: + +```python +def test_attachment_without_file_rejected(conn, store): + """Спека commands «Вложение без загруженного файла»: 422, пакет не создан.""" + missing = "0" * 64 + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store) + assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files" + with pytest.raises(CommandError, match=f"файл {SHA} не загружен"): + queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("params, msg", [ + ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"filename": " "}, "команда 1: filename пустой"), + ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), +]) +def test_attachment_params_rejected(conn, store, params, msg): + cmd = attach() + cmd["params"].update(params) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store) + assert str(e.value) == msg + + +def test_bug_with_attachment_one_batch(conn, store): + """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}}) + b = queue.create_batch(conn, "ошибка", "claude", [ + bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")], + files_dir=store) + assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2 + c1 = queue.claim(conn, files_dir=store) + assert c1["type"] == "ensure_issue" + queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"}) + c2 = queue.claim(conn, files_dir=store) + assert c2["type"] == "ensure_sprint" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn, files_dir=store) + assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30 + assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3} + again = queue.claim(conn, files_dir=store) # довыдача — тоже с size + assert again["id"] == c3["id"] and again["params"]["size"] == 3 + stored = queue.get_command(conn, c3["id"])["params"] + assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size + + +def test_attachment_size_null_when_file_gone(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + (store / SHA).unlink() + c = queue.claim(conn, files_dir=store) + assert c["type"] == "ensure_attachment" and c["params"]["size"] is None + assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение + assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None + + +def test_attachment_retry_passes_validation(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + queue.report_result(conn, c["id"], "failed", error="HTTP 413") + r = queue.retry_command(conn, c["id"], "claude", files_dir=store) + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"} + (store / SHA).unlink() # убран уборкой + with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"): + queue.retry_command(conn, c["id"], "claude", files_dir=store) + + +def test_attachment_not_confirmed_by_state(conn, store): + mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], []) + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed" +``` + +- [ ] **Step 3: Падающий тест API** + +В конец `tests/test_api.py`: + +```python +def test_attachment_flow_over_api(hub): + """POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size.""" + c, a = hub["client"], hub["agent"] + sha = c.post("/files", {"content": _b64(b"abc")})["file"] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]}) + assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files" + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]}) + cmd = a.post("/agent/claim", {})["command"] + assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3} + assert cmd["pause_seconds"] == 30 + assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3 + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} +``` + +- [ ] **Step 4: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py -k "attachment or check_confirmed"` +Expected: FAIL — «неизвестный тип «ensure_attachment»», `KeyError: 'ensure_attachment'` в темпе, `TypeError: create_batch() +got an unexpected keyword argument 'files_dir'`, `AttributeError: … 'deliver_params'`. + +- [ ] **Step 5: `commands` и `settings`** + +В `src/jira_hub/commands.py` в `SPEC` после записи `"ensure_sprint": {…},` (перед `"refresh"`) вставить: + +```python + "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"], + "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу", + "filename": "имя вложения"}}, +``` + +В `check_confirmed` заменить docstring и первую проверку: + +```python +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не + подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля + (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в + зеркале нет (bug-and-attachments).""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"): + return False, None +``` + +В `src/jira_hub/settings.py` заменить `DEFAULT_PACING` на + +```python +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} +``` + +и в `pacing_from_config` строку возврата на + +```python + return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2} +``` + +- [ ] **Step 6: `queue` — проверки вложения, `size` при выдаче, `files_dir`** + +В `src/jira_hub/queue.py` строку `from . import db, mirror, settings` заменить на `from . import db, files, mirror, settings`. +`_check_setup` из Task 1 заменить целиком: + +```python +def _check_setup(conn, cmds, files_dir) -> None: + """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 + до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). + files_dir=None — хранилища нет: любой файл «не загружен».""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + p = c["params"] + if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: + raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") + if c["type"] == "ensure_attachment": + if not files.is_sha(p["file"]): + raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") + if files_dir is None or not files.exists(files_dir, p["file"]): + raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") + if not p["filename"].strip(): + raise C.CommandError(f"команда {i}: filename пустой") + if "/" in p["filename"] or "\\" in p["filename"]: + raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") +``` + +В `create_batch` заменить сигнатуру и вызов: + +```python +def create_batch(conn, title, author, cmds, files_dir=None) -> dict: + cmds = C.validate(cmds) + _check_setup(conn, cmds, files_dir) +``` + +Перед `claim` добавить: + +```python +def deliver_params(cmd, files_dir) -> dict: + """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, + если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + if cmd["type"] != "ensure_attachment": + return cmd["params"] + size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None + return {**cmd["params"], "size": size} +``` + +Сигнатуру `claim` заменить на + +```python +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: +``` + +и последнюю строку `claim` на + +```python + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause, + "batch_id": cmd["batch_id"]} +``` + +`retry_command` заменить на + +```python +def retry_command(conn, command_id, author, files_dir=None) -> dict: + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], + files_dir=files_dir) +``` + +- [ ] **Step 7: `api` — передать `files_dir`** + +В `src/jira_hub/api.py`: +- в `h_claim` вызов `queue.claim(...)` заменить на + +```python + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup, files_dir=hub.files_dir) +``` + +- `h_agent_command` заменить на + +```python +def h_agent_command(hub, conn, m, q, body): + """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" + cmd = _not_found(queue.get_command(conn, _int(m)), "command") + return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)} +``` + +- в `h_batches_post` вызов на + `return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir)`; +- в `h_command_retry` вызов на + `return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir)`. + +`h_refresh` и `queue.maybe_auto_refresh` не меняются (в них только `refresh`). + +- [ ] **Step 8: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS (в том числе `test_batch_create_description_lists_params_from_spec` в `tests/test_mcp.py` — +строка `ensure_attachment` в описании собирается из `SPEC`). + +- [ ] **Step 9: Документация и решения 17–18** + +`README.md`, таблица «Контракт команд» — после строки `ensure_sprint` вставить + +```markdown +| `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/` и `\`); `size` хаб дописывает при выдаче агенту | +``` + +и абзац про зазоры заменить на + +```markdown +Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). + +`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не +загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба +(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет). +``` + +`ARCHITECTURE.md`: строку модуля `queue` заменить на + +```markdown +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | +``` + +и в «Потоки данных» после строки «Запись в Jira: …» добавить + +```markdown +- Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. +``` + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +17. 15.10.2026 — тексты 422 без формулировки в спеке: «команда N: file — sha256 из POST /files (64 символа 0-9a-f)», + «команда N: filename пустой» (только пробелы; пустая строка — общее «нет filename; ожидаются …»), «команда N: + filename без / и \ — имя вложения, а не путь». Порядок проверок: формат `file`, наличие файла, `filename`. + Цена: формулировки правятся одной строкой. +18. 15.10.2026 — `queue.create_batch`, `queue.retry_command` и `queue.claim` получают `files_dir` параметром (по + умолчанию `None`: вложение не ставится — «файл не загружен», при выдаче `size = null`); `api` передаёт + `Hub.files_dir`; `GET /agent/commands/{id}` дописывает `size` через `queue.deliver_params`. Обоснование: `queue` + остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без `files_dir` молча отвергает вложения. +``` + +- [ ] **Step 10: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/commands.py src/jira_hub/settings.py src/jira_hub/queue.py src/jira_hub/api.py tests/test_commands.py tests/test_settings.py tests/test_queue.py tests/test_api.py README.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)" -m "SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл не загружен: POST /files»), filename без / и \\; claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18)." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: MCP `batch_create` — `path` у `ensure_attachment` (mcp) и полная проверка + +**Модель исполнителя:** haiku (код готов, один модуль и его тесты). + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` (импорты, `_read_local`, `_attach_files`, `batch_create`, описание) +- Modify: `README.md` (раздел MCP), `ARCHITECTURE.md` (`mcp_server`, «Границы»), `openspec/changes/bug-and-attachments/design.md` (решение 19) +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `files.MAX_SIZE` (Task 2); `POST /files` → `{file, size}` (Task 3); `ensure_attachment` и 422 постановки + (Tasks 1, 4); `HubClient.post(path, body) -> dict` (без изменений). +- Produces: `mcp_server._read_local(path) -> bytes | str` (содержимое или текст причины с путём); + `mcp_server._attach_files(client, commands) -> list | str` (команды с `file`/`filename` вместо `path` или текст ошибки); + `mcp_server.BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"`. + +- [ ] **Step 1: Падающие тесты** + +В `tests/test_mcp.py` заменить импорты на + +```python +import asyncio +import hashlib +import json +import os +import re +import threading +from types import SimpleNamespace + +import pytest + +from jira_hub import api, commands, files, mcp_server, settings +from jira_hub.client import HubClient +``` + +и в конец файла добавить: + +```python +BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", + "labels": ["AWPCA"]}} + + +def _batch_commands(out): + batch_id = int(out.split("#")[1].split()[0]) + return mcp_server._client().get(f"/batches/{batch_id}")["commands"] + + +def _write(path, data): + path.write_bytes(data) + return str(path) + + +def test_batch_create_bug_with_local_file(live, tmp_path): + """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" + mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, + "bug": {"name": "Ошибка", "id": None}})) + f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx" + f.write_bytes(b"PK\x03\x04 xlsx") + out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", + "params": {"key": "new:1", "path": str(f)}}]) + assert "3 команд" in out + cmds = _batch_commands(out) + sha = hashlib.sha256(f.read_bytes()).hexdigest() + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"] + assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"} + assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes() + + +def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "скрин.png").write_bytes(b"png") + out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) + assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(), + "filename": "Экран 1.png"} + + +@pytest.mark.parametrize("make, expected", [ + (lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"), + (lambda d: str(d), "не файл: {p}"), + (lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"), + (lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"), + (lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)), + "файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"), +]) +def test_batch_create_bad_path(live, tmp_path, make, expected): + """Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено.""" + src = tmp_path / "src" + src.mkdir() + p = make(src) + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) + assert out == expected.format(p=p) + assert mcp_server._client().get("/batches")["batches"] == [] + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_unreadable_file(live, tmp_path): + f = tmp_path / "закрыт.xlsx" + f.write_bytes(b"x") + f.chmod(0) + try: + if os.access(f, os.R_OK): + pytest.skip("файл читается и без прав (root)") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) + assert out == f"нет прав на чтение: {f}" + finally: + f.chmod(0o600) + + +def test_batch_create_path_and_file(live, tmp_path): + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": { + "key": "A-1", "path": str(f), "file": "0" * 64}}]) + assert out == "у ensure_attachment либо path, либо file" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path): + good = tmp_path / "a.xlsx" + good.write_bytes(b"a") + missing = tmp_path / "нет.xlsx" + out = mcp_server.batch_create("x", [ + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}}, + {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}]) + assert out == f"файл не найден: {missing}" + assert list((tmp_path / "files").iterdir()) == [] + + +def test_batch_create_hub_422_after_upload(live, tmp_path): + """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" + f = tmp_path / "a.xlsx" + f.write_bytes(b"a") + out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) + assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug" + assert mcp_server._client().get("/batches")["batches"] == [] + + +def test_batch_create_description_mentions_path(): + desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description + assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc + assert "issue_type (task|epic|bug)" in desc + assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider tests/test_mcp.py -k "batch_create"` +Expected: FAIL — хаб отвечает «ошибка хаба 422 bad_batch: команда 1 (ensure_attachment): нет file, filename; …» или +«лишние параметры path», нет текста «вместо file передайте path». + +- [ ] **Step 3: Реализация** + +В `src/jira_hub/mcp_server.py` импорты заменить на + +```python +import asyncio +import base64 +import functools +import inspect +import json +import stat +import time +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from .client import HubClient, HubError, HubUnavailable +from .commands import describe +from .files import MAX_SIZE +``` + +после `AUTHOR = "claude"` добавить + +```python +BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" +``` + +перед `def _doc` добавить: + +```python +def _read_local(path) -> bytes | str: + """Содержимое локального файла для ensure_attachment.path или текст причины с путём (spec mcp, решение 19).""" + if not isinstance(path, str) or not path: + return "path — строка с абсолютным путём к файлу" + p = Path(path).expanduser() + if not p.is_absolute(): + return f"нужен абсолютный путь: {path}" + try: + st = p.stat() + if not stat.S_ISREG(st.st_mode): + return f"не файл: {path}" + if st.st_size > MAX_SIZE: + return f"файл больше 10 МБ ({st.st_size} байт): {path}" + data = p.read_bytes() + except FileNotFoundError: + return f"файл не найден: {path}" + except PermissionError: + return f"нет прав на чтение: {path}" + except OSError as e: + return f"файл не прочитан ({e.strerror or e}): {path}" + if not data: + return f"пустой файл: {path}" + if len(data) > MAX_SIZE: # вырос между stat и чтением + return f"файл больше 10 МБ ({len(data)} байт): {path}" + return data + + +def _attach_files(client, commands): + """ensure_attachment с path: сначала прочитать все файлы пакета, затем загрузить их POST /files и подставить file и + filename (по умолчанию — имя файла из path). Ошибка любого файла — текст, в хаб ничего не загружено. Остальные + команды и форму пакета проверяет хаб.""" + if not isinstance(commands, list): + return commands + read = {} + for i, cmd in enumerate(commands): + params = cmd.get("params") if isinstance(cmd, dict) and cmd.get("type") == "ensure_attachment" else None + if not isinstance(params, dict) or "path" not in params: + continue + if params.get("file") not in (None, ""): + return BOTH_FILE_AND_PATH + data = _read_local(params["path"]) + if isinstance(data, str): + return data + read[i] = data + out = [] + for i, cmd in enumerate(commands): + if i not in read: + out.append(cmd) + continue + params = {k: v for k, v in cmd["params"].items() if k != "path"} + up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) + params["file"] = up["file"] + params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name + out.append({**cmd, "params": params}) + return out +``` + +Описание и тело `batch_create` заменить: + +```python +@mcp.tool() +@guard +@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. +Параметры по типам (необязательные — в [ ]): +""" + describe() + """ +ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, +задача без неё выпадет из базовой JQL и из зеркала. +ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит +файл в хаб и подставит file, filename по умолчанию — имя файла.""") +def batch_create(title: str, commands: list[dict]) -> str: + client = _client() + prepared = _attach_files(client, commands) + if isinstance(prepared, str): + return prepared + b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared}) + return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" +``` + +- [ ] **Step 4: Тесты зелёные** + +Run: `uv run --directory /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach pytest -q -p no:cacheprovider` +Expected: весь набор PASS. + +- [ ] **Step 5: Документация и решение 19** + +`README.md`, таблица инструментов MCP — строку `batch_create` заменить на + +```markdown +| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | +``` + +и после строки «Автор команд из MCP: `claude`; из API без автора: `api`.» добавить: + +````markdown +«Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, +загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, +нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится. + +```json +[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}, + {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] +``` +```` + +`ARCHITECTURE.md`: строку модуля `mcp_server` заменить на + +```markdown +| `mcp_server` | инструменты MCP по stdio; `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) | +``` + +и в «Границы» строку «`mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.» на +«`mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел +`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).» + +`openspec/changes/bug-and-attachments/design.md`, конец раздела `## Decisions made without discussion`: + +```markdown +19. 15.10.2026 — MCP `batch_create` с `path`: `~` раскрывается; относительный путь — «нужен абсолютный путь: »; + пустой файл отвергается до загрузки — «пустой файл: »; прочие тексты — «не файл: », «нет прав на + чтение: », «файл больше 10 МБ ( байт): », «файл не прочитан (<причина>): »; сначала читаются + все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: cwd сервера MCP + неочевиден; спека дословно задаёт только «файл не найден» и «либо path, либо file». Цена: пакет с N файлами держит + их в памяти MCP до загрузки (≤ N × 10 МБ). +``` + +- [ ] **Step 6: Полная проверка** + +Run: `make -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach check` +Expected: pytest — все PASS (без `ResourceWarning`), `npm run lint` — без ошибок, `npm test` (Vitest) — все PASS. Если +`web/node_modules` нет — сначала `npm --prefix /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/web ci`. + +- [ ] **Step 7: Коммит** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach add src/jira_hub/mcp_server.py tests/test_mcp.py README.md ARCHITECTURE.md openspec/changes/bug-and-attachments/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach commit -m "feat(mcp): batch_create принимает path у ensure_attachment и сам загружает файл (#43)" -m "Все файлы пакета читаются до загрузки: «файл не найден: », «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19)." -m "Co-Authored-By: Claude Opus 5.5 " +```