Агент v14: команда fetch_attachment (#43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
711f62e1df
commit
4819950738
@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
| Раздел | Классы и функции | Ответственность | Что использует |
|
||||
|---|---|---|---|
|
||||
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
|
||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
|
||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` |
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
|
||||
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
|
||||
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, `ensure_attachment`, где у хаба берётся файл вложения, и `fetch_attachment`, где скачанный из Jira файл отдаётся хабу).
|
||||
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
|
||||
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
|
||||
- Подробнее: `ARCHITECTURE.md`.
|
||||
@ -48,7 +48,7 @@
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`.
|
||||
Последний change — `../jira-hub/openspec/changes/attachment-fetch/` (агент v14: список вложений в `refresh`, команда `fetch_attachment`; решения агента — `openspec/changes/agent-v14-attachment-fetch/design.md`), план — `docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md`.
|
||||
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
38
src/agent.py
38
src/agent.py
@ -645,6 +645,18 @@ class HubError(Exception):
|
||||
|
||||
|
||||
FILE_GONE = "файл удалён с хаба, загрузите заново"
|
||||
MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт)
|
||||
|
||||
|
||||
def _fmt_size(n: Optional[int]) -> str:
|
||||
"""Размер для текстов ошибок: «N Б», «N,N КБ», «N,N МБ» (по 1024, запятая), как у хаба."""
|
||||
if n is None:
|
||||
return "размер неизвестен"
|
||||
if n < 1024:
|
||||
return "%d Б" % n
|
||||
if n < 1024 * 1024:
|
||||
return ("%.1f КБ" % (n / 1024)).replace(".", ",")
|
||||
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
|
||||
|
||||
|
||||
class Executor:
|
||||
@ -1010,6 +1022,32 @@ class Executor:
|
||||
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
|
||||
return self._out("done", {"attachment_id": str(first["id"]), "created": True})
|
||||
|
||||
def do_fetch_attachment(self, p: dict, cmd: dict) -> dict:
|
||||
"""Файл вложения из Jira — хабу (POST /agent/files). В Jira не пишет; адрес содержимого собирается из base_url,
|
||||
перенаправление на другой адрес — отказ (учётные данные туда не уходят). Содержимое не логируется."""
|
||||
key, aid = p["key"], str(p["attachment_id"])
|
||||
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
|
||||
found = [a for a in (issue.get("fields") or {}).get("attachment") or [] if str(a.get("id")) == aid]
|
||||
if not found:
|
||||
return self._out("failed", error="вложение #%s удалено из %s в Jira" % (aid, key))
|
||||
filename, size = found[0].get("filename") or "", found[0].get("size")
|
||||
size = size if isinstance(size, int) and not isinstance(size, bool) else None
|
||||
too_big = "файл больше 10 МБ (%s) — скачайте в Jira"
|
||||
if size is not None and size > MAX_ATTACHMENT:
|
||||
return self._out("failed", error=too_big % _fmt_size(size))
|
||||
try:
|
||||
data = self.jira.download("/secure/attachment/%s/%s" % (aid, urllib.parse.quote(filename, safe="")),
|
||||
MAX_ATTACHMENT)
|
||||
except TooBig:
|
||||
return self._out("failed", error=too_big % ("больше " + _fmt_size(MAX_ATTACHMENT)))
|
||||
except RedirectAway as e:
|
||||
return self._out("failed", error="Jira перенаправила скачивание на %s" % e.host)
|
||||
if size is not None and len(data) != size:
|
||||
return self._out("failed", error="файл пришёл не целиком (%d из %d)" % (len(data), size))
|
||||
log(" вложение #%s «%s» (%s) ← %s" % (aid, filename, _fmt_size(len(data)), key))
|
||||
got = self._hub(self.hub.upload_file, data)
|
||||
return self._out("done", {"attachment_id": aid, "file": got.get("file"), "size": got.get("size")})
|
||||
|
||||
# --- refresh ---------------------------------------------------------------
|
||||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||||
"issuetype", "created", "updated", "issuelinks", "comment", "attachment"]
|
||||
|
||||
@ -139,3 +139,106 @@ class EnsureAttachmentTest(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FetchAttachmentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.jira = agent.JiraClient(cfg)
|
||||
self.jira.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||||
self.ex.sleep = no_sleep
|
||||
self.fake.add("AURORA-1650")
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
|
||||
def fetch(self, a, key="AURORA-1650"):
|
||||
return self.ex.run({"id": 7, "type": "fetch_attachment", "params": {"key": key, "attachment_id": a["id"]}})
|
||||
|
||||
def test_fetch_screenshot(self): # сценарий «Скачивание скриншота»
|
||||
data = bytes(range(256)) * 900
|
||||
a = self.fake.add_attachment("AURORA-1650", "скрин 1.png", data, mime="image/png")
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"], {"attachment_id": a["id"], "file": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data)})
|
||||
self.assertEqual(self.hub.uploaded, [data])
|
||||
self.assertEqual(self.fake.requests[0], ("GET", "/rest/api/2/issue/AURORA-1650"))
|
||||
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
|
||||
self.assertEqual(self.fake.requests[1][1],
|
||||
"/secure/attachment/%s/%%D1%%81%%D0%%BA%%D1%%80%%D0%%B8%%D0%%BD%%201.png" % a["id"])
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_fetch_deleted(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.fake.attachments["AURORA-1650"] = []
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "вложение #%s удалено из AURORA-1650 в Jira" % a["id"]))
|
||||
self.assertEqual(self.fake.content_gets, 0)
|
||||
|
||||
def test_fetch_too_large_known(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "запись.mp4", b"x")
|
||||
a["size"] = 25000000
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"))
|
||||
self.assertEqual(self.fake.content_gets, 0)
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_too_large_unknown(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "big.bin", b"x" * (12 * 1024 * 1024))
|
||||
self.fake.hide_size.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_unknown_size_ok(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.json", b'{"a": 1}', mime="application/json")
|
||||
self.fake.hide_size.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["size"], 8)
|
||||
|
||||
def test_fetch_redirect_away(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.png" % (self.fake.server.server_port, a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "Jira перенаправила скачивание на localhost"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_truncated(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.bin", b"abcdef")
|
||||
self.fake.short_content.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "файл пришёл не целиком (5 из 6)"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_empty(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "empty.txt", b"")
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["size"], 0)
|
||||
self.assertEqual(self.hub.uploaded, [b""])
|
||||
|
||||
def test_fetch_hub_down(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.hub.reject_files = "drop"
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(out["peer"], "хабом")
|
||||
self.assertTrue(out["error"].startswith("хаб: "))
|
||||
|
||||
def test_fetch_hub_422(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.hub.reject_files = (422, {"error": "bad_request", "detail": "content — не base64"})
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user