| """ |
| FastAPI server for the Incident Post-Mortem Writer environment. |
| Exposes all required OpenEnv endpoints: |
| REST: /health /reset /step /state /tasks /grade /docs |
| WebSocket: /ws (persistent session — required by OpenEnv spec) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import logging |
| import os |
| import uuid |
| from typing import Any, Dict |
|
|
| from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
|
|
| from env.models import Action, ActionType, SectionName |
| from server.environment import PostMortemEnvironment |
|
|
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| app = FastAPI( |
| title="Incident Post-Mortem Writer", |
| description=( |
| "An OpenEnv environment where an AI agent learns to write structured " |
| "incident post-mortems from raw alert logs and Slack threads. " |
| "3 difficulty levels: easy, medium, hard." |
| ), |
| version="1.0.0", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| MAX_CONCURRENT_ENVS = int(os.environ.get("MAX_CONCURRENT_ENVS", "100")) |
| _sessions: Dict[str, PostMortemEnvironment] = {} |
|
|
| _http_envs: Dict[str, PostMortemEnvironment] = { |
| "easy": PostMortemEnvironment(difficulty="easy"), |
| "medium": PostMortemEnvironment(difficulty="medium"), |
| "hard": PostMortemEnvironment(difficulty="hard"), |
| "expert": PostMortemEnvironment(difficulty="expert"), |
| } |
| _active_difficulty = os.environ.get("DIFFICULTY", "easy") |
|
|
|
|
| def _get_http_env() -> PostMortemEnvironment: |
| return _http_envs[_active_difficulty] |
|
|
|
|
| class ResetRequest(BaseModel): |
| difficulty: str = "easy" |
|
|
|
|
| class ActionRequest(BaseModel): |
| action_type: str |
| section_name: str | None = None |
| section_content: str | None = None |
| query_service: str | None = None |
| query_from: str | None = None |
| query_to: str | None = None |
| action_item_description: str | None = None |
| action_item_owner: str | None = None |
| action_item_due_date: str | None = None |
|
|
|
|
| def _parse_action(req: ActionRequest) -> Action: |
| try: |
| action_type = ActionType(req.action_type) |
| except ValueError: |
| raise HTTPException( |
| status_code=400, |
| detail=f"Invalid action_type '{req.action_type}'. Valid: {[a.value for a in ActionType]}" |
| ) |
| section_name = None |
| if req.section_name: |
| try: |
| section_name = SectionName(req.section_name) |
| except ValueError: |
| raise HTTPException( |
| status_code=400, |
| detail=f"Invalid section_name '{req.section_name}'. Valid: {[s.value for s in SectionName]}" |
| ) |
| return Action( |
| action_type=action_type, |
| section_name=section_name, |
| section_content=req.section_content, |
| query_service=req.query_service, |
| query_from=req.query_from, |
| query_to=req.query_to, |
| action_item_description=req.action_item_description, |
| action_item_owner=req.action_item_owner, |
| action_item_due_date=req.action_item_due_date, |
| ) |
|
|
|
|
| def _parse_action_dict(data: dict) -> Action: |
| req = ActionRequest(**{k: v for k, v in data.items() if k != "command"}) |
| return _parse_action(req) |
|
|
|
|
| @app.on_event("startup") |
| async def startup(): |
| logger.info("Incident Post-Mortem Writer environment starting...") |
| logger.info(f"MAX_CONCURRENT_ENVS={MAX_CONCURRENT_ENVS}") |
| logger.info("Scenarios loaded: easy, medium, hard. Server ready.") |
|
|
|
|
| @app.on_event("shutdown") |
| async def shutdown(): |
| logger.info(f"Shutting down. Cleaning {len(_sessions)} sessions.") |
| _sessions.clear() |
|
|
|
|
| @app.get("/health") |
| def health() -> Dict[str, Any]: |
| """Health check — required by OpenEnv validator.""" |
| return { |
| "status": "healthy", |
| "version": "1.0.0", |
| "active_sessions": len(_sessions), |
| "max_sessions": MAX_CONCURRENT_ENVS, |
| } |
|
|
|
|
| @app.post("/reset") |
| def reset(req: ResetRequest = ResetRequest()) -> Dict[str, Any]: |
| """Reset environment. difficulty: easy | medium | hard""" |
| global _active_difficulty |
| if req.difficulty not in _http_envs: |
| raise HTTPException(status_code=400, detail=f"difficulty must be one of {list(_http_envs.keys())}") |
| _active_difficulty = req.difficulty |
| result = _get_http_env().reset() |
| return result.dict() |
|
|
|
|
| @app.post("/step") |
| def step(req: ActionRequest) -> Dict[str, Any]: |
| """Execute one action in the environment.""" |
| action = _parse_action(req) |
| result = _get_http_env().step(action) |
| return result.dict() |
|
|
|
|
| @app.get("/state") |
| def state() -> Dict[str, Any]: |
| """Return current episode state.""" |
| return _get_http_env().state |
|
|
|
|
| @app.get("/tasks") |
| def list_tasks() -> Dict[str, Any]: |
| """List all available tasks.""" |
| return { |
| "tasks": [ |
| {"id": "easy", "name": "Single-Service Database Outage", "difficulty": "easy", "max_steps": 25, "max_queries": 8}, |
| {"id": "medium", "name": "Cascading Microservices Failure", "difficulty": "medium", "max_steps": 25, "max_queries": 8}, |
| {"id": "hard", "name": "Multi-Service Degradation with False Causes","difficulty": "hard", "max_steps": 25, "max_queries": 8}, |
| {"id": "expert", "name": "Security Breach — Compromised API Credential", "difficulty": "expert", "max_steps": 25, "max_queries": 6}, |
| ] |
| } |
|
|
|
|
| @app.post("/grade") |
| def grade_current() -> Dict[str, Any]: |
| """Return current grade without ending the episode.""" |
| env = _get_http_env() |
| if env._grade_result: |
| return env._grade_result.dict() |
| return {"message": "No grade yet. Call SUBMIT action to trigger grading."} |
|
|
|
|
| @app.get("/") |
| def root() -> Dict[str, str]: |
| return {"name": "Incident Post-Mortem Writer", "version": "1.0.0", |
| "docs": "/docs", "health": "/health", "tasks": "/tasks", "websocket": "/ws"} |
|
|
|
|
| @app.websocket("/ws") |
| async def websocket_endpoint(websocket: WebSocket): |
| """ |
| Persistent WebSocket session — each connection gets its own isolated env. |
| |
| Client sends JSON: |
| {"command": "reset", "difficulty": "easy"} |
| {"command": "step", "action_type": "QUERY_LOGS", "query_service": "payments", ...} |
| {"command": "state"} |
| {"command": "close"} |
| |
| Server responds with JSON: |
| {"type": "reset_result", "data": {...}} |
| {"type": "step_result", "data": {...}} |
| {"type": "state_result", "data": {...}} |
| {"type": "error", "message": "..."} |
| """ |
| if len(_sessions) >= MAX_CONCURRENT_ENVS: |
| await websocket.close(code=1013, reason="Max concurrent sessions reached") |
| return |
|
|
| await websocket.accept() |
| session_id = str(uuid.uuid4())[:8] |
| session_env = PostMortemEnvironment(difficulty="easy") |
| _sessions[session_id] = session_env |
| logger.info(f"WS session {session_id} opened. Active: {len(_sessions)}") |
|
|
| try: |
| await websocket.send_json({ |
| "type": "connected", |
| "session_id": session_id, |
| "message": "Send {\"command\": \"reset\", \"difficulty\": \"easy\"} to start.", |
| }) |
|
|
| while True: |
| try: |
| raw = await asyncio.wait_for(websocket.receive_text(), timeout=300) |
| except asyncio.TimeoutError: |
| await websocket.send_json({"type": "error", "message": "Session timeout."}) |
| break |
|
|
| try: |
| msg = json.loads(raw) |
| except json.JSONDecodeError: |
| await websocket.send_json({"type": "error", "message": "Invalid JSON."}) |
| continue |
|
|
| command = msg.get("command", "") |
|
|
| if command == "reset": |
| difficulty = msg.get("difficulty", "easy") |
| if difficulty not in ["easy", "medium", "hard"]: |
| await websocket.send_json({"type": "error", "message": f"Invalid difficulty '{difficulty}'."}) |
| continue |
| session_env.difficulty = difficulty |
| result = session_env.reset() |
| await websocket.send_json({"type": "reset_result", "data": result.dict()}) |
|
|
| elif command == "step": |
| try: |
| action = _parse_action_dict(msg) |
| result = session_env.step(action) |
| await websocket.send_json({"type": "step_result", "data": result.dict()}) |
| except HTTPException as e: |
| await websocket.send_json({"type": "error", "message": e.detail}) |
| except Exception as e: |
| await websocket.send_json({"type": "error", "message": str(e)}) |
|
|
| elif command == "state": |
| await websocket.send_json({"type": "state_result", "data": session_env.state}) |
|
|
| elif command == "close": |
| await websocket.send_json({"type": "closed", "message": "Session closed."}) |
| break |
|
|
| else: |
| await websocket.send_json({"type": "error", "message": f"Unknown command '{command}'. Valid: reset, step, state, close"}) |
|
|
| except WebSocketDisconnect: |
| logger.info(f"WS session {session_id} disconnected.") |
| except Exception as e: |
| logger.error(f"WS session {session_id} error: {e}") |
| finally: |
| _sessions.pop(session_id, None) |
| logger.info(f"Session {session_id} cleaned up. Active: {len(_sessions)}") |
|
|
|
|
| def main(): |
| """Entry point for the OpenEnv server — callable via project.scripts.""" |
| import os |
| import uvicorn |
| uvicorn.run( |
| "server.app:app", |
| host=os.environ.get("HOST", "0.0.0.0"), |
| port=int(os.environ.get("PORT", "7860")), |
| workers=int(os.environ.get("WORKERS", "1")), |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|