initial commit

This commit is contained in:
2026-03-16 06:52:12 +01:00
parent b496d726d3
commit 251dc829b3
14 changed files with 465 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.venv/

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# S01E05 — Railway Route Activator
Aplikacja aktywująca trasę kolejową **X-01** przez API `hub.ag3nts.org`.
Zadanie z kursu AI Devs 4 — zarządzanie jawnymi oraz niejawnymi limitami modeli.
## Architektura (Clean Architecture)
```
domain/ # Encje i porty (interfejsy)
├── entities.py # Route, RouteStatus, RouteMode, ApiResponse
└── ports.py # RailwayApiPort, EventBusPort
application/ # Use case'y (logika biznesowa)
└── activate_route.py
infrastructure/ # Implementacje
├── api_client.py # Klient API z retry i obsługą limitów
├── config.py # Konfiguracja z .env
├── event_bus.py # Szyna zdarzeń
└── logger_setup.py # Logowanie i monitoring zdarzeń
main.py # Punkt wejścia
```
### Koncepcje z lekcji
- **Event-driven architecture** — `EventBus` emituje zdarzenia (`api:request`, `api:response`, `api:retry`, `workflow:step`) umożliwiając monitoring i reakcję na zmiany stanu
- **Obsługa limitów API** — automatyczny retry z respektowaniem `retry_after` przy 429
- **Obsługa błędów 503** — retry z backoff (symulacja przeciążenia serwera)
- **Heartbeat** — logi informujące o postępie każdego kroku workflow
- **Logowanie interakcji** — każde wywołanie i odpowiedź API jest rejestrowane
- **Dependency Injection** — porty (abstrakcje) w `domain/`, implementacje w `infrastructure/`
## Uruchomienie
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Utwórz plik `.env`:
```env
RAILWAY_API_KEY=twoj-klucz-api
```
Uruchom:
```bash
python main.py # domyślnie trasa x-01
python main.py x-01 # lub jawnie podaj nazwę trasy
```
## Sekwencja API
1. `help` — pobranie dokumentacji API
2. `getstatus` — sprawdzenie bieżącego statusu trasy
3. `reconfigure` — włączenie trybu rekonfiguracji
4. `setstatus` (RTOPEN) — otwarcie trasy
5. `save` — zapisanie zmian, zwraca flagę

0
application/__init__.py Normal file
View File

View File

@@ -0,0 +1,96 @@
import logging
from dataclasses import dataclass
from domain.entities import Route, RouteMode, RouteStatus
from domain.ports import RailwayApiPort, EventBusPort
logger = logging.getLogger(__name__)
@dataclass
class ActivateRouteResult:
success: bool
route: Route
flag: str | None = None
error: str | None = None
class ActivateRouteUseCase:
"""Use case: aktywacja trasy kolejowej.
Realizuje sekwencję kroków zgodnie z dokumentacją API:
1. help — pobranie dokumentacji (weryfikacja dostępności)
2. getstatus — sprawdzenie bieżącego statusu trasy
3. reconfigure — włączenie trybu rekonfiguracji
4. setstatus — ustawienie statusu na RTOPEN
5. save — zapisanie zmian i wyjście z trybu rekonfiguracji
Zgodnie z lekcją — logika agenta opiera się na:
- pętli z jasno zdefiniowanymi krokami
- zdarzeniach informujących o postępie (heartbeat)
- obsłudze błędów z możliwością recovery
"""
def __init__(self, api: RailwayApiPort, event_bus: EventBusPort) -> None:
self._api = api
self._event_bus = event_bus
def execute(self, route_name: str) -> ActivateRouteResult:
route = Route(name=route_name)
try:
self._step("Pobieranie dokumentacji API (help)")
help_resp = self._api.send_action("help")
if not help_resp.ok:
return self._fail(route, "Nie udało się pobrać dokumentacji API")
logger.info("Dokumentacja API pobrana, dostępne akcje: %s",
[a["action"] for a in help_resp.data.get("help", {}).get("actions", [])])
self._step(f"Sprawdzanie statusu trasy {route_name}")
status_resp = self._api.send_action("getstatus", route=route_name)
if not status_resp.ok:
return self._fail(route, f"Nie udało się pobrać statusu: {status_resp.message}")
route.status = status_resp.data.get("status", "unknown")
route.mode = RouteMode(status_resp.data.get("mode", "normal"))
logger.info("Trasa %s: status=%s, mode=%s", route_name, route.status, route.mode.value)
if route.is_open:
logger.info("Trasa %s jest już otwarta!", route_name)
return ActivateRouteResult(success=True, route=route)
self._step(f"Włączanie trybu rekonfiguracji dla {route_name}")
reconf_resp = self._api.send_action("reconfigure", route=route_name)
if not reconf_resp.ok:
return self._fail(route, f"Reconfigure failed: {reconf_resp.message}")
route.mode = RouteMode.RECONFIGURE
logger.info("Tryb rekonfiguracji włączony")
self._step(f"Ustawianie statusu RTOPEN dla {route_name}")
set_resp = self._api.send_action("setstatus", route=route_name, value=RouteStatus.OPEN.value)
if not set_resp.ok:
return self._fail(route, f"Setstatus failed: {set_resp.message}")
route.status = "open"
logger.info("Status zmieniony na OPEN")
self._step(f"Zapisywanie konfiguracji trasy {route_name}")
save_resp = self._api.send_action("save", route=route_name)
route.mode = RouteMode.NORMAL
flag = save_resp.flag
if flag:
logger.info("Flaga znaleziona: %s", flag)
self._event_bus.emit("workflow:complete", {"flag": flag})
return ActivateRouteResult(success=True, route=route, flag=flag)
return ActivateRouteResult(success=True, route=route)
except RuntimeError as exc:
return self._fail(route, str(exc))
def _step(self, description: str) -> None:
self._event_bus.emit("workflow:step", {"description": description})
def _fail(self, route: Route, error: str) -> ActivateRouteResult:
logger.error("Błąd: %s", error)
self._event_bus.emit("workflow:error", {"error": error})
return ActivateRouteResult(success=False, route=route, error=error)

0
domain/__init__.py Normal file
View File

59
domain/entities.py Normal file
View File

@@ -0,0 +1,59 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class RouteStatus(Enum):
OPEN = "RTOPEN"
CLOSE = "RTCLOSE"
class RouteMode(Enum):
NORMAL = "normal"
RECONFIGURE = "reconfigure"
@dataclass
class Route:
name: str
mode: RouteMode = RouteMode.NORMAL
status: str = "close"
@property
def is_open(self) -> bool:
return self.status == "open"
@property
def is_reconfigurable(self) -> bool:
return self.mode == RouteMode.RECONFIGURE
@dataclass
class ApiResponse:
ok: bool
data: dict[str, Any]
http_code: int
headers: dict[str, str] = field(default_factory=dict)
@property
def is_rate_limited(self) -> bool:
return self.http_code == 429
@property
def is_server_error(self) -> bool:
return self.http_code == 503
@property
def retry_after(self) -> int:
return self.data.get("retry_after", 5)
@property
def message(self) -> str:
return self.data.get("message", "")
@property
def flag(self) -> str | None:
msg = self.message
if msg and "{FLG:" in msg:
return msg
return None

24
domain/ports.py Normal file
View File

@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
from typing import Any
from domain.entities import ApiResponse
class RailwayApiPort(ABC):
"""Port (interfejs) do komunikacji z API kolejowym."""
@abstractmethod
def send_action(self, action: str, **params: Any) -> ApiResponse:
...
class EventBusPort(ABC):
"""Port do architektury opartej o zdarzenia (event-driven)."""
@abstractmethod
def emit(self, event: str, data: dict[str, Any]) -> None:
...
@abstractmethod
def on(self, event: str, callback: Any) -> None:
...

View File

View File

@@ -0,0 +1,85 @@
import time
import logging
from typing import Any
import requests
from domain.entities import ApiResponse
from domain.ports import RailwayApiPort, EventBusPort
from infrastructure.config import Config
logger = logging.getLogger(__name__)
class RailwayApiClient(RailwayApiPort):
"""Implementacja klienta API kolejowego z obsługą:
- Retry z backoff przy błędach 503 (symulacja przeciążenia)
- Respektowanie limitów zapytań (429) z retry_after
- Logowanie każdego wywołania i odpowiedzi
- Emitowanie zdarzeń przez EventBus
"""
def __init__(self, config: Config, event_bus: EventBusPort) -> None:
self._config = config
self._event_bus = event_bus
self._session = requests.Session()
self._session.headers.update({"Content-Type": "application/json"})
def send_action(self, action: str, **params: Any) -> ApiResponse:
payload = {
"apikey": self._config.api_key,
"task": self._config.task_name,
"answer": {"action": action, **params},
}
for attempt in range(1, self._config.max_retries + 1):
self._event_bus.emit("api:request", {
"action": action,
"params": params,
"attempt": attempt,
})
try:
resp = self._session.post(self._config.api_url, json=payload, timeout=30)
except requests.RequestException as exc:
logger.error("Request failed: %s", exc)
self._wait(self._config.retry_base_delay * attempt)
continue
data = resp.json()
api_resp = ApiResponse(
ok=data.get("ok", False),
data=data,
http_code=resp.status_code,
headers=dict(resp.headers),
)
self._event_bus.emit("api:response", {
"action": action,
"http_code": resp.status_code,
"data": data,
})
if api_resp.is_server_error:
delay = self._config.retry_base_delay * attempt
logger.warning("503 Server outage (attempt %d/%d), retrying in %.1fs",
attempt, self._config.max_retries, delay)
self._event_bus.emit("api:retry", {"reason": "503", "delay": delay})
self._wait(delay)
continue
if api_resp.is_rate_limited:
delay = api_resp.retry_after + 1
logger.warning("429 Rate limited, waiting %ds", delay)
self._event_bus.emit("api:retry", {"reason": "429", "delay": delay})
self._wait(delay)
continue
return api_resp
raise RuntimeError(f"Action '{action}' failed after {self._config.max_retries} retries")
def _wait(self, seconds: float) -> None:
logger.info("Waiting %.1fs...", seconds)
time.sleep(seconds)

21
infrastructure/config.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
api_url: str
api_key: str
task_name: str
max_retries: int
retry_base_delay: float
@classmethod
def from_env(cls) -> "Config":
return cls(
api_url=os.getenv("RAILWAY_API_URL", "https://hub.ag3nts.org/verify"),
api_key=os.environ["RAILWAY_API_KEY"],
task_name=os.getenv("RAILWAY_TASK_NAME", "railway"),
max_retries=int(os.getenv("RAILWAY_MAX_RETRIES", "10")),
retry_base_delay=float(os.getenv("RAILWAY_RETRY_BASE_DELAY", "2.0")),
)

View File

@@ -0,0 +1,24 @@
from collections import defaultdict
from typing import Any, Callable
from domain.ports import EventBusPort
class EventBus(EventBusPort):
"""Prosta implementacja szyny zdarzeń (event bus).
Zgodnie z lekcją — architektura oparta o zdarzenia umożliwia:
- monitorowanie działań agenta
- podejmowanie akcji (np. kompresja kontekstu, logowanie)
- subskrypcję zdarzeń z różnych komponentów
"""
def __init__(self) -> None:
self._listeners: dict[str, list[Callable]] = defaultdict(list)
def emit(self, event: str, data: dict[str, Any]) -> None:
for callback in self._listeners[event]:
callback(data)
def on(self, event: str, callback: Callable) -> None:
self._listeners[event].append(callback)

View File

@@ -0,0 +1,40 @@
import logging
import sys
from typing import Any
from domain.ports import EventBusPort
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout,
)
def attach_event_logger(event_bus: EventBusPort) -> None:
"""Podłącza monitorowanie zdarzeń API — zgodnie z lekcją:
'warto zapisywać i monitorować wszystkie zdarzenia'."""
log = logging.getLogger("events")
def on_request(data: dict[str, Any]) -> None:
log.info(">>> [%s] attempt=%d params=%s",
data["action"], data["attempt"], data.get("params", {}))
def on_response(data: dict[str, Any]) -> None:
log.info("<<< [%s] http=%d data=%s",
data["action"], data["http_code"], data["data"])
def on_retry(data: dict[str, Any]) -> None:
log.warning("... retry reason=%s delay=%.1fs", data["reason"], data["delay"])
def on_step(data: dict[str, Any]) -> None:
log.info("=== STEP: %s", data.get("description", ""))
event_bus.on("api:request", on_request)
event_bus.on("api:response", on_response)
event_bus.on("api:retry", on_retry)
event_bus.on("workflow:step", on_step)

49
main.py Normal file
View File

@@ -0,0 +1,49 @@
"""Railway Route Activator — AI Devs 4, S01E05
Aplikacja aktywująca trasę kolejową X-01 przez API hub.ag3nts.org.
Zbudowana w clean architecture z uwzględnieniem koncepcji z lekcji:
- Architektura oparta o zdarzenia (EventBus)
- Obsługa limitów API (rate limiting, retry z backoff)
- Obsługa błędów 503 (symulacja przeciążenia serwera)
- Logowanie i monitorowanie wszystkich interakcji
- Konfiguracja przez zmienne środowiskowe
"""
import sys
from dotenv import load_dotenv
from infrastructure.config import Config
from infrastructure.event_bus import EventBus
from infrastructure.api_client import RailwayApiClient
from infrastructure.logger_setup import setup_logging, attach_event_logger
from application.activate_route import ActivateRouteUseCase
def main() -> None:
load_dotenv()
setup_logging()
config = Config.from_env()
event_bus = EventBus()
attach_event_logger(event_bus)
api_client = RailwayApiClient(config, event_bus)
use_case = ActivateRouteUseCase(api_client, event_bus)
route_name = sys.argv[1] if len(sys.argv) > 1 else "x-01"
result = use_case.execute(route_name)
if result.success:
print(f"\nTrasa {result.route.name} aktywowana pomyślnie!")
if result.flag:
print(f"Flaga: {result.flag}")
else:
print(f"\nBłąd: {result.error}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests
python-dotenv