Spaces:
Sleeping
Sleeping
feat: ARIA Incident Generator — procedural incidents from seeds
Browse files- api.py +278 -11
- curriculum/__init__.py +3 -0
- curriculum/engine.py +108 -0
- generator/__init__.py +2 -0
- generator/incident_factory.py +79 -0
- tasks/task_generated.py +165 -0
- validate.py +3 -3
api.py
CHANGED
|
@@ -4,14 +4,20 @@ from fastapi.responses import HTMLResponse
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from typing import Optional
|
|
|
|
| 7 |
from env import DevOpsIncidentEnv
|
| 8 |
from models import Action, ActionType, Observation, StepResult, State
|
| 9 |
from collections import deque
|
| 10 |
from datetime import datetime
|
| 11 |
import uuid
|
| 12 |
import statistics
|
|
|
|
|
|
|
| 13 |
|
|
|
|
|
|
|
| 14 |
episode_history = deque(maxlen=1000)
|
|
|
|
| 15 |
|
| 16 |
def track_episode(state_obj: State):
|
| 17 |
from graders.grader import grade_episode
|
|
@@ -56,8 +62,9 @@ app = FastAPI(
|
|
| 56 |
description=(
|
| 57 |
"An OpenEnv-compliant RL environment where AI agents diagnose and remediate "
|
| 58 |
"production software incidents across a simulated microservices architecture. "
|
| 59 |
-
"
|
| 60 |
-
"
|
|
|
|
| 61 |
),
|
| 62 |
version="1.0.0",
|
| 63 |
)
|
|
@@ -78,6 +85,11 @@ class ResetRequest(BaseModel):
|
|
| 78 |
seed: Optional[int] = None
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@app.get("/", response_class=HTMLResponse)
|
| 82 |
def dashboard():
|
| 83 |
env_state = None
|
|
@@ -134,6 +146,29 @@ def dashboard():
|
|
| 134 |
.task {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.25rem; }}
|
| 135 |
.task h3 {{ margin: 0 0 0.5rem; color: #ff6b35; font-size: 1rem; }}
|
| 136 |
.task p {{ margin: 0; color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
.badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-bottom: 0.5rem; }}
|
| 138 |
.easy {{ background: #1a3a1a; color: #4caf50; }}
|
| 139 |
.medium {{ background: #3a2a1a; color: #ff9800; }}
|
|
@@ -149,6 +184,12 @@ def dashboard():
|
|
| 149 |
.path {{ color: #81c784; font-family: monospace; font-size: 0.85rem; }}
|
| 150 |
.desc {{ color: #888; font-size: 0.8rem; }}
|
| 151 |
.footer {{ color: #555; font-size: 0.8rem; text-align: center; margin-top: 2rem; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</style>
|
| 153 |
</head>
|
| 154 |
<body>
|
|
@@ -196,6 +237,41 @@ def dashboard():
|
|
| 196 |
<p>Partial region failure. Discriminate between services that support auto-failover and those that require human escalation. Max 25 steps.</p>
|
| 197 |
</div>
|
| 198 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
<div class="endpoints">
|
| 201 |
<h3>API Endpoints</h3>
|
|
@@ -222,7 +298,7 @@ def dashboard():
|
|
| 222 |
<div class="endpoint">
|
| 223 |
<span class="method">GET</span>
|
| 224 |
<span class="path">/validate</span>
|
| 225 |
-
<span class="desc">Self-validation report for all
|
| 226 |
</div>
|
| 227 |
<div class="endpoint">
|
| 228 |
<span class="method">GET</span>
|
|
@@ -239,6 +315,118 @@ def dashboard():
|
|
| 239 |
<a href="/metrics" style="color:#ff6b35;">Metrics</a> |
|
| 240 |
<a href="/leaderboard" style="color:#ff6b35;">Leaderboard</a>
|
| 241 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
</body>
|
| 243 |
</html>"""
|
| 244 |
return html
|
|
@@ -249,18 +437,38 @@ def health():
|
|
| 249 |
return {"status": "ok", "env": "devops-incident-response", "version": "1.0.0"}
|
| 250 |
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
@app.post("/reset", response_model=Observation)
|
| 253 |
def reset(req: Optional[ResetRequest] = None):
|
| 254 |
if req is None:
|
| 255 |
req = ResetRequest()
|
| 256 |
global _env
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
@app.post("/step", response_model=StepResult)
|
|
@@ -269,7 +477,21 @@ def step(action: Action):
|
|
| 269 |
raise HTTPException(status_code=400, detail="Call /reset before /step")
|
| 270 |
res = _env.step(action)
|
| 271 |
if res.done:
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
return res
|
| 274 |
|
| 275 |
|
|
@@ -352,10 +574,55 @@ def list_tasks():
|
|
| 352 |
"and which do not. Failing over the wrong services causes severe data inconsistency penalties."
|
| 353 |
),
|
| 354 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
]
|
| 356 |
}
|
| 357 |
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
@app.get("/validate")
|
| 360 |
def validate():
|
| 361 |
"""
|
|
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from typing import Optional
|
| 7 |
+
from curriculum import CurriculumEngine
|
| 8 |
from env import DevOpsIncidentEnv
|
| 9 |
from models import Action, ActionType, Observation, StepResult, State
|
| 10 |
from collections import deque
|
| 11 |
from datetime import datetime
|
| 12 |
import uuid
|
| 13 |
import statistics
|
| 14 |
+
from generator import IncidentFactory
|
| 15 |
+
from tasks.task_generated import GeneratedTask
|
| 16 |
|
| 17 |
+
curriculum_engine = CurriculumEngine()
|
| 18 |
+
episode_tracker: dict = {} # tracks active episode task_id per session
|
| 19 |
episode_history = deque(maxlen=1000)
|
| 20 |
+
_factory = IncidentFactory()
|
| 21 |
|
| 22 |
def track_episode(state_obj: State):
|
| 23 |
from graders.grader import grade_episode
|
|
|
|
| 62 |
description=(
|
| 63 |
"An OpenEnv-compliant RL environment where AI agents diagnose and remediate "
|
| 64 |
"production software incidents across a simulated microservices architecture. "
|
| 65 |
+
"Seven tasks of escalating difficulty: OOM crash-loop, cascading failure, "
|
| 66 |
+
"silent data corruption, dual simultaneous failure, DDoS attack, database "
|
| 67 |
+
"degradation, and multi-region failover."
|
| 68 |
),
|
| 69 |
version="1.0.0",
|
| 70 |
)
|
|
|
|
| 85 |
seed: Optional[int] = None
|
| 86 |
|
| 87 |
|
| 88 |
+
class CurriculumRecordRequest(BaseModel):
|
| 89 |
+
task_id: str
|
| 90 |
+
score: float
|
| 91 |
+
|
| 92 |
+
|
| 93 |
@app.get("/", response_class=HTMLResponse)
|
| 94 |
def dashboard():
|
| 95 |
env_state = None
|
|
|
|
| 146 |
.task {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.25rem; }}
|
| 147 |
.task h3 {{ margin: 0 0 0.5rem; color: #ff6b35; font-size: 1rem; }}
|
| 148 |
.task p {{ margin: 0; color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
| 149 |
+
.curriculum-section {{ background: #121621; border: 1px solid #2d3148; border-radius: 10px; padding: 1.25rem; margin-bottom: 2rem; }}
|
| 150 |
+
.curriculum-section h3 {{ margin: 0 0 0.35rem; color: #fff; }}
|
| 151 |
+
.curriculum-section p {{ margin: 0 0 1rem; color: #7f8799; font-size: 0.85rem; }}
|
| 152 |
+
.curriculum-meta {{ color: #9aa4b2; font-size: 0.8rem; margin-bottom: 0.75rem; }}
|
| 153 |
+
.curriculum-table {{ display: flex; flex-direction: column; gap: 0.65rem; }}
|
| 154 |
+
.curriculum-header, .curriculum-row {{ display: grid; grid-template-columns: minmax(90px, 1fr) 110px minmax(180px, 1.4fr) minmax(160px, 1fr); gap: 1rem; align-items: center; }}
|
| 155 |
+
.curriculum-header {{ color: #7f8799; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.35rem; border-bottom: 1px solid #273042; }}
|
| 156 |
+
.curriculum-row {{ background: #171c29; border: 1px solid #242d40; border-radius: 8px; padding: 0.9rem 1rem; }}
|
| 157 |
+
.curriculum-task-name {{ font-weight: 600; color: #f5f7fa; text-transform: capitalize; }}
|
| 158 |
+
.curriculum-stars {{ font-size: 1rem; letter-spacing: 0.12em; color: #ffd166; }}
|
| 159 |
+
.curriculum-bar-wrap {{ display: flex; align-items: center; gap: 0.75rem; }}
|
| 160 |
+
.curriculum-bar {{ flex: 1; height: 0.7rem; background: #222a3b; border-radius: 999px; overflow: hidden; border: 1px solid #303a4f; }}
|
| 161 |
+
.curriculum-fill {{ height: 100%; border-radius: 999px; transition: width 0.3s ease; }}
|
| 162 |
+
.curriculum-score {{ min-width: 3rem; text-align: right; color: #d5dbe3; font-size: 0.8rem; font-variant-numeric: tabular-nums; }}
|
| 163 |
+
.curriculum-badges {{ display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-start; }}
|
| 164 |
+
.curriculum-pill {{ display: inline-block; padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; }}
|
| 165 |
+
.scaffold-pill {{ background: rgba(239, 83, 80, 0.18); color: #ff8a80; border: 1px solid rgba(239, 83, 80, 0.35); }}
|
| 166 |
+
.recommended-pill {{ background: rgba(102, 187, 106, 0.18); color: #8de28f; border: 1px solid rgba(102, 187, 106, 0.35); }}
|
| 167 |
+
.curriculum-loading, .curriculum-error {{ color: #9aa4b2; font-size: 0.9rem; padding: 0.25rem 0; }}
|
| 168 |
+
@media (max-width: 900px) {{
|
| 169 |
+
.curriculum-header {{ display: none; }}
|
| 170 |
+
.curriculum-row {{ grid-template-columns: 1fr; gap: 0.7rem; }}
|
| 171 |
+
}}
|
| 172 |
.badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-bottom: 0.5rem; }}
|
| 173 |
.easy {{ background: #1a3a1a; color: #4caf50; }}
|
| 174 |
.medium {{ background: #3a2a1a; color: #ff9800; }}
|
|
|
|
| 184 |
.path {{ color: #81c784; font-family: monospace; font-size: 0.85rem; }}
|
| 185 |
.desc {{ color: #888; font-size: 0.8rem; }}
|
| 186 |
.footer {{ color: #555; font-size: 0.8rem; text-align: center; margin-top: 2rem; }}
|
| 187 |
+
#incident-generator {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; }}
|
| 188 |
+
#incident-result {{ margin-top: 1.5rem; padding: 1rem; background: #0f1117; border-radius: 6px; border-left: 4px solid #ff6b35; }}
|
| 189 |
+
.badge-mono {{ font-family: monospace; background: #333; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }}
|
| 190 |
+
.difficulty-bar-container {{ height: 8px; background: #333; border-radius: 4px; margin: 10px 0; }}
|
| 191 |
+
.difficulty-bar {{ height: 100%; border-radius: 4px; transition: width 0.5s ease; }}
|
| 192 |
+
.tag {{ font-size: 0.75rem; color: #888; background: #222; padding: 0.1rem 0.4rem; border-radius: 10px; margin-right: 5px; }}
|
| 193 |
</style>
|
| 194 |
</head>
|
| 195 |
<body>
|
|
|
|
| 237 |
<p>Partial region failure. Discriminate between services that support auto-failover and those that require human escalation. Max 25 steps.</p>
|
| 238 |
</div>
|
| 239 |
</div>
|
| 240 |
+
|
| 241 |
+
<div id="incident-generator">
|
| 242 |
+
<h3 style="margin-top:0; color:#ff6b35;">ARIA Incident Generator</h3>
|
| 243 |
+
<div style="display:flex; gap:10px; align-items:center;">
|
| 244 |
+
<input type="number" id="seed-input" min="0" max="99999" value="42"
|
| 245 |
+
style="background:#0f1117; color:#fff; border:1px solid #2d3148; padding:8px; border-radius:4px; width:100px;">
|
| 246 |
+
<button onclick="generateIncident()"
|
| 247 |
+
style="background:#ff6b35; color:#fff; border:none; padding:8px 16px; border-radius:4px; cursor:pointer; font-weight:600;">
|
| 248 |
+
Generate Incident
|
| 249 |
+
</button>
|
| 250 |
+
</div>
|
| 251 |
+
<div id="incident-result" style="display:none;">
|
| 252 |
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
| 253 |
+
<span id="res-id" class="badge-mono"></span>
|
| 254 |
+
<div id="res-badges"></div>
|
| 255 |
+
</div>
|
| 256 |
+
<p id="res-affected" style="margin:5px 0; font-weight:600;"></p>
|
| 257 |
+
<div class="difficulty-bar-container">
|
| 258 |
+
<div id="res-diff-bar" class="difficulty-bar"></div>
|
| 259 |
+
</div>
|
| 260 |
+
<p id="res-desc" style="margin:10px 0; line-height:1.4; color:#ccc;"></p>
|
| 261 |
+
<div id="res-noise" style="margin-bottom:15px;"></div>
|
| 262 |
+
<p style="font-size:0.75rem; color:#555; font-family:monospace; margin:0;">
|
| 263 |
+
Use with: POST /reset body {{"task_id":"generated","seed":<span id="res-seed-val"></span>}}
|
| 264 |
+
</p>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<div class="curriculum-section">
|
| 269 |
+
<h3>ARIA Curriculum Status</h3>
|
| 270 |
+
<p>Live mastery tracking across the seven incident types.</p>
|
| 271 |
+
<div id="curriculum-status">
|
| 272 |
+
<div class="curriculum-loading">Loading curriculum status...</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
|
| 276 |
<div class="endpoints">
|
| 277 |
<h3>API Endpoints</h3>
|
|
|
|
| 298 |
<div class="endpoint">
|
| 299 |
<span class="method">GET</span>
|
| 300 |
<span class="path">/validate</span>
|
| 301 |
+
<span class="desc">Self-validation report for all 7 tasks</span>
|
| 302 |
</div>
|
| 303 |
<div class="endpoint">
|
| 304 |
<span class="method">GET</span>
|
|
|
|
| 315 |
<a href="/metrics" style="color:#ff6b35;">Metrics</a> |
|
| 316 |
<a href="/leaderboard" style="color:#ff6b35;">Leaderboard</a>
|
| 317 |
</div>
|
| 318 |
+
<script>
|
| 319 |
+
const curriculumContainer = document.getElementById("curriculum-status");
|
| 320 |
+
|
| 321 |
+
function masteryStars(level) {{
|
| 322 |
+
return "★".repeat(level) + "☆".repeat(3 - level);
|
| 323 |
+
}}
|
| 324 |
+
|
| 325 |
+
function avgColor(avg) {{
|
| 326 |
+
if (avg < 0.3) {{
|
| 327 |
+
return "#ef5350";
|
| 328 |
+
}}
|
| 329 |
+
if (avg < 0.6) {{
|
| 330 |
+
return "#ffd54f";
|
| 331 |
+
}}
|
| 332 |
+
return "#66bb6a";
|
| 333 |
+
}}
|
| 334 |
+
|
| 335 |
+
function renderCurriculumStatus(payload) {{
|
| 336 |
+
const tasks = payload.tasks || {{}};
|
| 337 |
+
const recommendedTask = payload.recommended_task;
|
| 338 |
+
const rows = Object.entries(tasks).map(([taskId, task]) => {{
|
| 339 |
+
const avg = Number(task.rolling_avg || 0);
|
| 340 |
+
const width = Math.max(0, Math.min(100, avg * 100));
|
| 341 |
+
const badges = [];
|
| 342 |
+
if (task.scaffold_needed) {{
|
| 343 |
+
badges.push('<span class="curriculum-pill scaffold-pill">SCAFFOLD</span>');
|
| 344 |
+
}}
|
| 345 |
+
if (taskId === recommendedTask) {{
|
| 346 |
+
badges.push('<span class="curriculum-pill recommended-pill">RECOMMENDED</span>');
|
| 347 |
+
}}
|
| 348 |
+
|
| 349 |
+
return `
|
| 350 |
+
<div class="curriculum-row">
|
| 351 |
+
<div class="curriculum-task-name">${{taskId}}</div>
|
| 352 |
+
<div class="curriculum-stars" title="${{task.mastery_label}}">${{masteryStars(task.mastery_level)}}</div>
|
| 353 |
+
<div class="curriculum-bar-wrap">
|
| 354 |
+
<div class="curriculum-bar">
|
| 355 |
+
<div class="curriculum-fill" style="width:${{width}}%; background:${{avgColor(avg)}};"></div>
|
| 356 |
+
</div>
|
| 357 |
+
<div class="curriculum-score">${{avg.toFixed(2)}}</div>
|
| 358 |
+
</div>
|
| 359 |
+
<div class="curriculum-badges">${{badges.join("")}}</div>
|
| 360 |
+
</div>
|
| 361 |
+
`;
|
| 362 |
+
}}).join("");
|
| 363 |
+
|
| 364 |
+
curriculumContainer.innerHTML = `
|
| 365 |
+
<div class="curriculum-meta">Total episodes recorded: ${{payload.total_episodes_recorded}}</div>
|
| 366 |
+
<div class="curriculum-table">
|
| 367 |
+
<div class="curriculum-header">
|
| 368 |
+
<div>Task</div>
|
| 369 |
+
<div>Mastery</div>
|
| 370 |
+
<div>Rolling Avg</div>
|
| 371 |
+
<div>Status</div>
|
| 372 |
+
</div>
|
| 373 |
+
${{rows}}
|
| 374 |
+
</div>
|
| 375 |
+
`;
|
| 376 |
+
}}
|
| 377 |
+
|
| 378 |
+
async function refreshCurriculumStatus() {{
|
| 379 |
+
try {{
|
| 380 |
+
const response = await fetch("/curriculum/status");
|
| 381 |
+
if (!response.ok) {{
|
| 382 |
+
throw new Error("Failed to load curriculum status");
|
| 383 |
+
}}
|
| 384 |
+
const payload = await response.json();
|
| 385 |
+
renderCurriculumStatus(payload);
|
| 386 |
+
}} catch (error) {{
|
| 387 |
+
curriculumContainer.innerHTML = '<div class="curriculum-error">Curriculum status unavailable.</div>';
|
| 388 |
+
}}
|
| 389 |
+
}}
|
| 390 |
+
|
| 391 |
+
refreshCurriculumStatus();
|
| 392 |
+
setInterval(refreshCurriculumStatus, 15000);
|
| 393 |
+
|
| 394 |
+
function generateIncident() {{
|
| 395 |
+
const seed = document.getElementById('seed-input').value;
|
| 396 |
+
fetch(`/generate/preview?seed=${{seed}}`)
|
| 397 |
+
.then(r => r.json())
|
| 398 |
+
.then(data => {{
|
| 399 |
+
document.getElementById('res-id').innerText = data.incident_id;
|
| 400 |
+
document.getElementById('res-seed-val').innerText = seed;
|
| 401 |
+
document.getElementById('res-affected').innerText = "Affected: " + data.affected_service;
|
| 402 |
+
document.getElementById('res-desc').innerText = data.description;
|
| 403 |
+
|
| 404 |
+
const modeColors = {{
|
| 405 |
+
oom: "#f44336", cascade: "#ff9800", corruption: "#9c27b0",
|
| 406 |
+
security: "#ffeb3b", database: "#4fc3f7", network_partition: "#009688"
|
| 407 |
+
}};
|
| 408 |
+
const sevColors = {{ sev1: "#f44336", sev2: "#ff9800", sev3: "#ffeb3b" }};
|
| 409 |
+
|
| 410 |
+
document.getElementById('res-badges').innerHTML = `
|
| 411 |
+
<span class="badge" style="background:${{modeColors[data.failure_mode]}}; color:#000; margin-right:5px;">${{data.failure_mode.toUpperCase()}}</span>
|
| 412 |
+
<span class="badge" style="background:${{sevColors[data.severity]}}; color:#000;">${{data.severity.toUpperCase()}}</span>
|
| 413 |
+
`;
|
| 414 |
+
|
| 415 |
+
const diffBar = document.getElementById('res-diff-bar');
|
| 416 |
+
diffBar.style.width = (data.difficulty_score * 100) + "%";
|
| 417 |
+
diffBar.style.background = avgColor(data.difficulty_score);
|
| 418 |
+
|
| 419 |
+
const noiseDiv = document.getElementById('res-noise');
|
| 420 |
+
if (data.noise_alerts.length === 0) {{
|
| 421 |
+
noiseDiv.innerHTML = '<span style="font-size:0.8rem; color:#555;">No noise alerts</span>';
|
| 422 |
+
}} else {{
|
| 423 |
+
noiseDiv.innerHTML = data.noise_alerts.map(n => `<span class="tag">${{n}}</span>`).join("");
|
| 424 |
+
}}
|
| 425 |
+
|
| 426 |
+
document.getElementById('incident-result').style.display = 'block';
|
| 427 |
+
}});
|
| 428 |
+
}}
|
| 429 |
+
</script>
|
| 430 |
</body>
|
| 431 |
</html>"""
|
| 432 |
return html
|
|
|
|
| 437 |
return {"status": "ok", "env": "devops-incident-response", "version": "1.0.0"}
|
| 438 |
|
| 439 |
|
| 440 |
+
@app.get("/generate/preview")
|
| 441 |
+
def preview_incident(seed: int = 42):
|
| 442 |
+
return _factory.generate(seed)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
@app.post("/reset", response_model=Observation)
|
| 446 |
def reset(req: Optional[ResetRequest] = None):
|
| 447 |
if req is None:
|
| 448 |
req = ResetRequest()
|
| 449 |
global _env
|
| 450 |
+
|
| 451 |
+
if req.task_id == "generated":
|
| 452 |
+
seed = req.seed if req.seed is not None else 42
|
| 453 |
+
incident = _factory.generate(seed)
|
| 454 |
+
task = GeneratedTask(incident_dict=incident)
|
| 455 |
+
state = task.initialize()
|
| 456 |
+
_env = DevOpsIncidentEnv(task_id="easy", seed=seed)
|
| 457 |
+
_env.task_id = "generated"
|
| 458 |
+
_env._task = task
|
| 459 |
+
_env._internal_state = state
|
| 460 |
+
observation = state._build_observation()
|
| 461 |
+
else:
|
| 462 |
+
if req.task_id not in VALID_TASKS:
|
| 463 |
+
raise HTTPException(
|
| 464 |
+
status_code=400,
|
| 465 |
+
detail=f"task_id must be one of {VALID_TASKS}. Got: {req.task_id}",
|
| 466 |
+
)
|
| 467 |
+
_env = DevOpsIncidentEnv(task_id=req.task_id, seed=req.seed)
|
| 468 |
+
observation = _env.reset()
|
| 469 |
+
|
| 470 |
+
episode_tracker[_env.state().episode_id] = req.task_id
|
| 471 |
+
return observation
|
| 472 |
|
| 473 |
|
| 474 |
@app.post("/step", response_model=StepResult)
|
|
|
|
| 477 |
raise HTTPException(status_code=400, detail="Call /reset before /step")
|
| 478 |
res = _env.step(action)
|
| 479 |
if res.done:
|
| 480 |
+
from graders.grader import grade_episode
|
| 481 |
+
|
| 482 |
+
current_state = _env.state()
|
| 483 |
+
current_task_id = episode_tracker.get(current_state.episode_id, current_state.task_id)
|
| 484 |
+
final_score = grade_episode(
|
| 485 |
+
task_id=current_task_id,
|
| 486 |
+
action_history=current_state.action_history,
|
| 487 |
+
ground_truth_root_cause=current_state.ground_truth_root_cause,
|
| 488 |
+
ground_truth_fix=current_state.ground_truth_fix,
|
| 489 |
+
incident_resolved=current_state.incident_resolved,
|
| 490 |
+
total_reward=current_state.total_reward,
|
| 491 |
+
)
|
| 492 |
+
curriculum_engine.record_episode(current_task_id, float(final_score))
|
| 493 |
+
episode_tracker.pop(current_state.episode_id, None)
|
| 494 |
+
track_episode(current_state)
|
| 495 |
return res
|
| 496 |
|
| 497 |
|
|
|
|
| 574 |
"and which do not. Failing over the wrong services causes severe data inconsistency penalties."
|
| 575 |
),
|
| 576 |
},
|
| 577 |
+
{
|
| 578 |
+
"id": "generated",
|
| 579 |
+
"name": "Procedural Incident",
|
| 580 |
+
"difficulty": "variable",
|
| 581 |
+
"max_steps": 20,
|
| 582 |
+
"description": "Procedurally generated incident. Use with any seed 0-99999 for infinite unique scenarios.",
|
| 583 |
+
},
|
| 584 |
]
|
| 585 |
}
|
| 586 |
|
| 587 |
|
| 588 |
+
@app.get("/curriculum/status")
|
| 589 |
+
def get_curriculum_status():
|
| 590 |
+
return curriculum_engine.get_status()
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
@app.get("/curriculum/next")
|
| 594 |
+
def get_next_curriculum_task():
|
| 595 |
+
return {
|
| 596 |
+
"recommended_task": curriculum_engine.get_next_curriculum_task(),
|
| 597 |
+
"reasoning": "Lowest rolling average among non-mastered tasks.",
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
@app.post("/curriculum/record")
|
| 602 |
+
def record_curriculum_episode(req: CurriculumRecordRequest):
|
| 603 |
+
try:
|
| 604 |
+
curriculum_engine.record_episode(req.task_id, req.score)
|
| 605 |
+
except ValueError as exc:
|
| 606 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 607 |
+
return {
|
| 608 |
+
"recorded": True,
|
| 609 |
+
"new_status": curriculum_engine.get_status()["tasks"][req.task_id],
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
@app.get("/curriculum/hint/{task_id}")
|
| 614 |
+
def get_curriculum_hint(task_id: str):
|
| 615 |
+
try:
|
| 616 |
+
return {
|
| 617 |
+
"task_id": task_id,
|
| 618 |
+
"hint": curriculum_engine.get_hint(task_id),
|
| 619 |
+
"scaffold_needed": curriculum_engine.should_scaffold(task_id),
|
| 620 |
+
"mastery_level": curriculum_engine.get_mastery(task_id),
|
| 621 |
+
}
|
| 622 |
+
except ValueError as exc:
|
| 623 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 624 |
+
|
| 625 |
+
|
| 626 |
@app.get("/validate")
|
| 627 |
def validate():
|
| 628 |
"""
|
curriculum/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from curriculum.engine import CurriculumEngine
|
| 2 |
+
|
| 3 |
+
__all__ = ["CurriculumEngine"]
|
curriculum/engine.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import deque
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class CurriculumEngine:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.tasks = ["easy", "medium", "hard", "bonus", "security", "database", "failover"]
|
| 9 |
+
self.scores: dict[str, deque] = {
|
| 10 |
+
task_id: deque(maxlen=5) for task_id in self.tasks
|
| 11 |
+
}
|
| 12 |
+
self.mastery: dict[str, int] = {task_id: 0 for task_id in self.tasks}
|
| 13 |
+
self._hints: dict[str, str] = {
|
| 14 |
+
"easy": "Focus on the service with highest memory_percent. Read its logs before acting.",
|
| 15 |
+
"medium": "Follow the dependency map backwards from the erroring service to find the root cause.",
|
| 16 |
+
"hard": "All services look green. Check WARN-level logs and business metrics, not error rates.",
|
| 17 |
+
"bonus": "There are two independent failures. Fix each one separately — do not conflate them.",
|
| 18 |
+
"security": "Look for repeated login failures from the same IP range in the access logs.",
|
| 19 |
+
"database": "Check for sequential scans in slow query logs. The fix is structural, not a restart.",
|
| 20 |
+
"failover": "Read the failover runbook first. Not all services are safe — check compliance constraints.",
|
| 21 |
+
}
|
| 22 |
+
self._rotation_index = 0
|
| 23 |
+
self._total_episodes_recorded = 0
|
| 24 |
+
|
| 25 |
+
def _ensure_task(self, task_id: str) -> None:
|
| 26 |
+
if task_id not in self.scores:
|
| 27 |
+
raise ValueError(f"Unknown task_id: {task_id}")
|
| 28 |
+
|
| 29 |
+
def record_episode(self, task_id: str, score: float) -> None:
|
| 30 |
+
self._ensure_task(task_id)
|
| 31 |
+
self.scores[task_id].append(float(score))
|
| 32 |
+
self._total_episodes_recorded += 1
|
| 33 |
+
self._update_mastery(task_id)
|
| 34 |
+
|
| 35 |
+
def _update_mastery(self, task_id: str) -> None:
|
| 36 |
+
self._ensure_task(task_id)
|
| 37 |
+
rolling_avg = self.get_rolling_avg(task_id)
|
| 38 |
+
if rolling_avg > 0.75 and self.mastery[task_id] < 3:
|
| 39 |
+
self.mastery[task_id] += 1
|
| 40 |
+
elif rolling_avg < 0.30 and self.mastery[task_id] > 0:
|
| 41 |
+
self.mastery[task_id] -= 1
|
| 42 |
+
|
| 43 |
+
def get_mastery(self, task_id: str) -> int:
|
| 44 |
+
self._ensure_task(task_id)
|
| 45 |
+
return self.mastery[task_id]
|
| 46 |
+
|
| 47 |
+
def get_rolling_avg(self, task_id: str) -> float:
|
| 48 |
+
self._ensure_task(task_id)
|
| 49 |
+
recent_scores = self.scores[task_id]
|
| 50 |
+
if not recent_scores:
|
| 51 |
+
return 0.0
|
| 52 |
+
return sum(recent_scores) / len(recent_scores)
|
| 53 |
+
|
| 54 |
+
def should_scaffold(self, task_id: str) -> bool:
|
| 55 |
+
self._ensure_task(task_id)
|
| 56 |
+
return len(self.scores[task_id]) >= 3 and self.get_rolling_avg(task_id) < 0.30
|
| 57 |
+
|
| 58 |
+
def get_hint(self, task_id: str) -> str:
|
| 59 |
+
self._ensure_task(task_id)
|
| 60 |
+
return self._hints[task_id]
|
| 61 |
+
|
| 62 |
+
def _get_non_mastered_tasks(self) -> list[str]:
|
| 63 |
+
return [task_id for task_id in self.tasks if self.mastery[task_id] < 3]
|
| 64 |
+
|
| 65 |
+
def _sorted_candidates(self) -> list[str]:
|
| 66 |
+
candidates = self._get_non_mastered_tasks()
|
| 67 |
+
return sorted(
|
| 68 |
+
candidates,
|
| 69 |
+
key=lambda task_id: (self.get_rolling_avg(task_id), self.tasks.index(task_id)),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def get_recommended_task(self) -> str:
|
| 73 |
+
candidates = self._sorted_candidates()
|
| 74 |
+
if not candidates:
|
| 75 |
+
return "bonus"
|
| 76 |
+
return candidates[0]
|
| 77 |
+
|
| 78 |
+
def get_next_curriculum_task(self) -> str:
|
| 79 |
+
candidates = self._sorted_candidates()
|
| 80 |
+
if not candidates:
|
| 81 |
+
return "bonus"
|
| 82 |
+
task_id = candidates[self._rotation_index % len(candidates)]
|
| 83 |
+
self._rotation_index = (self._rotation_index + 1) % len(candidates)
|
| 84 |
+
return task_id
|
| 85 |
+
|
| 86 |
+
def get_status(self) -> dict:
|
| 87 |
+
mastery_labels = {
|
| 88 |
+
0: "novice",
|
| 89 |
+
1: "intermediate",
|
| 90 |
+
2: "advanced",
|
| 91 |
+
3: "mastered",
|
| 92 |
+
}
|
| 93 |
+
tasks = {}
|
| 94 |
+
for task_id in self.tasks:
|
| 95 |
+
scaffold_needed = self.should_scaffold(task_id)
|
| 96 |
+
tasks[task_id] = {
|
| 97 |
+
"mastery_level": self.mastery[task_id],
|
| 98 |
+
"mastery_label": mastery_labels[self.mastery[task_id]],
|
| 99 |
+
"rolling_avg": self.get_rolling_avg(task_id),
|
| 100 |
+
"recent_scores": list(self.scores[task_id]),
|
| 101 |
+
"scaffold_needed": scaffold_needed,
|
| 102 |
+
"hint": self.get_hint(task_id) if scaffold_needed else None,
|
| 103 |
+
}
|
| 104 |
+
return {
|
| 105 |
+
"tasks": tasks,
|
| 106 |
+
"recommended_task": self.get_recommended_task(),
|
| 107 |
+
"total_episodes_recorded": self._total_episodes_recorded,
|
| 108 |
+
}
|
generator/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from generator.incident_factory import IncidentFactory
|
| 2 |
+
__all__ = ["IncidentFactory"]
|
generator/incident_factory.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
|
| 3 |
+
class IncidentFactory:
|
| 4 |
+
FAILURE_MODES = ["oom", "cascade", "corruption", "security", "database", "network_partition"]
|
| 5 |
+
SERVICES = ["payment-service", "order-service", "user-service",
|
| 6 |
+
"inventory-service", "api-gateway", "notification-service",
|
| 7 |
+
"data-pipeline", "ml-inference-service"]
|
| 8 |
+
SEVERITIES = ["sev1", "sev2", "sev3"]
|
| 9 |
+
NOISE_ALERTS = [
|
| 10 |
+
"Scheduled batch job running — high CPU expected",
|
| 11 |
+
"SSL certificate renewal in progress",
|
| 12 |
+
"Nightly analytics aggregation running",
|
| 13 |
+
"CDN cache warming after deployment",
|
| 14 |
+
"Routine health check latency spike"
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
def generate(self, seed: int) -> dict:
|
| 18 |
+
rng = random.Random(seed)
|
| 19 |
+
failure_mode = rng.choice(self.FAILURE_MODES)
|
| 20 |
+
affected_service = rng.choice(self.SERVICES)
|
| 21 |
+
severity = rng.choice(self.SEVERITIES)
|
| 22 |
+
noise_count = rng.randint(0, 3)
|
| 23 |
+
noise_alerts = rng.sample(self.NOISE_ALERTS, min(noise_count, len(self.NOISE_ALERTS)))
|
| 24 |
+
|
| 25 |
+
# Templates for description
|
| 26 |
+
descriptions = {
|
| 27 |
+
"oom": f"{affected_service} is crash-looping due to memory exhaustion. Process memory exceeded limits.",
|
| 28 |
+
"cascade": f"{affected_service} bad deployment causing downstream connection pool exhaustion across dependent services.",
|
| 29 |
+
"corruption": f"Silent data corruption in {affected_service}. Invalid records being written. No error-rate alerts — signal buried in business metrics.",
|
| 30 |
+
"security": f"Credential stuffing botnet targeting {affected_service} from IP range 185.220.x.x.",
|
| 31 |
+
"database": f"Missing index on {affected_service} database after migration. Full table scans degrading all queries.",
|
| 32 |
+
"network_partition": f"Network partition isolating {affected_service} in us-east-1. Failover decision required."
|
| 33 |
+
}
|
| 34 |
+
description = descriptions[failure_mode]
|
| 35 |
+
|
| 36 |
+
# Templates for root cause
|
| 37 |
+
root_causes = {
|
| 38 |
+
"oom": f"Memory leak in {affected_service} causing OOM crash-loop",
|
| 39 |
+
"cascade": f"Bad deployment in {affected_service} causing cascading connection failures",
|
| 40 |
+
"corruption": f"Data corruption in {affected_service} writing invalid records",
|
| 41 |
+
"security": f"DDoS credential stuffing from 185.220.0.0/16 targeting {affected_service}",
|
| 42 |
+
"database": f"Missing index causing sequential scans on {affected_service}",
|
| 43 |
+
"network_partition": f"Network partition in us-east-1 affecting {affected_service}"
|
| 44 |
+
}
|
| 45 |
+
ground_truth_root_cause = root_causes[failure_mode]
|
| 46 |
+
|
| 47 |
+
# Templates for fix
|
| 48 |
+
fixes = {
|
| 49 |
+
"oom": "restart_service",
|
| 50 |
+
"cascade": "rollback",
|
| 51 |
+
"corruption": "rollback",
|
| 52 |
+
"security": "block_ip_range",
|
| 53 |
+
"database": "create_index",
|
| 54 |
+
"network_partition": "failover"
|
| 55 |
+
}
|
| 56 |
+
ground_truth_fix = fixes[failure_mode]
|
| 57 |
+
|
| 58 |
+
# Difficulty score
|
| 59 |
+
base_scores = {
|
| 60 |
+
"oom": 0.2, "cascade": 0.5, "corruption": 0.8,
|
| 61 |
+
"security": 0.6, "database": 0.6, "network_partition": 0.7
|
| 62 |
+
}
|
| 63 |
+
score = base_scores[failure_mode] + (noise_count * 0.05)
|
| 64 |
+
score = min(score, 1.0)
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"task_id": "generated",
|
| 68 |
+
"incident_id": f"INC-{seed:05d}",
|
| 69 |
+
"seed": seed,
|
| 70 |
+
"failure_mode": failure_mode,
|
| 71 |
+
"affected_service": affected_service,
|
| 72 |
+
"severity": severity,
|
| 73 |
+
"noise_alerts": noise_alerts,
|
| 74 |
+
"description": description,
|
| 75 |
+
"ground_truth_root_cause": ground_truth_root_cause,
|
| 76 |
+
"ground_truth_fix": ground_truth_fix,
|
| 77 |
+
"difficulty_score": round(score, 2),
|
| 78 |
+
"estimated_optimal_score": round(0.99 - score * 0.3, 2)
|
| 79 |
+
}
|
tasks/task_generated.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from tasks.base import BaseTask, InternalState, StepOutput
|
| 5 |
+
from models import Action, StepResult, ServiceStatus, Alert, ActionType
|
| 6 |
+
|
| 7 |
+
class GeneratedTask(BaseTask):
|
| 8 |
+
def __init__(self, incident_dict: dict):
|
| 9 |
+
self.incident = incident_dict
|
| 10 |
+
self.task_id = "generated"
|
| 11 |
+
self.max_steps = 20
|
| 12 |
+
# For compatibility with BaseTask which expects rng in __init__
|
| 13 |
+
super().__init__(random.Random(incident_dict.get("seed", 42)))
|
| 14 |
+
|
| 15 |
+
def initialize(self) -> InternalState:
|
| 16 |
+
affected = self.incident["affected_service"]
|
| 17 |
+
failure_mode = self.incident["failure_mode"]
|
| 18 |
+
|
| 19 |
+
services_dict = {}
|
| 20 |
+
SERVICES = ["payment-service", "order-service", "user-service",
|
| 21 |
+
"inventory-service", "api-gateway", "notification-service",
|
| 22 |
+
"data-pipeline", "ml-inference-service"]
|
| 23 |
+
|
| 24 |
+
for svc in SERVICES:
|
| 25 |
+
if svc == affected:
|
| 26 |
+
services_dict[svc] = {
|
| 27 |
+
"name": svc,
|
| 28 |
+
"status": "degraded",
|
| 29 |
+
"cpu_percent": 85.0,
|
| 30 |
+
"memory_percent": 92.0,
|
| 31 |
+
"error_rate": 0.35,
|
| 32 |
+
"latency_p99_ms": 2800.0,
|
| 33 |
+
"replicas_running": 2,
|
| 34 |
+
"replicas_desired": 3,
|
| 35 |
+
"current_version": "v1.2.4",
|
| 36 |
+
"last_deployed": (datetime.utcnow()).isoformat(),
|
| 37 |
+
"minutes_degraded": 0,
|
| 38 |
+
"sla_breach": False
|
| 39 |
+
}
|
| 40 |
+
else:
|
| 41 |
+
services_dict[svc] = {
|
| 42 |
+
"name": svc,
|
| 43 |
+
"status": "healthy",
|
| 44 |
+
"cpu_percent": 25.0,
|
| 45 |
+
"memory_percent": 40.0,
|
| 46 |
+
"error_rate": 0.01,
|
| 47 |
+
"latency_p99_ms": 120.0,
|
| 48 |
+
"replicas_running": 3,
|
| 49 |
+
"replicas_desired": 3,
|
| 50 |
+
"current_version": "v1.2.3",
|
| 51 |
+
"last_deployed": (datetime.utcnow()).isoformat(),
|
| 52 |
+
"minutes_degraded": 0,
|
| 53 |
+
"sla_breach": False
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
active_alerts = []
|
| 57 |
+
# One CRITICAL alert
|
| 58 |
+
active_alerts.append({
|
| 59 |
+
"id": str(uuid.uuid4())[:8],
|
| 60 |
+
"service": affected,
|
| 61 |
+
"severity": "critical",
|
| 62 |
+
"message": self.incident["description"],
|
| 63 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 64 |
+
"acknowledged": False
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
# Noise alerts
|
| 68 |
+
for noise in self.incident["noise_alerts"]:
|
| 69 |
+
active_alerts.append({
|
| 70 |
+
"id": str(uuid.uuid4())[:8],
|
| 71 |
+
"service": "notification-service",
|
| 72 |
+
"severity": "warning",
|
| 73 |
+
"message": noise,
|
| 74 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 75 |
+
"acknowledged": False
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
log_lines = {
|
| 79 |
+
"oom": ["ERROR OutOfMemoryError: Java heap space",
|
| 80 |
+
"WARN Memory usage at 98%, GC overhead limit exceeded"],
|
| 81 |
+
"cascade": ["ERROR Connection pool exhausted: timeout after 30s",
|
| 82 |
+
"ERROR Failed to acquire connection from pool"],
|
| 83 |
+
"corruption": ["WARN Price mismatch detected: expected 29.99 got 299.9",
|
| 84 |
+
"WARN Data validation failed for 847 records"],
|
| 85 |
+
"security": ["WARN 1847 failed login attempts in 60s",
|
| 86 |
+
"WARN Rate limit exceeded from 185.220.101.x"],
|
| 87 |
+
"database": ["WARN Slow query: seq_scan on orders (847ms)",
|
| 88 |
+
"WARN Query planner chose sequential scan, missing index"],
|
| 89 |
+
"network_partition": ["ERROR Connection timeout to us-east-1",
|
| 90 |
+
"ERROR Health check failed: unreachable"]
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
logs = {}
|
| 94 |
+
for svc in SERVICES:
|
| 95 |
+
if svc == affected:
|
| 96 |
+
logs[svc] = log_lines.get(failure_mode, ["INFO Service running normally"])
|
| 97 |
+
else:
|
| 98 |
+
logs[svc] = ["INFO Service running normally", "INFO Health check passed"]
|
| 99 |
+
|
| 100 |
+
state = InternalState(
|
| 101 |
+
episode_id=str(uuid.uuid4()),
|
| 102 |
+
task_id="generated",
|
| 103 |
+
step=0,
|
| 104 |
+
max_steps=self.max_steps,
|
| 105 |
+
services=services_dict,
|
| 106 |
+
alerts=active_alerts,
|
| 107 |
+
logs=logs,
|
| 108 |
+
action_history=[],
|
| 109 |
+
total_reward=0.0,
|
| 110 |
+
incident_resolved=False,
|
| 111 |
+
ground_truth_root_cause=self.incident["ground_truth_root_cause"],
|
| 112 |
+
ground_truth_fix=self.incident["ground_truth_fix"],
|
| 113 |
+
incident_start_time=datetime.utcnow().isoformat(),
|
| 114 |
+
rewards_given=set()
|
| 115 |
+
)
|
| 116 |
+
state._scenario = self.incident
|
| 117 |
+
return state
|
| 118 |
+
|
| 119 |
+
def step(self, state: InternalState, action: Action) -> StepOutput:
|
| 120 |
+
reward = 0.0
|
| 121 |
+
result_text, error_text = self._apply_action_to_logs(state, action)
|
| 122 |
+
|
| 123 |
+
# ActionType can be enum or string
|
| 124 |
+
at = action.action_type
|
| 125 |
+
at_val = at.value if hasattr(at, "value") else str(at)
|
| 126 |
+
|
| 127 |
+
if at_val == "read_logs":
|
| 128 |
+
if action.service == self.incident["affected_service"]:
|
| 129 |
+
if "read_logs" not in state.rewards_given:
|
| 130 |
+
reward += 0.10
|
| 131 |
+
state.rewards_given.add("read_logs")
|
| 132 |
+
|
| 133 |
+
if at_val == "diagnose":
|
| 134 |
+
diagnosis = action.diagnosis or action.root_cause or ""
|
| 135 |
+
if state.ground_truth_root_cause.lower() in diagnosis.lower():
|
| 136 |
+
if "diagnose" not in state.rewards_given:
|
| 137 |
+
reward += 0.30
|
| 138 |
+
state.rewards_given.add("diagnose")
|
| 139 |
+
|
| 140 |
+
if at_val == self.incident["ground_truth_fix"]:
|
| 141 |
+
if action.service == self.incident["affected_service"] and "fix" not in state.rewards_given:
|
| 142 |
+
reward += 0.45
|
| 143 |
+
state.rewards_given.add("fix")
|
| 144 |
+
state.incident_resolved = True
|
| 145 |
+
state.services[self.incident["affected_service"]]["status"] = "healthy"
|
| 146 |
+
state.services[self.incident["affected_service"]]["cpu_percent"] = 25.0
|
| 147 |
+
state.services[self.incident["affected_service"]]["memory_percent"] = 40.0
|
| 148 |
+
state.services[self.incident["affected_service"]]["error_rate"] = 0.01
|
| 149 |
+
state.services[self.incident["affected_service"]]["latency_p99_ms"] = 120.0
|
| 150 |
+
|
| 151 |
+
state.step += 1
|
| 152 |
+
state.total_reward = self._clamp(state.total_reward + reward)
|
| 153 |
+
|
| 154 |
+
done = state.incident_resolved or state.step >= self.max_steps
|
| 155 |
+
info = {}
|
| 156 |
+
if state.incident_resolved: info["resolution"] = "incident_resolved"
|
| 157 |
+
if state.step >= self.max_steps: info["reason"] = "max_steps_reached"
|
| 158 |
+
|
| 159 |
+
state.action_history.append({
|
| 160 |
+
"step": state.step,
|
| 161 |
+
"action": action.model_dump(),
|
| 162 |
+
"reward": round(reward, 4)
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
return StepOutput(next_state=state, reward=round(reward, 4), done=done, info=info)
|
validate.py
CHANGED
|
@@ -68,7 +68,7 @@ def main():
|
|
| 68 |
|
| 69 |
def check_reset_all_tasks():
|
| 70 |
from env import DevOpsIncidentEnv
|
| 71 |
-
for task_id in ["easy", "medium", "hard", "bonus"]:
|
| 72 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 73 |
obs = env.reset()
|
| 74 |
assert obs.task_id == task_id, f"task_id mismatch for {task_id}"
|
|
@@ -121,7 +121,7 @@ def main():
|
|
| 121 |
from env import DevOpsIncidentEnv
|
| 122 |
from models import Action, ActionType
|
| 123 |
rng = random.Random(0)
|
| 124 |
-
for task_id in ["easy", "medium", "hard", "bonus"]:
|
| 125 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 126 |
env.reset()
|
| 127 |
done = False
|
|
@@ -177,7 +177,7 @@ def main():
|
|
| 177 |
from models import Action, ActionType
|
| 178 |
from graders.grader import grade_episode
|
| 179 |
rng = random.Random(99)
|
| 180 |
-
for task_id in ["easy", "medium", "hard", "bonus"]:
|
| 181 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 182 |
env.reset()
|
| 183 |
done = False
|
|
|
|
| 68 |
|
| 69 |
def check_reset_all_tasks():
|
| 70 |
from env import DevOpsIncidentEnv
|
| 71 |
+
for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
|
| 72 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 73 |
obs = env.reset()
|
| 74 |
assert obs.task_id == task_id, f"task_id mismatch for {task_id}"
|
|
|
|
| 121 |
from env import DevOpsIncidentEnv
|
| 122 |
from models import Action, ActionType
|
| 123 |
rng = random.Random(0)
|
| 124 |
+
for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
|
| 125 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 126 |
env.reset()
|
| 127 |
done = False
|
|
|
|
| 177 |
from models import Action, ActionType
|
| 178 |
from graders.grader import grade_episode
|
| 179 |
rng = random.Random(99)
|
| 180 |
+
for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
|
| 181 |
env = DevOpsIncidentEnv(task_id=task_id, seed=42)
|
| 182 |
env.reset()
|
| 183 |
done = False
|