Files
microdao-daarion/services/city-service/routes_city.py

2604 lines
98 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
City Backend API Routes
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request, UploadFile, File, Form
from pydantic import BaseModel
from typing import List, Optional
import logging
import httpx
import os
import io
import uuid
from PIL import Image
import shutil
from models_city import (
CityRoomRead,
CityRoomCreate,
CityRoomDetail,
CityRoomMessageRead,
CityRoomMessageCreate,
CityFeedEventRead,
CityMapRoom,
CityMapConfig,
CityMapResponse,
AgentRead,
AgentPresence,
AgentSummary,
MicrodaoBadge,
HomeNodeView,
NodeProfile,
PublicCitizenSummary,
PublicCitizenProfile,
CitizenInteractionInfo,
CitizenAskRequest,
CitizenAskResponse,
AgentMicrodaoMembership,
MicrodaoSummary,
MicrodaoDetail,
MicrodaoAgentView,
MicrodaoChannelView,
MicrodaoCitizenView,
MicrodaoOption,
CityRoomSummary,
MicrodaoRoomsList,
MicrodaoRoomUpdate,
AttachExistingRoomRequest
)
import repo_city
from common.redis_client import PresenceRedis, get_redis
from matrix_client import create_matrix_room, find_matrix_room_by_alias
from dagi_router_client import get_dagi_router_client, DagiRouterClient
logger = logging.getLogger(__name__)
# JWT validation (simplified for MVP)
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020")
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025")
# Helper for image processing
def process_image(image_bytes: bytes, target_size: tuple = (256, 256)) -> tuple[bytes, bytes]:
"""
Process image:
1. Convert to PNG
2. Resize/Crop to target_size (default 256x256)
3. Generate thumbnail 128x128
Returns (processed_bytes, thumb_bytes)
"""
with Image.open(io.BytesIO(image_bytes)) as img:
# Convert to RGBA/RGB
if img.mode in ('P', 'CMYK'):
img = img.convert('RGBA')
# Resize/Crop to target_size
img_ratio = img.width / img.height
target_ratio = target_size[0] / target_size[1]
if img_ratio > target_ratio:
# Wider than target
new_height = target_size[1]
new_width = int(new_height * img_ratio)
else:
# Taller than target
new_width = target_size[0]
new_height = int(new_width / img_ratio)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Center crop
left = (new_width - target_size[0]) / 2
top = (new_height - target_size[1]) / 2
right = (new_width + target_size[0]) / 2
bottom = (new_height + target_size[1]) / 2
img = img.crop((left, top, right, bottom))
# Save processed
processed_io = io.BytesIO()
img.save(processed_io, format='PNG', optimize=True)
processed_bytes = processed_io.getvalue()
# Thumbnail
img.thumbnail((128, 128))
thumb_io = io.BytesIO()
img.save(thumb_io, format='PNG', optimize=True)
thumb_bytes = thumb_io.getvalue()
return processed_bytes, thumb_bytes
router = APIRouter(prefix="/city", tags=["city"])
public_router = APIRouter(prefix="/public", tags=["public"])
api_router = APIRouter(prefix="/api/v1", tags=["api_v1"])
class MicrodaoMembershipPayload(BaseModel):
microdao_id: str
role: Optional[str] = None
is_core: bool = False
# =============================================================================
# Agents API (for Agent Console)
# =============================================================================
@public_router.get("/agents")
async def list_agents(
kind: Optional[str] = Query(None, description="Filter by agent kind"),
node_id: Optional[str] = Query(None, description="Filter by node_id"),
microdao_id: Optional[str] = Query(None, description="Filter by microDAO id"),
is_public: Optional[bool] = Query(None, description="Filter by public status"),
visibility_scope: Optional[str] = Query(None, description="Filter by visibility: global, microdao, private"),
include_system: bool = Query(True, description="Include system agents"),
limit: int = Query(100, le=200),
offset: int = Query(0, ge=0)
):
"""Список всіх агентів для Agent Console (unified API)"""
try:
kinds_list = [kind] if kind else None
agents, total = await repo_city.list_agent_summaries(
node_id=node_id,
microdao_id=microdao_id,
is_public=is_public,
visibility_scope=visibility_scope,
kinds=kinds_list,
include_system=include_system,
limit=limit,
offset=offset
)
items: List[AgentSummary] = []
for agent in agents:
# Build home_node if available
home_node_data = agent.get("home_node")
home_node = None
if home_node_data:
home_node = HomeNodeView(
id=home_node_data.get("id"),
name=home_node_data.get("name"),
hostname=home_node_data.get("hostname"),
roles=home_node_data.get("roles", []),
environment=home_node_data.get("environment")
)
# Build microdao badges
microdaos = [
MicrodaoBadge(
id=m.get("id", ""),
name=m.get("name", ""),
slug=m.get("slug"),
role=m.get("role")
)
for m in agent.get("microdaos", [])
]
items.append(AgentSummary(
id=agent["id"],
slug=agent.get("slug"),
display_name=agent["display_name"],
title=agent.get("title"),
tagline=agent.get("tagline"),
kind=agent.get("kind", "assistant"),
avatar_url=agent.get("avatar_url"),
status=agent.get("status", "offline"),
node_id=agent.get("node_id"),
node_label=agent.get("node_label"),
home_node=home_node,
# Governance & DAIS (A1, A2)
gov_level=agent.get("gov_level"),
dais_identity_id=agent.get("dais_identity_id"),
visibility_scope=agent.get("visibility_scope", "city"),
is_listed_in_directory=agent.get("is_listed_in_directory", True),
is_system=agent.get("is_system", False),
is_public=agent.get("is_public", False),
is_orchestrator=agent.get("is_orchestrator", False),
# MicroDAO (A3)
primary_microdao_id=agent.get("primary_microdao_id"),
primary_microdao_name=agent.get("primary_microdao_name"),
primary_microdao_slug=agent.get("primary_microdao_slug"),
home_microdao_id=agent.get("home_microdao_id"),
home_microdao_name=agent.get("home_microdao_name"),
home_microdao_slug=agent.get("home_microdao_slug"),
district=agent.get("district"),
microdaos=microdaos,
microdao_memberships=agent.get("microdao_memberships", []),
public_skills=agent.get("public_skills", [])
))
return {"items": items, "total": total}
except Exception as e:
logger.error(f"Failed to list agents: {e}")
raise HTTPException(status_code=500, detail="Failed to list agents")
class AgentVisibilityPayload(BaseModel):
"""Agent visibility update payload (Task 039)"""
is_public: bool
public_title: Optional[str] = None
public_tagline: Optional[str] = None
public_slug: Optional[str] = None
visibility_scope: Optional[str] = None # 'global' | 'microdao' | 'private'
@router.put("/agents/{agent_id}/visibility")
async def update_agent_visibility_endpoint(
agent_id: str,
payload: AgentVisibilityPayload
):
"""Оновити налаштування видимості агента (Task 039)"""
try:
# Validate visibility_scope if provided
valid_scopes = ("global", "microdao", "private", "city", "owner_only") # support legacy too
if payload.visibility_scope and payload.visibility_scope not in valid_scopes:
raise HTTPException(
status_code=400,
detail=f"visibility_scope must be one of: {', '.join(valid_scopes)}"
)
# Normalize legacy values
scope = payload.visibility_scope
if scope == "city":
scope = "global"
elif scope == "owner_only":
scope = "private"
# Validate: if is_public, slug is required
if payload.is_public and not payload.public_slug:
# If slug is missing but we have agent_id, maybe we can't generate it here safely without duplicate check?
# The prompt says "якщо is_public = true → slug обовʼязковий"
# But let's see if we can fetch existing slug if not provided?
# For now, enforce requirement as per prompt
raise HTTPException(status_code=400, detail="public_slug is required when is_public is true")
# Validate slug format if provided
if payload.public_slug:
import re
if not re.match(r'^[a-z0-9_-]+$', payload.public_slug.lower()):
raise HTTPException(status_code=400, detail="public_slug must contain only lowercase letters, numbers, underscores, and hyphens")
# Use unified update_agent_public_profile
# We need to fetch existing values for fields not provided?
# repo_city.update_agent_public_profile replaces values.
# So we should probably fetch the agent first to preserve other fields if they are None in payload?
# But the payload fields are optional.
# Let's assume if they are passed as None, we might want to keep them or clear them?
# Usually PUT replaces. PATCH updates. This is PUT.
# But let's be safe and fetch current profile to merge if needed, or just update what we have.
# Actually, update_agent_public_profile updates all passed fields.
# Let's fetch current to ensure we don't wipe out existing data if payload sends None but we want to keep it.
# However, the prompt implies these are the fields to update.
current_profile = await repo_city.get_agent_public_profile(agent_id)
if not current_profile:
raise HTTPException(status_code=404, detail="Agent not found")
result = await repo_city.update_agent_public_profile(
agent_id=agent_id,
is_public=payload.is_public,
public_slug=payload.public_slug or current_profile.get("public_slug"),
public_title=payload.public_title if payload.public_title is not None else current_profile.get("public_title"),
public_tagline=payload.public_tagline if payload.public_tagline is not None else current_profile.get("public_tagline"),
public_skills=current_profile.get("public_skills"), # Preserve skills
public_district=current_profile.get("public_district"), # Preserve district
public_primary_room_slug=current_profile.get("public_primary_room_slug") # Preserve room
)
# Also update visibility_scope if provided
if scope:
await repo_city.update_agent_visibility(
agent_id=agent_id,
is_public=payload.is_public,
visibility_scope=scope
)
result["visibility_scope"] = scope
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update agent visibility: {e}")
raise HTTPException(status_code=500, detail="Failed to update visibility")
# =============================================================================
# Assets & Branding API (Task 042)
# =============================================================================
@router.post("/assets/upload")
async def upload_asset(
file: UploadFile = File(...),
type: str = Form(...) # microdao_logo, microdao_banner, room_logo, room_banner
):
"""Upload asset (logo/banner) with auto-processing"""
try:
# Validate type
if type not in ['microdao_logo', 'microdao_banner', 'room_logo', 'room_banner']:
raise HTTPException(status_code=400, detail="Invalid asset type")
# Validate file size (5MB limit) - done by reading content
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 5MB)")
# Process image
target_size = (256, 256)
if 'banner' in type:
target_size = (1200, 400) # Standard banner size
processed_bytes, thumb_bytes = process_image(content, target_size=target_size)
# Save to disk
filename = f"{uuid.uuid4()}.png"
filepath = f"static/uploads/{filename}"
thumb_filepath = f"static/uploads/thumb_{filename}"
with open(filepath, "wb") as f:
f.write(processed_bytes)
with open(thumb_filepath, "wb") as f:
f.write(thumb_bytes)
# Construct URLs
base_url = "/static/uploads"
return {
"original_url": f"{base_url}/{filename}",
"processed_url": f"{base_url}/{filename}",
"thumb_url": f"{base_url}/thumb_{filename}"
}
except Exception as e:
logger.error(f"Upload failed: {e}")
raise HTTPException(status_code=500, detail="Upload failed")
class BrandingUpdatePayload(BaseModel):
logo_url: Optional[str] = None
banner_url: Optional[str] = None
@router.patch("/microdao/{slug}/branding")
async def update_microdao_branding_endpoint(slug: str, payload: BrandingUpdatePayload):
"""Update MicroDAO branding"""
try:
# Check exists
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail="MicroDAO not found")
# Update
result = await repo_city.update_microdao_branding(
microdao_slug=slug,
logo_url=payload.logo_url,
banner_url=payload.banner_url
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update branding: {e}")
raise HTTPException(status_code=500, detail="Failed to update branding")
@router.patch("/rooms/{room_id}/branding")
async def update_room_branding_endpoint(room_id: str, payload: BrandingUpdatePayload):
"""Update Room branding"""
try:
# Check exists
room = await repo_city.get_room_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Update
result = await repo_city.update_room_branding(
room_id=room_id,
logo_url=payload.logo_url,
banner_url=payload.banner_url
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update room branding: {e}")
raise HTTPException(status_code=500, detail="Failed to update room branding")
# =============================================================================
# Nodes API (for Node Directory)
# =============================================================================
@public_router.get("/nodes/join/instructions")
async def get_node_join_instructions():
"""
Отримати інструкції з підключення нової ноди.
"""
instructions = """
# Як підключити нову ноду до DAARION
Вітаємо! Ви вирішили розширити обчислювальну потужність мережі DAARION.
Цей гайд допоможе вам розгорнути власну ноду та підключити її до кластера.
## Вимоги до заліза (Мінімальні)
- **CPU**: 4 cores
- **RAM**: 16 GB (рекомендовано 32+ GB для LLM)
- **Disk**: 100 GB SSD
- **OS**: Ubuntu 22.04 LTS / Debian 11+
- **Network**: Статична IP адреса, відкриті порти
## Крок 1: Підготовка сервера
Встановіть Docker та Docker Compose:
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
```
## Крок 2: Отримання токенів
Для підключення вам знадобляться:
1. **NATS Connection URL** (від адміністратора)
2. **NATS Credentials File** (`.creds`) (від адміністратора)
Зверніться до адміністраторів мережі у [Discord/Matrix], щоб отримати доступ.
## Крок 3: Розгортання Node Runtime
Створіть директорію `daarion-node` та файл `docker-compose.yml`:
```yaml
version: '3.8'
services:
# 1. NATS Leaf Node (міст до ядра)
nats-leaf:
image: nats:2.10-alpine
volumes:
- ./nats.conf:/etc/nats/nats.conf
- ./creds:/etc/nats/creds
ports:
- "4222:4222"
# 2. Node Registry (реєстрація в мережі)
node-registry:
image: daarion/node-registry:latest
environment:
- NODE_ID=my-node-01 # Змініть на унікальне ім'я
- NATS_URL=nats://nats-leaf:4222
- REGION=eu-central
depends_on:
- nats-leaf
# 3. Ollama (AI Runtime)
ollama:
image: ollama/ollama:latest
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
ollama_data:
```
## Крок 4: Запуск
```bash
docker compose up -d
```
## Крок 5: Перевірка
Перейдіть у консоль **Nodes** на https://app.daarion.space/nodes.
Ваша нода має з'явитися у списку зі статусом **Online**.
"""
return {"content": instructions}
@public_router.get("/nodes")
async def list_nodes():
"""Список всіх нод мережі"""
try:
nodes = await repo_city.get_all_nodes()
items: List[NodeProfile] = []
for node in nodes:
items.append(NodeProfile(
node_id=node["node_id"],
name=node["name"],
hostname=node.get("hostname"),
roles=list(node.get("roles") or []),
environment=node.get("environment", "unknown"),
status=node.get("status", "offline"),
gpu_info=node.get("gpu"),
agents_total=node.get("agents_total", 0),
agents_online=node.get("agents_online", 0),
last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None
))
return {"items": items, "total": len(items)}
except Exception as e:
logger.error(f"Failed to list nodes: {e}")
raise HTTPException(status_code=500, detail="Failed to list nodes")
@public_router.get("/nodes/{node_id}")
async def get_node_profile(node_id: str):
"""Отримати профіль ноди з Guardian та Steward агентами"""
try:
node = await repo_city.get_node_by_id(node_id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
# Build guardian agent summary
guardian_agent = None
if node.get("guardian_agent"):
from models_city import NodeAgentSummary
guardian_agent = NodeAgentSummary(
id=node["guardian_agent"]["id"],
name=node["guardian_agent"]["name"],
kind=node["guardian_agent"].get("kind"),
slug=node["guardian_agent"].get("slug")
)
# Build steward agent summary
steward_agent = None
if node.get("steward_agent"):
from models_city import NodeAgentSummary
steward_agent = NodeAgentSummary(
id=node["steward_agent"]["id"],
name=node["steward_agent"]["name"],
kind=node["steward_agent"].get("kind"),
slug=node["steward_agent"].get("slug")
)
return NodeProfile(
node_id=node["node_id"],
name=node["name"],
hostname=node.get("hostname"),
roles=list(node.get("roles") or []),
environment=node.get("environment", "unknown"),
status=node.get("status", "offline"),
gpu_info=node.get("gpu"),
agents_total=node.get("agents_total", 0),
agents_online=node.get("agents_online", 0),
last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None,
guardian_agent_id=node.get("guardian_agent_id"),
steward_agent_id=node.get("steward_agent_id"),
guardian_agent=guardian_agent,
steward_agent=steward_agent
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get node {node_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to get node")
# =============================================================================
# Public Citizens API
# =============================================================================
@public_router.get("/citizens")
async def list_public_citizens(
district: Optional[str] = Query(None, description="Filter by district"),
kind: Optional[str] = Query(None, description="Filter by agent kind"),
q: Optional[str] = Query(None, description="Search by display name or title"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0)
):
"""Публічний список громадян з фільтрами"""
try:
citizens, total = await repo_city.get_public_citizens(
district=district,
kind=kind,
q=q,
limit=limit,
offset=offset
)
items: List[PublicCitizenSummary] = []
for citizen in citizens:
# Build home_node if available
home_node_data = citizen.get("home_node")
home_node = None
if home_node_data:
home_node = HomeNodeView(
id=home_node_data.get("id"),
name=home_node_data.get("name"),
hostname=home_node_data.get("hostname"),
roles=home_node_data.get("roles", []),
environment=home_node_data.get("environment")
)
items.append(PublicCitizenSummary(
slug=citizen["public_slug"],
display_name=citizen["display_name"],
public_title=citizen.get("public_title"),
public_tagline=citizen.get("public_tagline"),
avatar_url=citizen.get("avatar_url"),
kind=citizen.get("kind"),
district=citizen.get("public_district"),
primary_room_slug=citizen.get("public_primary_room_slug"),
public_skills=citizen.get("public_skills", []),
online_status=citizen.get("online_status"),
status=citizen.get("status"),
home_node=home_node
))
return {"items": items, "total": total}
except Exception as e:
logger.error(f"Failed to list public citizens: {e}")
raise HTTPException(status_code=500, detail="Failed to list public citizens")
@public_router.get("/citizens/{slug}")
async def get_public_citizen(slug: str, request: Request):
"""Отримати публічний профіль громадянина"""
try:
include_admin_url = False
authorization = request.headers.get("Authorization")
if authorization:
user_info = await validate_jwt_token(authorization)
if user_info:
roles = user_info.get("roles", [])
if any(role in ["admin", "architect"] for role in roles):
include_admin_url = True
citizen = await repo_city.get_public_citizen_by_slug(slug)
if not citizen:
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
if not include_admin_url:
citizen["admin_panel_url"] = None
return PublicCitizenProfile(**citizen)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get public citizen {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to get citizen")
@public_router.get("/citizens/{slug}/interaction", response_model=CitizenInteractionInfo)
async def get_citizen_interaction_info(slug: str):
"""Отримати інформацію для взаємодії з громадянином"""
try:
agent = await repo_city.get_public_agent_by_slug(slug)
if not agent:
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
matrix_config = await repo_city.get_agent_matrix_config(agent["id"])
matrix_user_id = matrix_config.get("matrix_user_id") if matrix_config else None
primary_room_slug = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
primary_room_id = matrix_config.get("primary_room_id") if matrix_config else None
primary_room_name = None
room_record = None
if primary_room_id:
room_record = await repo_city.get_room_by_id(primary_room_id)
elif primary_room_slug:
room_record = await repo_city.get_room_by_slug(primary_room_slug)
if room_record:
primary_room_id = room_record.get("id")
primary_room_name = room_record.get("name")
primary_room_slug = room_record.get("slug") or primary_room_slug
microdao = await repo_city.get_microdao_for_agent(agent["id"])
return CitizenInteractionInfo(
slug=slug,
display_name=agent["display_name"],
primary_room_slug=primary_room_slug,
primary_room_id=primary_room_id,
primary_room_name=primary_room_name,
matrix_user_id=matrix_user_id,
district=agent.get("public_district"),
microdao_slug=microdao.get("slug") if microdao else None,
microdao_name=microdao.get("name") if microdao else None,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get interaction info for citizen {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to load interaction info")
@public_router.post("/citizens/{slug}/ask", response_model=CitizenAskResponse)
async def ask_citizen(
slug: str,
payload: CitizenAskRequest,
router_client: DagiRouterClient = Depends(get_dagi_router_client),
):
"""Надіслати запитання громадянину через DAGI Router"""
question = (payload.question or "").strip()
if not question:
raise HTTPException(status_code=400, detail="Question is required")
try:
agent = await repo_city.get_public_agent_by_slug(slug)
if not agent:
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
router_response = await router_client.ask_agent(
agent_id=agent["id"],
prompt=question,
system_prompt=payload.context,
)
answer = (
router_response.get("response")
or router_response.get("answer")
or router_response.get("result")
)
if answer:
answer = answer.strip()
if not answer:
answer = "Вибач, агент наразі не може відповісти."
return CitizenAskResponse(
answer=answer,
agent_display_name=agent["display_name"],
agent_id=agent["id"],
)
except HTTPException:
raise
except httpx.HTTPError as e:
logger.error(f"DAGI Router request failed for citizen {slug}: {e}")
raise HTTPException(status_code=502, detail="Citizen is temporarily unavailable")
except Exception as e:
logger.error(f"Failed to ask citizen {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to ask citizen")
# =============================================================================
# API v1 — Node Dashboard
# =============================================================================
@api_router.get("/nodes/{node_id}/dashboard")
async def get_node_dashboard(node_id: str):
"""
Отримати мінімальний Dashboard для ноди (MVP).
Повертає базову інформацію + placeholder для метрик:
- node_id, name, kind, status
- tags (roles)
- agents_total, agents_online
- uptime (null — placeholder)
- metrics_available (false — placeholder)
"""
try:
node = await repo_city.get_node_by_id(node_id)
if not node:
raise HTTPException(status_code=404, detail=f"Node not found: {node_id}")
return {
"node_id": node["node_id"],
"name": node["name"],
"kind": node.get("kind"),
"status": node.get("status", "unknown"),
"tags": list(node.get("roles") or []),
"agents_total": node.get("agents_total", 0),
"agents_online": node.get("agents_online", 0),
"uptime": None, # placeholder — буде заповнено коли з'явиться Prometheus/NATS
"metrics_available": False # прапорець, що розширеного дашборду ще немає
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get node dashboard for {node_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to get node dashboard")
# Legacy endpoint for frontend compatibility
@api_router.get("/node/dashboard")
async def get_node_dashboard_legacy(node_id: str = Query(..., description="Node ID")):
"""
Legacy endpoint: /api/v1/node/dashboard?nodeId=...
Redirects to canonical /api/v1/nodes/{node_id}/dashboard
"""
return await get_node_dashboard(node_id)
# =============================================================================
# API v1 — MicroDAO Membership
# =============================================================================
@api_router.get("/microdao/options")
async def get_microdao_options():
"""Отримати список MicroDAO для селектора"""
try:
options = await repo_city.get_microdao_options()
items = [MicrodaoOption(**option) for option in options]
return {"items": items}
except Exception as e:
logger.error(f"Failed to get microdao options: {e}")
raise HTTPException(status_code=500, detail="Failed to get microdao options")
@api_router.put("/agents/{agent_id}/microdao-membership")
async def assign_agent_microdao_membership(
agent_id: str,
payload: MicrodaoMembershipPayload,
authorization: Optional[str] = Header(None)
):
"""Призначити/оновити членство агента в MicroDAO"""
await ensure_architect_or_admin(authorization)
try:
membership = await repo_city.upsert_agent_microdao_membership(
agent_id=agent_id,
microdao_id=payload.microdao_id,
role=payload.role,
is_core=payload.is_core
)
if not membership:
raise HTTPException(status_code=404, detail="MicroDAO not found")
return membership
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to assign microdao membership: {e}")
raise HTTPException(status_code=500, detail="Failed to assign microdao membership")
@api_router.delete("/agents/{agent_id}/microdao-membership/{microdao_id}")
async def delete_agent_microdao_membership(
agent_id: str,
microdao_id: str,
authorization: Optional[str] = Header(None)
):
"""Видалити членство агента в MicroDAO"""
await ensure_architect_or_admin(authorization)
try:
deleted = await repo_city.remove_agent_microdao_membership(agent_id, microdao_id)
if not deleted:
raise HTTPException(status_code=404, detail="Membership not found")
return {"status": "deleted"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete microdao membership: {e}")
raise HTTPException(status_code=500, detail="Failed to delete microdao membership")
# =============================================================================
# City Rooms API
# =============================================================================
@router.get("/rooms", response_model=List[CityRoomRead])
async def get_city_rooms(limit: int = 100, offset: int = 0):
"""
Отримати список всіх City Rooms
"""
try:
rooms = await repo_city.get_all_rooms(limit=limit, offset=offset)
# Додати online count (приблизно)
online_count = await PresenceRedis.get_online_count()
result = []
for room in rooms:
result.append({
**room,
"members_online": online_count if room.get("is_default") else max(1, online_count // 2),
"last_event": None # TODO: з останнього повідомлення
})
return result
except Exception as e:
logger.error(f"Failed to get city rooms: {e}")
raise HTTPException(status_code=500, detail="Failed to get city rooms")
@router.post("/rooms", response_model=CityRoomRead)
async def create_city_room(payload: CityRoomCreate):
"""
Створити нову City Room (автоматично створює Matrix room)
"""
try:
# TODO: витягнути user_id з JWT
created_by = "u_system" # Mock для MVP
# Перевірити чи не існує вже
existing = await repo_city.get_room_by_slug(payload.slug)
if existing:
raise HTTPException(status_code=409, detail="Room with this slug already exists")
# Створити Matrix room
matrix_room_id, matrix_room_alias = await create_matrix_room(
slug=payload.slug,
name=payload.name,
visibility="public"
)
if not matrix_room_id:
logger.warning(f"Failed to create Matrix room for {payload.slug}, proceeding without Matrix")
room = await repo_city.create_room(
slug=payload.slug,
name=payload.name,
description=payload.description,
created_by=created_by,
matrix_room_id=matrix_room_id,
matrix_room_alias=matrix_room_alias
)
# Додати початкове повідомлення
await repo_city.create_room_message(
room_id=room["id"],
body=f"Кімната '{payload.name}' створена! Ласкаво просимо! 🎉",
author_agent_id="ag_system"
)
# Додати в feed
await repo_city.create_feed_event(
kind="system",
room_id=room["id"],
payload={"action": "room_created", "room_name": payload.name, "matrix_room_id": matrix_room_id}
)
return {**room, "members_online": 1, "last_event": None}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create city room: {e}")
raise HTTPException(status_code=500, detail="Failed to create city room")
@router.get("/rooms/{room_id}", response_model=CityRoomDetail)
async def get_city_room(room_id: str):
"""
Отримати деталі City Room з повідомленнями
"""
try:
room = await repo_city.get_room_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
messages = await repo_city.get_room_messages(room_id, limit=50)
# Додати username до повідомлень
for msg in messages:
if msg.get("author_user_id"):
msg["username"] = f"User-{msg['author_user_id'][-4:]}" # Mock
elif msg.get("author_agent_id"):
msg["username"] = "System Agent"
else:
msg["username"] = "Anonymous"
online_users = await PresenceRedis.get_all_online()
return {
**room,
"members_online": len(online_users),
"last_event": None,
"messages": messages,
"online_members": online_users[:20] # Перші 20
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get city room: {e}")
raise HTTPException(status_code=500, detail="Failed to get city room")
@router.post("/rooms/{room_id}/messages", response_model=CityRoomMessageRead)
async def send_city_room_message(room_id: str, payload: CityRoomMessageCreate):
"""
Надіслати повідомлення в City Room
"""
try:
# Перевірити чи кімната існує
room = await repo_city.get_room_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# TODO: витягнути user_id з JWT
author_user_id = "u_mock_user" # Mock для MVP
# Створити повідомлення
message = await repo_city.create_room_message(
room_id=room_id,
body=payload.body,
author_user_id=author_user_id
)
# Додати в feed
await repo_city.create_feed_event(
kind="room_message",
room_id=room_id,
user_id=author_user_id,
payload={"body": payload.body[:100], "message_id": message["id"]}
)
# TODO: Broadcast WS event
# await ws_manager.broadcast_to_room(room_id, {
# "event": "room.message",
# "message": message
# })
# Додати username
message["username"] = f"User-{author_user_id[-4:]}"
return message
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to send room message: {e}")
raise HTTPException(status_code=500, detail="Failed to send message")
@router.post("/rooms/{room_id}/join")
async def join_city_room(room_id: str):
"""
Приєднатися до City Room (для tracking)
"""
# TODO: витягнути user_id з JWT
user_id = "u_mock_user"
# Для MVP просто повертаємо success
# У production можна зберігати active memberships в Redis
logger.info(f"User {user_id} joined room {room_id}")
return {"status": "joined", "room_id": room_id}
@router.post("/rooms/{room_id}/leave")
async def leave_city_room(room_id: str):
"""
Покинути City Room
"""
# TODO: витягнути user_id з JWT
user_id = "u_mock_user"
logger.info(f"User {user_id} left room {room_id}")
return {"status": "left", "room_id": room_id}
# =============================================================================
# Matrix Backfill API (Internal)
# =============================================================================
@router.post("/matrix/backfill")
async def backfill_matrix_rooms():
"""
Backfill Matrix rooms for existing City Rooms that don't have Matrix integration.
This is an internal endpoint for admin use.
"""
try:
rooms_without_matrix = await repo_city.get_rooms_without_matrix()
results = {
"processed": 0,
"created": 0,
"found": 0,
"failed": 0,
"details": []
}
for room in rooms_without_matrix:
results["processed"] += 1
slug = room["slug"]
name = room["name"]
room_id = room["id"]
# Спочатку спробувати знайти існуючу Matrix room
alias = f"#city_{slug}:daarion.space"
matrix_room_id, matrix_room_alias = await find_matrix_room_by_alias(alias)
if matrix_room_id:
# Знайдено існуючу
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
results["found"] += 1
results["details"].append({
"room_id": room_id,
"slug": slug,
"status": "found",
"matrix_room_id": matrix_room_id
})
else:
# Створити нову
matrix_room_id, matrix_room_alias = await create_matrix_room(slug, name, "public")
if matrix_room_id:
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
results["created"] += 1
results["details"].append({
"room_id": room_id,
"slug": slug,
"status": "created",
"matrix_room_id": matrix_room_id
})
else:
results["failed"] += 1
results["details"].append({
"room_id": room_id,
"slug": slug,
"status": "failed",
"error": "Could not create Matrix room"
})
logger.info(f"Matrix backfill completed: {results['processed']} processed, "
f"{results['created']} created, {results['found']} found, {results['failed']} failed")
return results
except Exception as e:
logger.error(f"Matrix backfill failed: {e}")
raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
# =============================================================================
# Chat Bootstrap API (Matrix Integration)
# =============================================================================
async def validate_jwt_token(authorization: str) -> Optional[dict]:
"""Validate JWT token via auth-service introspect endpoint."""
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.post(
f"{AUTH_SERVICE_URL}/api/auth/introspect",
json={"token": token}
)
if resp.status_code == 200:
data = resp.json()
if data.get("active"):
return {"user_id": data.get("sub"), "email": data.get("email"), "roles": data.get("roles", [])}
return None
except Exception as e:
logger.error(f"JWT validation error: {e}")
return None
async def ensure_architect_or_admin(authorization: Optional[str]) -> dict:
"""Переконатися, що користувач має роль architect/admin"""
if not authorization:
raise HTTPException(status_code=403, detail="Missing authorization token")
user_info = await validate_jwt_token(authorization)
if not user_info:
raise HTTPException(status_code=403, detail="Invalid authorization token")
roles = user_info.get("roles", [])
if not any(role in ["admin", "architect"] for role in roles):
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user_info
@router.get("/chat/bootstrap")
async def chat_bootstrap(
room_slug: str = Query(..., description="City room slug"),
authorization: Optional[str] = Header(None)
):
"""
Bootstrap Matrix chat for a city room.
Returns Matrix credentials and room info for the authenticated user.
"""
# Validate JWT
user_info = await validate_jwt_token(authorization)
if not user_info:
raise HTTPException(status_code=401, detail="Invalid or missing authorization token")
user_id = user_info.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token: missing user_id")
# Get room by slug
room = await repo_city.get_room_by_slug(room_slug)
if not room:
raise HTTPException(status_code=404, detail=f"Room '{room_slug}' not found")
# Check if room has Matrix integration
matrix_room_id = room.get("matrix_room_id")
matrix_room_alias = room.get("matrix_room_alias")
if not matrix_room_id:
raise HTTPException(
status_code=400,
detail="Room does not have Matrix integration. Run /city/matrix/backfill first."
)
# Get Matrix user token from matrix-gateway
async with httpx.AsyncClient(timeout=30.0) as client:
try:
token_resp = await client.post(
f"{MATRIX_GATEWAY_URL}/internal/matrix/users/token",
json={"user_id": user_id}
)
if token_resp.status_code != 200:
error = token_resp.json()
logger.error(f"Failed to get Matrix token: {error}")
raise HTTPException(status_code=500, detail="Failed to get Matrix credentials")
matrix_creds = token_resp.json()
except httpx.RequestError as e:
logger.error(f"Matrix gateway request error: {e}")
raise HTTPException(status_code=503, detail="Matrix service unavailable")
# Return bootstrap data
return {
"matrix_hs_url": f"https://app.daarion.space", # Through nginx proxy
"matrix_user_id": matrix_creds["matrix_user_id"],
"matrix_access_token": matrix_creds["access_token"],
"matrix_device_id": matrix_creds["device_id"],
"matrix_room_id": matrix_room_id,
"matrix_room_alias": matrix_room_alias,
"room": {
"id": room["id"],
"slug": room["slug"],
"name": room["name"],
"description": room.get("description")
}
}
# =============================================================================
# Chat Room API (TASK_PHASE_AGENT_CHAT_WIDGET_MVP)
# =============================================================================
@api_router.get("/agents/{agent_id}/chat-room")
async def get_agent_chat_room(agent_id: str):
"""
Отримати інформацію про кімнату чату для агента.
Повертає room_id, agent info для ініціалізації чату.
"""
try:
agent = await repo_city.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
# Get agent's primary room or create room slug
room_slug = f"agent-console-{agent.get('public_slug') or agent_id}"
room = await repo_city.get_room_by_slug(room_slug)
# If room doesn't exist, try to get agent's primary room from dashboard
if not room:
rooms = await repo_city.get_agent_rooms(agent_id)
if rooms and len(rooms) > 0:
room = rooms[0]
room_slug = room.get("slug", room_slug)
return {
"agent_id": agent_id,
"agent_display_name": agent.get("display_name"),
"agent_avatar_url": agent.get("avatar_url"),
"agent_status": agent.get("status", "offline"),
"agent_kind": agent.get("kind"),
"room_slug": room_slug,
"room_id": room.get("id") if room else None,
"matrix_room_id": room.get("matrix_room_id") if room else None,
"chat_available": room is not None and room.get("matrix_room_id") is not None
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get agent chat room for {agent_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to get agent chat room")
@api_router.get("/nodes/{node_id}/chat-room")
async def get_node_chat_room(node_id: str):
"""
Отримати інформацію про кімнату чату для ноди.
Повертає room_id, guardian/steward agents info.
"""
try:
node = await repo_city.get_node_by_id(node_id)
if not node:
raise HTTPException(status_code=404, detail=f"Node not found: {node_id}")
# Get node support room
node_slug = node_id.replace("node-", "").replace("-", "_")
room_slug = f"node-support-{node_slug}"
room = await repo_city.get_room_by_slug(room_slug)
# Get guardian and steward agents
guardian_agent = None
steward_agent = None
if node.get("guardian_agent"):
guardian_agent = {
"id": node["guardian_agent"].get("id"),
"display_name": node["guardian_agent"].get("name"),
"kind": node["guardian_agent"].get("kind"),
"role": "guardian"
}
if node.get("steward_agent"):
steward_agent = {
"id": node["steward_agent"].get("id"),
"display_name": node["steward_agent"].get("name"),
"kind": node["steward_agent"].get("kind"),
"role": "steward"
}
return {
"node_id": node_id,
"node_name": node.get("name"),
"node_status": node.get("status", "offline"),
"room_slug": room_slug,
"room_id": room.get("id") if room else None,
"matrix_room_id": room.get("matrix_room_id") if room else None,
"chat_available": room is not None and room.get("matrix_room_id") is not None,
"agents": [a for a in [guardian_agent, steward_agent] if a is not None]
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get node chat room for {node_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to get node chat room")
@api_router.get("/microdaos/{slug}/chat-room")
async def get_microdao_chat_room(slug: str):
"""
Отримати інформацію про кімнату чату для MicroDAO.
Повертає room_id, orchestrator agent info.
"""
try:
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
# Get MicroDAO lobby room
room_slug = f"microdao-lobby-{slug}"
room = await repo_city.get_room_by_slug(room_slug)
# If no lobby room, try to get primary room
if not room:
rooms = await repo_city.get_microdao_rooms(dao["id"])
if rooms and len(rooms) > 0:
# Find primary room or first room
primary = next((r for r in rooms if r.get("room_role") == "primary"), rooms[0])
room = primary
room_slug = room.get("slug", room_slug)
# Get orchestrator agent
orchestrator = None
orchestrator_id = dao.get("orchestrator_agent_id")
if orchestrator_id:
orch_agent = await repo_city.get_agent_by_id(orchestrator_id)
if orch_agent:
orchestrator = {
"id": orchestrator_id,
"display_name": orch_agent.get("display_name"),
"avatar_url": orch_agent.get("avatar_url"),
"kind": orch_agent.get("kind"),
"role": "orchestrator"
}
return {
"microdao_id": dao["id"],
"microdao_slug": slug,
"microdao_name": dao.get("name"),
"room_slug": room_slug,
"room_id": room.get("id") if room else None,
"matrix_room_id": room.get("matrix_room_id") if room else None,
"chat_available": room is not None and room.get("matrix_room_id") is not None,
"orchestrator": orchestrator
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get microdao chat room for {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to get microdao chat room")
# =============================================================================
# Presence API (TASK_PHASE_AGENT_PRESENCE_INDICATORS_MVP)
# =============================================================================
@api_router.get("/agents/presence")
async def get_agents_presence():
"""
Отримати presence статус всіх активних агентів.
Повертає Matrix presence + DAGI router health.
"""
try:
# Get all agents from DB
agents = await repo_city.list_agent_summaries(limit=1000)
# Get Matrix presence from matrix-presence-aggregator
matrix_presence = await get_matrix_presence_status()
# Get DAGI router health (simplified for MVP)
dagi_health = await get_dagi_router_health()
# Combine presence data
presence_data = []
for agent in agents:
agent_id = agent["id"]
node_id = agent.get("node_id")
# Matrix presence
matrix_status = matrix_presence.get(agent_id, {}).get("status", "offline")
last_seen = matrix_presence.get(agent_id, {}).get("last_seen")
# DAGI router presence (node-level)
dagi_status = "unknown"
if node_id and node_id in dagi_health:
dagi_status = dagi_health[node_id].get("router_status", "unknown")
presence_data.append({
"agent_id": agent_id,
"display_name": agent.get("display_name", agent_id),
"matrix_presence": matrix_status,
"dagi_router_presence": dagi_status,
"last_seen": last_seen,
"node_id": node_id
})
return {"presence": presence_data}
except Exception as e:
logger.error(f"Failed to get agents presence: {e}")
raise HTTPException(status_code=500, detail="Failed to get agents presence")
async def get_matrix_presence_status():
"""
Get Matrix presence from matrix-presence-aggregator.
Returns dict: agent_id -> {status: 'online'|'unavailable'|'offline', last_seen: timestamp}
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("http://matrix-presence-aggregator:8080/api/presence/agents")
if response.status_code == 200:
data = response.json()
return data.get("agents", {})
else:
logger.warning(f"Matrix presence aggregator returned {response.status_code}")
return {}
except Exception as e:
logger.warning(f"Failed to get Matrix presence: {e}")
return {}
async def get_dagi_router_health():
"""
Get DAGI router health from node-registry or direct ping.
Returns dict: node_id -> {router_status: 'healthy'|'degraded'|'offline'}
"""
try:
# Try to get from node-registry first
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("http://dagi-node-registry:9205/api/v1/health")
if response.status_code == 200:
data = response.json()
return data.get("nodes", {})
else:
logger.warning(f"Node registry returned {response.status_code}")
except Exception as e:
logger.warning(f"Failed to get DAGI router health from node-registry: {e}")
# Fallback: try direct ping to known nodes
try:
known_nodes = ["node-1-hetzner-gex44", "node-2-macbook-m4max"]
health_data = {}
for node_id in known_nodes:
try:
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(f"http://dagi-router-{node_id}:8080/health")
if response.status_code == 200:
health_data[node_id] = {"router_status": "healthy"}
else:
health_data[node_id] = {"router_status": "degraded"}
except Exception:
health_data[node_id] = {"router_status": "offline"}
return health_data
except Exception as e:
logger.warning(f"Failed to get DAGI router health: {e}")
return {}
# =============================================================================
# City Feed API
# =============================================================================
@router.get("/feed", response_model=List[CityFeedEventRead])
async def get_city_feed(limit: int = 20, offset: int = 0):
"""
Отримати City Feed (останні події)
"""
try:
events = await repo_city.get_feed_events(limit=limit, offset=offset)
return events
except Exception as e:
logger.error(f"Failed to get city feed: {e}")
raise HTTPException(status_code=500, detail="Failed to get city feed")
# =============================================================================
# City Map API (2D Map)
# =============================================================================
@router.get("/map", response_model=CityMapResponse)
async def get_city_map():
"""
Отримати дані для 2D мапи міста.
Повертає:
- config: розміри сітки та налаштування
- rooms: список кімнат з координатами
"""
try:
# Отримати конфігурацію
config_data = await repo_city.get_map_config()
config = CityMapConfig(
grid_width=config_data.get("grid_width", 6),
grid_height=config_data.get("grid_height", 3),
cell_size=config_data.get("cell_size", 100),
background_url=config_data.get("background_url")
)
# Отримати кімнати з координатами
rooms_data = await repo_city.get_rooms_for_map()
rooms = []
for room in rooms_data:
rooms.append(CityMapRoom(
id=room["id"],
slug=room["slug"],
name=room["name"],
description=room.get("description"),
room_type=room.get("room_type", "public"),
zone=room.get("zone", "central"),
icon=room.get("icon"),
color=room.get("color"),
x=room.get("map_x", 0),
y=room.get("map_y", 0),
w=room.get("map_w", 1),
h=room.get("map_h", 1),
matrix_room_id=room.get("matrix_room_id")
))
return CityMapResponse(config=config, rooms=rooms)
except Exception as e:
logger.error(f"Failed to get city map: {e}")
raise HTTPException(status_code=500, detail="Failed to get city map")
# =============================================================================
# Agents API
# =============================================================================
@router.put("/agents/{agent_id}/public-profile")
async def update_agent_public_profile(agent_id: str, request: Request):
"""
Оновити публічний профіль агента.
Тільки для Architect/Admin.
"""
try:
# Check agent exists
agent = await repo_city.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
# Parse body
body = await request.json()
is_public = body.get("is_public", False)
public_slug = body.get("public_slug")
public_title = body.get("public_title")
public_tagline = body.get("public_tagline")
public_skills = body.get("public_skills", [])
public_district = body.get("public_district")
public_primary_room_slug = body.get("public_primary_room_slug")
# Validate: if is_public, slug is required
if is_public and not public_slug:
raise HTTPException(status_code=400, detail="public_slug is required when is_public is true")
# Validate slug format
if public_slug:
import re
if not re.match(r'^[a-z0-9_-]+$', public_slug.lower()):
raise HTTPException(status_code=400, detail="public_slug must contain only lowercase letters, numbers, underscores, and hyphens")
# Validate skills (max 10, max 64 chars each)
if public_skills:
public_skills = [s[:64] for s in public_skills[:10]]
# Update
result = await repo_city.update_agent_public_profile(
agent_id=agent_id,
is_public=is_public,
public_slug=public_slug,
public_title=public_title,
public_tagline=public_tagline,
public_skills=public_skills,
public_district=public_district,
public_primary_room_slug=public_primary_room_slug
)
logger.info(f"Updated public profile for agent {agent_id}: is_public={is_public}, slug={public_slug}")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update agent public profile: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to update agent public profile")
@router.get("/citizens")
async def get_public_citizens_legacy(limit: int = 50, offset: int = 0):
"""
Отримати список публічних громадян DAARION City.
"""
try:
citizens, total = await repo_city.get_public_citizens(limit=limit, offset=offset)
return {"citizens": citizens, "total": total}
except Exception as e:
logger.error(f"Failed to get public citizens: {e}")
raise HTTPException(status_code=500, detail="Failed to get public citizens")
@router.get("/citizens/{slug}")
async def get_citizen_by_slug(slug: str, request: Request):
"""
Отримати публічного громадянина за slug.
Для адмінів/архітекторів додається admin_panel_url.
"""
try:
include_admin_url = True # legacy endpoint доступний тільки з адмінської панелі
citizen = await repo_city.get_public_citizen_by_slug(slug)
if not citizen:
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
if not include_admin_url:
citizen["admin_panel_url"] = None
return citizen
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get citizen: {e}")
raise HTTPException(status_code=500, detail="Failed to get citizen")
@router.put("/agents/{agent_id}/prompts/{kind}")
async def update_agent_prompt(agent_id: str, kind: str, request: Request):
"""
Оновити системний промт агента.
Тільки для Architect/Admin.
kind: core | safety | governance | tools
"""
try:
# Validate kind
valid_kinds = ["core", "safety", "governance", "tools"]
if kind not in valid_kinds:
raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}")
# Check agent exists
agent = await repo_city.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
# Parse body
body = await request.json()
content = body.get("content")
note = body.get("note")
if not content or not content.strip():
raise HTTPException(status_code=400, detail="Content is required")
# TODO: Get user from JWT and check permissions
# For now, use a placeholder
created_by = "ARCHITECT" # Will be replaced with actual user from auth
# Update prompt
result = await repo_city.update_agent_prompt(
agent_id=agent_id,
kind=kind,
content=content.strip(),
created_by=created_by,
note=note
)
logger.info(f"Updated {kind} prompt for agent {agent_id} to version {result['version']}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update agent prompt: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to update agent prompt")
@router.get("/agents/{agent_id}/prompts/{kind}/history")
async def get_agent_prompt_history(agent_id: str, kind: str, limit: int = 10):
"""
Отримати історію версій промту агента.
"""
try:
valid_kinds = ["core", "safety", "governance", "tools"]
if kind not in valid_kinds:
raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}")
history = await repo_city.get_agent_prompt_history(agent_id, kind, limit)
return {"agent_id": agent_id, "kind": kind, "history": history}
except Exception as e:
logger.error(f"Failed to get prompt history: {e}")
raise HTTPException(status_code=500, detail="Failed to get prompt history")
@router.get("/agents/{agent_id}/dashboard")
async def get_agent_dashboard(agent_id: str):
"""
Отримати повний dashboard агента (DAIS Profile + Node + Metrics)
"""
try:
# Get agent profile
agent = await repo_city.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
# Get agent's rooms
rooms = await repo_city.get_agent_rooms(agent_id)
# Build DAIS profile
profile = {
"agent_id": agent["id"],
"display_name": agent["display_name"],
"kind": agent.get("kind", "assistant"),
"model": agent.get("model"),
"avatar_url": agent.get("avatar_url"),
"status": agent.get("status", "offline"),
"node_id": agent.get("node_id"),
"is_public": agent.get("is_public", False),
"public_slug": agent.get("public_slug"),
"is_orchestrator": agent.get("is_orchestrator", False),
"primary_microdao_id": agent.get("primary_microdao_id"),
"primary_microdao_name": agent.get("primary_microdao_name"),
"primary_microdao_slug": agent.get("primary_microdao_slug"),
"crew_info": agent.get("crew_info"),
"roles": [agent.get("role")] if agent.get("role") else [],
"tags": [],
"dais": {
"core": {
"title": agent.get("display_name"),
"bio": f"{agent.get('kind', 'assistant').title()} agent in DAARION",
"version": "1.0.0"
},
"vis": {
"avatar_url": agent.get("avatar_url"),
"color_primary": agent.get("color", "#22D3EE")
},
"cog": {
"base_model": agent.get("model", "default"),
"provider": "ollama",
"node_id": agent.get("node_id")
},
"act": {
"tools": agent.get("capabilities", [])
}
},
"city_presence": {
"primary_room_slug": agent.get("primary_room_slug"),
"district": agent.get("home_district"),
"rooms": rooms
}
}
# Get node info (detailed)
node_info = None
if agent.get("node_id"):
node_data = await repo_city.get_node_by_id(agent["node_id"])
if node_data:
node_info = {
"node_id": node_data["node_id"],
"name": node_data["name"],
"hostname": node_data.get("hostname"),
"roles": node_data.get("roles", []),
"environment": node_data.get("environment"),
"status": node_data.get("status", "offline"),
"guardian_agent": node_data.get("guardian_agent"),
"steward_agent": node_data.get("steward_agent")
}
else:
node_info = {
"node_id": agent["node_id"],
"status": "unknown",
"name": "Unknown Node"
}
# Get system prompts
system_prompts = await repo_city.get_agent_prompts(agent_id)
# Get public profile
public_profile = await repo_city.get_agent_public_profile(agent_id)
# MicroDAO memberships
memberships_raw = await repo_city.get_agent_microdao_memberships(agent_id)
memberships = [
AgentMicrodaoMembership(
microdao_id=item["microdao_id"],
microdao_slug=item.get("microdao_slug"),
microdao_name=item.get("microdao_name"),
logo_url=item.get("logo_url"),
role=item.get("role"),
is_core=item.get("is_core", False)
)
for item in memberships_raw
]
# Get primary city room for agent
primary_city_room = None
# Priority 1: agent's primary room from city_rooms
if rooms and len(rooms) > 0:
primary_room = rooms[0] # First room as primary
primary_city_room = {
"id": primary_room.get("id"),
"slug": primary_room.get("slug"),
"name": primary_room.get("name"),
"matrix_room_id": primary_room.get("matrix_room_id")
}
# Priority 2: Get from primary MicroDAO's main room
elif agent.get("primary_microdao_id"):
microdao_room = await repo_city.get_microdao_primary_room(agent["primary_microdao_id"])
if microdao_room:
primary_city_room = microdao_room
# Build dashboard response
dashboard = {
"profile": profile,
"node": node_info,
"primary_city_room": primary_city_room,
"runtime": {
"health": "healthy" if agent.get("status") == "online" else "unknown",
"last_success_at": None,
"last_error_at": None
},
"metrics": {
"tasks_1h": 0,
"tasks_24h": 0,
"errors_24h": 0,
"avg_latency_ms_1h": 0,
"success_rate_24h": 1.0
},
"recent_activity": [],
"system_prompts": system_prompts,
"public_profile": public_profile,
"microdao_memberships": memberships
}
return dashboard
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get agent dashboard: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to get agent dashboard")
@router.get("/agents", response_model=List[AgentRead])
async def get_agents():
"""
Отримати список всіх агентів
"""
try:
agents = await repo_city.get_all_agents()
result = []
for agent in agents:
capabilities = agent.get("capabilities", [])
if isinstance(capabilities, str):
import json
capabilities = json.loads(capabilities)
result.append(AgentRead(
id=agent["id"],
display_name=agent["display_name"],
kind=agent.get("kind", "assistant"),
avatar_url=agent.get("avatar_url"),
color=agent.get("color", "cyan"),
status=agent.get("status", "offline"),
current_room_id=agent.get("current_room_id"),
capabilities=capabilities
))
return result
except Exception as e:
logger.error(f"Failed to get agents: {e}")
raise HTTPException(status_code=500, detail="Failed to get agents")
@router.get("/agents/online", response_model=List[AgentPresence])
async def get_online_agents():
"""
Отримати список онлайн агентів (для presence)
"""
try:
agents = await repo_city.get_online_agents()
result = []
for agent in agents:
result.append(AgentPresence(
agent_id=agent["id"],
display_name=agent["display_name"],
kind=agent.get("kind", "assistant"),
status=agent.get("status", "offline"),
room_id=agent.get("current_room_id"),
color=agent.get("color", "cyan")
))
return result
except Exception as e:
logger.error(f"Failed to get online agents: {e}")
raise HTTPException(status_code=500, detail="Failed to get online agents")
@router.get("/rooms/{room_id}/agents", response_model=List[AgentPresence])
async def get_room_agents(room_id: str):
"""
Отримати агентів у конкретній кімнаті
"""
try:
agents = await repo_city.get_agents_by_room(room_id)
result = []
for agent in agents:
result.append(AgentPresence(
agent_id=agent["id"],
display_name=agent["display_name"],
kind=agent.get("kind", "assistant"),
status=agent.get("status", "offline"),
room_id=room_id,
color=agent.get("color", "cyan"),
node_id=agent.get("node_id"),
model=agent.get("model"),
role=agent.get("role")
))
return result
except Exception as e:
logger.error(f"Failed to get room agents: {e}")
raise HTTPException(status_code=500, detail="Failed to get room agents")
@router.get("/agents/presence-snapshot")
async def get_agents_presence_snapshot():
"""
Отримати snapshot всіх агентів для presence (50 агентів по 10 districts)
"""
try:
snapshot = await repo_city.get_agents_presence_snapshot()
return snapshot
except Exception as e:
logger.error(f"Failed to get agents presence snapshot: {e}")
raise HTTPException(status_code=500, detail="Failed to get agents presence snapshot")
# =============================================================================
# MicroDAO API
# =============================================================================
@router.get("/microdao", response_model=List[MicrodaoSummary])
async def get_microdaos(
district: Optional[str] = Query(None, description="Filter by district"),
is_public: Optional[bool] = Query(True, description="Filter by public status (default: True)"),
is_platform: Optional[bool] = Query(None, description="Filter by platform status"),
q: Optional[str] = Query(None, description="Search by name/description"),
include_all: bool = Query(False, description="Include non-public (admin only)"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0)
):
"""
Отримати список MicroDAOs.
- **district**: фільтр по дістрікту (Core, Energy, Green, Labs, etc.)
- **is_public**: фільтр по публічності (за замовчуванням True)
- **is_platform**: фільтр по типу (платформа/дістрікт)
- **q**: пошук по назві або опису
- **include_all**: включити всі (для адмінів)
"""
try:
# If include_all is True (admin mode), don't filter by is_public
public_filter = None if include_all else is_public
daos = await repo_city.list_microdao_summaries(
district=district,
is_public=public_filter,
is_platform=is_platform,
q=q,
limit=limit,
offset=offset
)
result = []
for dao in daos:
result.append(MicrodaoSummary(
id=dao["id"],
slug=dao["slug"],
name=dao["name"],
description=dao.get("description"),
district=dao.get("district"),
is_public=dao.get("is_public", True),
is_platform=dao.get("is_platform", False),
is_active=dao.get("is_active", True),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
orchestrator_agent_name=dao.get("orchestrator_agent_name"),
parent_microdao_id=dao.get("parent_microdao_id"),
parent_microdao_slug=dao.get("parent_microdao_slug"),
logo_url=dao.get("logo_url"),
member_count=dao.get("member_count", 0),
agents_count=dao.get("agents_count", 0),
room_count=dao.get("room_count", 0),
rooms_count=dao.get("rooms_count", 0),
channels_count=dao.get("channels_count", 0)
))
return result
except Exception as e:
logger.error(f"Failed to get microdaos: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to get microdaos")
@router.get("/microdao/{slug}", response_model=MicrodaoDetail)
async def get_microdao_by_slug(slug: str):
"""
Отримати детальну інформацію про MicroDAO.
Включає:
- Базову інформацію про DAO
- Список агентів (з ролями)
- Список каналів (Telegram, Matrix, City rooms, CrewAI)
"""
try:
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
# Build agents list
agents = []
for agent in dao.get("agents", []):
agents.append(MicrodaoAgentView(
agent_id=agent["agent_id"],
display_name=agent.get("display_name", agent["agent_id"]),
role=agent.get("role"),
is_core=agent.get("is_core", False)
))
# Build channels list
channels = []
for channel in dao.get("channels", []):
channels.append(MicrodaoChannelView(
kind=channel["kind"],
ref_id=channel["ref_id"],
display_name=channel.get("display_name"),
is_primary=channel.get("is_primary", False)
))
public_citizens = []
for citizen in dao.get("public_citizens", []):
public_citizens.append(MicrodaoCitizenView(
slug=citizen["slug"],
display_name=citizen["display_name"],
public_title=citizen.get("public_title"),
public_tagline=citizen.get("public_tagline"),
avatar_url=citizen.get("avatar_url"),
district=citizen.get("public_district"),
primary_room_slug=citizen.get("public_primary_room_slug")
))
# Build child microDAOs list
child_microdaos = []
for child in dao.get("child_microdaos", []):
child_microdaos.append(MicrodaoSummary(
id=child["id"],
slug=child["slug"],
name=child["name"],
is_public=child.get("is_public", True),
is_platform=child.get("is_platform", False)
))
# Get all rooms for MicroDAO (multi-room support)
all_rooms = await repo_city.get_microdao_rooms(dao["id"])
rooms_list = [
CityRoomSummary(
id=room["id"],
slug=room["slug"],
name=room["name"],
matrix_room_id=room.get("matrix_room_id"),
microdao_id=room.get("microdao_id"),
microdao_slug=room.get("microdao_slug"),
room_role=room.get("room_role"),
is_public=room.get("is_public", True),
sort_order=room.get("sort_order", 100)
)
for room in all_rooms
]
# Get primary city room (first room with role='primary' or first by sort_order)
primary_room_summary = None
if rooms_list:
primary = next((r for r in rooms_list if r.room_role == 'primary'), rooms_list[0])
primary_room_summary = primary
return MicrodaoDetail(
id=dao["id"],
slug=dao["slug"],
name=dao["name"],
description=dao.get("description"),
district=dao.get("district"),
is_public=dao.get("is_public", True),
is_platform=dao.get("is_platform", False),
is_active=dao.get("is_active", True),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
orchestrator_display_name=dao.get("orchestrator_display_name"),
parent_microdao_id=dao.get("parent_microdao_id"),
parent_microdao_slug=dao.get("parent_microdao_slug"),
child_microdaos=child_microdaos,
logo_url=dao.get("logo_url"),
agents=agents,
channels=channels,
public_citizens=public_citizens,
primary_city_room=primary_room_summary,
rooms=rooms_list
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get microdao {slug}: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to get microdao")
# =============================================================================
# MicroDAO Multi-Room API (Task 034)
# =============================================================================
@router.get("/microdao/{slug}/rooms", response_model=MicrodaoRoomsList)
async def get_microdao_rooms_endpoint(slug: str):
"""
Отримати всі кімнати MicroDAO (Task 034).
Повертає список кімнат, впорядкованих за sort_order.
"""
try:
result = await repo_city.get_microdao_rooms_by_slug(slug)
if not result:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
rooms = [
CityRoomSummary(
id=room["id"],
slug=room["slug"],
name=room["name"],
matrix_room_id=room.get("matrix_room_id"),
microdao_id=room.get("microdao_id"),
microdao_slug=room.get("microdao_slug"),
room_role=room.get("room_role"),
is_public=room.get("is_public", True),
sort_order=room.get("sort_order", 100)
)
for room in result["rooms"]
]
return MicrodaoRoomsList(
microdao_id=result["microdao_id"],
microdao_slug=result["microdao_slug"],
rooms=rooms
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get microdao rooms for {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to get microdao rooms")
@router.post("/microdao/{slug}/rooms/attach-existing", response_model=CityRoomSummary)
async def attach_existing_room_endpoint(
slug: str,
payload: AttachExistingRoomRequest
):
"""
Прив'язати існуючу кімнату до MicroDAO (Task 036).
Потребує прав адміністратора або оркестратора MicroDAO.
"""
try:
# Get microdao by slug
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
# TODO: Add authorization check (assert_can_manage_microdao)
result = await repo_city.attach_room_to_microdao(
microdao_id=dao["id"],
room_id=payload.room_id,
room_role=payload.room_role,
is_public=payload.is_public,
sort_order=payload.sort_order
)
if not result:
raise HTTPException(status_code=404, detail="Room not found")
return CityRoomSummary(
id=result["id"],
slug=result["slug"],
name=result["name"],
matrix_room_id=result.get("matrix_room_id"),
microdao_id=result.get("microdao_id"),
room_role=result.get("room_role"),
is_public=result.get("is_public", True),
sort_order=result.get("sort_order", 100)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to attach room to microdao {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to attach room")
@router.patch("/microdao/{slug}/rooms/{room_id}", response_model=CityRoomSummary)
async def update_microdao_room_endpoint(
slug: str,
room_id: str,
payload: MicrodaoRoomUpdate
):
"""
Оновити налаштування кімнати MicroDAO (Task 036).
Потребує прав адміністратора або оркестратора MicroDAO.
"""
try:
# Get microdao by slug
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
# TODO: Add authorization check (assert_can_manage_microdao)
result = await repo_city.update_microdao_room(
microdao_id=dao["id"],
room_id=room_id,
room_role=payload.room_role,
is_public=payload.is_public,
sort_order=payload.sort_order,
set_primary=payload.set_primary or False
)
if not result:
raise HTTPException(status_code=404, detail="Room not found or not attached to this MicroDAO")
return CityRoomSummary(
id=result["id"],
slug=result["slug"],
name=result["name"],
matrix_room_id=result.get("matrix_room_id"),
microdao_id=result.get("microdao_id"),
room_role=result.get("room_role"),
is_public=result.get("is_public", True),
sort_order=result.get("sort_order", 100)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update room {room_id} for microdao {slug}: {e}")
raise HTTPException(status_code=500, detail="Failed to update room")
# =============================================================================
# MicroDAO Visibility & Creation (Task 029)
# =============================================================================
class MicrodaoVisibilityPayload(BaseModel):
"""MicroDAO visibility update payload"""
is_public: bool
is_platform: Optional[bool] = None
@router.put("/microdao/{microdao_id}/visibility")
async def update_microdao_visibility_endpoint(
microdao_id: str,
payload: MicrodaoVisibilityPayload
):
"""Оновити налаштування видимості MicroDAO (Task 029)"""
try:
result = await repo_city.update_microdao_visibility(
microdao_id=microdao_id,
is_public=payload.is_public,
is_platform=payload.is_platform,
)
if not result:
raise HTTPException(status_code=404, detail="MicroDAO not found")
return {
"status": "ok",
"microdao_id": result.get("id"),
"slug": result.get("slug"),
"is_public": result.get("is_public"),
"is_platform": result.get("is_platform"),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update microdao visibility: {e}")
raise HTTPException(status_code=500, detail="Failed to update visibility")
class MicrodaoCreatePayload(BaseModel):
"""Create MicroDAO from agent (orchestrator flow)"""
name: str
slug: str
description: Optional[str] = None
make_platform: bool = False
is_public: bool = True
parent_microdao_id: Optional[str] = None
create_rooms: Optional[dict] = None # {"primary_lobby": bool, "governance": bool, "crew_team": bool}
@router.post("/agents/{agent_id}/microdao", response_model=dict)
async def create_microdao_for_agent_endpoint(
agent_id: str,
payload: MicrodaoCreatePayload
):
"""
Створити MicroDAO для агента (зробити його оркестратором).
Цей endpoint:
1. Створює новий MicroDAO
2. Призначає агента оркестратором
3. Додає агента як члена DAO
4. Встановлює primary_microdao_id якщо порожній
5. Опціонально створює кімнати (primary/governance/crew)
"""
try:
# Check if agent exists and is not archived
agent = await repo_city.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
# Check if slug is unique
existing = await repo_city.get_microdao_by_slug(payload.slug)
if existing:
raise HTTPException(status_code=400, detail=f"MicroDAO with slug '{payload.slug}' already exists")
# Create MicroDAO
result = await repo_city.create_microdao_for_agent(
orchestrator_agent_id=agent_id,
name=payload.name,
slug=payload.slug,
description=payload.description,
make_platform=payload.make_platform,
is_public=payload.is_public,
parent_microdao_id=payload.parent_microdao_id,
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create MicroDAO")
microdao_id = result["id"]
created_rooms = []
# Create Rooms if requested
if payload.create_rooms:
# Helper to create room
async def create_room_helper(room_slug: str, name: str, role: str, is_public: bool = True):
# Create room
matrix_room_id, matrix_room_alias = await create_matrix_room(
slug=room_slug,
name=name,
visibility="public" if is_public else "private"
)
room = await repo_city.create_room(
slug=room_slug,
name=name,
description=f"{role.title()} room for {payload.name}",
created_by="u_system", # TODO: use actual user if available
matrix_room_id=matrix_room_id,
matrix_room_alias=matrix_room_alias
)
# Attach to MicroDAO
attached = await repo_city.attach_room_to_microdao(
microdao_id=microdao_id,
room_id=room["id"],
room_role=role,
is_public=is_public,
sort_order=10 if role == 'primary' else 50
)
created_rooms.append(attached)
# Primary Lobby
if payload.create_rooms.get("primary_lobby"):
await create_room_helper(
room_slug=f"{payload.slug}-lobby",
name=f"{payload.name} Lobby",
role="primary",
is_public=True
)
# Governance
if payload.create_rooms.get("governance"):
await create_room_helper(
room_slug=f"{payload.slug}-gov",
name=f"{payload.name} Governance",
role="governance",
is_public=True
)
# Crew Team
if payload.create_rooms.get("crew_team"):
await create_room_helper(
room_slug=f"{payload.slug}-team",
name=f"{payload.name} Team",
role="team",
is_public=False # Team rooms usually private/internal
)
return {
"status": "ok",
"microdao": result,
"agent_id": agent_id,
"created_rooms": created_rooms
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create microdao for agent {agent_id}: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to create MicroDAO")
class AttachToMicrodaoPayload(BaseModel):
agent_id: str
role: str = "member" # orchestrator | member
@router.post("/microdao/{slug}/attach-agent")
async def attach_agent_to_microdao_endpoint(
slug: str,
payload: AttachToMicrodaoPayload
):
"""
Приєднати агента до існуючого MicroDAO (Task 040).
"""
try:
# Check MicroDAO
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
# Check Agent
agent = await repo_city.get_agent_by_id(payload.agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
# Determine flags
is_core = False
if payload.role == "orchestrator":
is_core = True
# Upsert membership
result = await repo_city.upsert_agent_microdao_membership(
agent_id=payload.agent_id,
microdao_id=dao["id"],
role=payload.role,
is_core=is_core
)
# If role is orchestrator, we might want to update the main record too if it's missing an orchestrator,
# or just rely on the membership table.
# The current repo_city.create_microdao_for_agent sets orchestrator_agent_id on the microdao table.
# Let's check if we should update that.
if payload.role == "orchestrator" and not dao.get("orchestrator_agent_id"):
# TODO: Update microdao table orchestrator_agent_id = payload.agent_id
# For now, memberships are the source of truth for "orchestrators list"
pass
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to attach agent to microdao: {e}")
raise HTTPException(status_code=500, detail="Failed to attach agent")
@router.post("/microdao/{slug}/ensure-orchestrator-room", response_model=CityRoomSummary)
async def ensure_orchestrator_room(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
Забезпечити існування кімнати команди оркестратора для MicroDAO.
Створює нову кімнату в Matrix та БД, якщо її ще немає.
Доступно для: Адмінів, Оркестратора MicroDAO.
"""
# 1. Validate JWT
user_info = await validate_jwt_token(authorization)
if not user_info:
raise HTTPException(status_code=401, detail="Invalid or missing authorization token")
user_id = user_info.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token: missing user_id")
# 2. Get MicroDAO
dao = await repo_city.get_microdao_by_slug(slug)
if not dao:
raise HTTPException(status_code=404, detail="MicroDAO not found")
microdao_id = dao["id"]
orchestrator_id = dao.get("orchestrator_agent_id")
# 3. Check permissions (mock logic for now, assuming valid user if token is present)
# TODO: In real app, check if user_id matches owner or has admin role
# For now, we assume if they can call this (protected by UI/proxy), they are authorized.
try:
room = await repo_city.get_or_create_orchestrator_team_room(microdao_id)
if not room:
raise HTTPException(status_code=500, detail="Failed to create or retrieve orchestrator room")
return CityRoomSummary(
id=room["id"],
slug=room["slug"],
name=room["name"],
matrix_room_id=room.get("matrix_room_id"),
microdao_id=room.get("microdao_id"),
microdao_slug=slug, # Ensure slug is passed
room_role=room.get("room_role"),
is_public=room.get("is_public", False),
sort_order=room.get("sort_order", 100),
logo_url=room.get("logo_url"),
banner_url=room.get("banner_url")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error ensuring orchestrator room for {slug}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")