ghostdrive1 commited on
Commit
f6ec2d4
Β·
1 Parent(s): 37f231b

v26: add GET /infra/events endpoint (SpacePromoter Redis event log)

Browse files
Files changed (1) hide show
  1. packages/brain/main.py +35 -44
packages/brain/main.py CHANGED
@@ -23,6 +23,7 @@ Endpoints:
23
  GET /keys β€” Pool status + key counts per provider (website dashboard).
24
  GET /memory/stm/{channel_id} β€” Redis STM context viewer for website Memory tab.
25
  GET /rd/history/{user_id} β€” R&D loop implemented improvements for website.
 
26
 
27
  Design decisions:
28
  - asynccontextmanager lifespan (FastAPI 0.93+ pattern). No @app.on_event.
@@ -351,18 +352,7 @@ async def lifespan(app: FastAPI):
351
  _health_ping_loop(_brain_url, interval_seconds=43200)
352
  )
353
 
354
- # ── Step 10: Discord bot (daemon thread β€” discord.py owns its own event loop) ──
355
- # M7: bot only calls Brain /infer via HTTP, never touches Redis directly β€”
356
- # no cross-loop aioredis issue. Passing redis_client as reference is safe
357
- # because bot module receives it but only the on_message handler uses it
358
- # for Redis calls that run inside its own thread's event loop via discord.py.
359
- # NOTE: discord_bot._ctx_append/_ctx_get use asyncio internally. They run
360
- # in the bot's own event loop (created by discord.py in the bot thread) β€”
361
- # NOT in FastAPI's loop. The aioredis client created here is bound to
362
- # FastAPI's loop. WORKAROUND: bot module must create its OWN aioredis client
363
- # internally if Redis calls needed. For now, Redis is passed but aioredis
364
- # may raise cross-loop errors β€” tracked as M7. Mitigation: pass redis=None
365
- # until bot-side Redis is refactored to create its own client.
366
  _bot_lifecycle = getattr(app.state, "lifecycle", None)
367
  _bot_thread = threading.Thread(
368
  target=_discord_bot.run,
@@ -389,7 +379,7 @@ async def lifespan(app: FastAPI):
389
 
390
  # ── Shutdown ──────────────────────────────────────────────────────────
391
  logger.info("[Shutdown] Cancelling background tasks...")
392
- _promoter_stop.set() # signal SpacePromoter to exit cleanly
393
  _ping_task.cancel()
394
  if memory_worker_task:
395
  memory_worker_task.cancel()
@@ -411,7 +401,6 @@ async def lifespan(app: FastAPI):
411
  await redis_client.aclose()
412
  if hasattr(app.state, "zilliz_store"):
413
  await app.state.zilliz_store.close()
414
- # Discord bot thread is daemon β€” dies with process. No explicit join needed.
415
  logger.info("[Shutdown] Ultron V4 Brain stopped cleanly.")
416
 
417
 
@@ -495,7 +484,6 @@ async def infer(body: InferRequest, request: Request) -> InferResponse:
495
 
496
  dispatcher: TaskDispatcher = request.app.state.dispatcher
497
 
498
- # Fire lifecycle.ingest() as background task (non-blocking) β€” CL6 mitigation
499
  lifecycle = getattr(request.app.state, "lifecycle", None)
500
  if lifecycle is not None:
501
  asyncio.create_task(
@@ -540,11 +528,6 @@ async def infer(body: InferRequest, request: Request) -> InferResponse:
540
 
541
  @app.post("/sentinel/event")
542
  async def sentinel_event(body: SentinelEvent, request: Request) -> JSONResponse:
543
- """
544
- Sentinel writes routing decisions and incident reports here.
545
- Fully wired in v22: delegates to Sentinel.handle_event() if Sentinel active.
546
- If Sentinel inactive (no GEMINI_SENTINEL_KEY), logs event and returns 200.
547
- """
548
  settings = request.app.state.settings
549
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
550
 
@@ -558,8 +541,7 @@ async def sentinel_event(body: SentinelEvent, request: Request) -> JSONResponse:
558
 
559
  if sentinel is None:
560
  logger.warning(
561
- f"[Sentinel] Event received but Sentinel INACTIVE "
562
- f"(GEMINI_SENTINEL_KEY not set). event_type={body.event_type}"
563
  )
564
  return JSONResponse({
565
  "status": "logged_only",
@@ -587,17 +569,12 @@ async def sentinel_event(body: SentinelEvent, request: Request) -> JSONResponse:
587
 
588
  @app.get("/keys")
589
  async def keys_status(request: Request) -> JSONResponse:
590
- """
591
- Returns per-provider key pool status for the website Credentials dashboard.
592
- Auth required.
593
- """
594
  settings = request.app.state.settings
595
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
596
 
597
  pool: KeyPool = request.app.state.pool
598
  pool_status = await pool.status()
599
 
600
- # Build per-provider breakdown
601
  providers: dict[str, dict] = {}
602
  for key_info in pool_status.get("general", []):
603
  provider = key_info.get("provider", "unknown")
@@ -627,11 +604,6 @@ async def keys_status(request: Request) -> JSONResponse:
627
 
628
  @app.get("/memory/stm/{channel_id}")
629
  async def memory_stm(channel_id: str, request: Request) -> JSONResponse:
630
- """
631
- Returns the STM (short-term memory) context for a channel.
632
- Used by website Memory tab β€” STM view.
633
- Auth required.
634
- """
635
  settings = request.app.state.settings
636
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
637
 
@@ -639,24 +611,18 @@ async def memory_stm(channel_id: str, request: Request) -> JSONResponse:
639
  if redis is None:
640
  return JSONResponse({"error": "Redis not available"}, status_code=503)
641
 
642
- # Read raw Redis context window (set by discord_bot.py)
643
  ctx_key = f"ultron:ctx:{channel_id}"
644
  try:
645
  entries = await redis.lrange(ctx_key, 0, -1)
646
- messages = [
647
- e.decode() if isinstance(e, bytes) else e
648
- for e in entries
649
- ]
650
  except Exception as e:
651
  logger.warning(f"[/memory/stm] Redis read failed: {e}")
652
  return JSONResponse({"error": str(e)}, status_code=500)
653
 
654
- # Also return lifecycle STM if available
655
  lifecycle = getattr(request.app.state, "lifecycle", None)
656
  lifecycle_cells: List[dict] = []
657
  if lifecycle is not None:
658
  try:
659
- # Use channel_id as user_id proxy for STM lookup (cells stored per user_id)
660
  cells = await lifecycle.get_stm(channel_id)
661
  lifecycle_cells = [c.to_dict() for c in cells]
662
  except Exception as e:
@@ -673,11 +639,6 @@ async def memory_stm(channel_id: str, request: Request) -> JSONResponse:
673
 
674
  @app.get("/rd/history/{user_id}")
675
  async def rd_history(user_id: str, request: Request) -> JSONResponse:
676
- """
677
- Returns the R&D loop implemented improvements for a user.
678
- Used by website Projects tab β€” R&D history view.
679
- Auth required.
680
- """
681
  settings = request.app.state.settings
682
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
683
 
@@ -701,6 +662,36 @@ async def rd_history(user_id: str, request: Request) -> JSONResponse:
701
  })
702
 
703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  # ---------------------------------------------------------------------------
705
  # Entrypoint (uvicorn)
706
  # ---------------------------------------------------------------------------
 
23
  GET /keys β€” Pool status + key counts per provider (website dashboard).
24
  GET /memory/stm/{channel_id} β€” Redis STM context viewer for website Memory tab.
25
  GET /rd/history/{user_id} β€” R&D loop implemented improvements for website.
26
+ GET /infra/events β€” SpacePromoter Redis event log for website Sentinel tab.
27
 
28
  Design decisions:
29
  - asynccontextmanager lifespan (FastAPI 0.93+ pattern). No @app.on_event.
 
352
  _health_ping_loop(_brain_url, interval_seconds=43200)
353
  )
354
 
355
+ # ── Step 10: Discord bot (daemon thread) ──────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
356
  _bot_lifecycle = getattr(app.state, "lifecycle", None)
357
  _bot_thread = threading.Thread(
358
  target=_discord_bot.run,
 
379
 
380
  # ── Shutdown ──────────────────────────────────────────────────────────
381
  logger.info("[Shutdown] Cancelling background tasks...")
382
+ _promoter_stop.set()
383
  _ping_task.cancel()
384
  if memory_worker_task:
385
  memory_worker_task.cancel()
 
401
  await redis_client.aclose()
402
  if hasattr(app.state, "zilliz_store"):
403
  await app.state.zilliz_store.close()
 
404
  logger.info("[Shutdown] Ultron V4 Brain stopped cleanly.")
405
 
406
 
 
484
 
485
  dispatcher: TaskDispatcher = request.app.state.dispatcher
486
 
 
487
  lifecycle = getattr(request.app.state, "lifecycle", None)
488
  if lifecycle is not None:
489
  asyncio.create_task(
 
528
 
529
  @app.post("/sentinel/event")
530
  async def sentinel_event(body: SentinelEvent, request: Request) -> JSONResponse:
 
 
 
 
 
531
  settings = request.app.state.settings
532
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
533
 
 
541
 
542
  if sentinel is None:
543
  logger.warning(
544
+ f"[Sentinel] Event received but Sentinel INACTIVE. event_type={body.event_type}"
 
545
  )
546
  return JSONResponse({
547
  "status": "logged_only",
 
569
 
570
  @app.get("/keys")
571
  async def keys_status(request: Request) -> JSONResponse:
 
 
 
 
572
  settings = request.app.state.settings
573
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
574
 
575
  pool: KeyPool = request.app.state.pool
576
  pool_status = await pool.status()
577
 
 
578
  providers: dict[str, dict] = {}
579
  for key_info in pool_status.get("general", []):
580
  provider = key_info.get("provider", "unknown")
 
604
 
605
  @app.get("/memory/stm/{channel_id}")
606
  async def memory_stm(channel_id: str, request: Request) -> JSONResponse:
 
 
 
 
 
607
  settings = request.app.state.settings
608
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
609
 
 
611
  if redis is None:
612
  return JSONResponse({"error": "Redis not available"}, status_code=503)
613
 
 
614
  ctx_key = f"ultron:ctx:{channel_id}"
615
  try:
616
  entries = await redis.lrange(ctx_key, 0, -1)
617
+ messages = [e.decode() if isinstance(e, bytes) else e for e in entries]
 
 
 
618
  except Exception as e:
619
  logger.warning(f"[/memory/stm] Redis read failed: {e}")
620
  return JSONResponse({"error": str(e)}, status_code=500)
621
 
 
622
  lifecycle = getattr(request.app.state, "lifecycle", None)
623
  lifecycle_cells: List[dict] = []
624
  if lifecycle is not None:
625
  try:
 
626
  cells = await lifecycle.get_stm(channel_id)
627
  lifecycle_cells = [c.to_dict() for c in cells]
628
  except Exception as e:
 
639
 
640
  @app.get("/rd/history/{user_id}")
641
  async def rd_history(user_id: str, request: Request) -> JSONResponse:
 
 
 
 
 
642
  settings = request.app.state.settings
643
  _check_auth(request, getattr(settings, "ultron_auth_token", ""))
644
 
 
662
  })
663
 
664
 
665
+ @app.get("/infra/events")
666
+ async def infra_events(request: Request) -> JSONResponse:
667
+ """
668
+ Returns last 100 SpacePromoter infrastructure events from Redis.
669
+ Used by website Sentinel tab β€” Infrastructure Events card.
670
+ Auth required.
671
+ Key: ultron:infra:events (list, JSON entries, written by space_promoter.py)
672
+ """
673
+ settings = request.app.state.settings
674
+ _check_auth(request, getattr(settings, "ultron_auth_token", ""))
675
+
676
+ redis = getattr(request.app.state, "redis", None)
677
+ if redis is None:
678
+ return JSONResponse([], status_code=200) # degrade gracefully β€” no Redis
679
+
680
+ try:
681
+ raw_entries = await redis.lrange("ultron:infra:events", 0, -1)
682
+ events = []
683
+ for entry in raw_entries:
684
+ try:
685
+ decoded = entry.decode() if isinstance(entry, bytes) else entry
686
+ events.append(json.loads(decoded))
687
+ except Exception:
688
+ events.append({"raw": str(entry)})
689
+ return JSONResponse(events)
690
+ except Exception as e:
691
+ logger.warning(f"[/infra/events] Redis read failed: {e}")
692
+ return JSONResponse([], status_code=200)
693
+
694
+
695
  # ---------------------------------------------------------------------------
696
  # Entrypoint (uvicorn)
697
  # ---------------------------------------------------------------------------