9008fbd512
Add OIDC auth for Command Center and runtime GPU endpoint selection pointed at atc-gpu-prod (10.0.10.106), matching what is currently deployed.
1503 lines
57 KiB
Python
1503 lines
57 KiB
Python
"""ATC Command Center API — FastAPI backend."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import time
|
||
import uuid
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import redis.asyncio as aioredis
|
||
from fastapi import Body, FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
import auth as cockpit_auth
|
||
from agent_terminal import (
|
||
get_all_terminals,
|
||
get_terminal_lines,
|
||
init_terminals,
|
||
make_logger,
|
||
set_terminal_publisher,
|
||
terminal_log,
|
||
)
|
||
from lab_context import collect_full_lab_context, format_context_for_agent
|
||
from presentation import build_presentation_payload, render_presentation_html
|
||
from presentation_upload import (
|
||
clear_live_override,
|
||
create_deck,
|
||
delete_deck,
|
||
get_asset_path,
|
||
get_deck,
|
||
get_live_override,
|
||
list_decks,
|
||
save_deck,
|
||
save_image,
|
||
save_live_override,
|
||
save_upload,
|
||
)
|
||
from presentation_static import get_static_deck, list_static_decks
|
||
from storage_s3 import router as storage_s3_router
|
||
from hdfs_api import router as hdfs_router
|
||
from pipeline_ops import router as pipeline_router
|
||
from hadoop_analytics import router as hadoop_router
|
||
from elasticsearch_api import router as elasticsearch_router
|
||
from sql_console import router as sql_router
|
||
from agent_ops import router as agent_ops_router, agent_dml_loop, etl_agent_loop, custodian_offload_loop
|
||
from agent_activity import agent_activity_loop
|
||
from streaming_ops import connector_autoheal_loop
|
||
from cdc_consumer import router as cdc_router, cdc_consumer_loop
|
||
from movements import router as movements_router
|
||
from movements import MOVEMENT_BY_ID, trigger_and_watch
|
||
from dataflow import router as dataflow_router
|
||
from streaming_ops import router as streaming_router
|
||
from spark_workbench import router as spark_workbench_router
|
||
from pii_catalog import router as pii_router
|
||
from trino_federated import router as federated_router
|
||
from etl_offload import router as etl_offload_router
|
||
from lineage import router as lineage_router
|
||
from dq_monitor import router as dq_router
|
||
from observability import router as observability_router
|
||
from catalog_governance import router as governance_router
|
||
from ssh_terminal import ssh_session
|
||
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
|
||
from node_ops import build_node_detail, probe_node, run_node_probe_task
|
||
from approval_service import (
|
||
APPROVAL_ACTION_TYPES,
|
||
approval_stats,
|
||
create_approval_request,
|
||
decide_approval_request,
|
||
detect_agent_proposed_action,
|
||
detect_approval_intent,
|
||
list_approvals,
|
||
)
|
||
from db import SessionLocal, db_health, init_database
|
||
from supervisor import mirror_terminal_line, mirror_to_supervisors
|
||
|
||
from workload import build_workload_payload
|
||
from gpu_config import (
|
||
get_gpu_config,
|
||
get_gpu_config_payload,
|
||
get_gpu_urls,
|
||
reset_gpu_config,
|
||
save_gpu_config,
|
||
test_gpu_target,
|
||
)
|
||
|
||
_workload_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||
_presentation_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||
WORKLOAD_CACHE_TTL = 30.0
|
||
PRESENTATION_CACHE_TTL = 45.0
|
||
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import Column, DateTime, String, Text, select
|
||
from sqlalchemy.orm import DeclarativeBase
|
||
|
||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||
DOCKHAND_URL = os.getenv("DOCKHAND_URL", "http://10.0.21.45:8082")
|
||
DOCKHAND_API_TOKEN = os.getenv("DOCKHAND_API_TOKEN", "")
|
||
|
||
|
||
def _dockhand_headers() -> dict[str, str]:
|
||
if DOCKHAND_API_TOKEN:
|
||
return {"Authorization": f"Bearer {DOCKHAND_API_TOKEN}"}
|
||
return {}
|
||
|
||
GPU_URL = os.getenv("GPU_URL", "http://10.0.10.106:9000")
|
||
GPU_UI_URL = os.getenv("GPU_UI_URL", GPU_URL)
|
||
LLM_URL = os.getenv("LLM_URL", "http://10.0.10.106:8001/v1")
|
||
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
|
||
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-local")
|
||
LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
|
||
# Llama-3-70B GPTQ on V100 is capped at 4096; keep a hard safety budget.
|
||
LLM_MAX_MODEL_LEN = int(os.getenv("LLM_MAX_MODEL_LEN", "4096"))
|
||
# Conservative estimate: Llama tokenizers often use ~2.2–2.8 chars/token on English+lab text.
|
||
LLM_CHARS_PER_TOKEN = float(os.getenv("LLM_CHARS_PER_TOKEN", "2.4"))
|
||
LLM_CONTEXT_MARGIN = int(os.getenv("LLM_CONTEXT_MARGIN", "160"))
|
||
LLM_MAX_OUTPUT = int(os.getenv("LLM_MAX_OUTPUT", "256"))
|
||
LLM_MAX_CONTEXT_CHARS = int(os.getenv("LLM_MAX_CONTEXT_CHARS", "5500"))
|
||
|
||
|
||
def _estimate_tokens(text: str) -> int:
|
||
# Slightly inflate so we never underestimate vs vLLM's tokenizer.
|
||
return max(1, int(len(text) / LLM_CHARS_PER_TOKEN) + 32)
|
||
|
||
|
||
def _compact_lab_context(context: str) -> str:
|
||
"""Keep primary section + short per-domain summaries; drop verbose inventory lines."""
|
||
lines = context.splitlines()
|
||
out: list[str] = []
|
||
in_full = False
|
||
detail = 0
|
||
max_detail = 4
|
||
for line in lines:
|
||
if line.startswith("=== FULL LAB"):
|
||
in_full = True
|
||
out.append(line)
|
||
continue
|
||
if line.startswith("=== PRIMARY"):
|
||
in_full = False
|
||
detail = 0
|
||
out.append(line)
|
||
continue
|
||
if line.startswith("--- "):
|
||
detail = 0
|
||
out.append(line)
|
||
continue
|
||
# Drop long platform-capabilities essays if present — keep a one-liner marker
|
||
if line.startswith("=== PLATFORM CAPABILITIES"):
|
||
out.append(line)
|
||
out.append(" (see Command Center UI for full feature list)")
|
||
continue
|
||
if out and out[-1].startswith(" (see Command Center"):
|
||
if line.startswith("===") or line.startswith("--- ") or line.startswith("=== AGENTS") or line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
|
||
pass
|
||
else:
|
||
continue
|
||
# Always keep masking / PII evidence sections in full (demo-critical)
|
||
if line.startswith("=== DATA MASKING") or line.startswith("=== PII MASKING"):
|
||
# flush remaining lines of this section without detail limits by marking
|
||
out.append(line)
|
||
continue
|
||
# Limit bullet detail in general lab dump, but keep masking evidence intact
|
||
keep_full = any(s in "\n".join(out[-5:]) for s in ("=== DATA MASKING", "=== PII MASKING"))
|
||
if (line.startswith(" - ") or line.startswith(" - ")) and not keep_full:
|
||
detail += 1
|
||
if detail > max_detail:
|
||
if detail == max_detail + 1:
|
||
out.append(" …")
|
||
continue
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
def _truncate_for_llm(context: str, max_chars: int) -> str:
|
||
context = _compact_lab_context(context)
|
||
if len(context) <= max_chars:
|
||
return context
|
||
# Prefer keeping PRIMARY section; cut FULL LAB first
|
||
primary_end = context.find("=== FULL LAB")
|
||
if primary_end > 200:
|
||
head = context[:primary_end].rstrip()
|
||
tail_budget = max(400, max_chars - len(head) - 80)
|
||
tail = context[primary_end: primary_end + tail_budget]
|
||
trimmed = head + "\n" + tail
|
||
else:
|
||
trimmed = context[:max_chars]
|
||
if len(trimmed) > max_chars:
|
||
trimmed = trimmed[: max_chars - 60].rsplit("\n", 1)[0]
|
||
if len(context) > len(trimmed):
|
||
trimmed += f"\n\n[… truncated for {LLM_MAX_MODEL_LEN}-token model window …]"
|
||
return trimmed
|
||
|
||
|
||
def _fit_llm_payload(system_rules: str, context: str, user_message: str) -> tuple[str, int]:
|
||
"""Fit prompt+completion into the served model length with a safety margin."""
|
||
user_tok = _estimate_tokens(user_message)
|
||
rules_tok = _estimate_tokens(system_rules)
|
||
budget = LLM_MAX_MODEL_LEN - LLM_CONTEXT_MARGIN
|
||
max_out = min(LLM_MAX_OUTPUT, 256)
|
||
|
||
# Absolute char cap first (independent of estimate errors)
|
||
context = _truncate_for_llm(context, LLM_MAX_CONTEXT_CHARS)
|
||
|
||
for _ in range(6):
|
||
ctx_budget_tok = budget - user_tok - rules_tok - max_out
|
||
if ctx_budget_tok < 200:
|
||
max_out = max(64, max_out // 2)
|
||
continue
|
||
ctx_max_chars = max(600, int(ctx_budget_tok * LLM_CHARS_PER_TOKEN * 0.85))
|
||
fitted = _truncate_for_llm(context, ctx_max_chars)
|
||
total = rules_tok + _estimate_tokens(fitted) + user_tok + max_out
|
||
if total <= budget:
|
||
return fitted, max_out
|
||
# Still too big — shrink context harder, then output
|
||
context = fitted
|
||
LLM_MAX = max(800, int(len(fitted) * 0.7))
|
||
context = _truncate_for_llm(context, LLM_MAX)
|
||
max_out = max(64, max_out - 32)
|
||
|
||
return _truncate_for_llm(context, 800), 64
|
||
|
||
|
||
AGENTS = [
|
||
{
|
||
"id": "etl-guardian",
|
||
"name": "ETL Guardian",
|
||
"color": "#00f0ff",
|
||
"zone": "etl",
|
||
"role": "Airflow, Kafka, Debezium, S3 pipeline",
|
||
"icon": "⚡",
|
||
"motto": "Pipelines never sleep",
|
||
"capabilities": ["Airflow", "Kafka", "Debezium", "S3", "Connectors"],
|
||
"suggested_prompts": [
|
||
"How is Debezium doing?",
|
||
"Are all Airflow DAGs healthy?",
|
||
"Kafka connector status?",
|
||
],
|
||
},
|
||
{
|
||
"id": "lakehouse-ops",
|
||
"name": "Lakehouse Ops",
|
||
"color": "#ff00aa",
|
||
"zone": "lakehouse",
|
||
"role": "Spark, Trino, Iceberg",
|
||
"icon": "🏔️",
|
||
"motto": "Query the lake, trust the table",
|
||
"capabilities": ["Spark", "Trino", "Iceberg", "Delta", "SQL"],
|
||
"suggested_prompts": [
|
||
"Lakehouse stack status?",
|
||
"Is Trino reachable?",
|
||
"How many lakehouse containers are running?",
|
||
],
|
||
},
|
||
{
|
||
"id": "data-custodian",
|
||
"name": "Data Custodian",
|
||
"color": "#ffaa00",
|
||
"zone": "db",
|
||
"role": "PostgreSQL, MySQL, Mongo, Cassandra, Neo4j",
|
||
"icon": "🛡️",
|
||
"motto": "Guardian of every row",
|
||
"capabilities": ["PostgreSQL", "MySQL", "MongoDB", "Cassandra", "Neo4j"],
|
||
"suggested_prompts": [
|
||
"How much data is in the databases?",
|
||
"What is in PostgreSQL sales_orders?",
|
||
"MongoDB supplychain overview",
|
||
],
|
||
},
|
||
{
|
||
"id": "hadoop-ranger",
|
||
"name": "Hadoop Ranger",
|
||
"color": "#39ff14",
|
||
"zone": "hadoop",
|
||
"role": "HDFS, YARN cluster",
|
||
"icon": "🌲",
|
||
"motto": "Patrol the data forest",
|
||
"capabilities": ["HDFS", "YARN", "NameNode", "DataNodes"],
|
||
"suggested_prompts": [
|
||
"Is HDFS NameNode up?",
|
||
"Hadoop cluster status?",
|
||
"YARN nodes healthy?",
|
||
],
|
||
},
|
||
{
|
||
"id": "infra-sentinel",
|
||
"name": "Infra Sentinel",
|
||
"color": "#9b72cf",
|
||
"zone": "docker",
|
||
"role": "Docker, Proxmox, GPU, monitoring",
|
||
"icon": "👁️",
|
||
"motto": "See everything, miss nothing",
|
||
"capabilities": ["Docker", "Proxmox", "GPU", "vLLM", "Monitoring"],
|
||
"suggested_prompts": [
|
||
"GPU status?",
|
||
"Which LLM model is running?",
|
||
"Docker container overview",
|
||
],
|
||
},
|
||
{
|
||
"id": "mo-commander",
|
||
"name": "Mo · Command",
|
||
"color": "#4c9aed",
|
||
"zone": "command",
|
||
"role": "Supervisor — full event intel, ingress, approvals",
|
||
"icon": "🎯",
|
||
"motto": "Nothing happens without Mo knowing",
|
||
"supervisor": True,
|
||
"person": "mo",
|
||
"capabilities": ["Events", "Ingress", "Approvals", "Agent dispatch", "Network IN"],
|
||
"suggested_prompts": [
|
||
"What happened today?",
|
||
"What events came in?",
|
||
"Pipeline status overview",
|
||
],
|
||
},
|
||
{
|
||
"id": "bart-commander",
|
||
"name": "Bart · Ops",
|
||
"color": "#3fb950",
|
||
"zone": "command",
|
||
"role": "Supervisor — egress, MCP comms, network OUT",
|
||
"icon": "📡",
|
||
"motto": "All traffic flows through Bart",
|
||
"supervisor": True,
|
||
"person": "bart",
|
||
"capabilities": ["Egress", "MCP routing", "Network OUT", "GPU inference", "S3 writes"],
|
||
"suggested_prompts": [
|
||
"What is leaving the cluster?",
|
||
"MCP agent communication status?",
|
||
"Network egress overview",
|
||
],
|
||
},
|
||
{
|
||
"id": "network-watcher",
|
||
"name": "Network Watcher",
|
||
"color": "#58a6ff",
|
||
"zone": "network",
|
||
"role": "VLAN 20/21 traffic, data in & out paths",
|
||
"icon": "🌐",
|
||
"motto": "Every packet tells a story",
|
||
"capabilities": ["VLAN 20", "VLAN 21", "Ingress", "Egress", "Firewall paths"],
|
||
"suggested_prompts": [
|
||
"Data ingress status?",
|
||
"What leaves the cluster?",
|
||
"Network path to S3?",
|
||
],
|
||
},
|
||
{
|
||
"id": "mcp-coordinator",
|
||
"name": "MCP Coordinator",
|
||
"color": "#f778ba",
|
||
"zone": "mcp",
|
||
"role": "MCP hub — routes all agent tool calls & comms",
|
||
"icon": "🔀",
|
||
"motto": "Route once, deliver everywhere",
|
||
"capabilities": ["MCP servers", "Tool routing", "Agent relay", "WebSocket bus"],
|
||
"suggested_prompts": [
|
||
"Which MCP agents are active?",
|
||
"MCP hub route status?",
|
||
"Agent communication overview",
|
||
],
|
||
},
|
||
]
|
||
|
||
ZONES = [
|
||
{"id": "docker", "label": "DOCKER RACK", "x": 8, "color": "#b366ff"},
|
||
{"id": "db", "label": "DB VAULT", "x": 28, "color": "#ffaa00"},
|
||
{"id": "lakehouse", "label": "LAKEHOUSE HUB", "x": 50, "color": "#ff00aa"},
|
||
{"id": "hadoop", "label": "HADOOP CLUSTER", "x": 72, "color": "#39ff14"},
|
||
{"id": "etl", "label": "ETL PIPE", "x": 92, "color": "#00f0ff"},
|
||
]
|
||
|
||
INTENT_KEYWORDS: dict[str, list[str]] = {
|
||
"data-custodian": [
|
||
"database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "cassandra", "neo4j", "sql", "db ",
|
||
"pii", "mask", "masked", "masking", "email", "e-mail", "phone", "iban", "address", "customer",
|
||
"employee", "gdpr", "privacy", "sensitive", "personal", "name", "ssn", "national_id",
|
||
],
|
||
"lakehouse-ops": ["trino", "spark", "lakehouse", "iceberg", "query", "table"],
|
||
"hadoop-ranger": [
|
||
"hadoop", "hdfs", "yarn", "datanode", "namenode", "replicatie", "replication",
|
||
"rf factor", "opslag", "bestanden", "blocks", "cluster opslag", "data op",
|
||
],
|
||
"infra-sentinel": ["docker", "container", "vm", "proxmox", "infra", "grafana", "gpu", "vllm", "llm", "nvidia", "inference", "model"],
|
||
"etl-guardian": ["airflow", "dag", "debezium", "kafka", "connector", "etl", "pipeline", "s3"],
|
||
"network-watcher": ["network", "vlan", "ingress", "egress", "traffic", "packet", "firewall", "route"],
|
||
"mcp-coordinator": ["mcp", "tool", "router", "relay", "websocket", "hub"],
|
||
"mo-commander": ["mo", "supervisor", "events", "overzicht", "alles", "gebeurd"],
|
||
"bart-commander": ["bart", "egress", "uitgaand", "communicatie", "mcp comm"],
|
||
}
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
pass
|
||
|
||
|
||
class FeedEntry(Base):
|
||
__tablename__ = "feed"
|
||
|
||
id = Column(String, primary_key=True)
|
||
ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||
agent_id = Column(String)
|
||
level = Column(String, default="info")
|
||
message = Column(Text)
|
||
|
||
|
||
class Approval(Base):
|
||
__tablename__ = "approvals"
|
||
|
||
id = Column(String, primary_key=True)
|
||
ts = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||
agent_id = Column(String)
|
||
action = Column(Text)
|
||
reason = Column(Text)
|
||
status = Column(String, default="pending")
|
||
action_type = Column(String, default="generic.mutate")
|
||
target = Column(Text, default="")
|
||
payload = Column(Text, default="{}")
|
||
decided_by = Column(String, nullable=True)
|
||
decide_note = Column(Text, nullable=True)
|
||
decided_at = Column(DateTime, nullable=True)
|
||
priority = Column(String, default="normal")
|
||
|
||
|
||
_db_info = init_database(Base)
|
||
|
||
redis_client: aioredis.Redis | None = None
|
||
ws_clients: set[WebSocket] = set()
|
||
|
||
|
||
class PromptRequest(BaseModel):
|
||
message: str = Field(min_length=1, max_length=2000)
|
||
agent_id: str | None = None
|
||
|
||
|
||
class NodeAskRequest(BaseModel):
|
||
message: str = Field(min_length=1, max_length=2000)
|
||
|
||
|
||
class ApprovalCreateRequest(BaseModel):
|
||
agent_id: str = Field(min_length=1, max_length=64)
|
||
action: str = Field(min_length=1, max_length=2000)
|
||
reason: str = Field(min_length=1, max_length=2000)
|
||
action_type: str = "generic.mutate"
|
||
target: str = ""
|
||
payload: dict[str, Any] | None = None
|
||
priority: str = "normal"
|
||
|
||
|
||
class ApprovalDecision(BaseModel):
|
||
approved: bool
|
||
decided_by: str = "mo-commander"
|
||
note: str = ""
|
||
|
||
|
||
def route_agent(message: str) -> str:
|
||
lower = message.lower()
|
||
# PII / privacy questions always go to Data Custodian (masking demo path)
|
||
pii_words = (
|
||
"pii", "mask", "masked", "masking", "email", "e-mail", "mail adres", "mail address",
|
||
"phone", "telefoon", "iban", "address", "adres", "customer name", "employee",
|
||
"gdpr", "privacy", "sensitive", "personal", "national_id", "ssn", "gevoelig",
|
||
)
|
||
if any(w in lower for w in pii_words):
|
||
return "data-custodian"
|
||
# Storage/data questions default to Hadoop unless clearly about databases
|
||
if any(w in lower for w in ("opslag", "gb", "replicatie", "replication", "hdfs", "hadoop", "datanode", "namenode")):
|
||
if not any(w in lower for w in ("postgres", "mysql", "mongo", "database", "sql", "neo4j", "cassandra", "pii", "email")):
|
||
return "hadoop-ranger"
|
||
if any(w in lower for w in ("gpu", "vllm", "llm", "nvidia", "inference", "vram", "model")):
|
||
return "infra-sentinel"
|
||
if any(w in lower for w in ("kafka", "airflow", "debezium", "connector", "etl", "pipeline", "dag")):
|
||
return "etl-guardian"
|
||
if any(w in lower for w in ("trino", "spark", "iceberg", "lakehouse")):
|
||
return "lakehouse-ops"
|
||
scores = {aid: sum(1 for kw in kws if kw in lower) for aid, kws in INTENT_KEYWORDS.items()}
|
||
best = max(scores, key=scores.get)
|
||
if scores[best] == 0:
|
||
return "infra-sentinel"
|
||
return best
|
||
|
||
|
||
|
||
def _is_pii_question(message: str) -> bool:
|
||
lower = message.lower()
|
||
return any(w in lower for w in (
|
||
"pii", "mask", "masked", "masking", "unmask", "visible", "email", "e-mail", "phone",
|
||
"iban", "address", "adres", "customer", "employee", "privacy", "gdpr", "sensitive",
|
||
"personal", "gevoelig", "name", "telefoon", "mail", "data flow", "national_id",
|
||
"ssn", "bsn", "geboorte", "birth",
|
||
))
|
||
|
||
|
||
async def _pii_evidence_block(message: str, log: Any | None = None) -> str:
|
||
"""Live policy + samples synced with Data Flow masking toggles."""
|
||
try:
|
||
from pii_catalog import build_policy_evidence
|
||
return build_policy_evidence()
|
||
except Exception as exc:
|
||
return f"=== PII MASKING EVIDENCE ===\n(unavailable: {exc})"
|
||
|
||
|
||
async def gather_agent_context(
|
||
agent_id: str,
|
||
status: dict[str, Any],
|
||
log: Any | None = None,
|
||
message: str | None = None,
|
||
) -> str:
|
||
"""Full lab snapshot for vLLM — all domains, agent's primary domain highlighted."""
|
||
snapshot = await collect_full_lab_context(gpu_data=status.get("gpu"), log=log, include_inventory=False)
|
||
snapshot["domains_summary"] = status.get("domains", {})
|
||
ctx = format_context_for_agent(agent_id, snapshot)
|
||
agent_lines = ["", "=== AGENTS & SUPERVISORS ==="]
|
||
for a in AGENTS:
|
||
sup = " [supervisor]" if a.get("supervisor") else ""
|
||
agent_lines.append(f" - {a['name']} ({a['id']}){sup}: {a['role']}")
|
||
ctx = ctx + "\n".join(agent_lines)
|
||
try:
|
||
from platform_context import build_masking_section
|
||
# Fresh policy so chat mirrors Data Flow toggles (skip huge business catalog).
|
||
ctx = ctx + "\n\n" + build_masking_section(fresh=True)
|
||
except Exception:
|
||
try:
|
||
from platform_context import build_llm_addendum
|
||
ctx = ctx + "\n\n" + build_llm_addendum()
|
||
except Exception:
|
||
pass
|
||
if message and _is_pii_question(message):
|
||
try:
|
||
evidence = await _pii_evidence_block(message, log=log)
|
||
ctx = ctx + "\n\n" + evidence
|
||
if log:
|
||
await log("ok", "fetch", "▸ PII masking evidence attached (synced with Data Flow)")
|
||
except Exception as exc:
|
||
if log:
|
||
await log("warn", "fetch", f"▸ PII evidence skipped: {exc}")
|
||
if log:
|
||
await log("info", "fetch", f"▸ Context assembled: {len(ctx)} chars for LLM")
|
||
return ctx
|
||
|
||
|
||
async def ask_llm(
|
||
agent_id: str,
|
||
message: str,
|
||
context: str,
|
||
log: Any | None = None,
|
||
) -> str | None:
|
||
agent = next(a for a in AGENTS if a["id"] == agent_id)
|
||
# Deterministic PII path: always mirror Data Flow masked vs visible toggles.
|
||
if _is_pii_question(message):
|
||
try:
|
||
from pii_catalog import format_pii_chat_answer
|
||
answer = format_pii_chat_answer(message)
|
||
if log:
|
||
await log("ok", "pii", "▸ Returning Data Flow–synced masking answer (masked + visible)")
|
||
return answer
|
||
except Exception as exc:
|
||
if log:
|
||
await log("warn", "pii", f"▸ PII answer builder failed: {exc}")
|
||
rules = f"""You are {agent['name']} ({agent['role']}) in the Dell ATC data lab.
|
||
Answer in English, briefly (max ~8 sentences). Use ONLY the live data below — never invent hosts/ports/numbers.
|
||
If data is missing or DOWN, say so.
|
||
|
||
PII / masking rules (critical — synced with Data Flow tab):
|
||
- MASKED columns: NEVER reveal raw values; quote the token 🔒 MASKED when present.
|
||
- VISIBLE columns (operator opted out in Data Flow): you MAY report the real sample values and say they are visible by policy.
|
||
- Never invent emails, phones, names, IBANs, or addresses that are not in the live samples.
|
||
- If asked what is masked vs visible, list columns from the DATA MASKING POLICY / PII EVIDENCE sections.
|
||
|
||
--- LIVE LAB DATA ---"""
|
||
fitted_ctx, max_tokens = _fit_llm_payload(rules, context, message)
|
||
system = rules + "\n" + fitted_ctx
|
||
urls = get_gpu_urls()
|
||
llm_url = urls["llm_url"]
|
||
if log:
|
||
est = _estimate_tokens(system) + _estimate_tokens(message)
|
||
await log("info", "llm", f"▸ Querying vLLM model={LLM_MODEL} @ {urls['host']} (~{est}+{max_tokens} tok)")
|
||
if len(context) > len(fitted_ctx):
|
||
await log("warn", "llm", f" context trimmed {len(context)} → {len(fitted_ctx)} chars")
|
||
await log("cmd", "llm", f"$ POST {llm_url.rstrip('/')}/chat/completions")
|
||
await log("info", "llm", f" user: {message[:160]}{'…' if len(message) > 160 else ''}")
|
||
try:
|
||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
|
||
t0 = time.monotonic()
|
||
r = await client.post(
|
||
f"{llm_url.rstrip('/')}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {LLM_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": LLM_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": message},
|
||
],
|
||
"max_tokens": max_tokens,
|
||
"temperature": 0.25,
|
||
},
|
||
)
|
||
r.raise_for_status()
|
||
content = r.json()["choices"][0]["message"]["content"].strip()
|
||
ms = int((time.monotonic() - t0) * 1000)
|
||
if content and content.strip("!"):
|
||
if log:
|
||
await log("ok", "llm", f"← vLLM response {len(content)} chars ({ms}ms)")
|
||
preview = content.replace("\n", " ")[:180]
|
||
await log("info", "llm", f" » {preview}{'…' if len(content) > 180 else ''}")
|
||
return content
|
||
if log:
|
||
await log("warn", "llm", f"← Empty or invalid LLM output ({ms}ms)")
|
||
except httpx.HTTPStatusError as exc:
|
||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||
if log:
|
||
await log("err", "llm", f"✗ vLLM HTTP {exc.response.status_code}: {detail}")
|
||
# One hard retry with a minimal context if we blew the window.
|
||
if exc.response is not None and exc.response.status_code == 400 and "maximum context length" in detail:
|
||
tiny = _truncate_for_llm(context, 1200)
|
||
system2 = rules + "\n" + tiny
|
||
max2 = 128
|
||
if log:
|
||
await log("warn", "llm", f" retry with tiny context ({len(tiny)} chars, max_tokens={max2})")
|
||
try:
|
||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
|
||
r2 = await client.post(
|
||
f"{llm_url.rstrip('/')}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {LLM_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": LLM_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": system2},
|
||
{"role": "user", "content": message},
|
||
],
|
||
"max_tokens": max2,
|
||
"temperature": 0.25,
|
||
},
|
||
)
|
||
r2.raise_for_status()
|
||
content2 = r2.json()["choices"][0]["message"]["content"].strip()
|
||
if content2:
|
||
if log:
|
||
await log("ok", "llm", f"← vLLM retry OK {len(content2)} chars")
|
||
return content2
|
||
except Exception as exc2:
|
||
if log:
|
||
await log("err", "llm", f"✗ vLLM retry failed: {exc2}")
|
||
except Exception as exc:
|
||
if log:
|
||
await log("err", "llm", f"✗ vLLM error: {exc}")
|
||
return None
|
||
|
||
|
||
def fallback_answer(agent_id: str, context: str, user_message: str = "") -> str:
|
||
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
|
||
if user_message and _is_pii_question(user_message):
|
||
try:
|
||
from pii_catalog import format_pii_chat_answer
|
||
return f"**{agent_name}**\n\n" + format_pii_chat_answer(user_message)
|
||
except Exception:
|
||
marker = "=== PII MASKING EVIDENCE"
|
||
if marker in context:
|
||
return (
|
||
f"**{agent_name}** — masking policy (synced with Data Flow):\n\n"
|
||
+ context[context.index(marker):].strip()
|
||
)
|
||
preview_lines: list[str] = []
|
||
for line in context.splitlines():
|
||
if line.startswith(("=== PRIMARY", "Health summary", "ATC Lab", "--- ")):
|
||
preview_lines.append(line)
|
||
if len(preview_lines) >= 14:
|
||
break
|
||
hint = "\n".join(preview_lines) if preview_lines else "Lab snapshot collected; LLM unavailable."
|
||
q = f"\n\nYour question: _{user_message[:200]}_" if user_message else ""
|
||
return (
|
||
f"**{agent_name}** — I could not get a reply from the GPU LLM "
|
||
f"(context window or vLLM error).{q}\n\n"
|
||
"Try a short, specific question "
|
||
"(e.g. *How many GPUs are online?* or *Is Kafka healthy?*).\n\n"
|
||
f"Quick snapshot:\n{hint}"
|
||
)
|
||
|
||
|
||
async def publish_event(event: dict[str, Any]) -> None:
|
||
payload = json.dumps(event, default=str)
|
||
if redis_client:
|
||
await redis_client.publish("ops", payload)
|
||
dead = []
|
||
for ws in ws_clients:
|
||
try:
|
||
await ws.send_text(payload)
|
||
except Exception:
|
||
dead.append(ws)
|
||
for ws in dead:
|
||
ws_clients.discard(ws)
|
||
|
||
et = event.get("type")
|
||
if et == "feed":
|
||
entry = event.get("entry") or {}
|
||
await mirror_to_supervisors(
|
||
entry.get("agent_id", "?"),
|
||
entry.get("message", ""),
|
||
level=entry.get("level", "info"),
|
||
)
|
||
elif et == "terminal":
|
||
await mirror_terminal_line(event.get("line") or {})
|
||
elif et in ("agent_dispatch", "agent_fetch", "agent_return"):
|
||
aid = event.get("agent_id", "?")
|
||
zone = event.get("zone", "")
|
||
await mirror_to_supervisors(aid, f"{et} → zone {zone}", level="info", phase="dispatch")
|
||
|
||
|
||
def add_feed(agent_id: str, message: str, level: str = "info") -> dict:
|
||
entry_id = str(uuid.uuid4())[:8]
|
||
with SessionLocal() as db:
|
||
row = FeedEntry(id=entry_id, agent_id=agent_id, message=message, level=level)
|
||
db.add(row)
|
||
db.commit()
|
||
return {
|
||
"id": entry_id,
|
||
"ts": datetime.now(timezone.utc).isoformat(),
|
||
"agent_id": agent_id,
|
||
"message": message,
|
||
"level": level,
|
||
}
|
||
|
||
|
||
async def dockhand_env_containers(env_id: int) -> list[dict]:
|
||
try:
|
||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||
r = await client.get(
|
||
f"{DOCKHAND_URL}/api/containers",
|
||
params={"env": env_id},
|
||
headers=_dockhand_headers(),
|
||
)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
async def probe_url(url: str) -> bool:
|
||
try:
|
||
async with httpx.AsyncClient(timeout=4.0, verify=False) as client:
|
||
r = await client.get(url)
|
||
return r.status_code < 500
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def collect_gpu() -> dict[str, Any]:
|
||
urls = get_gpu_urls()
|
||
cfg = get_gpu_config()
|
||
gpu_url = urls["gpu_url"]
|
||
host = urls["host"]
|
||
base = {
|
||
"ok": False,
|
||
"host": host,
|
||
"ip": host,
|
||
"ui_url": urls["gpu_ui_url"],
|
||
"config_source": cfg.get("source", "env"),
|
||
"preset_id": cfg.get("preset_id"),
|
||
"config_label": cfg.get("label"),
|
||
}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||
metrics_r, model_r, integration_r = await asyncio.gather(
|
||
client.get(f"{gpu_url}/api/gpu/metrics"),
|
||
client.get(f"{gpu_url}/api/active-model"),
|
||
client.get(f"{gpu_url}/api/integration"),
|
||
return_exceptions=True,
|
||
)
|
||
|
||
gpus: list[dict[str, Any]] = []
|
||
if isinstance(metrics_r, httpx.Response) and metrics_r.status_code == 200:
|
||
current = metrics_r.json().get("current", {})
|
||
gpus = [
|
||
{
|
||
"index": g["index"],
|
||
"name": g["name"],
|
||
"util_gpu": g.get("util_gpu", 0),
|
||
"memory_used_mib": g.get("memory_used_mib", 0),
|
||
"memory_total_mib": g.get("memory_total_mib", 0),
|
||
"temperature_c": g.get("temperature_c", 0),
|
||
"power_w": g.get("power_w", 0),
|
||
}
|
||
for g in current.get("gpus", [])
|
||
]
|
||
|
||
active_model = None
|
||
inference_active = False
|
||
vllm_url = None
|
||
|
||
if isinstance(model_r, httpx.Response) and model_r.status_code == 200:
|
||
model_data = model_r.json()
|
||
active_model = model_data.get("name")
|
||
inference_active = bool(model_data.get("inference_active"))
|
||
vllm_url = model_data.get("base_url")
|
||
|
||
if isinstance(integration_r, httpx.Response) and integration_r.status_code == 200:
|
||
integ = integration_r.json()
|
||
if not active_model:
|
||
active_model = integ.get("active_name")
|
||
if not inference_active:
|
||
inference_active = bool(integ.get("inference_active"))
|
||
if not vllm_url:
|
||
vllm_url = integ.get("recommended_base_url")
|
||
|
||
return {
|
||
**base,
|
||
"ok": len(gpus) > 0 or inference_active,
|
||
"inference_active": inference_active,
|
||
"active_model": active_model,
|
||
"vllm_url": vllm_url,
|
||
"gpu_count": len(gpus),
|
||
"gpus": gpus,
|
||
}
|
||
except Exception as exc:
|
||
return {**base, "error": str(exc)}
|
||
|
||
|
||
async def collect_status() -> dict[str, Any]:
|
||
db_containers = await dockhand_env_containers(5)
|
||
db_running = sum(1 for c in db_containers if c.get("state") == "running")
|
||
db_total = len(db_containers) or 6
|
||
|
||
lake_containers = await dockhand_env_containers(9)
|
||
lake_running = sum(1 for c in lake_containers if c.get("state") == "running")
|
||
lake_total = len(lake_containers) or 6
|
||
|
||
docker_containers = await dockhand_env_containers(1)
|
||
docker_running = sum(1 for c in docker_containers if c.get("state") == "running")
|
||
|
||
hdfs_ok = await probe_url("http://10.0.21.61:9870")
|
||
kafka_ok = await probe_url("http://10.0.21.36:9000")
|
||
airflow_ok = await probe_url("http://10.0.21.55:8080")
|
||
|
||
def level(running: int, total: int) -> str:
|
||
if total == 0:
|
||
return "unknown"
|
||
ratio = running / total
|
||
if ratio >= 0.9:
|
||
return "ok"
|
||
if ratio >= 0.5:
|
||
return "warn"
|
||
return "down"
|
||
|
||
gpu = await collect_gpu()
|
||
gpu_level = "ok" if gpu.get("ok") and gpu.get("inference_active") else ("warn" if gpu.get("ok") else "down")
|
||
gpu_label = gpu.get("active_model") or (f"{gpu.get('gpu_count', 0)} GPUs" if gpu.get("ok") else "offline")
|
||
|
||
return {
|
||
"ts": datetime.now(timezone.utc).isoformat(),
|
||
"domains": {
|
||
"docker": {"level": "ok" if docker_running >= 5 else "warn", "label": f"{docker_running} containers", "running": docker_running},
|
||
"databases": {"level": level(db_running, db_total), "label": f"{db_running}/{db_total} up", "running": db_running, "total": db_total},
|
||
"lakehouse": {"level": level(lake_running, lake_total), "label": f"{lake_running}/{lake_total} up", "running": lake_running, "total": lake_total},
|
||
"hadoop": {"level": "ok" if hdfs_ok else "warn", "label": "NN up" if hdfs_ok else "NN check"},
|
||
"etl": {"level": "ok" if kafka_ok and airflow_ok else "warn", "label": "Kafka+Airflow"},
|
||
"gpu": {"level": gpu_level, "label": gpu_label},
|
||
},
|
||
"gpu": gpu,
|
||
"kafka_ok": kafka_ok,
|
||
"airflow_ok": airflow_ok,
|
||
"hdfs_ok": hdfs_ok,
|
||
}
|
||
|
||
|
||
|
||
|
||
async def _run_agent_task_safe(agent_id: str, message: str, prompt_id: str) -> None:
|
||
try:
|
||
await run_agent_task(agent_id, message, prompt_id)
|
||
except Exception as exc:
|
||
agent_name = next((a["name"] for a in AGENTS if a["id"] == agent_id), agent_id)
|
||
err = f"Sorry — {agent_name} could not complete your request: {exc}"
|
||
await terminal_log(agent_id, f"[{prompt_id}] ✗ Error: {exc}", level="err", phase="error", prompt_id=prompt_id)
|
||
feed = add_feed(agent_id, f"{agent_name} failed: {str(exc)[:80]}", "err")
|
||
await publish_event({"type": "feed", "entry": feed})
|
||
await publish_event({
|
||
"type": "prompt_result",
|
||
"prompt_id": prompt_id,
|
||
"agent_id": agent_id,
|
||
"answer": err,
|
||
})
|
||
|
||
|
||
async def run_agent_task(agent_id: str, message: str, prompt_id: str) -> str:
|
||
zone = next(a["zone"] for a in AGENTS if a["id"] == agent_id)
|
||
agent_name = next(a["name"] for a in AGENTS if a["id"] == agent_id)
|
||
log = make_logger(agent_id, prompt_id)
|
||
approval_created = False
|
||
|
||
intent = detect_approval_intent(message)
|
||
if intent:
|
||
with SessionLocal() as db:
|
||
await create_approval_request(
|
||
db=db,
|
||
ApprovalModel=Approval,
|
||
agent_id=agent_id,
|
||
action=intent["action"],
|
||
reason=intent["reason"],
|
||
action_type=intent["action_type"],
|
||
terminal_log=terminal_log,
|
||
mirror_supervisors=mirror_to_supervisors,
|
||
publish=publish_event,
|
||
add_feed=add_feed,
|
||
)
|
||
approval_created = True
|
||
await terminal_log(
|
||
agent_id,
|
||
f"[{prompt_id}] Mutating request detected — approval queued for Mo & Bart",
|
||
level="warn",
|
||
phase="approval",
|
||
prompt_id=prompt_id,
|
||
)
|
||
|
||
await terminal_log(
|
||
agent_id,
|
||
f"[{prompt_id}] ▶ Mission accepted: {message}",
|
||
level="info",
|
||
phase="dispatch",
|
||
prompt_id=prompt_id,
|
||
)
|
||
await publish_event({"type": "agent_dispatch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||
await asyncio.sleep(0.4)
|
||
await terminal_log(agent_id, f"[{prompt_id}] Walking to zone: {zone}", level="info", phase="dispatch", prompt_id=prompt_id)
|
||
await publish_event({"type": "agent_fetch", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||
|
||
await log("info", "fetch", f"[{prompt_id}] Collecting live lab metrics…")
|
||
status = await collect_status()
|
||
context = await gather_agent_context(agent_id, status, log=log, message=message)
|
||
|
||
answer = await ask_llm(agent_id, message, context, log=log)
|
||
if not answer:
|
||
await log("warn", "llm", "LLM fallback — returning raw context")
|
||
answer = fallback_answer(agent_id, context, message)
|
||
|
||
if not approval_created:
|
||
proposed = detect_agent_proposed_action(answer, message)
|
||
if proposed:
|
||
with SessionLocal() as db:
|
||
await create_approval_request(
|
||
db=db,
|
||
ApprovalModel=Approval,
|
||
agent_id=agent_id,
|
||
action=proposed["action"],
|
||
reason=proposed["reason"],
|
||
action_type=proposed["action_type"],
|
||
target=proposed.get("target", ""),
|
||
terminal_log=terminal_log,
|
||
mirror_supervisors=mirror_to_supervisors,
|
||
publish=publish_event,
|
||
add_feed=add_feed,
|
||
)
|
||
approval_created = True
|
||
answer = (
|
||
f"{answer}\n\n⏸ **Approval required** — this action is in the Approval Inbox. "
|
||
f"Mo & Bart have been notified and must approve before we execute."
|
||
)
|
||
|
||
await asyncio.sleep(0.3)
|
||
await terminal_log(agent_id, f"[{prompt_id}] ✓ Mission complete", level="ok", phase="done", prompt_id=prompt_id)
|
||
await publish_event({"type": "agent_return", "agent_id": agent_id, "zone": zone, "prompt_id": prompt_id})
|
||
feed = add_feed(agent_id, f"{agent_name} completed a response (see Comms)", "info")
|
||
await publish_event({"type": "feed", "entry": feed})
|
||
await publish_event({"type": "prompt_result", "prompt_id": prompt_id, "agent_id": agent_id, "answer": answer})
|
||
return answer
|
||
|
||
|
||
async def heartbeat_loop() -> None:
|
||
while True:
|
||
try:
|
||
status = await collect_status()
|
||
workload = await collect_workload()
|
||
await publish_event({"type": "status", "data": status})
|
||
await publish_event({"type": "workload", "data": workload})
|
||
for domain, info in status["domains"].items():
|
||
if info["level"] == "down":
|
||
agent = "data-custodian" if domain == "databases" else "infra-sentinel"
|
||
feed = add_feed(agent, f"Alert: {domain} is DOWN ({info['label']})", "warn")
|
||
await publish_event({"type": "feed", "entry": feed})
|
||
except Exception as exc:
|
||
await publish_event({"type": "error", "message": str(exc)})
|
||
await asyncio.sleep(60)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
global redis_client
|
||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=True)
|
||
set_terminal_publisher(publish_event)
|
||
init_terminals([a["id"] for a in AGENTS] + NODE_IDS)
|
||
for a in AGENTS:
|
||
await terminal_log(a["id"], f"{a['name']} terminal online — awaiting missions", level="info", phase="boot")
|
||
for nid in NODE_IDS:
|
||
if nid not in NODE_REGISTRY:
|
||
continue
|
||
meta = NODE_REGISTRY[nid]
|
||
await terminal_log(nid, f"{meta['label']} shell ready — click node to connect", level="info", phase="boot")
|
||
task = asyncio.create_task(heartbeat_loop())
|
||
dml_task = asyncio.create_task(agent_dml_loop())
|
||
cdc_task = asyncio.create_task(cdc_consumer_loop())
|
||
etl_task = asyncio.create_task(etl_agent_loop())
|
||
cust_task = asyncio.create_task(custodian_offload_loop())
|
||
act_task = asyncio.create_task(agent_activity_loop())
|
||
heal_task = asyncio.create_task(connector_autoheal_loop())
|
||
add_feed("infra-sentinel", "ATC Command Center API online", "info")
|
||
yield
|
||
task.cancel()
|
||
dml_task.cancel()
|
||
cdc_task.cancel()
|
||
etl_task.cancel()
|
||
cust_task.cancel()
|
||
act_task.cancel()
|
||
heal_task.cancel()
|
||
if redis_client:
|
||
await redis_client.close()
|
||
|
||
|
||
app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
|
||
app.include_router(storage_s3_router)
|
||
app.include_router(hdfs_router)
|
||
app.include_router(pipeline_router)
|
||
app.include_router(hadoop_router)
|
||
app.include_router(elasticsearch_router)
|
||
app.include_router(sql_router)
|
||
app.include_router(agent_ops_router)
|
||
app.include_router(cdc_router)
|
||
app.include_router(movements_router)
|
||
app.include_router(dataflow_router)
|
||
app.include_router(streaming_router)
|
||
app.include_router(spark_workbench_router)
|
||
app.include_router(pii_router)
|
||
app.include_router(federated_router)
|
||
app.include_router(etl_offload_router)
|
||
app.include_router(lineage_router)
|
||
app.include_router(dq_router)
|
||
app.include_router(observability_router)
|
||
app.include_router(governance_router)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# Authentik OIDC session + API guard
|
||
cockpit_auth.init_auth_middleware(app)
|
||
cockpit_auth.setup_auth(app)
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
llm_ok = False
|
||
try:
|
||
async with httpx.AsyncClient(timeout=4.0) as client:
|
||
r = await client.get(f"{LLM_URL.rstrip('/')}/models", headers={"Authorization": f"Bearer {LLM_API_KEY}"})
|
||
llm_ok = r.status_code == 200
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": True,
|
||
"ts": datetime.now(timezone.utc).isoformat(),
|
||
"llm_url": LLM_URL,
|
||
"llm_ok": llm_ok,
|
||
"llm_model": LLM_MODEL,
|
||
"database": db_health(),
|
||
"db_init": _db_info,
|
||
}
|
||
|
||
|
||
async def collect_workload(*, fast: bool = True, use_cache: bool = True) -> dict[str, Any]:
|
||
import time as _time
|
||
now = _time.time()
|
||
if use_cache and _workload_cache.get("data") and now - float(_workload_cache.get("ts") or 0) < WORKLOAD_CACHE_TTL:
|
||
return _workload_cache["data"]
|
||
gpu = await collect_gpu()
|
||
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=not fast)
|
||
payload = build_workload_payload(snap)
|
||
_workload_cache["ts"] = now
|
||
_workload_cache["data"] = payload
|
||
return payload
|
||
|
||
|
||
|
||
|
||
async def get_presentation_data(*, use_cache: bool = True) -> dict[str, Any]:
|
||
import time as _time
|
||
now = _time.time()
|
||
if use_cache and _presentation_cache.get("data") and now - float(_presentation_cache.get("ts") or 0) < PRESENTATION_CACHE_TTL:
|
||
return _presentation_cache["data"]
|
||
gpu = await collect_gpu()
|
||
snap = await collect_full_lab_context(gpu_data=gpu, include_inventory=False)
|
||
data = build_presentation_payload(snap)
|
||
override = get_live_override()
|
||
if override and override.get("slides"):
|
||
data["title"] = override.get("title") or data.get("title")
|
||
data["subtitle"] = override.get("subtitle") or data.get("subtitle", "")
|
||
data["slides"] = override["slides"]
|
||
data["slide_count"] = len(override["slides"])
|
||
data["edited"] = True
|
||
data["override_ts"] = override.get("ts")
|
||
else:
|
||
data["edited"] = False
|
||
data["source"] = "live"
|
||
data["id"] = "live"
|
||
_presentation_cache["ts"] = now
|
||
_presentation_cache["data"] = data
|
||
return data
|
||
|
||
|
||
@app.get("/api/presentation")
|
||
async def get_presentation():
|
||
return await get_presentation_data()
|
||
|
||
|
||
@app.get("/api/presentation/html")
|
||
async def get_presentation_html():
|
||
from fastapi.responses import HTMLResponse
|
||
payload = await get_presentation_data()
|
||
return HTMLResponse(render_presentation_html(payload))
|
||
|
||
|
||
@app.get("/api/presentation/decks")
|
||
async def get_presentation_decks():
|
||
return {"live": True, "builtin": list_static_decks(), "uploaded": list_decks()}
|
||
|
||
|
||
@app.get("/api/presentation/decks/{deck_id}")
|
||
async def get_presentation_deck(deck_id: str):
|
||
if deck_id == "live":
|
||
return await get_presentation_data()
|
||
deck = get_static_deck(deck_id) or get_deck(deck_id)
|
||
if not deck:
|
||
return {"error": "deck not found"}
|
||
return deck
|
||
|
||
|
||
@app.get("/api/presentation/decks/{deck_id}/html")
|
||
async def get_presentation_deck_html(deck_id: str):
|
||
from fastapi.responses import HTMLResponse
|
||
if deck_id == "live":
|
||
payload = await get_presentation_data()
|
||
else:
|
||
payload = get_static_deck(deck_id) or get_deck(deck_id)
|
||
if not payload:
|
||
return HTMLResponse("<h1>Deck not found</h1>", status_code=404)
|
||
return HTMLResponse(render_presentation_html(payload))
|
||
|
||
|
||
@app.post("/api/presentation/upload")
|
||
async def upload_presentation(file: UploadFile = File(...)):
|
||
content = await file.read()
|
||
if len(content) > 50 * 1024 * 1024:
|
||
return {"error": "file too large (max 50MB)"}
|
||
deck = await save_upload(file.filename or "upload.pptx", content)
|
||
return {"ok": True, "deck": deck}
|
||
|
||
|
||
@app.post("/api/presentation/decks/new")
|
||
async def create_presentation_deck(body: dict[str, Any] | None = Body(default=None)):
|
||
body = body or {}
|
||
title = str(body.get("title") or "Untitled deck")
|
||
seed_id = body.get("from")
|
||
slides = None
|
||
if seed_id:
|
||
src = get_static_deck(seed_id) or get_deck(seed_id)
|
||
if seed_id == "live":
|
||
src = await get_presentation_data()
|
||
if src and src.get("slides"):
|
||
slides = [
|
||
{
|
||
"id": s.get("id") or f"slide-{i}",
|
||
"title": s.get("title") or f"Slide {i}",
|
||
"subtitle": s.get("subtitle") or "",
|
||
"bullets": list(s.get("bullets") or []),
|
||
"image": s.get("image") or "",
|
||
"kind": s.get("kind") or "narrative",
|
||
**({"animation": s["animation"]} if s.get("animation") else {}),
|
||
}
|
||
for i, s in enumerate(src["slides"], start=1)
|
||
]
|
||
if not title or title == "Untitled deck":
|
||
title = f"{src.get('title', 'Deck')} (copy)"
|
||
deck = create_deck(title, slides)
|
||
return {"ok": True, "deck": deck}
|
||
|
||
|
||
@app.put("/api/presentation/decks/{deck_id}")
|
||
async def update_presentation_deck(deck_id: str, body: dict[str, Any] = Body(...)):
|
||
if deck_id == "live":
|
||
save_live_override(body)
|
||
_presentation_cache["ts"] = 0
|
||
_presentation_cache["data"] = None
|
||
payload = await get_presentation_data(use_cache=False)
|
||
return {"ok": True, "deck": payload}
|
||
deck = save_deck(deck_id, body)
|
||
if not deck:
|
||
return {"error": "deck not found or not editable"}
|
||
return {"ok": True, "deck": deck}
|
||
|
||
|
||
@app.post("/api/presentation/live/reset")
|
||
async def reset_live_presentation():
|
||
clear_live_override()
|
||
_presentation_cache["ts"] = 0
|
||
_presentation_cache["data"] = None
|
||
payload = await get_presentation_data(use_cache=False)
|
||
return {"ok": True, "deck": payload}
|
||
|
||
|
||
@app.delete("/api/presentation/decks/{deck_id}")
|
||
async def remove_presentation_deck(deck_id: str):
|
||
return {"ok": delete_deck(deck_id)}
|
||
|
||
|
||
@app.post("/api/presentation/decks/{deck_id}/image")
|
||
async def upload_presentation_image(deck_id: str, file: UploadFile = File(...)):
|
||
content = await file.read()
|
||
if len(content) > 12 * 1024 * 1024:
|
||
return {"error": "image too large (max 12MB)"}
|
||
result = save_image(deck_id, file.filename or "image.png", content)
|
||
if not result:
|
||
return {"error": "deck not found"}
|
||
return {"ok": True, **result}
|
||
|
||
|
||
@app.get("/api/presentation/decks/{deck_id}/assets/{name}")
|
||
async def get_presentation_asset(deck_id: str, name: str):
|
||
from fastapi.responses import FileResponse, Response
|
||
path = get_asset_path(deck_id, name)
|
||
if not path:
|
||
return Response(status_code=404)
|
||
return FileResponse(str(path))
|
||
|
||
@app.get("/api/workload")
|
||
async def get_workload(fast: bool = True):
|
||
return await collect_workload(fast=fast, use_cache=True)
|
||
|
||
|
||
@app.get("/api/status")
|
||
async def get_status():
|
||
return await collect_status()
|
||
|
||
|
||
@app.get("/api/gpu")
|
||
async def get_gpu():
|
||
return await collect_gpu()
|
||
|
||
@app.get("/api/gpu/config")
|
||
async def get_gpu_config_endpoint():
|
||
return get_gpu_config_payload()
|
||
|
||
|
||
@app.post("/api/gpu/config")
|
||
async def post_gpu_config(body: dict[str, Any]):
|
||
try:
|
||
saved = save_gpu_config(
|
||
preset_id=body.get("preset_id"),
|
||
host=body.get("host"),
|
||
gpu_ui_port=body.get("gpu_ui_port"),
|
||
llm_port=body.get("llm_port"),
|
||
)
|
||
return {"ok": True, "active": saved, "presets": get_gpu_config_payload()["presets"]}
|
||
except ValueError as exc:
|
||
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/api/gpu/config/test")
|
||
async def post_gpu_config_test(body: dict[str, Any]):
|
||
return await test_gpu_target(
|
||
preset_id=body.get("preset_id"),
|
||
host=body.get("host"),
|
||
gpu_ui_port=body.get("gpu_ui_port"),
|
||
llm_port=body.get("llm_port"),
|
||
)
|
||
|
||
|
||
@app.delete("/api/gpu/config")
|
||
async def delete_gpu_config():
|
||
active = reset_gpu_config()
|
||
return {"ok": True, "active": active}
|
||
|
||
|
||
|
||
def agent_stats() -> dict[str, dict[str, Any]]:
|
||
stats: dict[str, dict[str, Any]] = {a["id"]: {"tasks": 0, "last_active": None, "alerts": 0} for a in AGENTS}
|
||
with SessionLocal() as db:
|
||
rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(200)).scalars().all()
|
||
for r in rows:
|
||
aid = r.agent_id
|
||
if aid not in stats:
|
||
continue
|
||
stats[aid]["tasks"] += 1
|
||
if r.level == "warn":
|
||
stats[aid]["alerts"] += 1
|
||
if stats[aid]["last_active"] is None and r.ts:
|
||
stats[aid]["last_active"] = r.ts.isoformat()
|
||
return stats
|
||
|
||
|
||
@app.get("/api/agents")
|
||
async def get_agents():
|
||
stats = agent_stats()
|
||
enriched = [{**a, "stats": stats.get(a["id"], {})} for a in AGENTS]
|
||
return {"agents": enriched, "zones": ZONES}
|
||
|
||
|
||
@app.get("/api/terminals")
|
||
async def get_terminals(limit: int = 200):
|
||
return {"terminals": get_all_terminals(limit)}
|
||
|
||
|
||
@app.get("/api/terminals/{subject_id}")
|
||
async def get_subject_terminal(subject_id: str, limit: int = 200):
|
||
valid_agents = {a["id"] for a in AGENTS}
|
||
if subject_id not in valid_agents and not is_node_id(subject_id):
|
||
return {"error": "unknown subject"}
|
||
return {"agent_id": subject_id, "lines": get_terminal_lines(subject_id, limit)}
|
||
|
||
|
||
@app.get("/api/nodes")
|
||
async def list_nodes():
|
||
workload = await collect_workload()
|
||
nodes = workload.get("topology", {}).get("nodes", [])
|
||
return {"nodes": [{"id": n["id"], "label": n["label"], "ip": n["ip"], "level": n["level"]} for n in nodes]}
|
||
|
||
|
||
@app.get("/api/nodes/{node_id}")
|
||
async def get_node(node_id: str):
|
||
if not is_node_id(node_id):
|
||
return {"error": "unknown node"}
|
||
workload = await collect_workload()
|
||
wn = next((n for n in workload.get("topology", {}).get("nodes", []) if n["id"] == node_id), None)
|
||
gpu = await collect_gpu()
|
||
snap = await collect_full_lab_context(gpu_data=gpu)
|
||
return build_node_detail(node_id, snap, wn)
|
||
|
||
|
||
@app.post("/api/nodes/{node_id}/probe")
|
||
async def post_node_probe(node_id: str):
|
||
if not is_node_id(node_id):
|
||
return {"error": "unknown node"}
|
||
asyncio.create_task(run_node_probe_task(node_id))
|
||
return {"ok": True, "node_id": node_id, "status": "probing"}
|
||
|
||
|
||
async def run_node_ask_task(node_id: str, message: str) -> None:
|
||
agent_id = NODE_AGENT.get(node_id, "infra-sentinel")
|
||
meta = NODE_REGISTRY[node_id]
|
||
await terminal_log(node_id, f"▶ Query: {message}", level="info", phase="ask")
|
||
await terminal_log(node_id, f"→ Routing to agent {agent_id}", level="info", phase="ask")
|
||
log = make_logger(node_id)
|
||
status = await collect_status()
|
||
context = await gather_agent_context(agent_id, status, log=log, message=message)
|
||
node_ctx = f"\n\n=== FOCUSED NODE: {meta['label']} ({meta['ip']}) ===\n{meta.get('description', '')}\n"
|
||
answer = await ask_llm(agent_id, message, context + node_ctx, log=log)
|
||
if not answer:
|
||
answer = fallback_answer(agent_id, context, message)
|
||
await terminal_log(node_id, f"◆ {answer}", level="llm", phase="answer")
|
||
await publish_event({"type": "node_ask_result", "node_id": node_id, "agent_id": agent_id, "answer": answer})
|
||
|
||
|
||
@app.post("/api/nodes/{node_id}/ask")
|
||
async def post_node_ask(node_id: str, body: NodeAskRequest):
|
||
if not is_node_id(node_id):
|
||
return {"error": "unknown node"}
|
||
asyncio.create_task(run_node_ask_task(node_id, body.message))
|
||
return {"ok": True, "node_id": node_id, "agent_id": NODE_AGENT.get(node_id), "status": "processing"}
|
||
|
||
|
||
@app.get("/api/feed")
|
||
async def get_feed(limit: int = 50):
|
||
with SessionLocal() as db:
|
||
rows = db.execute(select(FeedEntry).order_by(FeedEntry.ts.desc()).limit(limit)).scalars().all()
|
||
return {
|
||
"entries": [
|
||
{
|
||
"id": r.id,
|
||
"ts": r.ts.isoformat() if r.ts else None,
|
||
"agent_id": r.agent_id,
|
||
"message": r.message,
|
||
"level": r.level,
|
||
}
|
||
for r in rows
|
||
]
|
||
}
|
||
|
||
|
||
@app.get("/api/approvals")
|
||
async def get_approvals(status: str = "pending", limit: int = 100):
|
||
with SessionLocal() as db:
|
||
items = list_approvals(db, Approval, status=status, limit=limit)
|
||
stats = approval_stats(db, Approval)
|
||
return {"approvals": items, "stats": stats, "action_types": APPROVAL_ACTION_TYPES}
|
||
|
||
|
||
@app.get("/api/approvals/stats")
|
||
async def get_approval_stats():
|
||
with SessionLocal() as db:
|
||
return approval_stats(db, Approval)
|
||
|
||
|
||
@app.post("/api/approvals")
|
||
async def post_approval(body: ApprovalCreateRequest):
|
||
valid_ids = {a["id"] for a in AGENTS}
|
||
if body.agent_id not in valid_ids:
|
||
return {"error": "unknown agent_id"}
|
||
if body.action_type not in APPROVAL_ACTION_TYPES:
|
||
body.action_type = "generic.mutate"
|
||
with SessionLocal() as db:
|
||
item = await create_approval_request(
|
||
db=db,
|
||
ApprovalModel=Approval,
|
||
agent_id=body.agent_id,
|
||
action=body.action,
|
||
reason=body.reason,
|
||
action_type=body.action_type,
|
||
target=body.target,
|
||
payload=body.payload,
|
||
priority=body.priority,
|
||
terminal_log=terminal_log,
|
||
mirror_supervisors=mirror_to_supervisors,
|
||
publish=publish_event,
|
||
add_feed=add_feed,
|
||
)
|
||
return {"ok": True, "approval": item}
|
||
|
||
|
||
@app.post("/api/approvals/{approval_id}/decide")
|
||
async def decide_approval(approval_id: str, body: ApprovalDecision):
|
||
valid_supervisors = {"mo-commander", "bart-commander"}
|
||
decided_by = body.decided_by if body.decided_by in valid_supervisors else "mo-commander"
|
||
with SessionLocal() as db:
|
||
item = await decide_approval_request(
|
||
db=db,
|
||
ApprovalModel=Approval,
|
||
approval_id=approval_id,
|
||
approved=body.approved,
|
||
decided_by=decided_by,
|
||
note=body.note,
|
||
terminal_log=terminal_log,
|
||
publish=publish_event,
|
||
add_feed=add_feed,
|
||
)
|
||
if not item:
|
||
return {"error": "not found"}
|
||
|
||
# Approval-gated executor: run the underlying action once a supervisor approves it.
|
||
if item.get("status") == "approved":
|
||
payload = item.get("payload") or {}
|
||
if isinstance(payload, dict) and payload.get("executor") == "movement":
|
||
mid = payload.get("movement_id")
|
||
if mid in MOVEMENT_BY_ID:
|
||
add_feed("etl-guardian", f"Approval {approval_id} granted — executing movement '{mid}'", "info")
|
||
asyncio.create_task(trigger_and_watch(mid, payload.get("conf")))
|
||
|
||
return {"ok": True, "approval": item}
|
||
|
||
|
||
@app.post("/api/prompt")
|
||
async def post_prompt(body: PromptRequest):
|
||
prompt_id = str(uuid.uuid4())[:8]
|
||
valid_ids = {a["id"] for a in AGENTS}
|
||
agent_id = body.agent_id if body.agent_id in valid_ids else route_agent(body.message)
|
||
add_feed(agent_id, f"Prompt received: {body.message}", "info")
|
||
asyncio.create_task(_run_agent_task_safe(agent_id, body.message, prompt_id))
|
||
return {"prompt_id": prompt_id, "agent_id": agent_id, "status": "dispatched"}
|
||
|
||
|
||
@app.websocket("/api/ws/ssh")
|
||
async def ws_ssh(websocket: WebSocket):
|
||
await ssh_session(websocket)
|
||
|
||
|
||
@app.websocket("/api/ws/ops")
|
||
async def ws_ops(websocket: WebSocket):
|
||
await websocket.accept()
|
||
ws_clients.add(websocket)
|
||
try:
|
||
status = await collect_status()
|
||
workload = await collect_workload()
|
||
await websocket.send_text(json.dumps({"type": "status", "data": status}, default=str))
|
||
await websocket.send_text(json.dumps({"type": "workload", "data": workload}, default=str))
|
||
await websocket.send_text(json.dumps({
|
||
"type": "terminal_history",
|
||
"terminals": get_all_terminals(150),
|
||
}, default=str))
|
||
while True:
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
ws_clients.discard(websocket)
|