sdlc-hub/docs/superpowers/plans/2026-10-15-bug-and-attachments-hub.md
Andrey Epifancev a8672cf7c0 docs(plan): план хаба bug-and-attachments (#43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:06:42 +04:00

98 KiB
Raw Blame History

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/<sha256>), ставит и выдаёт агенту команду 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 <noreply@anthropic.com>. Не пушить.
  • Решения, принятые планом без обсуждения, дописываются в 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: Окружение

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 заменить строку

    ([{"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"),

и после test_unknown_param_error_names_expected добавить:

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 добавить хелпер и тесты в конец файла:

def bug(temp="new:1", summary="ОСВ 60 не распознана"):
    return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary,
                                               "labels": ["AWPCA"]}}
def test_bug_without_setting_rejected(conn):
    """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан."""
    settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}})
    with pytest.raises(CommandError) as e:
        queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()])
    assert str(e.value) == "команда 2: нет настройки issue_types.bug"
    assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0


@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None])
def test_bug_with_empty_setting_rejected(conn, value):
    settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value})
    with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
        queue.create_batch(conn, "ошибка", "claude", [bug()])


@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}])
def test_bug_with_setting_accepted(conn, value):
    settings.set_(conn, "issue_types", {"bug": value})
    b = queue.create_batch(conn, "ошибка", "claude", [bug()])
    assert b["count"] == 2                                             # ensure_issue + хвостовой refresh
  • Step 4: Падающие тесты init и API

В tests/test_settings.py заменить импорты на

import json
from pathlib import Path

import pytest

from jira_hub import queue, settings
from jira_hub.commands import CommandError

и в конец файла добавить:

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 добавить:

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 заменить

            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")
  • Step 7: settings — bug из конфига

В src/jira_hub/settings.py перед init_from_config добавить:

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 заменить

        "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),
  • Step 8: queue — проверка постановки

В src/jira_hub/queue.py перед create_batch добавить:

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

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

и сразу после таблицы (перед абзацем «Ключ в параметре может быть временным…») вставить абзац:

Тип `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 дописать:

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: Коммит
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 <noreply@anthropic.com>"

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:

"""Хранилище файлов вложений (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:

"""Хранилище файлов вложений: <каталог данных>/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 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 вставить:

| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `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 дописать:

10. 15.10.2026 — функции `files` принимают каталог хранилища первым параметром (`put(root, data)`, `path(root, sha)`,
    `exists`, `size`, `read`, `sweep(conn, root, now)`), каталог — `Hub.files_dir` (`<каталог базы>/files`), а не
    `paths.home()`. Обоснование: тесты поднимают хаб во временном каталоге без `JIRA_HUB_HOME` (как `release_dir`).
    Цена: лишний параметр в вызовах из `queue` и `api`.
11. 15.10.2026 — временный файл записи — `tempfile.mkstemp` в `files/` (`.<sha>.<случайное>.tmp`, права 0600), а не
    фиксированное `.<sha>.tmp`. Обоснование: две одновременные загрузки одного содержимого (ThreadingHTTPServer) не
    пишут в один временный файл и не ломают друг другу `os.replace`. Цена: брошенные после сбоя временные файлы
    убирает уборка общим правилом 30 дней.
12. 15.10.2026 — `files.path/exists/size/read` принимают только sha из 64 символов `[0-9a-f]`, иначе «файла нет»
    (`path` — `ValueError`). Обоснование: APFS не различает регистр — `BA78…` нашёл бы `ba78…`; `..` и `/` не строят
    путь вне `files/`. Цена: нет.
13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная
    запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого,
    агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент).
  • Step 6: Коммит
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/<sha256> с уборкой (#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 <noreply@anthropic.com>"

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 заменить импорты в начале файла на

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

и в конец файла добавить:

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 = … добавить

SWEEP_EVERY = 24 * 3600         # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)

В Hub.__init__ заменить сигнатуру и начало:

    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) добавить

        secure_dir(self.files_dir)                    # files/ — 0700 при каждом старте, как backups/
  • Step 4: tick и sweep_files

Заменить Hub.tick целиком на:

    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 добавить:

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 добавить:

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"), добавить

    ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),

и после строки ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), добавить

    ("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:

  • строки «Данные лежат…» заменить:
Данные лежат в `~/.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 вставить
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |

и после строки POST /refresh вставить

| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` |
  • в «Эксплуатация» после пункта «Резервные копии…» вставить пункт:
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
  раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в
  очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается
  заново.

CLAUDE.md: строку «- Данные: …» заменить на

- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.

и в разделе «Архитектура» после строки, начинающейся с «- db (схема, миграции) ←», добавить строку

- `files` (хранилище вложений `files/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.

ARCHITECTURE.md: строку модуля api заменить на

| `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:

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: Коммит
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 <noreply@anthropic.com>"

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" добавить

    ({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False),

и после test_validate_accepts_bug_issue_type добавить:

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:

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 заменить импорты на

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 добавить:

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_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:

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") вставить:

    "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"],
                          "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу",
                                    "filename": "имя вложения"}},

В check_confirmed заменить docstring и первую проверку:

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 на

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 строку возврата на

    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 заменить целиком:

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 заменить сигнатуру и вызов:

def create_batch(conn, title, author, cmds, files_dir=None) -> dict:
    cmds = C.validate(cmds)
    _check_setup(conn, cmds, files_dir)

Перед claim добавить:

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 заменить на

def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None:

и последнюю строку claim на

    return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause,
            "batch_id": cmd["batch_id"]}

retry_command заменить на

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(...) заменить на
        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 заменить на
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 вставить

| `ensure_attachment` | `key` (ключ или `new:<n>`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/` и `\`); `size` хаб дописывает при выдаче агенту |

и абзац про зазоры заменить на

Ответ: `{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: файл <sha> не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет).

ARCHITECTURE.md: строку модуля queue заменить на

| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` |

и в «Потоки данных» после строки «Запись в Jira: …» добавить

- Вложение: файл → `POST /files` → `files/<sha256>` → `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:

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: Коммит
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, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \\; claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18)." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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 заменить импорты на

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

и в конец файла добавить:

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 импорты заменить на

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" добавить

BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"

перед def _doc добавить:

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 заменить:

@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 заменить на

| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд |

и после строки «Автор команд из 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"}}]
```

ARCHITECTURE.md: строку модуля mcp_server заменить на

| `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:

19. 15.10.2026 — MCP `batch_create` с `path`: `~` раскрывается; относительный путь — «нужен абсолютный путь: <path>»;
    пустой файл отвергается до загрузки — «пустой файл: <path>»; прочие тексты — «не файл: <path>», «нет прав на
    чтение: <path>», «файл больше 10 МБ (<n> байт): <path>», «файл не прочитан (<причина>): <path>»; сначала читаются
    все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: 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: Коммит
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 "Все файлы пакета читаются до загрузки: «файл не найден: <path>», «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19)." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"