From d75c1a5567016149517596c59e829da0c7958d6a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:52:03 +0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=20ensure=5Fattachment=20=E2=80=94=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D1=83,=20=D1=84=D0=B0=D0=B9=D0=BB=20=D1=83=20?= =?UTF-8?q?=D1=85=D0=B0=D0=B1=D0=B0=20=D1=81=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=BE=D0=B9=20sha256=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 4 +- README.md | 1 + src/agent.py | 47 ++++++++++ tests/_common.py | 2 +- tests/test_executor_attachments.py | 141 +++++++++++++++++++++++++++++ tests/test_runner.py | 59 ++++++++++++ 7 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 tests/test_executor_attachments.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 509416d..48c2a56 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл | 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` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.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` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `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` | diff --git a/CLAUDE.md b/CLAUDE.md index 1f2f472..4d74e72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). +- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения). `Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. - Подробнее: `ARCHITECTURE.md`. @@ -39,7 +39,7 @@ - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. - Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), - `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`), + `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`), `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429), `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). diff --git a/README.md b/README.md index ad1826e..e6f4965 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ python3 agent.py prune | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог | +| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет | Правила работы: diff --git a/src/agent.py b/src/agent.py index 34e4535..5d1d3e8 100644 --- a/src/agent.py +++ b/src/agent.py @@ -549,6 +549,9 @@ class HubError(Exception): self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) +FILE_GONE = "файл удалён с хаба, загрузите заново" + + class Executor: def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): self.jira, self.s, self.hub = jira, settings, hub @@ -868,6 +871,50 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) + # --- ensure_attachment ----------------------------------------------------- + def hub_file(self, sha: str, size: int) -> Optional[bytes]: + """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, + как обрыв связи с хабом. Содержимое файла в текст ошибки не попадает.""" + try: + got = self.hub.file(sha) + except HttpError as e: + if e.code == 404: + return None + if 200 <= e.code < 300: # ответ не JSON: в тексте Http — начало тела, то есть содержимого + raise HubError(ConnectionError("ответ на файл %s не JSON" % sha)) from None + raise HubError(e) from None + except ConnectionError as e: + raise HubError(e) from None + content = got.get("content") if isinstance(got, dict) else None + data = None + if isinstance(content, str): + try: + data = base64.b64decode(content, validate=True) + except ValueError: # binascii.Error — подкласс ValueError + data = None + if data is None or len(data) != size or hashlib.sha256(data).hexdigest() != sha: + raise HubError(ConnectionError("файл %s пришёл повреждённым: sha256 или размер не сходятся" % sha)) + return data + + def do_ensure_attachment(self, p: dict, cmd: dict) -> dict: + key, sha, filename, size = p["key"], p["file"], p["filename"], p.get("size") + if size is None: # хаб не нашёл файл при выдаче + return self._out("failed", error=FILE_GONE) + names = {filename, wire_filename(filename)} # Jira могла сохранить имя в «браузерной» форме (R-23) + issue = self.jira.get(API + "/issue/%s" % key, fields="attachment") + for a in (issue.get("fields") or {}).get("attachment") or []: + if a.get("filename") in names and a.get("size") == size: + return self._out("done", {"attachment_id": str(a.get("id")), "existed": True}) + data = self.hub_file(sha, size) + if data is None: + return self._out("failed", error=FILE_GONE) + log(" вложение «%s» (%d байт) → %s" % (filename, size, key)) + created = self.jira.upload(key, filename, data) + first = created[0] if isinstance(created, list) and created and isinstance(created[0], dict) else {} + if not first.get("id"): + return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку") + return self._out("done", {"attachment_id": str(first["id"]), "created": True}) + # --- refresh --------------------------------------------------------------- STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", "issuetype", "created", "updated", "issuelinks", "comment"] diff --git a/tests/_common.py b/tests/_common.py index d5f4838..b581957 100644 --- a/tests/_common.py +++ b/tests/_common.py @@ -9,7 +9,7 @@ if SRC not in sys.path: import agent # noqa: E402 ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, - "ensure_sprint": 0, "refresh": 0} + "ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0} SETTINGS = { "base_jql": "project = AURORA AND labels = AWPCA", diff --git a/tests/test_executor_attachments.py b/tests/test_executor_attachments.py new file mode 100644 index 0000000..1aa352c --- /dev/null +++ b/tests/test_executor_attachments.py @@ -0,0 +1,141 @@ +import hashlib +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + +SHA_ABC = hashlib.sha256(b"abc").hexdigest() + + +class EnsureAttachmentTest(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-1") + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def run_cmd(self, **params): + return self.ex.run({"id": 1, "type": "ensure_attachment", "params": params}) + + def test_upload_cyrillic_name(self): + data = bytes(range(256)) * 8 + sha = self.hub.put_file(data) + name = "ОСВ сч. 60 за 2 кв. 2026.xlsx" + out = self.run_cmd(key="AURORA-1", file=sha, filename=name, size=len(data)) + self.assertEqual(out["status"], "done", out) + a = self.fake.attachments["AURORA-1"][0] + self.assertEqual(out["result"], {"attachment_id": a["id"], "created": True}) + self.assertEqual((a["filename"], a["size"], a["data"]), (name, len(data), data)) + self.assertEqual(self.fake.requests, [("GET", "/rest/api/2/issue/AURORA-1"), + ("POST", "/rest/api/2/issue/AURORA-1/attachments")]) + self.assertEqual(self.fake.queries[0].get("fields"), "attachment") + self.assertEqual(self.hub.file_requests, 1) + self.assertEqual(out["jira_calls"], 2) + + def test_existing_same_name_and_size(self): + a = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", b"xyz") # другое содержимое, тот же размер + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="ОСВ 60.xlsx", size=3) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True}) + self.assertEqual(out["jira_calls"], 1) + self.assertEqual(self.hub.file_requests, 0) # файл у хаба не запрашивается + self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1) + + def test_same_name_other_size_uploads_second(self): + old = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", "старое содержимое".encode("utf-8")) + sha = self.hub.put_file(b"abc") + out = self.run_cmd(key="AURORA-1", file=sha, filename="ОСВ 60.xlsx", size=3) + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["created"]) + self.assertNotEqual(out["result"]["attachment_id"], old["id"]) + self.assertEqual([x["filename"] for x in self.fake.attachments["AURORA-1"]], ["ОСВ 60.xlsx", "ОСВ 60.xlsx"]) + + def test_existing_under_wire_name(self): + name = 'итог "кв.2".txt' + a = self.fake.add_attachment("AURORA-1", agent.wire_filename(name), b"abc") # так Jira сохранила прошлую загрузку + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename=name, size=3) + self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True}) + self.assertEqual(self.hub.file_requests, 0) + + def test_size_null_fails_without_calls(self): + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=None) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново") + self.assertNotIn("retryable", out) + self.assertEqual(self.fake.requests, []) + self.assertEqual(self.hub.file_requests, 0) + + def test_hub_404_file_gone(self): + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) # в хранилище хаба файла нет + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново") + self.assertNotIn("retryable", out) + self.assertEqual(self.hub.file_requests, 1) + self.assertFalse(any(m == "POST" for m, _ in self.fake.requests)) + + def test_corrupted_sha_is_retryable_hub_failure(self): + self.hub.files[SHA_ABC] = b"xyz" # размер тот же, содержимое другое + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + self.assertEqual(out["peer"], "хабом") + self.assertTrue(out["error"].startswith("хаб: "), out["error"]) + self.assertNotIn("AURORA-1", self.fake.attachments) + self.assertFalse(any(m == "POST" for m, _ in self.fake.requests)) + + def test_length_mismatch_is_retryable_hub_failure(self): + self.hub.put_file(b"abc") + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=4) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + self.assertEqual(out["peer"], "хабом") + self.assertNotIn("AURORA-1", self.fake.attachments) + + def test_hub_non_json_file_no_content_in_error(self): + def broken(sha): + raise agent.HttpError(200, 'ответ не JSON: Unterminated string; начало: {"file": "x", "content": "U0VDUkVULUNPTlRFTlQ') + + self.ex.hub.file = broken + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + self.assertEqual(out["peer"], "хабом") + self.assertNotIn("U0VDUkVU", out["error"]) + self.assertNotIn("начало", out["error"]) + + def test_hub_4xx_other_than_404_not_retryable(self): + self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5}) + out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"]) + self.assertFalse(out["retryable"]) + + def test_jira_413_failed_with_code_and_text(self): + self.fake.attachment_limit = 2 + sha = self.hub.put_file(b"abc") + out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["error"].startswith("HTTP 413"), out["error"]) + self.assertIn("Размер вложения превышает предел", out["error"]) + self.assertFalse(out["retryable"]) + + def test_upload_response_without_id(self): + self.fake.bad_attachment_response = True + sha = self.hub.put_file(b"abc") + out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "Jira не вернула id вложения в ответе на загрузку") + self.assertNotIn("retryable", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 85478a8..28a770f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,3 +1,6 @@ +import base64 +import hashlib +import json import os import tempfile import threading @@ -580,6 +583,62 @@ class RunnerTest(unittest.TestCase): self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs) self.assertNotIn("peer", r) + def test_attachment_upload_dropped_no_duplicate(self): + sha = self.hub.put_file(b"abc") + self.fake.drop_next_post_attachment = True # Jira сохранила вложение, ответ потерян + self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "ОСВ 60.xlsx"}) + self.runner.loop() + r = self.hub.results[1] + self.assertEqual(r["status"], "done", r) + self.assertTrue(r["result"]["existed"]) + self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1) + self.assertEqual(r["result"]["attachment_id"], self.fake.attachments["AURORA-1"][0]["id"]) + self.assertEqual(self.hub.file_requests, 1) # повтор нашёл вложение, файл второй раз не нужен + self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) + self.assertEqual(r["jira_calls"], 2) # GET + повторный GET (оборванный POST не считается) + + def test_attachment_corrupted_file_retried_as_hub_drop(self): + sha = hashlib.sha256(b"abc").hexdigest() + self.hub.files[sha] = b"xyz" # размер тот же, содержимое другое + self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "a.txt"}) + self.runner.loop() + r = self.hub.results[1] + self.assertEqual(r["status"], "failed") + self.assertTrue(r["error"].startswith("результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: "), + r["error"]) + self.assertEqual(self.hub.file_requests, 4) # первый запуск + 3 повтора + self.assertFalse(any(m == "POST" for m, _ in self.fake.requests)) + self.assertEqual(self.fake.attachments, {}) + + def test_attachment_file_gone_at_claim(self): + self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": "0" * 64, "filename": "a.txt"}) + self.runner.loop() + r = self.hub.results[1] + self.assertEqual((r["status"], r["error"]), ("failed", "файл удалён с хаба, загрузите заново")) + self.assertEqual(r["jira_calls"], 0) + self.assertEqual(self.fake.requests, []) + + def test_attachment_content_not_in_journal_or_log(self): + data = b"SECRET-CONTENT-" + bytes(range(256)) * 4 + sha = self.hub.put_file(data) + secret_b64 = base64.b64encode(data).decode("ascii") + self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "отчёт.bin"}) + logs = [] + orig = agent.log + agent.log = logs.append + try: + self.runner.loop() + finally: + agent.log = orig + self.assertEqual(self.hub.results[1]["status"], "done") + with open(self.journal.path, encoding="utf-8") as f: + text = f.read() + text += "\n".join(logs) + json.dumps(self.hub.results[1], ensure_ascii=False) + for needle in ("SECRET-CONTENT", secret_b64[:40], secret_b64[-40:]): + self.assertNotIn(needle, text) + self.assertTrue(any(sha in l and "отчёт.bin" in l for l in logs), logs) # file и filename — в логе + self.assertTrue(any(str(len(data)) in l for l in logs), logs) # размер — в логе + if __name__ == "__main__": unittest.main()