Compare commits

..

2 Commits

Author SHA1 Message Date
Andrey Epifancev
b9a2260393 feat(agent): выбор транспорта по hub_url, check через папку, версия 2; README (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:48:42 +04:00
Andrey Epifancev
f9a2b7df38 feat(agent): транспорт folder: — обмен с хабом через общую папку (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:45:16 +04:00
8 changed files with 388 additions and 8 deletions

View File

@ -9,6 +9,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|---|---|---|---| |---|---|---|---|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` | | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `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` | файловая система | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |

View File

@ -15,7 +15,7 @@
Шаблон без секретов — `src/agent.example.json`. Шаблон без секретов — `src/agent.example.json`.
## Архитектура ## Архитектура
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`), - `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/changes/folder-transport/`),
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал → `Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`). выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`).
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`). - Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).

View File

@ -11,7 +11,7 @@
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`. 1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
2. Переименовать `agent.example.json` в `agent.json` и заполнить: 2. Переименовать `agent.example.json` в `agent.json` и заполнить:
- `hub_url` — `http://192.168.0.106:8790`; - `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini; - `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
- `jira.base_url` — адрес Jira банка; - `jira.base_url` — адрес Jira банка;
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`; - `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
@ -41,6 +41,19 @@ python3 agent.py check
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1. Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
Команда ничего не пишет в Jira. Команда ничего не пишет в Jira.
## Связь с хабом через общую папку
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
```json
"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"
```
Агент пишет каждый запрос к хабу файлом `req/<id>.json` в этой папке и ждёт ответ `resp/<id>.json` от реле хаба
(круг ≈ 0,8 с). Если ответа нет до срока — то же, что недоступный хаб по сети: агент пишет в лог и повторяет. Пароль Jira
в папку не попадает; в файлах запросов есть только токен хаба. `check` в этом режиме печатает
`хаб: доступен через папку …` или `НЕДОСТУПЕН` (нет папки; реле не ответило за 10 с).
## Запуск ## Запуск
В терминале: В терминале:
@ -137,7 +150,7 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 2) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. (`agent_version`) и в `/agent/state`.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.

View File

@ -0,0 +1,11 @@
# folder-transport — решения агента
Спека и дизайн: `jira-hub/openspec/changes/folder-transport/`. Здесь — решения, принятые при реализации агента.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-12 | `HubClient` остаётся наследником `Http`, при `folder:` переопределённый `call` делегирует `FolderTransport` | методы `claim/settings/...` и `Runner` не меняются; один класс для обоих транспортов | нет |
| 2026-10-12 | Агент создаёт `req/` и `resp/`, если их нет (корень папки должен существовать) | агент может стартовать раньше, чем реле подготовит папку | нет |
| 2026-10-13 | В белый список stdlib `tests/test_py39.py` добавлен `uuid` (R-2) | спека требует `uuid4().hex`, тест считал `uuid` сторонним модулем | нет |

View File

@ -4,6 +4,7 @@
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека, «проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md. Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md.
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
python3 agent.py check # доступ к хабу и Jira, без записи python3 agent.py check # доступ к хабу и Jira, без записи
python3 agent.py run # основной цикл (по умолчанию) python3 agent.py run # основной цикл (по умолчанию)
@ -21,10 +22,11 @@ import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
AGENT_VERSION = 1 AGENT_VERSION = 2
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2" API = "/rest/api/2"
AGILE = "/rest/agile/1.0" AGILE = "/rest/agile/1.0"
@ -49,6 +51,9 @@ def load_config(path: str) -> dict:
sys.exit("в %s нет обязательного поля %s" % (path, key)) sys.exit("в %s нет обязательного поля %s" % (path, key))
if not cfg["jira"].get("base_url"): if not cfg["jira"].get("base_url"):
sys.exit("в %s нет jira.base_url" % path) sys.exit("в %s нет jira.base_url" % path)
if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]):
sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например "
"folder:/Volumes/My Shared Files/RSHB/jira-bridge")
cfg.setdefault("timeout", 60) cfg.setdefault("timeout", 60)
cfg.setdefault("poll_seconds", 5) cfg.setdefault("poll_seconds", 5)
cfg.setdefault("verify_ssl", True) cfg.setdefault("verify_ssl", True)
@ -176,9 +181,93 @@ class JiraClient(Http):
return self.call("DELETE", path) return self.call("DELETE", path)
class FolderTransport:
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:
нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport."""
POLL = 0.1
BAD_JSON_WAIT = 2.0
def __init__(self, root: str, authorization: str):
self.root, self.authorization = root, authorization
def __repr__(self):
return "<FolderTransport %s>" % self.root
def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any:
if not os.path.isdir(self.root):
raise ConnectionError("нет папки %s" % self.root)
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
rid = uuid.uuid4().hex
try:
os.makedirs(req_dir, exist_ok=True)
os.makedirs(resp_dir, exist_ok=True)
deadline = time.time() + timeout
self._write(req_dir, rid + ".json", {
"id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization,
"deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()})
except OSError as e:
raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None
resp_path = os.path.join(resp_dir, rid + ".json")
end = time.monotonic() + timeout
while time.monotonic() < end:
if os.path.exists(resp_path):
return self._take(resp_path)
time.sleep(self.POLL)
try:
os.remove(os.path.join(req_dir, rid + ".json"))
except OSError:
pass
raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout))
@staticmethod
def _write(directory: str, name: str, payload: dict) -> None:
tmp = os.path.join(directory, "." + name + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, os.path.join(directory, name))
def _take(self, path: str) -> Any:
end = time.monotonic() + self.BAD_JSON_WAIT
while True:
try:
with open(path, encoding="utf-8") as f:
resp = json.load(f)
if not isinstance(resp, dict):
raise ValueError("ответ не объект")
break
except (ValueError, OSError):
if time.monotonic() >= end:
raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None
time.sleep(self.POLL)
try:
os.remove(path)
except OSError:
pass
status, text = int(resp.get("status") or 0), resp.get("body") or ""
if not 200 <= status < 300:
raise HttpError(status, text)
try:
return json.loads(text) if text else {}
except ValueError as e:
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None
class HubClient(Http): class HubClient(Http):
def __init__(self, cfg: dict): def __init__(self, cfg: dict):
super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60)) auth = "Bearer " + cfg["hub_token"]
super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60))
url = cfg["hub_url"]
self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
if self.folder is None:
return super().call(method, path, body, params, timeout)
if params:
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
return self.folder.call(method, path, body, timeout or self.timeout)
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
@ -1026,9 +1115,14 @@ def cmd_run(cfg: dict, journal_path: str) -> int:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cmd_check(cfg: dict) -> int: def cmd_check(cfg: dict) -> int:
ok = True ok = True
hub = HubClient(cfg)
via = ""
if hub.folder is not None:
hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен
via = " через папку %s" % hub.folder.root
try: try:
s = HubClient(cfg).settings() s = hub.settings()
log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key"))) log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
except (ConnectionError, HttpError) as e: except (ConnectionError, HttpError) as e:
log("хаб: НЕДОСТУПЕН — %s" % e) log("хаб: НЕДОСТУПЕН — %s" % e)
ok = False ok = False

74
tests/fake_relay.py Normal file
View File

@ -0,0 +1,74 @@
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
import json
import os
import threading
import time
import urllib.error
import urllib.request
def write_atomic(directory, name, payload):
tmp = os.path.join(directory, "." + name + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
os.replace(tmp, os.path.join(directory, name))
class FakeRelay:
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
self.requests = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True)
for sub in ("req", "resp"):
os.makedirs(os.path.join(root, sub), exist_ok=True)
def start(self):
self._thread.start()
return self
def stop(self):
self._stop.set()
self._thread.join(5)
def _loop(self):
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
while not self._stop.is_set():
for name in sorted(os.listdir(req_dir)):
if name.startswith(".") or not name.endswith(".json"):
continue
path = os.path.join(req_dir, name)
try:
with open(path, encoding="utf-8") as f:
req = json.load(f)
os.remove(path)
except (OSError, ValueError):
continue
self.requests.append(req)
if self.mode == "silent":
continue
if self.mode == "garbage":
write_atomic(resp_dir, name, "{не json")
continue
if self.mode == "late":
time.sleep(self.delay)
status, body = self._forward(req)
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
time.sleep(0.02)
def _forward(self, req):
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
r.add_header("Authorization", req["authorization"])
if data is not None:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=10) as resp:
return resp.status, resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
try:
return e.code, e.read().decode("utf-8")
finally:
e.close()

187
tests/test_folder.py Normal file
View File

@ -0,0 +1,187 @@
import io
import json
import os
import tempfile
import time
import unittest
from contextlib import redirect_stdout
from datetime import datetime, timezone
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira
from fake_hub import FakeHub
from fake_relay import FakeRelay
class FolderTransportTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути
os.makedirs(self.root)
self.hub = FakeHub(SETTINGS)
self.relay = None
def tearDown(self):
if self.relay:
self.relay.stop()
self.hub.stop()
self.tmp.cleanup()
def client(self, timeout=5):
cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
return agent.HubClient(cfg)
def run_relay(self, mode="forward", delay=0.0):
self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
return self.relay
def listing(self, sub):
return sorted(os.listdir(os.path.join(self.root, sub)))
def test_settings_through_folder(self):
self.run_relay()
hub = self.client()
self.assertIsNotNone(hub.folder)
self.assertEqual(hub.settings()["project_key"], "AURORA")
self.assertEqual(self.listing("resp"), [])
self.assertEqual(self.listing("req"), [])
req = self.relay.requests[0]
self.assertEqual(req["method"], "GET")
self.assertEqual(req["path"], "/agent/settings")
self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
self.assertEqual(len(req["id"]), 32)
def test_error_status_raises_http_error(self):
self.run_relay()
with self.assertRaises(agent.HttpError) as e:
self.client().result(999, {"status": "done"})
self.assertEqual(e.exception.code, 404)
def test_claim_deadline_at_least_45_seconds(self):
self.run_relay()
before = time.time()
self.client(timeout=5).claim(2, 1, None)
deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
self.assertIsNotNone(deadline.tzinfo)
self.assertGreaterEqual(deadline.timestamp() - before, 44.5)
def test_no_answer_gives_connection_error_and_removes_request(self):
hub = self.client(timeout=0.5)
t0 = time.monotonic()
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertLess(time.monotonic() - t0, 2)
self.assertIn("не ответил через папку", str(e.exception))
self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])
def test_missing_folder_gives_connection_error(self):
cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
with self.assertRaises(ConnectionError) as e:
agent.HubClient(cfg).settings()
self.assertIn("нет папки", str(e.exception))
def test_bad_json_response_gives_connection_error(self):
self.run_relay("garbage")
hub = self.client()
hub.folder.BAD_JSON_WAIT = 0.3
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertIn("нечитаемый ответ", str(e.exception))
def test_late_response_is_not_reused(self):
self.run_relay("late", delay=1.0)
with self.assertRaises(ConnectionError):
self.client(timeout=0.3).settings()
time.sleep(1.3) # опоздавший ответ лёг в resp/
late = self.listing("resp")
self.assertEqual(len(late), 1)
self.relay.mode = "forward"
self.assertEqual(self.client().settings()["project_key"], "AURORA")
self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут
def test_foreign_files_untouched(self):
resp_dir = os.path.join(self.root, "resp")
os.makedirs(resp_dir, exist_ok=True)
foreign = os.path.join(resp_dir, "0" * 32 + ".json")
with open(foreign, "w", encoding="utf-8") as f:
json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
self.run_relay()
self.client().settings()
self.assertTrue(os.path.exists(foreign))
def test_http_url_keeps_http_transport(self):
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
hub = agent.HubClient(cfg)
self.assertIsNone(hub.folder)
self.assertEqual(hub.settings()["project_key"], "AURORA")
class FolderIntegrationTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
os.makedirs(self.root)
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
self.relay = FakeRelay(self.root, self.hub.base_url).start()
def tearDown(self):
self.relay.stop()
self.fake.stop()
self.hub.stop()
self.tmp.cleanup()
def cfg(self):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version_is_2(self):
self.assertEqual(agent.AGENT_VERSION, 2)
def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20")
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
cfg = self.cfg()
journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), journal)
runner.sleep = lambda s: None
runner.jira.sleep = no_sleep
runner.idle_limit = 1
runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "done")
self.assertEqual(self.hub.results[cid]["agent_version"], 2)
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23")
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])
def test_check_reports_folder(self):
out = io.StringIO()
with redirect_stdout(out):
code = agent.cmd_check(self.cfg())
self.assertEqual(code, 0)
self.assertIn("хаб: доступен через папку %s, настройки v1, проект AURORA" % self.root, out.getvalue())
def test_check_folder_silent_relay(self):
self.relay.stop()
self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
out = io.StringIO()
t0 = time.monotonic()
with redirect_stdout(out):
code = agent.cmd_check(self.cfg())
self.assertEqual(code, 1)
self.assertLess(time.monotonic() - t0, 13)
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
self.assertIn("не ответил через папку", out.getvalue())
def test_relative_folder_url_rejected(self):
path = os.path.join(self.tmp.name, "agent.json")
for url in ("folder:relative/path", "folder:"):
cfg = self.cfg()
cfg["hub_url"] = url
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f)
with self.assertRaises(SystemExit) as e:
agent.load_config(path)
self.assertIn("абсолютный путь", str(e.exception.code))
if __name__ == "__main__":
unittest.main()

View File

@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
def test_no_third_party_imports(self): def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
tree = ast.parse(f.read()) tree = ast.parse(f.read())
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing"} allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid"}
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for a in node.names: for a in node.names: