eva-agent/tests/fake_hub.py
Andrey Epifancev 67f1a5a096 Каркас eva-agent: общие части jira-agent v14 (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:40:33 +04:00

159 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import base64
import hashlib
import json
import re
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class FakeHub:
token = "agent-token"
def __init__(self, settings):
self.settings = dict(settings)
self.queue = []
self.commands = {}
self.results = {}
self.claims = 0
self.claim_bodies = []
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.files = {} # sha256 → содержимое (хранилище файлов хаба)
self.file_requests = 0
self.pages = [] # тела POST /agent/wiki/pages
self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
self.lock = threading.Lock()
hub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
hub.handle(self, "GET")
def do_POST(self):
hub.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def enqueue(self, ctype, params, pause=0):
cid = len(self.commands) + 1
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
self.queue.append(cid)
return cid
def put_file(self, data):
sha = hashlib.sha256(data).hexdigest()
self.files[sha] = data
return sha
def _issued(self, c):
return dict(c["params"])
def _release_ref(self):
r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None
def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"})
path = h.path.split("?")[0]
if path == "/agent/files" and method == "POST" and self.reject_files == "drop":
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR)
return
if path == "/agent/claim" and self.claim_wait:
end = time.monotonic() + self.claim_wait
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
with self.lock:
if self.queue:
break
time.sleep(0.05)
with self.lock:
code, payload = self.route(method, path, body)
self._send(h, code, payload)
def _send(self, h, code, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
h.send_response(code)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(data)))
h.end_headers()
h.wfile.write(data)
def route(self, method, path, body):
if path == "/agent/claim":
self.claim_bodies.append(body or {})
self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release":
self.release_requests += 1
return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings":
return 200, self.settings
if path == "/agent/wiki/pages" and method == "POST":
if self.reject_pages:
return self.reject_pages
self.pages.append(body)
return 200, {"accepted": len(body.get("pages") or [])}
if path == "/agent/files" and method == "POST":
if self.reject_files:
return self.reject_files
data = base64.b64decode(body.get("content") or "", validate=True)
self.uploaded.append(data)
return 200, {"file": self.put_file(data), "size": len(data)}
m = re.match(r"^/agent/commands/(\d+)$", path)
if m:
c = self.commands.get(int(m.group(1)))
return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m:
cid = int(m.group(1))
if self.fail_results > 0:
self.fail_results -= 1
return 503, {"error": "busy"}
if cid in self.reject_results:
return self.reject_results[cid], {"error": "rejected"}
if cid not in self.commands:
return 404, {"error": "not_found"}
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
self.results[cid] = body
self.commands[cid]["status"] = body.get("status")
if self.in_flight == cid:
self.in_flight = None
return 200, self.commands[cid]
return 404, {"error": "not_found", "detail": path}