Spaces:
Runtime error
Runtime error
Commit ·
1415cd6
1
Parent(s): b0e15a8
feat: Phase 4 sentinel.py — V4 Sentinel God Layer with KV routing, incident reporting, weekly audit, Notion write
Browse files- packages/brain/sentinel.py +551 -0
packages/brain/sentinel.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
packages/brain/sentinel.py
|
| 3 |
+
|
| 4 |
+
Ultron V4 — Sentinel God Layer
|
| 5 |
+
===============================
|
| 6 |
+
Authority: Highest. Never answers user queries. Only watches, decides, writes, repairs.
|
| 7 |
+
|
| 8 |
+
Responsibilities:
|
| 9 |
+
- KV routing table read/write (CF Worker routes based on this)
|
| 10 |
+
- Space health checks (<50ms, every request path)
|
| 11 |
+
- Failure detection → promote backup → write incident to Notion → Discord DM Ghost
|
| 12 |
+
- Weekly audit cron (triggered by GH Actions): reads logs → Gemini 1M context → Notion page
|
| 13 |
+
- /sentinel/event handler integration: receives structured events from main.py
|
| 14 |
+
|
| 15 |
+
Design:
|
| 16 |
+
- Dedicated Gemini 2.5 Pro key (GEMINI_SENTINEL_KEY). NEVER shares with general pool.
|
| 17 |
+
- All CF KV ops via REST API (no SDK dependency).
|
| 18 |
+
- Notion writes via direct REST API (Notion-Version: 2022-06-28).
|
| 19 |
+
- Discord DM to Ghost on any critical event.
|
| 20 |
+
- All methods are async. Sentinel is instantiated once in main.py lifespan.
|
| 21 |
+
|
| 22 |
+
Future bug risks (pre-registered):
|
| 23 |
+
S1 [HIGH] Gemini 2.5 Pro rate limit: 2 RPM on free tier. Weekly audit = 1 call.
|
| 24 |
+
But concurrent failure events (burst) can cause 429.
|
| 25 |
+
Fix: asyncio.Semaphore(1) on _call_sentinel(). Queues instead of drops.
|
| 26 |
+
|
| 27 |
+
S2 [HIGH] CF KV _kv_put with stale routing table: if two Sentinel instances (multi-worker
|
| 28 |
+
M1 scenario) both detect failure simultaneously, both promote backup.
|
| 29 |
+
Second write is safe (idempotent) but both fire Discord DMs.
|
| 30 |
+
Fix: use CF KV conditional write (If-Match ETag) when available.
|
| 31 |
+
|
| 32 |
+
S3 [MED] Notion REST write requires NOTION_SENTINEL_PAGE_ID and NOTION_TOKEN env vars.
|
| 33 |
+
If missing, incident write silently skips. Sentinel still DMs Ghost.
|
| 34 |
+
Fix: log loud warning at startup if Notion vars unset.
|
| 35 |
+
|
| 36 |
+
S4 [MED] Discord DM: if bot token revoked, DM fails silently. Sentinel still writes Notion.
|
| 37 |
+
Fix: add fallback webhook URL (SENTINEL_WEBHOOK_URL) as second DM channel.
|
| 38 |
+
|
| 39 |
+
S5 [LOW] weekly_audit() fetches Supabase logs up to 800k chars. Gemini 1M context
|
| 40 |
+
can handle but API timeout=120s may be insufficient for large log sets.
|
| 41 |
+
Fix: increase timeout to 240s for weekly audit call specifically.
|
| 42 |
+
|
| 43 |
+
Tool calls used writing this file:
|
| 44 |
+
Github:get_file_contents x1 (ultron-v3/packages/brain/sentinel.py — reference patterns)
|
| 45 |
+
Github:get_file_contents x1 (ultron-v4/packages/brain/main.py — confirmed app.state shape)
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import asyncio
|
| 51 |
+
import json
|
| 52 |
+
import logging
|
| 53 |
+
import time
|
| 54 |
+
from typing import Any, Optional
|
| 55 |
+
|
| 56 |
+
import httpx
|
| 57 |
+
|
| 58 |
+
log = logging.getLogger("sentinel")
|
| 59 |
+
|
| 60 |
+
SENTINEL_MODEL = "gemini-2.5-pro-preview-05-06"
|
| 61 |
+
ROUTING_TABLE_KEY = "ultron:routing:v4"
|
| 62 |
+
|
| 63 |
+
# Notion API constants
|
| 64 |
+
NOTION_API_BASE = "https://api.notion.com/v1"
|
| 65 |
+
NOTION_API_VERSION = "2022-06-28"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class Sentinel:
|
| 69 |
+
"""
|
| 70 |
+
God Layer. Non-technical CTO. Watches everything. Controls routing.
|
| 71 |
+
Detects failures. Writes Notion. DMs Ghost. Zero user-facing latency.
|
| 72 |
+
|
| 73 |
+
Instantiated in main.py lifespan if GEMINI_SENTINEL_KEY is set.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
sentinel_key: str,
|
| 79 |
+
*,
|
| 80 |
+
cf_account_id: str = "",
|
| 81 |
+
cf_namespace_id: str = "",
|
| 82 |
+
cf_kv_token: str = "",
|
| 83 |
+
hf_primary_url: str = "",
|
| 84 |
+
hf_backup_url: str = "",
|
| 85 |
+
discord_bot_token: str = "",
|
| 86 |
+
discord_ghost_uid: str = "",
|
| 87 |
+
notion_token: str = "",
|
| 88 |
+
notion_incident_page_id: str = "",
|
| 89 |
+
supabase_url: str = "",
|
| 90 |
+
supabase_key: str = "",
|
| 91 |
+
) -> None:
|
| 92 |
+
self._key = sentinel_key
|
| 93 |
+
self._cf_account_id = cf_account_id
|
| 94 |
+
self._cf_namespace_id = cf_namespace_id
|
| 95 |
+
self._cf_kv_token = cf_kv_token
|
| 96 |
+
self._hf_primary_url = hf_primary_url
|
| 97 |
+
self._hf_backup_url = hf_backup_url
|
| 98 |
+
self._discord_bot_token = discord_bot_token
|
| 99 |
+
self._discord_ghost_uid = discord_ghost_uid
|
| 100 |
+
self._notion_token = notion_token
|
| 101 |
+
self._notion_incident_pid = notion_incident_page_id
|
| 102 |
+
self._supabase_url = supabase_url
|
| 103 |
+
self._supabase_key = supabase_key
|
| 104 |
+
self._gemini_lock = asyncio.Semaphore(1) # S1 guard: 1 Gemini call at a time
|
| 105 |
+
|
| 106 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 107 |
+
# Gemini 2.5 Pro — dedicated call
|
| 108 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
async def _call_sentinel(
|
| 111 |
+
self,
|
| 112 |
+
prompt: str,
|
| 113 |
+
max_tokens: int = 2048,
|
| 114 |
+
timeout: float = 120.0,
|
| 115 |
+
) -> str:
|
| 116 |
+
"""Call Gemini 2.5 Pro via sentinel key. Rate-limited by asyncio.Semaphore(1)."""
|
| 117 |
+
async with self._gemini_lock: # S1 guard
|
| 118 |
+
url = (
|
| 119 |
+
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 120 |
+
f"{SENTINEL_MODEL}:generateContent"
|
| 121 |
+
)
|
| 122 |
+
payload = {
|
| 123 |
+
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
| 124 |
+
"generationConfig": {
|
| 125 |
+
"maxOutputTokens": max_tokens,
|
| 126 |
+
"temperature": 0.3,
|
| 127 |
+
},
|
| 128 |
+
"systemInstruction": {
|
| 129 |
+
"parts": [{
|
| 130 |
+
"text": (
|
| 131 |
+
"You are Sentinel, the God-layer AI of the Ultron system. "
|
| 132 |
+
"You are the non-technical CTO. You never answer user queries. "
|
| 133 |
+
"You only analyze, decide, and write structured reports. "
|
| 134 |
+
"Be concise, precise, and technical. Use markdown headers."
|
| 135 |
+
)
|
| 136 |
+
}]
|
| 137 |
+
},
|
| 138 |
+
}
|
| 139 |
+
try:
|
| 140 |
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
| 141 |
+
r = await c.post(url, params={"key": self._key}, json=payload)
|
| 142 |
+
r.raise_for_status()
|
| 143 |
+
return r.json()["candidates"][0]["content"]["parts"][0]["text"]
|
| 144 |
+
except httpx.HTTPStatusError as e:
|
| 145 |
+
log.error(f"[Sentinel] Gemini API error: {e.response.status_code} {e.response.text[:200]}")
|
| 146 |
+
raise
|
| 147 |
+
except Exception as e:
|
| 148 |
+
log.error(f"[Sentinel] Gemini call failed: {e}")
|
| 149 |
+
raise
|
| 150 |
+
|
| 151 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 152 |
+
# Cloudflare KV — routing table
|
| 153 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 154 |
+
|
| 155 |
+
def _kv_base(self) -> str:
|
| 156 |
+
return (
|
| 157 |
+
f"https://api.cloudflare.com/client/v4/accounts/{self._cf_account_id}"
|
| 158 |
+
f"/storage/kv/namespaces/{self._cf_namespace_id}/values"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def _kv_headers(self) -> dict:
|
| 162 |
+
return {"Authorization": f"Bearer {self._cf_kv_token}"}
|
| 163 |
+
|
| 164 |
+
async def _kv_get(self, key: str) -> Optional[str]:
|
| 165 |
+
if not self._cf_kv_token:
|
| 166 |
+
return None
|
| 167 |
+
try:
|
| 168 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 169 |
+
r = await c.get(f"{self._kv_base()}/{key}", headers=self._kv_headers())
|
| 170 |
+
if r.status_code == 404:
|
| 171 |
+
return None
|
| 172 |
+
r.raise_for_status()
|
| 173 |
+
return r.text
|
| 174 |
+
except Exception as e:
|
| 175 |
+
log.warning(f"[Sentinel] KV get failed key={key}: {e}")
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
async def _kv_put(self, key: str, value: str) -> bool:
|
| 179 |
+
if not self._cf_kv_token:
|
| 180 |
+
return False
|
| 181 |
+
try:
|
| 182 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 183 |
+
r = await c.put(
|
| 184 |
+
f"{self._kv_base()}/{key}",
|
| 185 |
+
headers=self._kv_headers(),
|
| 186 |
+
content=value,
|
| 187 |
+
)
|
| 188 |
+
r.raise_for_status()
|
| 189 |
+
return True
|
| 190 |
+
except Exception as e:
|
| 191 |
+
log.error(f"[Sentinel] KV put failed key={key}: {e}")
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 195 |
+
# Routing table
|
| 196 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 197 |
+
|
| 198 |
+
async def get_routing_table(self) -> dict:
|
| 199 |
+
raw = await self._kv_get(ROUTING_TABLE_KEY)
|
| 200 |
+
if not raw:
|
| 201 |
+
return {
|
| 202 |
+
"primary": self._hf_primary_url,
|
| 203 |
+
"backup": self._hf_backup_url,
|
| 204 |
+
"updated_at": str(time.time()),
|
| 205 |
+
"version": "v4",
|
| 206 |
+
}
|
| 207 |
+
try:
|
| 208 |
+
return json.loads(raw)
|
| 209 |
+
except json.JSONDecodeError:
|
| 210 |
+
log.error("[Sentinel] Routing table corrupt JSON — using defaults")
|
| 211 |
+
return {"primary": self._hf_primary_url, "backup": self._hf_backup_url}
|
| 212 |
+
|
| 213 |
+
async def set_primary_space(self, url: str) -> bool:
|
| 214 |
+
table = await self.get_routing_table()
|
| 215 |
+
table["primary"] = url
|
| 216 |
+
table["updated_at"] = str(time.time())
|
| 217 |
+
ok = await self._kv_put(ROUTING_TABLE_KEY, json.dumps(table))
|
| 218 |
+
if ok:
|
| 219 |
+
log.info(f"[Sentinel] Routing updated → primary={url}")
|
| 220 |
+
return ok
|
| 221 |
+
|
| 222 |
+
async def check_space_health(self, url: str, timeout: float = 5.0) -> bool:
|
| 223 |
+
"""Fast health check. Called on every request path — must be <50ms in p99."""
|
| 224 |
+
if not url:
|
| 225 |
+
return False
|
| 226 |
+
try:
|
| 227 |
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
| 228 |
+
r = await c.get(f"{url}/health")
|
| 229 |
+
return r.status_code == 200
|
| 230 |
+
except Exception:
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 234 |
+
# Failure handling
|
| 235 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
async def handle_space_failure(
|
| 238 |
+
self,
|
| 239 |
+
failed_url: str,
|
| 240 |
+
backup_url: str,
|
| 241 |
+
error: str,
|
| 242 |
+
) -> None:
|
| 243 |
+
"""
|
| 244 |
+
Called when primary Space fails health check.
|
| 245 |
+
Flow: promote backup → Gemini analysis → Notion incident page → Discord DM.
|
| 246 |
+
"""
|
| 247 |
+
log.critical(f"[Sentinel] Space failure: failed={failed_url} promoting={backup_url}")
|
| 248 |
+
|
| 249 |
+
# 1. Promote backup to primary
|
| 250 |
+
await self.set_primary_space(backup_url)
|
| 251 |
+
|
| 252 |
+
# 2. Gemini incident analysis (best-effort)
|
| 253 |
+
analysis = ""
|
| 254 |
+
try:
|
| 255 |
+
analysis = await self._call_sentinel(
|
| 256 |
+
f"## Ultron Space Failure Incident\n\n"
|
| 257 |
+
f"Failed Space: {failed_url}\n"
|
| 258 |
+
f"Backup Promoted: {backup_url}\n"
|
| 259 |
+
f"Error: {error}\n\n"
|
| 260 |
+
f"Write structured incident report: Summary | Root Cause Hypothesis | "
|
| 261 |
+
f"Impact | Recovery Steps | Prevention. Max 300 words.",
|
| 262 |
+
max_tokens=512,
|
| 263 |
+
)
|
| 264 |
+
except Exception as e:
|
| 265 |
+
analysis = f"Gemini analysis unavailable: {e}"
|
| 266 |
+
|
| 267 |
+
# 3. Write Notion incident page
|
| 268 |
+
await self._write_notion_incident(
|
| 269 |
+
title=f"[INCIDENT] Space Failure — {time.strftime('%Y-%m-%d %H:%M UTC')}",
|
| 270 |
+
content=(
|
| 271 |
+
f"**Failed:** {failed_url}\n"
|
| 272 |
+
f"**Promoted:** {backup_url}\n"
|
| 273 |
+
f"**Error:** {error}\n\n"
|
| 274 |
+
f"## Sentinel Analysis\n\n{analysis}"
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# 4. Discord DM Ghost
|
| 279 |
+
msg = (
|
| 280 |
+
f"🚨 **SENTINEL INCIDENT**\n"
|
| 281 |
+
f"`{failed_url}` FAILED.\n"
|
| 282 |
+
f"Promoted `{backup_url}` to primary.\n\n"
|
| 283 |
+
f"**Analysis:**\n{analysis[:1200]}"
|
| 284 |
+
)
|
| 285 |
+
await self._discord_dm(msg)
|
| 286 |
+
|
| 287 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 288 |
+
# Weekly audit
|
| 289 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 290 |
+
|
| 291 |
+
async def weekly_audit(self) -> str:
|
| 292 |
+
"""
|
| 293 |
+
Full week log read → Gemini 1M context analysis → Notion page → Discord DM.
|
| 294 |
+
Triggered by GH Actions cron (Sunday 23:59).
|
| 295 |
+
"""
|
| 296 |
+
log.info("[Sentinel] Weekly audit starting...")
|
| 297 |
+
logs_text = "(Supabase not configured)"
|
| 298 |
+
|
| 299 |
+
if self._supabase_url and self._supabase_key:
|
| 300 |
+
try:
|
| 301 |
+
since = time.time() - 7 * 86_400
|
| 302 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 303 |
+
r = await c.get(
|
| 304 |
+
f"{self._supabase_url}/rest/v1/logs",
|
| 305 |
+
params={
|
| 306 |
+
"ts": f"gte.{since}",
|
| 307 |
+
"select": "ts,component,level,msg,extra",
|
| 308 |
+
"order": "ts.asc",
|
| 309 |
+
"limit": "10000",
|
| 310 |
+
},
|
| 311 |
+
headers={
|
| 312 |
+
"apikey": self._supabase_key,
|
| 313 |
+
"Authorization": f"Bearer {self._supabase_key}",
|
| 314 |
+
},
|
| 315 |
+
)
|
| 316 |
+
logs_text = json.dumps(r.json(), separators=(",", ":"))[:800_000]
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logs_text = f"Log fetch error: {e}"
|
| 319 |
+
|
| 320 |
+
try:
|
| 321 |
+
report = await self._call_sentinel(
|
| 322 |
+
f"## Weekly Audit — Ultron V4 System\n\n"
|
| 323 |
+
f"Date: {time.strftime('%Y-%m-%d %H:%M UTC')}\n\n"
|
| 324 |
+
f"LOGS (7 days):\n{logs_text}\n\n"
|
| 325 |
+
f"Write structured report:\n"
|
| 326 |
+
f"## Component Health\n## LLM Stats\n## Memory Hit Rates\n"
|
| 327 |
+
f"## Top 5 Errors + Root Cause Hypothesis\n"
|
| 328 |
+
f"## Key Rotation Events\n## Sentinel Recommendations for Next Version\n\n"
|
| 329 |
+
f"Max 1000 words.",
|
| 330 |
+
max_tokens=1800,
|
| 331 |
+
timeout=240.0, # S5: longer timeout for large log sets
|
| 332 |
+
)
|
| 333 |
+
except Exception as e:
|
| 334 |
+
report = f"Weekly audit Gemini call failed: {e}"
|
| 335 |
+
|
| 336 |
+
# Write to Notion
|
| 337 |
+
await self._write_notion_incident(
|
| 338 |
+
title=f"📊 Sentinel Weekly Audit — {time.strftime('%Y-%m-%d')}",
|
| 339 |
+
content=report,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
# DM Ghost
|
| 343 |
+
dm_msg = f"📊 **SENTINEL WEEKLY AUDIT**\n\n{report[:1800]}"
|
| 344 |
+
await self._discord_dm(dm_msg)
|
| 345 |
+
|
| 346 |
+
log.info("[Sentinel] Weekly audit complete.")
|
| 347 |
+
return report
|
| 348 |
+
|
| 349 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 350 |
+
# Event handler (called from main.py /sentinel/event)
|
| 351 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 352 |
+
|
| 353 |
+
async def handle_event(self, event_type: str, payload: dict) -> dict:
|
| 354 |
+
"""
|
| 355 |
+
Central event dispatcher. Called by main.py /sentinel/event endpoint.
|
| 356 |
+
|
| 357 |
+
event_type:
|
| 358 |
+
"space_failure" → handle_space_failure()
|
| 359 |
+
"routing_override" → set_primary_space(url)
|
| 360 |
+
"health_check" → check_space_health(url)
|
| 361 |
+
"weekly_audit" → weekly_audit()
|
| 362 |
+
"project_plan" → generate_project_plan(brief)
|
| 363 |
+
"""
|
| 364 |
+
log.info(f"[Sentinel] handle_event type={event_type} payload_keys={list(payload.keys())}")
|
| 365 |
+
|
| 366 |
+
if event_type == "space_failure":
|
| 367 |
+
await self.handle_space_failure(
|
| 368 |
+
failed_url=payload.get("failed_url", ""),
|
| 369 |
+
backup_url=payload.get("backup_url", self._hf_backup_url),
|
| 370 |
+
error=payload.get("error", "unknown"),
|
| 371 |
+
)
|
| 372 |
+
return {"status": "failover_complete"}
|
| 373 |
+
|
| 374 |
+
elif event_type == "routing_override":
|
| 375 |
+
url = payload.get("url", "")
|
| 376 |
+
ok = await self.set_primary_space(url)
|
| 377 |
+
return {"status": "ok" if ok else "kv_write_failed", "primary": url}
|
| 378 |
+
|
| 379 |
+
elif event_type == "health_check":
|
| 380 |
+
url = payload.get("url", self._hf_primary_url)
|
| 381 |
+
result = await self.check_space_health(url)
|
| 382 |
+
return {"url": url, "healthy": result}
|
| 383 |
+
|
| 384 |
+
elif event_type == "weekly_audit":
|
| 385 |
+
report = await self.weekly_audit()
|
| 386 |
+
return {"status": "complete", "report_preview": report[:200]}
|
| 387 |
+
|
| 388 |
+
elif event_type == "project_plan":
|
| 389 |
+
brief = payload.get("brief", "")
|
| 390 |
+
plan = await self.generate_project_plan(brief)
|
| 391 |
+
return {"status": "complete", "plan": plan}
|
| 392 |
+
|
| 393 |
+
else:
|
| 394 |
+
log.warning(f"[Sentinel] Unknown event_type={event_type}")
|
| 395 |
+
return {"status": "unknown_event_type"}
|
| 396 |
+
|
| 397 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 398 |
+
# Project plan generation
|
| 399 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 400 |
+
|
| 401 |
+
async def generate_project_plan(self, brief: str) -> str:
|
| 402 |
+
return await self._call_sentinel(
|
| 403 |
+
f"## Project Operational Plan\n\n"
|
| 404 |
+
f"Brief: {brief}\n\n"
|
| 405 |
+
f"Generate: DevOps strategy | Memory architecture | MOA config | "
|
| 406 |
+
f"Tool assignments | Success criteria | Risk assessment. Max 600 words.",
|
| 407 |
+
max_tokens=1200,
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 411 |
+
# Notion write
|
| 412 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 413 |
+
|
| 414 |
+
async def _write_notion_incident(
|
| 415 |
+
self,
|
| 416 |
+
title: str,
|
| 417 |
+
content: str,
|
| 418 |
+
) -> None:
|
| 419 |
+
"""
|
| 420 |
+
Create a new child page under NOTION_SENTINEL_PAGE_ID.
|
| 421 |
+
S3 guard: if notion creds not set, warn and skip.
|
| 422 |
+
"""
|
| 423 |
+
if not self._notion_token or not self._notion_incident_pid:
|
| 424 |
+
log.warning("[Sentinel] Notion creds unset — skipping incident write (S3)")
|
| 425 |
+
return
|
| 426 |
+
|
| 427 |
+
headers = {
|
| 428 |
+
"Authorization": f"Bearer {self._notion_token}",
|
| 429 |
+
"Notion-Version": NOTION_API_VERSION,
|
| 430 |
+
"Content-Type": "application/json",
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
body = {
|
| 434 |
+
"parent": {"page_id": self._notion_incident_pid},
|
| 435 |
+
"properties": {
|
| 436 |
+
"title": {
|
| 437 |
+
"title": [{"type": "text", "text": {"content": title}}]
|
| 438 |
+
}
|
| 439 |
+
},
|
| 440 |
+
"children": [
|
| 441 |
+
{
|
| 442 |
+
"object": "block",
|
| 443 |
+
"type": "paragraph",
|
| 444 |
+
"paragraph": {
|
| 445 |
+
"rich_text": [{
|
| 446 |
+
"type": "text",
|
| 447 |
+
"text": {"content": content[:2000]} # Notion block limit
|
| 448 |
+
}]
|
| 449 |
+
},
|
| 450 |
+
}
|
| 451 |
+
],
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
# If content > 2000 chars, add a second paragraph block
|
| 455 |
+
if len(content) > 2000:
|
| 456 |
+
body["children"].append({
|
| 457 |
+
"object": "block",
|
| 458 |
+
"type": "paragraph",
|
| 459 |
+
"paragraph": {
|
| 460 |
+
"rich_text": [{
|
| 461 |
+
"type": "text",
|
| 462 |
+
"text": {"content": content[2000:4000]}
|
| 463 |
+
}]
|
| 464 |
+
},
|
| 465 |
+
})
|
| 466 |
+
|
| 467 |
+
try:
|
| 468 |
+
async with httpx.AsyncClient(timeout=15) as c:
|
| 469 |
+
r = await c.post(
|
| 470 |
+
f"{NOTION_API_BASE}/pages",
|
| 471 |
+
headers=headers,
|
| 472 |
+
json=body,
|
| 473 |
+
)
|
| 474 |
+
r.raise_for_status()
|
| 475 |
+
log.info(f"[Sentinel] Notion incident page created: {title}")
|
| 476 |
+
except httpx.HTTPStatusError as e:
|
| 477 |
+
log.error(
|
| 478 |
+
f"[Sentinel] Notion write failed: {e.response.status_code} "
|
| 479 |
+
f"{e.response.text[:200]}"
|
| 480 |
+
)
|
| 481 |
+
except Exception as e:
|
| 482 |
+
log.error(f"[Sentinel] Notion write exception: {e}")
|
| 483 |
+
|
| 484 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 485 |
+
# Discord DM
|
| 486 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 487 |
+
|
| 488 |
+
async def _discord_dm(self, message: str) -> None:
|
| 489 |
+
"""DM Ghost via Discord bot. S4: silent fail if token revoked."""
|
| 490 |
+
if not self._discord_bot_token or not self._discord_ghost_uid:
|
| 491 |
+
log.warning("[Sentinel] Discord bot/UID not set — skipping DM (S4)")
|
| 492 |
+
return
|
| 493 |
+
|
| 494 |
+
headers = {
|
| 495 |
+
"Authorization": f"Bot {self._discord_bot_token}",
|
| 496 |
+
"Content-Type": "application/json",
|
| 497 |
+
}
|
| 498 |
+
try:
|
| 499 |
+
async with httpx.AsyncClient(timeout=15) as c:
|
| 500 |
+
dm = await c.post(
|
| 501 |
+
"https://discord.com/api/v10/users/@me/channels",
|
| 502 |
+
headers=headers,
|
| 503 |
+
json={"recipient_id": self._discord_ghost_uid},
|
| 504 |
+
)
|
| 505 |
+
if dm.status_code not in (200, 201):
|
| 506 |
+
log.error(f"[Sentinel] DM channel creation failed: {dm.status_code}")
|
| 507 |
+
return
|
| 508 |
+
channel_id = dm.json()["id"]
|
| 509 |
+
# Chunk into 1990-char Discord safe pieces
|
| 510 |
+
for chunk in [message[i:i + 1990] for i in range(0, len(message), 1990)]:
|
| 511 |
+
await c.post(
|
| 512 |
+
f"https://discord.com/api/v10/channels/{channel_id}/messages",
|
| 513 |
+
headers=headers,
|
| 514 |
+
json={"content": chunk},
|
| 515 |
+
)
|
| 516 |
+
except Exception as e:
|
| 517 |
+
log.error(f"[Sentinel] Discord DM failed: {e}")
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 521 |
+
# Factory — called from main.py lifespan
|
| 522 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 523 |
+
|
| 524 |
+
def build_sentinel(settings: Any) -> Optional["Sentinel"]:
|
| 525 |
+
"""
|
| 526 |
+
Build Sentinel from settings. Returns None if GEMINI_SENTINEL_KEY not set.
|
| 527 |
+
Called during FastAPI lifespan — non-fatal if Sentinel unavailable.
|
| 528 |
+
"""
|
| 529 |
+
import os
|
| 530 |
+
sentinel_key = (
|
| 531 |
+
getattr(settings, "gemini_sentinel_key", "")
|
| 532 |
+
or os.environ.get("GEMINI_SENTINEL_KEY", "")
|
| 533 |
+
)
|
| 534 |
+
if not sentinel_key:
|
| 535 |
+
log.warning("[Sentinel] GEMINI_SENTINEL_KEY not set — Sentinel INACTIVE")
|
| 536 |
+
return None
|
| 537 |
+
|
| 538 |
+
return Sentinel(
|
| 539 |
+
sentinel_key=sentinel_key,
|
| 540 |
+
cf_account_id=getattr(settings, "cf_account_id", "") or os.environ.get("CF_ACCOUNT_ID", ""),
|
| 541 |
+
cf_namespace_id=getattr(settings, "cf_kv_namespace_id", "") or os.environ.get("CF_KV_NAMESPACE_ID", ""),
|
| 542 |
+
cf_kv_token=getattr(settings, "cf_kv_api_token", "") or os.environ.get("CF_KV_API_TOKEN", ""),
|
| 543 |
+
hf_primary_url=os.environ.get("HF_PRIMARY_URL", "https://ghostdrive1-ultron1.hf.space"),
|
| 544 |
+
hf_backup_url=os.environ.get("HF_BACKUP_URL", ""),
|
| 545 |
+
discord_bot_token=getattr(settings, "discord_bot_token", "") or os.environ.get("DISCORD_BOT_TOKEN", ""),
|
| 546 |
+
discord_ghost_uid=os.environ.get("DISCORD_GHOST_UID", ""),
|
| 547 |
+
notion_token=os.environ.get("NOTION_TOKEN", ""),
|
| 548 |
+
notion_incident_page_id=os.environ.get("NOTION_SENTINEL_PAGE_ID", ""),
|
| 549 |
+
supabase_url=getattr(settings, "supabase_url", "") or os.environ.get("SUPABASE_URL", ""),
|
| 550 |
+
supabase_key=getattr(settings, "supabase_key", "") or os.environ.get("SUPABASE_KEY", ""),
|
| 551 |
+
)
|