1747 lines
98 KiB
Markdown
1747 lines
98 KiB
Markdown
# 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 «команда <N>: нет настройки issue_types.bug»; 422 «команда <N>: файл <sha> не загружен: 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: «файл не найден: <path>», «у 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: Окружение**
|
||
|
||
```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:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
|
||
```
|
||
|
||
на
|
||
|
||
```markdown
|
||
| `ensure_issue` | `temp_key` (`new:<n>`), `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 <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`:
|
||
|
||
```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/<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` вставить:
|
||
|
||
```markdown
|
||
| `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` дописать:
|
||
|
||
```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/` (`.<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: Коммит**
|
||
|
||
```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/<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` заменить импорты в начале файла на
|
||
|
||
```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/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
|
||
раз — через минуту после старта) хаб удаляет файлы старше 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/<sha256>`, каталог — параметр `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 <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"` добавить
|
||
|
||
```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:<n>`), `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: файл <sha> не
|
||
загружен: 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/<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`:
|
||
|
||
```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, файл загружен («файл <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` заменить импорты на
|
||
|
||
```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`: `~` раскрывается; относительный путь — «нужен абсолютный путь: <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: Коммит**
|
||
|
||
```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 "Все файлы пакета читаются до загрузки: «файл не найден: <path>», «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19)." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|