feat(agent): транспорт folder: — обмен с хабом через общую папку (#5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 00:45:16 +04:00
parent de0a3d9003
commit f9a2b7df38
4 changed files with 278 additions and 2 deletions

View File

@ -21,6 +21,7 @@ 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
@ -176,9 +177,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,

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()

117
tests/test_folder.py Normal file
View File

@ -0,0 +1,117 @@
import json
import os
import tempfile
import time
import unittest
from datetime import datetime, timezone
from _common import SETTINGS, agent
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")
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: