diff --git a/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md b/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md new file mode 100644 index 0000000..100379c --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md @@ -0,0 +1,92 @@ +# Вложения из Jira по запросу (агент v14) — Implementation Plan + +> **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:** агент v14 в `refresh` присылает список вложений задач (без содержимого) и выполняет новую команду +`fetch_attachment`: скачивает файл вложения из Jira и передаёт хабу `POST /agent/files`. + +**Architecture:** список — поле `attachment` в `search` и `Executor.attachments_of(raw)` в `to_state`; скачивание — +`JiraClient.download` (байты, предел, без перенаправлений на другой хост) и `Executor.do_fetch_attachment`; отправка — +`HubClient.put_file` (работает и через `FolderTransport`). Python 3.9, только stdlib. + +**Tech Stack:** Python 3.9 (`/usr/bin/python3`), stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`. + +**Spec:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/attach-fetch/openspec/changes/attachment-fetch/` — +`design.md` (решения 1, 4–6, 8, 17, 18), `specs/agent/spec.md` («Чтение состояния», «Скачивание вложения»), +`specs/commands/spec.md` («Скачивание вложения по запросу»), `specs/hub-api/spec.md` (`POST /agent/files`). Решения агента — +`openspec/changes/agent-v14-attachment-fetch/design.md` этого репозитория (таблица `R-n`). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/attach-fetch`, ветка `feat/43-attachment-fetch` +(aep/jira-agent#43). Все команды — из корня worktree. + +## Global Constraints + +- `AGENT_VERSION = 14`. +- Python 3.9 stdlib; без `match`, без `X | Y` в аннотациях (`tests/test_py39.py`). +- Предел — `MAX_ATTACHMENT = 10 * 1024 * 1024` (10 485 760 байт). +- Адрес содержимого — `/secure/attachment//`; `content` из ответа Jira не используется. +- Таймаут скачивания — `max(self.timeout, 120)`. +- Элемент `attachments`: `{id, filename, size, mime_type, author, created}` (`author` — `displayName`, иначе `name`, иначе `None`). +- Тексты ошибок (точно): «вложение # удалено из в Jira»; «файл больше 10 МБ (<размер>) — скачайте в Jira» (неизвестный размер — «больше 10,0 МБ»); «Jira перенаправила скачивание на <хост>»; «файл пришёл не целиком (<получено> из <размер>)». Размер — «N Б» / «N,N КБ» / «N,N МБ» по 1024, запятая. +- Содержимое файла не попадает в лог, журнал и текст ошибок. +- Каждый коммит — с `(#43)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +- Решения вне спеки и плана — в таблицу `openspec/changes/agent-v14-attachment-fetch/design.md`; находки ревью задачи n — `T.`. + +## Review Focus + +1. Перенаправление 302 на тот же хост (Jira иногда так отдаёт файл) — проходит; на другой хост или схему — `failed` без запроса туда (тесты в Task 2). +2. Вложение без `size` в Jira и содержимое больше предела — чтение обрывается, хабу ничего не уходит (тест в Task 3). +3. Пустое вложение (0 байт) — `done`, хаб получил пустой `content` (тест в Task 3). +4. Хаб недоступен на `POST /agent/files` — `failed` с `retryable`, как прочие обрывы связи с хабом (тест в Task 3). +5. Кириллица и пробелы в имени — путь скачивания в URL-кодировке, фейк Jira находит файл (тест в Task 3). + +--- + +### Task 1: Список вложений в состоянии и версия 14 + +**Files:** +- Modify: `src/agent.py` — `AGENT_VERSION = 14`, `STATE_FIELDS` + `"attachment"`, `Executor.attachment_state(a) -> dict` (статический), `Executor.attachments_of(raw) -> List[dict]`, `to_state` добавляет `attachments` +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Produces: `attachments_of(raw)` — вложения в порядке Jira, без `id` — отброшено с `log` (ключ задачи); нет поля `attachment` — `[]`. + +- [ ] **Step 1: Тесты** — `test_refresh_attachments_in_state` (сценарий «Вложения в состоянии» из дельты `agent`: два вложения, `author = "Потапова Анна"`, `size = 240000`, `mime_type = "image/png"`; число вызовов `search` прежнее; к содержимому обращений нет — фейк считает GET `/secure/attachment/`), `test_refresh_attachment_without_id_dropped`, `test_refresh_no_attachments_empty_list`. +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL. +- [ ] **Step 3: Реализация** по сигнатурам. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент v14: список вложений в состоянии refresh (#43)`. + +### Task 2: Скачивание байтов из Jira и файл хабу + +**Files:** +- Modify: `src/agent.py` — `Http.fetch_bytes(path, limit, timeout) -> bytes` (GET, без разбора JSON; перенаправления — свой `HTTPRedirectHandler`: тот же схема+хост+порт, что у `base`, — идти; иначе `RedirectAway(host)`); `JiraClient.download(path, limit) -> bytes` (через `_retry`, счётчик вызовов); класс `RedirectAway(Exception)` с полем `host`; `TooBig(Exception)`; `HubClient.put_file(data: bytes) -> dict` (`POST /agent/files {content: base64}`) +- Modify: `tests/fake_jira.py` — `GET /secure/attachment//<имя>` отдаёт `data` вложения (имя сверяется после раскодирования), счётчик `self.content_gets`; `self.redirect_content = None` (адрес для 302); `self.hide_size = set()` (id без `size` в JSON) +- Modify: `tests/fake_hub.py` — `POST /agent/files` кладёт в `self.files`, отвечает `{file, size}`; `self.reject_files = None` (код ответа или `"drop"`) +- Test: `tests/test_clients.py` + +**Interfaces:** +- Produces: `JiraClient.download(path: str, limit: int) -> bytes` — читает не больше `limit + 1` байт; если прочитано больше `limit` — `TooBig`; 429/503 — повтор как у `call`; `RedirectAway` и `TooBig` наружу. + +- [ ] **Step 1: Тесты** — `test_download_bytes` (тело как есть, `calls` +1, заголовок `Authorization` ушёл); `test_download_redirect_same_host` (302 на тот же хост → байты); `test_download_redirect_other_host` (302 на `http://127.0.0.2:<порт>/x` → `RedirectAway`, второй сервер запроса не получил); `test_download_limit` (limit=10, тело 20 байт → `TooBig`); `test_download_429_retry`; `test_put_file_http` и `test_put_file_folder` (через `FolderTransport` и `tests/fake_relay.py`). +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_clients -q` → FAIL. +- [ ] **Step 3: Реализация.** Обработчик перенаправлений — `urllib.request.build_opener(_SameHostRedirect(base), HTTPSHandler(context=ctx))`; `urlopen` в `Http.send` не трогать. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: скачивание байтов из Jira без чужих перенаправлений, файл хабу (#43)`. + +### Task 3: Команда `fetch_attachment` + +**Files:** +- Modify: `src/agent.py` — `Executor.do_fetch_attachment(p, cmd) -> dict`, `_fmt_size(n: Optional[int]) -> str`; ARCHITECTURE.md (раздел «Исполнитель», `JiraClient.download`, `HubClient.put_file`), `CLAUDE.md` (граница `Executor` знает хаб и в `fetch_attachment`; последний change) +- Test: `tests/test_executor_attachments.py` + +**Interfaces:** +- Consumes: `JiraClient.download`, `RedirectAway`, `TooBig`, `HubClient.put_file` (Task 2); `self._hub(fn, *args)` для вызова хаба. +- Produces: результат `done` — `{"attachment_id", "file", "size"}`; `file` и `size` — из ответа хаба. + +- [ ] **Step 1: Тесты** — `test_fetch_screenshot` (сценарий «Скачивание скриншота»: имя «скрин 1.png», фейк Jira отдал файл, хаб получил те же байты, результат `done` с sha256 и размером, в журнале агента нет содержимого); `test_fetch_deleted` («вложение #31337 удалено из AURORA-1650 в Jira», содержимое не запрашивалось); `test_fetch_too_large_known` (size 25 000 000 в JSON → «файл больше 10 МБ (23,8 МБ) — скачайте в Jira», `content_gets == 0`); `test_fetch_too_large_unknown` (`hide_size`, содержимое 12 МБ → «файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira», хаб ничего не получил); `test_fetch_redirect_away` («Jira перенаправила скачивание на 127.0.0.2»); `test_fetch_truncated` (фейк отдаёт меньше байт, чем `size` → «файл пришёл не целиком (N из M)»); `test_fetch_empty` (0 байт → `done`, `size = 0`); `test_fetch_hub_down` (`reject_files = "drop"` → `failed`, `retryable`, `peer = "хабом"`); `test_fetch_hub_422` (ошибка с «хаб: »). +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_attachments -q` → FAIL. +- [ ] **Step 3: Реализация** по контракту `commands` «Скачивание вложения по запросу» (шаги 1–5); лог — `log(" вложение #%s «%s» (%s) ← %s" % (...))`. +- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS. +- [ ] **Step 5: Commit** — `Агент v14: команда fetch_attachment (#43)`.