diff --git a/apps/web/src/app/nodes/[nodeId]/page.tsx b/apps/web/src/app/nodes/[nodeId]/page.tsx index 24c58706..60495d26 100644 --- a/apps/web/src/app/nodes/[nodeId]/page.tsx +++ b/apps/web/src/app/nodes/[nodeId]/page.tsx @@ -18,6 +18,7 @@ import { NodeMetricsCard } from '@/components/node-dashboard'; import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard'; +import { NodeSwapperCard } from '@/components/node-dashboard/NodeSwapperCard'; import { AgentChatWidget } from '@/components/chat/AgentChatWidget'; function getNodeLabel(nodeId: string): string { @@ -151,6 +152,9 @@ export default function NodeCabinetPage() { steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent} /> + {/* Swapper Service */} + + {/* MicroDAO Presence */} {nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
@@ -310,6 +314,11 @@ export default function NodeCabinetPage() { />
+ {/* Swapper Service (if production or dev with services) */} +
+ +
+ {/* Node Metrics (GPU/CPU/RAM/Disk) */}
diff --git a/apps/web/src/components/node-dashboard/NodeSwapperCard.tsx b/apps/web/src/components/node-dashboard/NodeSwapperCard.tsx new file mode 100644 index 00000000..7f2003ca --- /dev/null +++ b/apps/web/src/components/node-dashboard/NodeSwapperCard.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useNodeSwapper, SwapperModel } from '@/hooks/useNodeSwapper'; +import { useState } from 'react'; + +interface NodeSwapperCardProps { + nodeId: string; +} + +export function NodeSwapperCard({ nodeId }: NodeSwapperCardProps) { + const { swapper, isLoading, error, mutate } = useNodeSwapper(nodeId); + const [expanded, setExpanded] = useState(false); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+

+ 🧠 Swapper Service +

+

Failed to load status

+
+ ); + } + + if (!swapper) return null; + + const statusColor = swapper.healthy ? 'text-green-400' : 'text-red-400'; + const statusIcon = swapper.healthy ? '🟢' : '🔴'; + const statusText = swapper.healthy ? 'Healthy' : 'Down'; + + return ( +
+
+

+ 🧠 Swapper Service +

+ +
+ +
+ {/* Status Row */} +
+ Status: + + {statusIcon} {statusText} + +
+ + {/* Summary Row */} +
+ Models: + + {swapper.models_loaded} / {swapper.models_total} loaded + +
+ + {/* Details Toggle */} + + + {/* Models List */} + {expanded && ( +
+ {swapper.models.length === 0 ? ( +

No models found

+ ) : ( + swapper.models.map((model, idx) => ( +
+
+ {model.name} + {model.type || 'unknown'} +
+
+ + {model.loaded ? '● LOADED' : '○ UNLOADED'} + + {model.vram_gb && ( + {model.vram_gb.toFixed(1)} GB + )} +
+
+ )) + )} +
+ )} +
+
+ ); +} + diff --git a/apps/web/src/hooks/useNodeSwapper.ts b/apps/web/src/hooks/useNodeSwapper.ts new file mode 100644 index 00000000..4580e8b3 --- /dev/null +++ b/apps/web/src/hooks/useNodeSwapper.ts @@ -0,0 +1,36 @@ +import useSWR from "swr"; + +const fetcher = (url: string) => fetch(url, { credentials: "include" }).then(r => { + if (!r.ok) throw new Error(`Failed to load ${url}`); + return r.json(); +}); + +export interface SwapperModel { + name: string; + loaded: boolean; + type?: string; + vram_gb?: number; +} + +export interface NodeSwapperDetail { + node_id: string; + healthy: boolean; + models_loaded: number; + models_total: number; + models: SwapperModel[]; +} + +export function useNodeSwapper(nodeId?: string) { + const { data, error, isLoading, mutate } = useSWR( + nodeId ? `/api/v1/internal/node/${nodeId}/swapper` : null, + fetcher + ); + + return { + swapper: data, + isLoading, + error, + mutate, + }; +} + diff --git a/docs/tasks/TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1.md b/docs/tasks/TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1.md new file mode 100644 index 00000000..42ff8b6f --- /dev/null +++ b/docs/tasks/TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1.md @@ -0,0 +1,261 @@ +# TASK_PHASE_SWAPPER_NODE_METRICS_AND_UI_v1 + +Проєкт: DAARION.city — Swapper Service / Node Cabinet +Фаза: Метрики Swapper + відображення у Кабінеті Ноди +Мета: зробити так, щоб Swapper був «першокласним» сервісом у нодовій архітектурі: +- node-guardian-loop збирає метрики Swapper; +- ці метрики зберігаються в node_cache / внутрішньому API; +- у Кабінеті Ноди з’являється блок Swapper (статус + моделі); +- (опційно) Swapper Agent використовує ці дані для self-healing. + +--- + +## 0. Поточний стан + +- Heartbeat нод живий, node-guardian-loop запущений на NODE1 і NODE2. +- Node metrics (`/internal/node/{id}/metrics/current`) оновлюються. +- DAGI Router UI більше не дає «Unknown error» (оновлений URL). +- Swapper Service працює, але: + - не збираються і не передаються метрики Swapper; + - Кабінет Ноди не відображає стан Swapper; + - Swapper Agent поки не має даних для діагностики. + +--- + +## 1. Scope + +### Включено + +1. Збір метрик Swapper у node-guardian-loop. +2. Розширення node_cache / node metrics API полями для Swapper. +3. Новий internal endpoint для детальнішої інформації про Swapper: + - списки моделей / базові показники. +4. Оновлення Кабінету Ноди: + - блок «Swapper Service» зі статусом і основною інформацією. +5. Базові тести (backend + frontend). + +### Виключено + +- Повний self-healing Swapper Agent (рестарти, pull моделей) — це окрема фаза (service agents). +- Розгорнуті Charts по Swapper (latency history) — можна додати пізніше. + +--- + +## 2. Swapper API — припущення + +Для MVP вважаємо: + +- Swapper HTTP base URL (з кабінетів/compose): + `http://swapper-service:8890` +- Корисні endpoints: + - `GET /healthz` — здоров’я Swapper. + - `GET /v1/models` — список моделей, щось на кшталт: + ```json + { + "models": [ + { "name": "model-a", "loaded": true, "type": "llm", "vram_gb": 8.0 }, + { "name": "model-b", "loaded": false, "type": "vlm" } + ] + } + ``` + +Якщо формат інший — адаптувати, але інтерфейс на стороні city-service робити уніфікованим. + +--- + +## 3. Backend: node-guardian-loop → Swapper метрики + +### 3.1. Розширити node_cache (якщо потрібно) + +Перевірити існуючу структуру `node_cache`. Якщо полів для Swapper немає — додати міграцію, наприклад: + +```sql +alter table node_cache + add column swapper_healthy boolean, + add column swapper_models_loaded integer, + add column swapper_models_total integer, + add column swapper_state jsonb; -- Для повного списку моделей +``` + +Міграція: `migrations/039_node_cache_swapper_metrics.sql`. + +### 3.2. Оновити node-guardian-loop + +У `node-guardian-loop.py` (або відповідному worker): + +1. Додати функцію для збору Swapper-метрик: + +```python +import requests + +def collect_swapper_metrics(swapper_base_url: str) -> dict: + result = { + "swapper_healthy": False, + "swapper_models_loaded": 0, + "swapper_models_total": 0, + "swapper_state": {} + } + try: + # healthz + r = requests.get(f"{swapper_base_url}/healthz", timeout=3) + result["swapper_healthy"] = (r.status_code == 200) + except Exception: + result["swapper_healthy"] = False + + try: + r = requests.get(f"{swapper_base_url}/v1/models", timeout=5) + if r.status_code == 200: + data = r.json() + models = data.get("models", []) + total = len(models) + loaded = sum(1 for m in models if m.get("loaded") is True) + result["swapper_models_total"] = total + result["swapper_models_loaded"] = loaded + result["swapper_state"] = data # Зберігаємо весь стан + except Exception: + pass + + return result +``` + +2. У циклі node-guardian-loop (перед `metrics/update`): + +* викликати `collect_swapper_metrics(...)`; +* передавати ці поля до city-service через `POST /internal/node/{id}/metrics/update`: + +```python +payload = { + "agent_count_router": ..., + "agent_count_system": ..., + "gpu": {...}, + "cpu": {...}, + # ... + "swapper_healthy": swapper_metrics["swapper_healthy"], + "swapper_models_loaded": swapper_metrics["swapper_models_loaded"], + "swapper_models_total": swapper_metrics["swapper_models_total"], + "swapper_state": swapper_metrics["swapper_state"] +} +requests.post(f"{CITY_URL}/internal/node/{node_id}/metrics/update", json=payload, timeout=5) +``` + +### 3.3. Оновити обробник `/internal/node/{id}/metrics/update` + +У `routes_city.py`: + +* приймати нові поля; +* оновлювати відповідні колонки в `node_cache`. + +--- + +## 4. Backend: Swapper detail endpoint + +Додати окремий internal endpoint для детального перегляду Swapper-стану ноди: + +`GET /internal/node/{node_id}/swapper` + +Відповідь: + +```json +{ + "node_id": "node-2-macbook-m4max", + "healthy": true, + "models_loaded": 3, + "models_total": 5, + "models": [ + { "name": "daarion-small-3b", "loaded": true, "type": "llm" }, + { "name": "daarion-code-7b", "loaded": true, "type": "code" }, + { "name": "vision-8b", "loaded": false, "type": "vlm" } + ] +} +``` + +Джерело: +* `node_cache` (нові колонки + jsonb `swapper_state`). + +--- + +## 5. Frontend: Кабінет Ноди → Swapper + +### 5.1. Хук `useNodeSwapper(nodeId)` + +У `apps/web/src/hooks`: + +```ts +import useSWR from "swr"; + +export function useNodeSwapper(nodeId: string) { + return useSWR(`/internal/node/${nodeId}/swapper`, fetcher); +} +``` + +### 5.2. Компонент `NodeSwapperCard` + +Новий компонент у `components/node-dashboard/NodeSwapperCard.tsx`, який показує: + +* заголовок: `Swapper Service`; +* статус: + * `🟢 Healthy` / `🟡 Degraded` / `🔴 Down` (на основі `healthy` + `models_loaded`); +* коротке резюме: + * `Моделі: 3/5 завантажено`; +* кнопка/кнопка-розкривалка `Переглянути моделі`: + * список моделей (name, type, loaded). + +### 5.3. Інтеграція в Node Cabinet + +На сторінці `/nodes/[nodeId]`: + +* додати `NodeSwapperCard` поруч із `NodeMetricsCard` / `DAGIRouterCard` у секцію Service Agents. + +--- + +## 6. Tests + +### 6.1. Backend + +* Тест міграції `node_cache` (наявність нових колонок). +* Тест `POST /internal/node/{id}/metrics/update`: + * при передачі полів Swapper правильно оновлює `node_cache`. +* Тест `GET /internal/node/{id}/swapper`: + * при коректній відповіді Swapper API повертає очікувану структуру; + * якщо Swapper лежить — `healthy=false`, моделі порожні. + +### 6.2. Frontend + +* Snapshot-тест `NodeSwapperCard` для випадків: + * healthy + моделі є; + * unhealthy + немає моделей; + * loading/error state. + +--- + +## 7. Acceptance Criteria + +1. node-guardian-loop регулярно збирає Swapper-метрики та відправляє їх у city-service. +2. `node_cache` містить свіжі поля: + * `swapper_healthy` + * `swapper_models_loaded` + * `swapper_models_total` + * `swapper_state` (JSONB) +3. `GET /internal/node/{id}/metrics/current` відображає Swapper-метрики для обох нод. +4. `GET /internal/node/{id}/swapper` повертає детальну інформацію про Swapper (мінімум: healthy + models). +5. У Кабінеті Ноди (`/nodes/[nodeId]`) є блок Swapper Service: + * показує статус, + * показує кількість завантажених моделей, + * дозволяє переглянути список моделей. +6. `scripts/check-deploy-post.py` оновлено для перевірки Swapper. + +--- + +## 8. Deliverables + +* `migrations/039_node_cache_swapper_metrics.sql` +* Оновлений `node-guardian-loop.py` (з Swapper-метриками) +* Оновлені endpoints: + * `POST /internal/node/{id}/metrics/update` + * `GET /internal/node/{id}/swapper` +* Frontend: + * `useNodeSwapper(nodeId)` + * `NodeSwapperCard` + * Інтеграція в Node Cabinet +* Тести (backend + frontend) + diff --git a/migrations/039_node_cache_swapper_metrics.sql b/migrations/039_node_cache_swapper_metrics.sql new file mode 100644 index 00000000..e0e15231 --- /dev/null +++ b/migrations/039_node_cache_swapper_metrics.sql @@ -0,0 +1,13 @@ +-- Migration 039: Add Swapper metrics to node_cache +-- Додаємо поля для метрик Swapper Service в node_cache + +ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_healthy boolean DEFAULT false; +ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_loaded integer DEFAULT 0; +ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_models_total integer DEFAULT 0; +ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS swapper_state jsonb DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN node_cache.swapper_healthy IS 'Статус здоров`я Swapper Service'; +COMMENT ON COLUMN node_cache.swapper_models_loaded IS 'Кількість завантажених моделей'; +COMMENT ON COLUMN node_cache.swapper_models_total IS 'Загальна кількість відомих моделей'; +COMMENT ON COLUMN node_cache.swapper_state IS 'Повний стан моделей Swapper (JSON)'; + diff --git a/scripts/node-guardian-loop.py b/scripts/node-guardian-loop.py index 36a2bf8c..6d70c2c5 100755 --- a/scripts/node-guardian-loop.py +++ b/scripts/node-guardian-loop.py @@ -181,10 +181,49 @@ class NodeGuardian: return {"error": str(e)} async def collect_metrics(self) -> Dict[str, Any]: - """Зібрати метрики ноди (базова реалізація)""" - # TODO: Implement real metrics collection - # For now, return empty metrics - return {} + """Зібрати метрики ноди та Swapper""" + metrics = { + "cpu_usage": 0.0, # TODO: Implement real metrics + "gpu_vram_used": 0, + "ram_used": 0, + "disk_used": 0, + "agent_count_router": 0, + "agent_count_system": 0, + "dagi_router_url": "http://dagi-router:9102", + # Swapper defaults + "swapper_healthy": False, + "swapper_models_loaded": 0, + "swapper_models_total": 0, + "swapper_state": {} + } + + # Collect Swapper Metrics + swapper_url = os.getenv("SWAPPER_URL", "http://swapper-service:8890") + try: + # Check healthz + try: + r = await self.client.get(f"{swapper_url}/healthz", timeout=3.0) + metrics["swapper_healthy"] = (r.status_code == 200) + except Exception: + metrics["swapper_healthy"] = False + + # Check models + try: + r = await self.client.get(f"{swapper_url}/v1/models", timeout=5.0) + if r.status_code == 200: + data = r.json() + models = data.get("models", []) + metrics["swapper_models_total"] = len(models) + metrics["swapper_models_loaded"] = sum(1 for m in models if m.get("loaded") is True) + metrics["swapper_state"] = data + except Exception as e: + logger.warning(f"Failed to fetch Swapper models: {e}") + pass + + except Exception as e: + logger.warning(f"Swapper metrics collection failed: {e}") + + return metrics async def run_health_check(self) -> Dict[str, Any]: """ diff --git a/services/city-service/models_city.py b/services/city-service/models_city.py index f0b82ae1..45f039bd 100644 --- a/services/city-service/models_city.py +++ b/services/city-service/models_city.py @@ -1,601 +1,621 @@ -""" -Pydantic Models для City Backend -""" - -from pydantic import BaseModel, Field -from typing import Optional, List, Dict, Any -from datetime import datetime - - -# ============================================================================= -# City Rooms -# ============================================================================= - -class CityRoomBase(BaseModel): - slug: str - name: str - description: Optional[str] = None - - -class CityRoomCreate(CityRoomBase): - pass - - -class CityRoomRead(CityRoomBase): - id: str - is_default: bool - created_at: datetime - created_by: Optional[str] = None - members_online: int = 0 - last_event: Optional[str] = None - # Branding - logo_url: Optional[str] = None - banner_url: Optional[str] = None - # Context - microdao_id: Optional[str] = None - microdao_name: Optional[str] = None - microdao_slug: Optional[str] = None - microdao_logo_url: Optional[str] = None - # Matrix integration - matrix_room_id: Optional[str] = None - matrix_room_alias: Optional[str] = None - - -# ============================================================================= -# City Room Messages -# ============================================================================= - -class CityRoomMessageBase(BaseModel): - body: str = Field(..., min_length=1, max_length=10000) - - -class CityRoomMessageCreate(CityRoomMessageBase): - pass - - -class CityRoomMessageRead(CityRoomMessageBase): - id: str - room_id: str - author_user_id: Optional[str] = None - author_agent_id: Optional[str] = None - username: Optional[str] = "Anonymous" # Для frontend - created_at: datetime - - -# ============================================================================= -# City Room Detail (з повідомленнями) -# ============================================================================= - -class CityRoomDetail(CityRoomRead): - messages: List[CityRoomMessageRead] = [] - online_members: List[str] = [] # user_ids - - -# ============================================================================= -# City Feed Events -# ============================================================================= - -class CityFeedEventRead(BaseModel): - id: str - kind: str # 'room_message', 'agent_reply', 'system', 'dao_event' - room_id: Optional[str] = None - user_id: Optional[str] = None - agent_id: Optional[str] = None - payload: dict - created_at: datetime - - -# ============================================================================= -# Presence -# ============================================================================= - -class PresenceUpdate(BaseModel): - user_id: str - status: str # 'online', 'offline', 'away' - last_seen: Optional[datetime] = None - - -class PresenceBulkUpdate(BaseModel): - users: List[PresenceUpdate] - - -# ============================================================================= -# WebSocket Messages -# ============================================================================= - -class WSRoomMessage(BaseModel): - event: str # 'room.message', 'room.join', 'room.leave' - room_id: Optional[str] = None - user_id: Optional[str] = None - message: Optional[CityRoomMessageRead] = None - - -class WSPresenceMessage(BaseModel): - event: str # 'presence.heartbeat', 'presence.update' - user_id: str - status: Optional[str] = None - - -# ============================================================================= -# City Map (2D Map) -# ============================================================================= - -class CityMapRoom(BaseModel): - """Room representation on 2D city map""" - id: str - slug: str - name: str - description: Optional[str] = None - room_type: str = "public" - zone: str = "central" - icon: Optional[str] = None - color: Optional[str] = None - # Map coordinates - x: int = 0 - y: int = 0 - w: int = 1 - h: int = 1 - # Matrix integration - matrix_room_id: Optional[str] = None - - -class CityMapConfig(BaseModel): - """Global city map configuration""" - grid_width: int = 6 - grid_height: int = 3 - cell_size: int = 100 - background_url: Optional[str] = None - - -class CityMapResponse(BaseModel): - """Full city map response""" - config: CityMapConfig - rooms: List[CityMapRoom] - - -# ============================================================================= -# Branding & Assets -# ============================================================================= - -class BrandingUpdatePayload(BaseModel): - logo_url: Optional[str] = None - banner_url: Optional[str] = None - - -class AssetUploadResponse(BaseModel): - original_url: str - processed_url: str - thumb_url: Optional[str] = None - - -# ============================================================================= -# Agents (for Agent Presence) -# ============================================================================= - -class AgentRead(BaseModel): - """Agent representation""" - id: str - display_name: str - kind: str = "assistant" # assistant, civic, oracle, builder - avatar_url: Optional[str] = None - color: str = "cyan" - status: str = "offline" # online, offline, busy - current_room_id: Optional[str] = None - capabilities: List[str] = [] - - -class AgentPresence(BaseModel): - """Agent presence in a room""" - agent_id: str - display_name: str - kind: str - status: str - room_id: Optional[str] = None - color: Optional[str] = None - node_id: Optional[str] = None - district: Optional[str] = None - model: Optional[str] = None - role: Optional[str] = None - avatar_url: Optional[str] = None - - -# ============================================================================= -# Citizens -# ============================================================================= - -class CityPresenceRoomView(BaseModel): - room_id: Optional[str] = None - slug: Optional[str] = None - name: Optional[str] = None - - -class CityPresenceView(BaseModel): - primary_room_slug: Optional[str] = None - rooms: List[CityPresenceRoomView] = [] - - -class HomeNodeView(BaseModel): - """Home node information for agent/citizen""" - id: Optional[str] = None - name: Optional[str] = None - hostname: Optional[str] = None - roles: List[str] = [] - environment: Optional[str] = None - - -class NodeAgentSummary(BaseModel): - """Summary of a node agent (Guardian or Steward)""" - id: str - name: Optional[str] = None - kind: Optional[str] = None - slug: Optional[str] = None - - -class NodeMicrodaoSummary(BaseModel): - """Summary of a MicroDAO hosted on a node (via orchestrator)""" - id: str - slug: str - name: str - rooms_count: int = 0 - - -class NodeMetrics(BaseModel): - """Node metrics for Node Directory cards""" - cpu_model: Optional[str] = None - cpu_cores: int = 0 - cpu_usage: float = 0.0 - gpu_model: Optional[str] = None - gpu_vram_total: int = 0 - gpu_vram_used: int = 0 - ram_total: int = 0 - ram_used: int = 0 - disk_total: int = 0 - disk_used: int = 0 - agent_count_router: int = 0 - agent_count_system: int = 0 - dagi_router_url: Optional[str] = None - - -class NodeProfile(BaseModel): - """Node profile for Node Directory""" - node_id: str - name: str - hostname: Optional[str] = None - roles: List[str] = [] - environment: str = "unknown" - status: str = "offline" - gpu_info: Optional[str] = None - agents_total: int = 0 - agents_online: int = 0 - last_heartbeat: Optional[str] = None - guardian_agent_id: Optional[str] = None - steward_agent_id: Optional[str] = None - guardian_agent: Optional[NodeAgentSummary] = None - steward_agent: Optional[NodeAgentSummary] = None - microdaos: List[NodeMicrodaoSummary] = [] - metrics: Optional[NodeMetrics] = None - - -class ModelBindings(BaseModel): - """Agent model bindings for AI capabilities""" - primary_model: Optional[str] = None # e.g., "qwen3:8b" - supported_kinds: List[str] = [] # e.g., ["text", "vision", "audio"] - - -class UsageStats(BaseModel): - """Agent usage statistics""" - tokens_total_24h: Optional[int] = None - calls_total_24h: Optional[int] = None - last_active: Optional[str] = None - - -class MicrodaoBadge(BaseModel): - """MicroDAO badge for agent display""" - id: str - name: str - slug: Optional[str] = None - role: Optional[str] = None # orchestrator, member, etc. - is_public: bool = True - is_platform: bool = False - logo_url: Optional[str] = None - banner_url: Optional[str] = None - - -class AgentCrewInfo(BaseModel): - """Information about agent's CrewAI team""" - has_crew_team: bool - crew_team_key: Optional[str] = None - matrix_room_id: Optional[str] = None - - -class AgentSummary(BaseModel): - """Unified Agent summary for Agent Console and Citizens""" - id: str - slug: Optional[str] = None - display_name: str - title: Optional[str] = None # public_title - tagline: Optional[str] = None # public_tagline - kind: str = "assistant" - avatar_url: Optional[str] = None - status: str = "offline" - - # Node info - node_id: Optional[str] = None - node_label: Optional[str] = None # "НОДА1" / "НОДА2" - home_node: Optional[HomeNodeView] = None - - # Governance & DAIS (A1, A2) - gov_level: Optional[str] = None # personal, core_team, orchestrator, district_lead, city_governance - dais_identity_id: Optional[str] = None # DAIS identity reference - - # Visibility & roles - visibility_scope: str = "city" # global, microdao, private - is_listed_in_directory: bool = True - is_system: bool = False - is_public: bool = False - is_orchestrator: bool = False # Can create/manage microDAOs - - # MicroDAO (A3) - primary_microdao_id: Optional[str] = None - primary_microdao_name: Optional[str] = None - primary_microdao_slug: Optional[str] = None - home_microdao_id: Optional[str] = None # Owner microDAO - home_microdao_name: Optional[str] = None - home_microdao_slug: Optional[str] = None - district: Optional[str] = None - microdaos: List[MicrodaoBadge] = [] - microdao_memberships: List[Dict[str, Any]] = [] # backward compatibility - - # Skills - public_skills: List[str] = [] - - # CrewAI - crew_info: Optional[AgentCrewInfo] = None - - # Future: model bindings and usage stats - model_bindings: Optional[ModelBindings] = None - usage_stats: Optional[UsageStats] = None - - -class PublicCitizenSummary(BaseModel): - slug: str - display_name: str - public_title: Optional[str] = None - public_tagline: Optional[str] = None - avatar_url: Optional[str] = None - kind: Optional[str] = None - district: Optional[str] = None - primary_room_slug: Optional[str] = None - public_skills: List[str] = [] - online_status: Optional[str] = "unknown" - status: Optional[str] = None # backward compatibility - # Home node info - home_node: Optional[HomeNodeView] = None - node_id: Optional[str] = None - - # TASK 037A: Alignment - home_microdao_slug: Optional[str] = None - home_microdao_name: Optional[str] = None - primary_city_room: Optional["CityRoomSummary"] = None - - -class PublicCitizenProfile(BaseModel): - slug: str - display_name: str - kind: Optional[str] = None - public_title: Optional[str] = None - public_tagline: Optional[str] = None - district: Optional[str] = None - avatar_url: Optional[str] = None - status: Optional[str] = None - node_id: Optional[str] = None - public_skills: List[str] = [] - city_presence: Optional[CityPresenceView] = None - dais_public: Dict[str, Any] - interaction: Dict[str, Any] - metrics_public: Dict[str, Any] - admin_panel_url: Optional[str] = None - microdao: Optional[Dict[str, Any]] = None - # Home node info - home_node: Optional[HomeNodeView] = None - - -class CitizenInteractionInfo(BaseModel): - slug: str - display_name: str - primary_room_slug: Optional[str] = None - primary_room_id: Optional[str] = None - primary_room_name: Optional[str] = None - matrix_user_id: Optional[str] = None - district: Optional[str] = None - microdao_slug: Optional[str] = None - microdao_name: Optional[str] = None - - -class CitizenAskRequest(BaseModel): - question: str - context: Optional[str] = None - - -class CitizenAskResponse(BaseModel): - answer: str - agent_display_name: str - agent_id: str - - -# ============================================================================= -# MicroDAO -# ============================================================================= - -class MicrodaoCitizenView(BaseModel): - slug: str - display_name: str - public_title: Optional[str] = None - public_tagline: Optional[str] = None - avatar_url: Optional[str] = None - district: Optional[str] = None - primary_room_slug: Optional[str] = None - - -class MicrodaoSummary(BaseModel): - """MicroDAO summary for list view""" - id: str - slug: str - name: str - description: Optional[str] = None - district: Optional[str] = None - - # Visibility & type - is_public: bool = True - is_platform: bool = False # Is a platform/district - is_active: bool = True - - # Orchestrator - orchestrator_agent_id: Optional[str] = None - orchestrator_agent_name: Optional[str] = None - - # Hierarchy - parent_microdao_id: Optional[str] = None - parent_microdao_slug: Optional[str] = None - - # Stats - logo_url: Optional[str] = None - banner_url: Optional[str] = None - member_count: int = 0 # alias for agents_count - agents_count: int = 0 # backward compatibility - room_count: int = 0 # alias for rooms_count - rooms_count: int = 0 # backward compatibility - channels_count: int = 0 - - -class MicrodaoChannelView(BaseModel): - """Channel/integration view for MicroDAO""" - kind: str # 'matrix' | 'telegram' | 'city_room' | 'crew' - ref_id: str - display_name: Optional[str] = None - is_primary: bool - - -class MicrodaoAgentView(BaseModel): - """Agent view within MicroDAO""" - agent_id: str - display_name: str - role: Optional[str] = None - is_core: bool - - -class CityRoomSummary(BaseModel): - """Summary of a city room for chat embedding and multi-room support""" - id: str - slug: str - name: str - matrix_room_id: Optional[str] = None - microdao_id: Optional[str] = None - microdao_slug: Optional[str] = None - room_role: Optional[str] = None # 'primary', 'lobby', 'team', 'research', 'security', 'governance', 'orchestrator_team' - is_public: bool = True - sort_order: int = 100 - logo_url: Optional[str] = None - banner_url: Optional[str] = None - - -class MicrodaoRoomsList(BaseModel): - """List of rooms belonging to a MicroDAO""" - microdao_id: str - microdao_slug: str - rooms: List[CityRoomSummary] = [] - - -class MicrodaoRoomUpdate(BaseModel): - """Update request for MicroDAO room settings""" - room_role: Optional[str] = None - is_public: Optional[bool] = None - sort_order: Optional[int] = None - set_primary: Optional[bool] = None # if true, mark as primary - - -class AttachExistingRoomRequest(BaseModel): - """Request to attach an existing city room to a MicroDAO""" - room_id: str - room_role: Optional[str] = None - is_public: bool = True - sort_order: int = 100 - - -class MicrodaoDetail(BaseModel): - """Full MicroDAO detail view""" - id: str - slug: str - name: str - description: Optional[str] = None - district: Optional[str] = None - - # Visibility & type - is_public: bool = True - is_platform: bool = False - is_active: bool = True - - # Orchestrator - orchestrator_agent_id: Optional[str] = None - orchestrator_display_name: Optional[str] = None - - # Hierarchy - parent_microdao_id: Optional[str] = None - parent_microdao_slug: Optional[str] = None - child_microdaos: List["MicrodaoSummary"] = [] - - # Content - logo_url: Optional[str] = None - banner_url: Optional[str] = None - agents: List[MicrodaoAgentView] = [] - channels: List[MicrodaoChannelView] = [] - - # Multi-room support - rooms: List[CityRoomSummary] = [] - public_citizens: List[MicrodaoCitizenView] = [] - - # Primary city room for chat - primary_city_room: Optional[CityRoomSummary] = None - - -class AgentMicrodaoMembership(BaseModel): - microdao_id: str - microdao_slug: str - microdao_name: str - logo_url: Optional[str] = None - role: Optional[str] = None - is_core: bool = False - - -class MicrodaoOption(BaseModel): - id: str - slug: str - name: str - district: Optional[str] = None - is_active: bool = True - - -# ============================================================================= -# Visibility Updates (Task 029) -# ============================================================================= - -class AgentVisibilityUpdate(BaseModel): - """Update agent visibility settings""" - is_public: bool - visibility_scope: Optional[str] = None # 'global' | 'microdao' | 'private' - - -class MicrodaoVisibilityUpdate(BaseModel): - """Update MicroDAO visibility settings""" - is_public: bool - is_platform: Optional[bool] = None # Upgrade to platform/district - - -class MicrodaoCreateRequest(BaseModel): - """Request to create MicroDAO from agent (orchestrator flow)""" - name: str - slug: str - description: Optional[str] = None - make_platform: bool = False # If true -> is_platform = true - is_public: bool = True - parent_microdao_id: Optional[str] = None + 1|""" + 2|Pydantic Models для City Backend + 3|""" + 4| + 5|from pydantic import BaseModel, Field + 6|from typing import Optional, List, Dict, Any + 7|from datetime import datetime + 8| + 9| + 10|# ============================================================================= + 11|# City Rooms + 12|# ============================================================================= + 13| + 14|class CityRoomBase(BaseModel): + 15| slug: str + 16| name: str + 17| description: Optional[str] = None + 18| + 19| + 20|class CityRoomCreate(CityRoomBase): + 21| pass + 22| + 23| + 24|class CityRoomRead(CityRoomBase): + 25| id: str + 26| is_default: bool + 27| created_at: datetime + 28| created_by: Optional[str] = None + 29| members_online: int = 0 + 30| last_event: Optional[str] = None + 31| # Branding + 32| logo_url: Optional[str] = None + 33| banner_url: Optional[str] = None + 34| # Context + 35| microdao_id: Optional[str] = None + 36| microdao_name: Optional[str] = None + 37| microdao_slug: Optional[str] = None + 38| microdao_logo_url: Optional[str] = None + 39| # Matrix integration + 40| matrix_room_id: Optional[str] = None + 41| matrix_room_alias: Optional[str] = None + 42| + 43| + 44|# ============================================================================= + 45|# City Room Messages + 46|# ============================================================================= + 47| + 48|class CityRoomMessageBase(BaseModel): + 49| body: str = Field(..., min_length=1, max_length=10000) + 50| + 51| + 52|class CityRoomMessageCreate(CityRoomMessageBase): + 53| pass + 54| + 55| + 56|class CityRoomMessageRead(CityRoomMessageBase): + 57| id: str + 58| room_id: str + 59| author_user_id: Optional[str] = None + 60| author_agent_id: Optional[str] = None + 61| username: Optional[str] = "Anonymous" # Для frontend + 62| created_at: datetime + 63| + 64| + 65|# ============================================================================= + 66|# City Room Detail (з повідомленнями) + 67|# ============================================================================= + 68| + 69|class CityRoomDetail(CityRoomRead): + 70| messages: List[CityRoomMessageRead] = [] + 71| online_members: List[str] = [] # user_ids + 72| + 73| + 74|# ============================================================================= + 75|# City Feed Events + 76|# ============================================================================= + 77| + 78|class CityFeedEventRead(BaseModel): + 79| id: str + 80| kind: str # 'room_message', 'agent_reply', 'system', 'dao_event' + 81| room_id: Optional[str] = None + 82| user_id: Optional[str] = None + 83| agent_id: Optional[str] = None + 84| payload: dict + 85| created_at: datetime + 86| + 87| + 88|# ============================================================================= + 89|# Presence + 90|# ============================================================================= + 91| + 92|class PresenceUpdate(BaseModel): + 93| user_id: str + 94| status: str # 'online', 'offline', 'away' + 95| last_seen: Optional[datetime] = None + 96| + 97| + 98|class PresenceBulkUpdate(BaseModel): + 99| users: List[PresenceUpdate] + 100| + 101| + 102|# ============================================================================= + 103|# WebSocket Messages + 104|# ============================================================================= + 105| + 106|class WSRoomMessage(BaseModel): + 107| event: str # 'room.message', 'room.join', 'room.leave' + 108| room_id: Optional[str] = None + 109| user_id: Optional[str] = None + 110| message: Optional[CityRoomMessageRead] = None + 111| + 112| + 113|class WSPresenceMessage(BaseModel): + 114| event: str # 'presence.heartbeat', 'presence.update' + 115| user_id: str + 116| status: Optional[str] = None + 117| + 118| + 119|# ============================================================================= + 120|# City Map (2D Map) + 121|# ============================================================================= + 122| + 123|class CityMapRoom(BaseModel): + 124| """Room representation on 2D city map""" + 125| id: str + 126| slug: str + 127| name: str + 128| description: Optional[str] = None + 129| room_type: str = "public" + 130| zone: str = "central" + 131| icon: Optional[str] = None + 132| color: Optional[str] = None + 133| # Map coordinates + 134| x: int = 0 + 135| y: int = 0 + 136| w: int = 1 + 137| h: int = 1 + 138| # Matrix integration + 139| matrix_room_id: Optional[str] = None + 140| + 141| + 142|class CityMapConfig(BaseModel): + 143| """Global city map configuration""" + 144| grid_width: int = 6 + 145| grid_height: int = 3 + 146| cell_size: int = 100 + 147| background_url: Optional[str] = None + 148| + 149| + 150|class CityMapResponse(BaseModel): + 151| """Full city map response""" + 152| config: CityMapConfig + 153| rooms: List[CityMapRoom] + 154| + 155| + 156|# ============================================================================= + 157|# Branding & Assets + 158|# ============================================================================= + 159| + 160|class BrandingUpdatePayload(BaseModel): + 161| logo_url: Optional[str] = None + 162| banner_url: Optional[str] = None + 163| + 164| + 165|class AssetUploadResponse(BaseModel): + 166| original_url: str + 167| processed_url: str + 168| thumb_url: Optional[str] = None + 169| + 170| + 171|# ============================================================================= + 172|# Agents (for Agent Presence) + 173|# ============================================================================= + 174| + 175|class AgentRead(BaseModel): + 176| """Agent representation""" + 177| id: str + 178| display_name: str + 179| kind: str = "assistant" # assistant, civic, oracle, builder + 180| avatar_url: Optional[str] = None + 181| color: str = "cyan" + 182| status: str = "offline" # online, offline, busy + 183| current_room_id: Optional[str] = None + 184| capabilities: List[str] = [] + 185| + 186| + 187|class AgentPresence(BaseModel): + 188| """Agent presence in a room""" + 189| agent_id: str + 190| display_name: str + 191| kind: str + 192| status: str + 193| room_id: Optional[str] = None + 194| color: Optional[str] = None + 195| node_id: Optional[str] = None + 196| district: Optional[str] = None + 197| model: Optional[str] = None + 198| role: Optional[str] = None + 199| avatar_url: Optional[str] = None + 200| + 201| + 202|# ============================================================================= + 203|# Citizens + 204|# ============================================================================= + 205| + 206|class CityPresenceRoomView(BaseModel): + 207| room_id: Optional[str] = None + 208| slug: Optional[str] = None + 209| name: Optional[str] = None + 210| + 211| + 212|class CityPresenceView(BaseModel): + 213| primary_room_slug: Optional[str] = None + 214| rooms: List[CityPresenceRoomView] = [] + 215| + 216| + 217|class HomeNodeView(BaseModel): + 218| """Home node information for agent/citizen""" + 219| id: Optional[str] = None + 220| name: Optional[str] = None + 221| hostname: Optional[str] = None + 222| roles: List[str] = [] + 223| environment: Optional[str] = None + 224| + 225| + 226|class NodeAgentSummary(BaseModel): + 227| """Summary of a node agent (Guardian or Steward)""" + 228| id: str + 229| name: Optional[str] = None + 230| kind: Optional[str] = None + 231| slug: Optional[str] = None + 232| + 233| + 234|class NodeMicrodaoSummary(BaseModel): + 235| """Summary of a MicroDAO hosted on a node (via orchestrator)""" + 236| id: str + 237| slug: str + 238| name: str + 239| rooms_count: int = 0 + 240| + 241| + 242|class NodeMetrics(BaseModel): + 243| """Node metrics for Node Directory cards""" + 244| cpu_model: Optional[str] = None + 245| cpu_cores: int = 0 + 246| cpu_usage: float = 0.0 + 247| gpu_model: Optional[str] = None + 248| gpu_vram_total: int = 0 + 249| gpu_vram_used: int = 0 + 250| ram_total: int = 0 + 251| ram_used: int = 0 + 252| disk_total: int = 0 + 253| disk_used: int = 0 + 254| agent_count_router: int = 0 + 255| agent_count_system: int = 0 + 256| dagi_router_url: Optional[str] = None + 257| swapper_healthy: bool = False + 258| swapper_models_loaded: int = 0 + 259| swapper_models_total: int = 0 + 260| + 261| + 262|class NodeProfile(BaseModel): + 263| """Node profile for Node Directory""" + 264| node_id: str + 265| name: str + 266| hostname: Optional[str] = None + 267| roles: List[str] = [] + 268| environment: str = "unknown" + 269| status: str = "offline" + 270| gpu_info: Optional[str] = None + 271| agents_total: int = 0 + 272| agents_online: int = 0 + 273| last_heartbeat: Optional[str] = None + 274| guardian_agent_id: Optional[str] = None + 275| steward_agent_id: Optional[str] = None + 276| guardian_agent: Optional[NodeAgentSummary] = None + 277| steward_agent: Optional[NodeAgentSummary] = None + 278| microdaos: List[NodeMicrodaoSummary] = [] + 279| metrics: Optional[NodeMetrics] = None + 280| + 281| + 282|class ModelBindings(BaseModel): + 283| """Agent model bindings for AI capabilities""" + 284| primary_model: Optional[str] = None # e.g., "qwen3:8b" + 285| supported_kinds: List[str] = [] # e.g., ["text", "vision", "audio"] + 286| + 287| + 288|class UsageStats(BaseModel): + 289| """Agent usage statistics""" + 290| tokens_total_24h: Optional[int] = None + 291| calls_total_24h: Optional[int] = None + 292| last_active: Optional[str] = None + 293| + 294| + 295|class MicrodaoBadge(BaseModel): + 296| """MicroDAO badge for agent display""" + 297| id: str + 298| name: str + 299| slug: Optional[str] = None + 300| role: Optional[str] = None # orchestrator, member, etc. + 301| is_public: bool = True + 302| is_platform: bool = False + 303| logo_url: Optional[str] = None + 304| banner_url: Optional[str] = None + 305| + 306| + 307|class AgentCrewInfo(BaseModel): + 308| """Information about agent's CrewAI team""" + 309| has_crew_team: bool + 310| crew_team_key: Optional[str] = None + 311| matrix_room_id: Optional[str] = None + 312| + 313| + 314|class AgentSummary(BaseModel): + 315| """Unified Agent summary for Agent Console and Citizens""" + 316| id: str + 317| slug: Optional[str] = None + 318| display_name: str + 319| title: Optional[str] = None # public_title + 320| tagline: Optional[str] = None # public_tagline + 321| kind: str = "assistant" + 322| avatar_url: Optional[str] = None + 323| status: str = "offline" + 324| + 325| # Node info + 326| node_id: Optional[str] = None + 327| node_label: Optional[str] = None # "НОДА1" / "НОДА2" + 328| home_node: Optional[HomeNodeView] = None + 329| + 330| # Governance & DAIS (A1, A2) + 331| gov_level: Optional[str] = None # personal, core_team, orchestrator, district_lead, city_governance + 332| dais_identity_id: Optional[str] = None # DAIS identity reference + 333| + 334| # Visibility & roles + 335| visibility_scope: str = "city" # global, microdao, private + 336| is_listed_in_directory: bool = True + 337| is_system: bool = False + 338| is_public: bool = False + 339| is_orchestrator: bool = False # Can create/manage microDAOs + 340| + 341| # MicroDAO (A3) + 342| primary_microdao_id: Optional[str] = None + 343| primary_microdao_name: Optional[str] = None + 344| primary_microdao_slug: Optional[str] = None + 345| home_microdao_id: Optional[str] = None # Owner microDAO + 346| home_microdao_name: Optional[str] = None + 347| home_microdao_slug: Optional[str] = None + 348| district: Optional[str] = None + 349| microdaos: List[MicrodaoBadge] = [] + 350| microdao_memberships: List[Dict[str, Any]] = [] # backward compatibility + 351| + 352| # Skills + 353| public_skills: List[str] = [] + 354| + 355| # CrewAI + 356| crew_info: Optional[AgentCrewInfo] = None + 357| + 358| # Future: model bindings and usage stats + 359| model_bindings: Optional[ModelBindings] = None + 360| usage_stats: Optional[UsageStats] = None + 361| + 362| + 363|class PublicCitizenSummary(BaseModel): + 364| slug: str + 365| display_name: str + 366| public_title: Optional[str] = None + 367| public_tagline: Optional[str] = None + 368| avatar_url: Optional[str] = None + 369| kind: Optional[str] = None + 370| district: Optional[str] = None + 371| primary_room_slug: Optional[str] = None + 372| public_skills: List[str] = [] + 373| online_status: Optional[str] = "unknown" + 374| status: Optional[str] = None # backward compatibility + 375| # Home node info + 376| home_node: Optional[HomeNodeView] = None + 377| node_id: Optional[str] = None + 378| + 379| # TASK 037A: Alignment + 380| home_microdao_slug: Optional[str] = None + 381| home_microdao_name: Optional[str] = None + 382| primary_city_room: Optional["CityRoomSummary"] = None + 383| + 384| + 385|class PublicCitizenProfile(BaseModel): + 386| slug: str + 387| display_name: str + 388| kind: Optional[str] = None + 389| public_title: Optional[str] = None + 390| public_tagline: Optional[str] = None + 391| district: Optional[str] = None + 392| avatar_url: Optional[str] = None + 393| status: Optional[str] = None + 394| node_id: Optional[str] = None + 395| public_skills: List[str] = [] + 396| city_presence: Optional[CityPresenceView] = None + 397| dais_public: Dict[str, Any] + 398| interaction: Dict[str, Any] + 399| metrics_public: Dict[str, Any] + 400| admin_panel_url: Optional[str] = None + 401| microdao: Optional[Dict[str, Any]] = None + 402| # Home node info + 403| home_node: Optional[HomeNodeView] = None + 404| + 405| + 406|class CitizenInteractionInfo(BaseModel): + 407| slug: str + 408| display_name: str + 409| primary_room_slug: Optional[str] = None + 410| primary_room_id: Optional[str] = None + 411| primary_room_name: Optional[str] = None + 412| matrix_user_id: Optional[str] = None + 413| district: Optional[str] = None + 414| microdao_slug: Optional[str] = None + 415| microdao_name: Optional[str] = None + 416| + 417| + 418|class CitizenAskRequest(BaseModel): + 419| question: str + 420| context: Optional[str] = None + 421| + 422| + 423|class CitizenAskResponse(BaseModel): + 424| answer: str + 425| agent_display_name: str + 426| agent_id: str + 427| + 428| + 429|# ============================================================================= + 430|# MicroDAO + 431|# ============================================================================= + 432| + 433|class MicrodaoCitizenView(BaseModel): + 434| slug: str + 435| display_name: str + 436| public_title: Optional[str] = None + 437| public_tagline: Optional[str] = None + 438| avatar_url: Optional[str] = None + 439| district: Optional[str] = None + 440| primary_room_slug: Optional[str] = None + 441| + 442| + 443|class MicrodaoSummary(BaseModel): + 444| """MicroDAO summary for list view""" + 445| id: str + 446| slug: str + 447| name: str + 448| description: Optional[str] = None + 449| district: Optional[str] = None + 450| + 451| # Visibility & type + 452| is_public: bool = True + 453| is_platform: bool = False # Is a platform/district + 454| is_active: bool = True + 455| + 456| # Orchestrator + 457| orchestrator_agent_id: Optional[str] = None + 458| orchestrator_agent_name: Optional[str] = None + 459| + 460| # Hierarchy + 461| parent_microdao_id: Optional[str] = None + 462| parent_microdao_slug: Optional[str] = None + 463| + 464| # Stats + 465| logo_url: Optional[str] = None + 466| banner_url: Optional[str] = None + 467| member_count: int = 0 # alias for agents_count + 468| agents_count: int = 0 # backward compatibility + 469| room_count: int = 0 # alias for rooms_count + 470| rooms_count: int = 0 # backward compatibility + 471| channels_count: int = 0 + 472| + 473| + 474|class MicrodaoChannelView(BaseModel): + 475| """Channel/integration view for MicroDAO""" + 476| kind: str # 'matrix' | 'telegram' | 'city_room' | 'crew' + 477| ref_id: str + 478| display_name: Optional[str] = None + 479| is_primary: bool + 480| + 481| + 482|class MicrodaoAgentView(BaseModel): + 483| """Agent view within MicroDAO""" + 484| agent_id: str + 485| display_name: str + 486| role: Optional[str] = None + 487| is_core: bool + 488| + 489| + 490|class CityRoomSummary(BaseModel): + 491| """Summary of a city room for chat embedding and multi-room support""" + 492| id: str + 493| slug: str + 494| name: str + 495| matrix_room_id: Optional[str] = None + 496| microdao_id: Optional[str] = None + 497| microdao_slug: Optional[str] = None + 498| room_role: Optional[str] = None # 'primary', 'lobby', 'team', 'research', 'security', 'governance', 'orchestrator_team' + 499| is_public: bool = True + 500| sort_order: int = 100 + 501| logo_url: Optional[str] = None + 502| banner_url: Optional[str] = None + 503| + 504| + 505|class MicrodaoRoomsList(BaseModel): + 506| """List of rooms belonging to a MicroDAO""" + 507| microdao_id: str + 508| microdao_slug: str + 509| rooms: List[CityRoomSummary] = [] + 510| + 511| + 512|class MicrodaoRoomUpdate(BaseModel): + 513| """Update request for MicroDAO room settings""" + 514| room_role: Optional[str] = None + 515| is_public: Optional[bool] = None + 516| sort_order: Optional[int] = None + 517| set_primary: Optional[bool] = None # if true, mark as primary + 518| + 519| + 520|class AttachExistingRoomRequest(BaseModel): + 521| """Request to attach an existing city room to a MicroDAO""" + 522| room_id: str + 523| room_role: Optional[str] = None + 524| is_public: bool = True + 525| sort_order: int = 100 + 526| + 527| + 528|class MicrodaoDetail(BaseModel): + 529| """Full MicroDAO detail view""" + 530| id: str + 531| slug: str + 532| name: str + 533| description: Optional[str] = None + 534| district: Optional[str] = None + 535| + 536| # Visibility & type + 537| is_public: bool = True + 538| is_platform: bool = False + 539| is_active: bool = True + 540| + 541| # Orchestrator + 542| orchestrator_agent_id: Optional[str] = None + 543| orchestrator_display_name: Optional[str] = None + 544| + 545| # Hierarchy + 546| parent_microdao_id: Optional[str] = None + 547| parent_microdao_slug: Optional[str] = None + 548| child_microdaos: List["MicrodaoSummary"] = [] + 549| + 550| # Content + 551| logo_url: Optional[str] = None + 552| banner_url: Optional[str] = None + 553| agents: List[MicrodaoAgentView] = [] + 554| channels: List[MicrodaoChannelView] = [] + 555| + 556| # Multi-room support + 557| rooms: List[CityRoomSummary] = [] + 558| public_citizens: List[MicrodaoCitizenView] = [] + 559| + 560| # Primary city room for chat + 561| primary_city_room: Optional[CityRoomSummary] = None + 562| + 563| + 564|class AgentMicrodaoMembership(BaseModel): + 565| microdao_id: str + 566| microdao_slug: str + 567| microdao_name: str + 568| logo_url: Optional[str] = None + 569| role: Optional[str] = None + 570| is_core: bool = False + 571| + 572| + 573|class MicrodaoOption(BaseModel): + 574| id: str + 575| slug: str + 576| name: str + 577| district: Optional[str] = None + 578| is_active: bool = True + 579| + 580| + 581|# ============================================================================= + 582|# Visibility Updates (Task 029) + 583|# ============================================================================= + 584| + 585|class AgentVisibilityUpdate(BaseModel): + 586| """Update agent visibility settings""" + 587| is_public: bool + 588| visibility_scope: Optional[str] = None # 'global' | 'microdao' | 'private' + 589| + 590| + 591|class MicrodaoVisibilityUpdate(BaseModel): + 592| """Update MicroDAO visibility settings""" + 593| is_public: bool + 594| is_platform: Optional[bool] = None # Upgrade to platform/district + 595| + 596| + 597|class MicrodaoCreateRequest(BaseModel): + 598| """Request to create MicroDAO from agent (orchestrator flow)""" + 599| name: str + 600| slug: str + 601| description: Optional[str] = None + 602| make_platform: bool = False # If true -> is_platform = true + 603| is_public: bool = True + 604| parent_microdao_id: Optional[str] = None + 605| + 606| + 607|class SwapperModel(BaseModel): + 608| """Model info from Swapper service""" + 609| name: str + 610| loaded: bool + 611| type: Optional[str] = None + 612| vram_gb: Optional[float] = None + 613| + 614| + 615|class NodeSwapperDetail(BaseModel): + 616| """Detailed Swapper info for Node Cabinet""" + 617| node_id: str + 618| healthy: bool + 619| models_loaded: int + 620| models_total: int + 621| models: List[SwapperModel] = [] diff --git a/services/city-service/repo_city.py b/services/city-service/repo_city.py index f287fba8..7763cffe 100644 --- a/services/city-service/repo_city.py +++ b/services/city-service/repo_city.py @@ -1,3293 +1,3 @@ -""" -Repository для City Backend (PostgreSQL) -""" - -import os -import uuid -import asyncpg -import json -from typing import Optional, List, Dict, Any, Tuple -from datetime import datetime, timezone -import secrets -import httpx -import logging - -logger = logging.getLogger(__name__) - -# Database connection -_pool: Optional[asyncpg.Pool] = None - -MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://matrix-gateway:8000") - -async def get_pool() -> asyncpg.Pool: - """Отримати connection pool""" - global _pool - - if _pool is None: - database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion") - _pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10) - - return _pool - - -async def close_pool(): - """Закрити connection pool""" - global _pool - if _pool is not None: - await _pool.close() - _pool = None - - -def generate_id(prefix: str) -> str: - """Генерувати простий ID""" - return f"{prefix}_{secrets.token_urlsafe(12)}" - - -def _normalize_capabilities(value: Any) -> List[str]: - """Ensure capabilities are returned as a list.""" - if value is None: - return [] - if isinstance(value, list): - return value - if isinstance(value, str): - import json - try: - return json.loads(value) - except Exception: - return [] - return list(value) - - -# ============================================================================= -# City Rooms Repository -# ============================================================================= - -async def get_all_rooms(limit: int = 100, offset: int = 0) -> List[dict]: - """Отримати всі кімнати з додатковими полями""" - pool = await get_pool() - - query = """ - SELECT - cr.id, cr.slug, cr.name, cr.description, - cr.room_type, cr.owner_type, cr.owner_id, cr.space_scope, cr.visibility, - cr.is_default, cr.is_public, cr.sort_order, - cr.created_at, cr.created_by, - cr.matrix_room_id, cr.matrix_room_alias, - cr.logo_url, cr.banner_url, - cr.room_role - FROM city_rooms cr - WHERE cr.is_public = true OR cr.space_scope = 'city' - ORDER BY cr.sort_order ASC, cr.is_default DESC, cr.created_at DESC - LIMIT $1 OFFSET $2 - """ - - rows = await pool.fetch(query, limit, offset) - return [dict(row) for row in rows] - - -async def get_city_rooms_for_list(limit: int = 100) -> List[dict]: - """Отримати City Rooms для відображення у списку""" - pool = await get_pool() - - query = """ - SELECT - cr.id, cr.slug, cr.name, cr.description, - cr.room_type, cr.owner_type, cr.owner_id, cr.space_scope, cr.visibility, - cr.is_public, cr.sort_order, - cr.matrix_room_id, cr.matrix_room_alias, - cr.logo_url, cr.banner_url, - cr.room_role, - cr.created_at - FROM city_rooms cr - WHERE cr.space_scope = 'city' AND cr.is_public = true - ORDER BY cr.sort_order ASC, cr.name ASC - LIMIT $1 - """ - - rows = await pool.fetch(query, limit) - return [dict(row) for row in rows] - - -async def get_room_by_id(room_id: str) -> Optional[dict]: - """Отримати кімнату по ID""" - pool = await get_pool() - - query = """ - SELECT - cr.id, cr.slug, cr.name, cr.description, cr.is_default, cr.created_at, cr.created_by, - cr.matrix_room_id, cr.matrix_room_alias, cr.logo_url, cr.banner_url, - cr.microdao_id, m.name AS microdao_name, m.slug AS microdao_slug, m.logo_url AS microdao_logo_url - FROM city_rooms cr - LEFT JOIN microdaos m ON cr.microdao_id::text = m.id - WHERE cr.id = $1 - """ - - row = await pool.fetchrow(query, room_id) - return dict(row) if row else None - - -async def get_room_by_slug(slug: str) -> Optional[dict]: - """Отримати кімнату по slug""" - pool = await get_pool() - - query = """ - SELECT - cr.id, cr.slug, cr.name, cr.description, cr.is_default, cr.created_at, cr.created_by, - cr.matrix_room_id, cr.matrix_room_alias, cr.logo_url, cr.banner_url, - cr.microdao_id, m.name AS microdao_name, m.slug AS microdao_slug, m.logo_url AS microdao_logo_url - FROM city_rooms cr - LEFT JOIN microdaos m ON cr.microdao_id::text = m.id - WHERE cr.slug = $1 - """ - - row = await pool.fetchrow(query, slug) - return dict(row) if row else None - - -async def get_room_by_id(room_id: str) -> Optional[dict]: - """Отримати кімнату по ID (UUID)""" - pool = await get_pool() - - query = """ - SELECT - cr.id, cr.slug, cr.name, cr.description, cr.is_default, cr.created_at, cr.created_by, - cr.matrix_room_id, cr.matrix_room_alias, cr.logo_url, cr.banner_url, - cr.microdao_id, m.name AS microdao_name, m.slug AS microdao_slug, m.logo_url AS microdao_logo_url - FROM city_rooms cr - LEFT JOIN microdaos m ON cr.microdao_id::text = m.id - WHERE cr.id = $1 - """ - - row = await pool.fetchrow(query, room_id) - return dict(row) if row else None - - -async def create_room( - slug: str, - name: str, - description: Optional[str], - created_by: Optional[str], - matrix_room_id: Optional[str] = None, - matrix_room_alias: Optional[str] = None -) -> dict: - """Створити кімнату""" - pool = await get_pool() - - room_id = f"room_city_{slug}" - - query = """ - INSERT INTO city_rooms (id, slug, name, description, created_by, matrix_room_id, matrix_room_alias) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, slug, name, description, is_default, created_at, created_by, matrix_room_id, matrix_room_alias - """ - - row = await pool.fetchrow(query, room_id, slug, name, description, created_by, matrix_room_id, matrix_room_alias) - return dict(row) - - -async def update_room_matrix(room_id: str, matrix_room_id: str, matrix_room_alias: str) -> Optional[dict]: - """Оновити Matrix поля кімнати""" - pool = await get_pool() - - query = """ - UPDATE city_rooms - SET matrix_room_id = $2, matrix_room_alias = $3 - WHERE id = $1 - RETURNING id, slug, name, description, is_default, created_at, created_by, matrix_room_id, matrix_room_alias - """ - - row = await pool.fetchrow(query, room_id, matrix_room_id, matrix_room_alias) - return dict(row) - - -async def get_rooms_without_matrix() -> List[dict]: - """Отримати кімнати без Matrix інтеграції""" - pool = await get_pool() - - query = """ - SELECT id, slug, name, description, is_default, created_at, created_by, - matrix_room_id, matrix_room_alias - FROM city_rooms - WHERE matrix_room_id IS NULL - ORDER BY created_at - """ - - rows = await pool.fetch(query) - return [dict(row) for row in rows] - - -# ============================================================================= -# City Room Messages Repository -# ============================================================================= - -async def get_room_messages(room_id: str, limit: int = 50) -> List[dict]: - """Отримати повідомлення кімнати""" - pool = await get_pool() - - query = """ - SELECT id, room_id, author_user_id, author_agent_id, body, created_at - FROM city_room_messages - WHERE room_id = $1 - ORDER BY created_at DESC - LIMIT $2 - """ - - rows = await pool.fetch(query, room_id, limit) - # Reverse для правильного порядку (старі → нові) - return [dict(row) for row in reversed(rows)] - - -async def create_room_message( - room_id: str, - body: str, - author_user_id: Optional[str] = None, - author_agent_id: Optional[str] = None -) -> dict: - """Створити повідомлення в кімнаті""" - pool = await get_pool() - - message_id = generate_id("m_city") - - query = """ - INSERT INTO city_room_messages (id, room_id, author_user_id, author_agent_id, body) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, room_id, author_user_id, author_agent_id, body, created_at - """ - - row = await pool.fetchrow(query, message_id, room_id, author_user_id, author_agent_id, body) - return dict(row) - - -# ============================================================================= -# City Feed Events Repository -# ============================================================================= - -async def get_feed_events(limit: int = 20, offset: int = 0) -> List[dict]: - """Отримати події feed""" - pool = await get_pool() - - query = """ - SELECT id, kind, room_id, user_id, agent_id, payload, created_at - FROM city_feed_events - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - """ - - rows = await pool.fetch(query, limit, offset) - return [dict(row) for row in rows] - - -async def create_feed_event( - kind: str, - payload: dict, - room_id: Optional[str] = None, - user_id: Optional[str] = None, - agent_id: Optional[str] = None -) -> dict: - """Створити подію в feed""" - pool = await get_pool() - - event_id = generate_id("evt_city") - - query = """ - INSERT INTO city_feed_events (id, kind, room_id, user_id, agent_id, payload) - VALUES ($1, $2, $3, $4, $5, $6::jsonb) - RETURNING id, kind, room_id, user_id, agent_id, payload, created_at - """ - - import json - payload_json = json.dumps(payload) - - row = await pool.fetchrow(query, event_id, kind, room_id, user_id, agent_id, payload_json) - return dict(row) - - -# ============================================================================= -# City Map Repository -# ============================================================================= - -async def get_map_config() -> dict: - """Отримати конфігурацію мапи міста""" - pool = await get_pool() - - query = """ - SELECT id, grid_width, grid_height, cell_size, background_url, updated_at - FROM city_map_config - WHERE id = 'default' - """ - - row = await pool.fetchrow(query) - if row: - return dict(row) - - # Повернути дефолтні значення якщо немає запису - return { - "id": "default", - "grid_width": 6, - "grid_height": 3, - "cell_size": 100, - "background_url": None - } - - -async def get_rooms_for_map() -> List[dict]: - """Отримати кімнати з координатами для 2D мапи""" - pool = await get_pool() - - query = """ - SELECT - id, slug, name, description, - room_type, zone, icon, color, - map_x, map_y, map_w, map_h, - matrix_room_id - FROM city_rooms - ORDER BY map_y, map_x - """ - - rows = await pool.fetch(query) - return [dict(row) for row in rows] - - -# ============================================================================= -# Agents Repository -# ============================================================================= - -async def list_agent_summaries( - *, - node_id: Optional[str] = None, - microdao_id: Optional[str] = None, - is_public: Optional[bool] = None, - visibility_scope: Optional[str] = None, - listed_only: Optional[bool] = None, - kinds: Optional[List[str]] = None, - include_system: bool = True, - include_archived: bool = False, - limit: int = 200, - offset: int = 0 -) -> Tuple[List[dict], int]: - """ - Unified method to list agents with all necessary data. - Used by both Agent Console and Citizens page. - """ - pool = await get_pool() - - params: List[Any] = [] - where_clauses = [] - - # Always filter archived unless explicitly included - if not include_archived: - where_clauses.append("COALESCE(a.is_archived, false) = false") - where_clauses.append("COALESCE(a.is_test, false) = false") - where_clauses.append("a.deleted_at IS NULL") - - if node_id: - params.append(node_id) - where_clauses.append(f"a.node_id = ${len(params)}") - - if microdao_id: - params.append(microdao_id) - where_clauses.append(f"EXISTS (SELECT 1 FROM microdao_agents ma WHERE ma.agent_id = a.id AND ma.microdao_id = ${len(params)})") - - if is_public is not None: - params.append(is_public) - where_clauses.append(f"COALESCE(a.is_public, false) = ${len(params)}") - - if visibility_scope: - params.append(visibility_scope) - where_clauses.append(f"COALESCE(a.visibility_scope, 'city') = ${len(params)}") - - if listed_only is True: - where_clauses.append("COALESCE(a.is_listed_in_directory, true) = true") - elif listed_only is False: - where_clauses.append("COALESCE(a.is_listed_in_directory, true) = false") - - if kinds: - params.append(kinds) - where_clauses.append(f"a.kind = ANY(${len(params)})") - - if not include_system: - where_clauses.append("COALESCE(a.is_system, false) = false") - - where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" - - query = f""" - SELECT - a.id, - COALESCE(a.slug, a.public_slug, LOWER(REPLACE(a.display_name, ' ', '-'))) AS slug, - a.display_name, - COALESCE(a.public_title, '') AS title, - COALESCE(a.public_tagline, '') AS tagline, - a.kind, - a.avatar_url, - COALESCE(a.status, 'offline') AS status, - a.node_id, - nc.node_name AS node_label, - nc.hostname AS node_hostname, - nc.roles AS node_roles, - nc.environment AS node_environment, - COALESCE(a.visibility_scope, 'city') AS visibility_scope, - COALESCE(a.is_listed_in_directory, true) AS is_listed_in_directory, - COALESCE(a.is_system, false) AS is_system, - COALESCE(a.is_public, false) AS is_public, - COALESCE(a.is_orchestrator, false) AS is_orchestrator, - a.primary_microdao_id, - pm.name AS primary_microdao_name, - pm.slug AS primary_microdao_slug, - pm.district AS district, - COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills, - a.crew_team_key, - -- DAIS & Governance fields (A1, A2) - a.gov_level, - a.dais_identity_id, - a.home_microdao_id, - hm.name AS home_microdao_name, - hm.slug AS home_microdao_slug, - COUNT(*) OVER() AS total_count - FROM agents a - LEFT JOIN node_cache nc ON a.node_id = nc.node_id - LEFT JOIN microdaos pm ON a.primary_microdao_id = pm.id - LEFT JOIN microdaos hm ON a.home_microdao_id = hm.id - WHERE {where_sql} - ORDER BY a.display_name - LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} - """ - - params.append(limit) - params.append(offset) - - rows = await pool.fetch(query, *params) - if not rows: - return [], 0 - - total = rows[0]["total_count"] - items = [] - - for row in rows: - data = dict(row) - data.pop("total_count", None) - - # Build home_node object - if data.get("node_id"): - data["home_node"] = { - "id": data.get("node_id"), - "name": data.get("node_label"), - "hostname": data.get("node_hostname"), - "roles": list(data.get("node_roles") or []), - "environment": data.get("node_environment") - } - else: - data["home_node"] = None - - # Clean up intermediate fields - for key in ["node_hostname", "node_roles", "node_environment"]: - data.pop(key, None) - - # Get MicroDAO memberships - memberships = await get_agent_microdao_memberships(data["id"]) - data["microdaos"] = [ - { - "id": m.get("microdao_id", ""), - "name": m.get("name", ""), - "slug": m.get("slug"), - "role": m.get("role") - } - for m in memberships - ] - data["microdao_memberships"] = memberships # backward compatibility - - data["public_skills"] = list(data.get("public_skills") or []) - - # Populate crew_info - if data.get("crew_team_key"): - # Try to find orchestrator team room for their primary microdao - # This is a bit expensive for list view, so maybe just return basic info - data["crew_info"] = { - "has_crew_team": True, - "crew_team_key": data["crew_team_key"], - "matrix_room_id": None # Loaded lazily if needed - } - else: - data["crew_info"] = { - "has_crew_team": False, - "crew_team_key": None, - "matrix_room_id": None - } - - items.append(data) - - return items, total - - -async def get_all_agents() -> List[dict]: - """Отримати всіх агентів (non-archived) - legacy method""" - pool = await get_pool() - - query = """ - SELECT id, display_name, kind, avatar_url, color, status, - current_room_id, capabilities, created_at, updated_at - FROM agents - WHERE COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - AND deleted_at IS NULL - ORDER BY display_name - """ - - rows = await pool.fetch(query) - return [dict(row) for row in rows] - - -async def update_agent_visibility( - agent_id: str, - *, - is_public: bool, - visibility_scope: Optional[str] = None, -) -> Optional[dict]: - """ - Оновити налаштування видимості агента. - Returns updated agent data or None if not found. - """ - pool = await get_pool() - - # Build dynamic update - set_parts = ["is_public = $2", "updated_at = NOW()"] - params = [agent_id, is_public] - - if visibility_scope is not None: - params.append(visibility_scope) - set_parts.append(f"visibility_scope = ${len(params)}") - - # Also update is_listed_in_directory based on is_public - set_parts.append("is_listed_in_directory = $2") # same as is_public - - query = f""" - UPDATE agents - SET {', '.join(set_parts)} - WHERE id = $1 - AND COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - RETURNING id, display_name, is_public, visibility_scope, is_listed_in_directory - """ - - result = await pool.fetchrow(query, *params) - return dict(result) if result else None - - -async def update_agent_visibility_legacy( - agent_id: str, - visibility_scope: str, - is_listed_in_directory: bool -) -> bool: - """Legacy: Оновити налаштування видимості агента (backward compatibility)""" - pool = await get_pool() - - query = """ - UPDATE agents - SET visibility_scope = $2, - is_listed_in_directory = $3, - is_public = $3, - updated_at = NOW() - WHERE id = $1 - AND COALESCE(is_archived, false) = false - RETURNING id - """ - - result = await pool.fetchrow(query, agent_id, visibility_scope, is_listed_in_directory) - return result is not None - - -async def get_agent_prompts(agent_id: str) -> dict: - """Отримати системні промти агента""" - pool = await get_pool() - - query = """ - SELECT kind, content, version, created_at, note - FROM agent_prompts - WHERE agent_id = $1 - AND is_active = true - ORDER BY kind - """ - - rows = await pool.fetch(query, agent_id) - - result = { - "core": None, - "safety": None, - "governance": None, - "tools": None - } - - for row in rows: - kind = row["kind"] - if kind in result: - result[kind] = { - "content": row["content"], - "version": row["version"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "note": row.get("note") - } - - return result - - -async def get_runtime_prompts(agent_id: str) -> Dict[str, Any]: - """ - Отримати системні промти агента для DAGI Router runtime. - - Returns: - { - "agent_id": str, - "has_prompts": bool, - "prompts": { - "core": str | None, - "safety": str | None, - "governance": str | None, - "tools": str | None - } - } - """ - pool = await get_pool() - - query = """ - SELECT kind, content - FROM agent_prompts - WHERE agent_id = $1 - AND is_active = true - ORDER BY kind - """ - - rows = await pool.fetch(query, agent_id) - - prompts = { - "core": None, - "safety": None, - "governance": None, - "tools": None - } - - for row in rows: - kind = row["kind"] - if kind in prompts: - prompts[kind] = row["content"] - - has_prompts = prompts["core"] is not None - - return { - "agent_id": agent_id, - "has_prompts": has_prompts, - "prompts": prompts - } - - -def build_system_prompt( - agent: Dict[str, Any], - prompts: Dict[str, str], - context: Optional[Dict[str, Any]] = None -) -> str: - """ - Побудувати повний system prompt для LLM виклику. - - Args: - agent: dict з інформацією про агента (name, kind, node_id, district_id, etc.) - prompts: dict з промтами {"core": str, "safety": str, "governance": str, "tools": str} - context: додатковий контекст (node info, district info, user role, etc.) - - Returns: - str - зібраний system prompt - """ - parts = [] - - # Core prompt (required) - if prompts.get("core"): - parts.append(prompts["core"]) - else: - # Fallback: basic prompt from agent info - agent_name = agent.get("display_name") or agent.get("name") or "Agent" - agent_kind = agent.get("kind") or "assistant" - parts.append( - f"You are {agent_name}, an AI {agent_kind} in DAARION.city ecosystem. " - f"Be helpful, accurate, and follow ethical guidelines." - ) - - # Governance rules - if prompts.get("governance"): - parts.append("\n\n## Governance\n" + prompts["governance"]) - - # Safety guidelines - if prompts.get("safety"): - parts.append("\n\n## Safety Guidelines\n" + prompts["safety"]) - - # Tools instructions - if prompts.get("tools"): - parts.append("\n\n## Tools & Capabilities\n" + prompts["tools"]) - - # Context additions - if context: - context_parts = [] - - if context.get("node"): - node = context["node"] - context_parts.append( - f"**Node**: {node.get('name', 'Unknown')} ({node.get('environment', 'unknown')} environment)" - ) - - if context.get("district"): - district = context["district"] - context_parts.append( - f"**District**: {district.get('name', 'Unknown')}" - ) - - if context.get("user_role"): - context_parts.append( - f"**User Role**: {context['user_role']}" - ) - - if context.get("microdao"): - microdao = context["microdao"] - context_parts.append( - f"**MicroDAO**: {microdao.get('name', 'Unknown')}" - ) - - if context_parts: - parts.append("\n\n## Current Context\n" + "\n".join(context_parts)) - - return "\n".join(parts) - - -async def get_agent_with_runtime_prompt(agent_id: str) -> Optional[Dict[str, Any]]: - """ - Отримати агента з зібраним runtime system prompt. - Використовується DAGI Router для inference. - """ - pool = await get_pool() - - # Get agent info - agent_query = """ - SELECT - a.id, a.name, a.display_name, a.kind, a.status, - a.node_id, a.district_id, a.microdao_id, - a.external_id, a.public_slug - FROM agents a - WHERE a.id = $1 OR a.external_id = $2 OR a.public_slug = $3 - LIMIT 1 - """ - - agent_row = await pool.fetchrow(agent_query, agent_id, f"agent:{agent_id}", agent_id) - - if not agent_row: - return None - - agent = dict(agent_row) - - # Get prompts - runtime_data = await get_runtime_prompts(agent["id"]) - - # Build context - context = {} - - # Add node context if agent has node_id - if agent.get("node_id"): - node = await get_node_by_id(agent["node_id"]) - if node: - context["node"] = { - "name": node.get("name"), - "environment": node.get("environment") - } - - # Build full system prompt - system_prompt = build_system_prompt(agent, runtime_data["prompts"], context) - - return { - "agent_id": agent["id"], - "agent_name": agent.get("display_name") or agent.get("name"), - "agent_kind": agent.get("kind"), - "has_prompts": runtime_data["has_prompts"], - "system_prompt": system_prompt, - "prompts": runtime_data["prompts"] - } - - -async def check_agents_prompts_status(agent_ids: List[str]) -> Dict[str, bool]: - """ - Перевірити наявність промтів для списку агентів. - Використовується для індикаторів у UI. - """ - if not agent_ids: - return {} - - pool = await get_pool() - - # Get all agents with at least core prompt - query = """ - SELECT DISTINCT agent_id - FROM agent_prompts - WHERE agent_id = ANY($1) - AND kind = 'core' - AND is_active = true - """ - - rows = await pool.fetch(query, agent_ids) - agents_with_prompts = {row["agent_id"] for row in rows} - - return { - agent_id: agent_id in agents_with_prompts - for agent_id in agent_ids - } - - -async def update_agent_prompt( - agent_id: str, - kind: str, - content: str, - created_by: str, - note: Optional[str] = None -) -> dict: - """ - Оновити або створити системний промт агента. - Деактивує попередню версію та створює нову. - """ - pool = await get_pool() - - valid_kinds = ("core", "safety", "governance", "tools") - if kind not in valid_kinds: - raise ValueError(f"Invalid kind: {kind}. Must be one of {valid_kinds}") - - async with pool.acquire() as conn: - async with conn.transaction(): - # Деактивувати попередню версію - await conn.execute( - """ - UPDATE agent_prompts - SET is_active = false - WHERE agent_id = $1 AND kind = $2 AND is_active = true - """, - agent_id, kind - ) - - # Отримати наступну версію - max_version = await conn.fetchval( - """ - SELECT COALESCE(MAX(version), 0) FROM agent_prompts - WHERE agent_id = $1 AND kind = $2 - """, - agent_id, kind - ) - new_version = max_version + 1 - - # Створити новий запис - row = await conn.fetchrow( - """ - INSERT INTO agent_prompts ( - agent_id, kind, content, version, created_by, note, is_active, created_at - ) - VALUES ($1, $2, $3, $4, $5, $6, true, NOW()) - RETURNING id, agent_id, kind, content, version, created_at, created_by, note - """, - agent_id, kind, content, new_version, created_by, note - ) - - return { - "agent_id": row["agent_id"], - "kind": row["kind"], - "content": row["content"], - "version": row["version"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_by": row["created_by"], - "note": row["note"] - } - - -async def upsert_agent_prompts(agent_id: str, prompts: List[dict], created_by: str) -> List[dict]: - """ - Пакетне оновлення промтів агента. - """ - results = [] - for p in prompts: - res = await update_agent_prompt( - agent_id=agent_id, - kind=p["kind"], - content=p["content"], - created_by=created_by, - note=p.get("note") - ) - results.append(res) - return results - - -async def get_agent_prompt_history(agent_id: str, kind: str, limit: int = 10) -> List[dict]: - """ - Отримати історію версій промту агента. - """ - pool = await get_pool() - - query = """ - SELECT id, version, content, created_at, created_by, note, is_active - FROM agent_prompts - WHERE agent_id = $1 AND kind = $2 - ORDER BY version DESC - LIMIT $3 - """ - - rows = await pool.fetch(query, agent_id, kind, limit) - - return [ - { - "id": str(row["id"]), - "version": row["version"], - "content": row["content"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "created_by": row["created_by"], - "note": row["note"], - "is_active": row["is_active"] - } - for row in rows - ] - - -async def get_agent_public_profile(agent_id: str) -> Optional[dict]: - """Отримати публічний профіль агента""" - pool = await get_pool() - - query = """ - SELECT - is_public, - public_slug, - public_title, - public_tagline, - COALESCE(public_skills, ARRAY[]::text[]) AS public_skills, - public_district, - public_primary_room_slug, - COALESCE(visibility_scope, 'city') AS visibility_scope, - COALESCE(is_listed_in_directory, true) AS is_listed_in_directory, - COALESCE(is_system, false) AS is_system - FROM agents - WHERE id = $1 - """ - - row = await pool.fetchrow(query, agent_id) - if not row: - return None - - return { - "is_public": row["is_public"], - "public_slug": row["public_slug"], - "public_title": row["public_title"], - "public_tagline": row["public_tagline"], - "public_skills": list(row["public_skills"] or []), - "public_district": row["public_district"], - "public_primary_room_slug": row["public_primary_room_slug"], - "visibility_scope": row["visibility_scope"], - "is_listed_in_directory": row["is_listed_in_directory"], - "is_system": row["is_system"] - } - - -async def get_agents_with_home_node( - kind: Optional[str] = None, - node_id: Optional[str] = None, - limit: int = 100, - offset: int = 0 -) -> Tuple[List[dict], int]: - """Отримати агентів з інформацією про home_node""" - pool = await get_pool() - - params: List[Any] = [] - where_clauses = [ - "COALESCE(a.is_archived, false) = false", - "COALESCE(a.is_test, false) = false", - "a.deleted_at IS NULL" - ] - - if kind: - params.append(kind) - where_clauses.append(f"a.kind = ${len(params)}") - - if node_id: - params.append(node_id) - where_clauses.append(f"a.node_id = ${len(params)}") - - where_sql = " AND ".join(where_clauses) - - query = f""" - SELECT - a.id, - a.display_name, - a.kind, - a.avatar_url, - a.status, - a.is_public, - a.public_slug, - a.public_title, - a.public_district, - a.node_id, - nc.node_name AS home_node_name, - nc.hostname AS home_node_hostname, - nc.roles AS home_node_roles, - nc.environment AS home_node_environment, - COUNT(*) OVER() AS total_count - FROM agents a - LEFT JOIN node_cache nc ON a.node_id = nc.node_id - WHERE {where_sql} - ORDER BY a.display_name - LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} - """ - - params.append(limit) - params.append(offset) - - rows = await pool.fetch(query, *params) - if not rows: - return [], 0 - - total = rows[0]["total_count"] - items = [] - - for row in rows: - data = dict(row) - data.pop("total_count", None) - - # Build home_node object - if data.get("node_id"): - data["home_node"] = { - "id": data.get("node_id"), - "name": data.get("home_node_name"), - "hostname": data.get("home_node_hostname"), - "roles": list(data.get("home_node_roles") or []), - "environment": data.get("home_node_environment") - } - else: - data["home_node"] = None - - # Clean up intermediate fields - for key in ["home_node_name", "home_node_hostname", "home_node_roles", "home_node_environment"]: - data.pop(key, None) - - items.append(data) - - return items, total - - -async def get_agents_by_room(room_id: str) -> List[dict]: - """Отримати агентів у конкретній кімнаті""" - pool = await get_pool() - - query = """ - SELECT id, display_name, kind, avatar_url, color, status, - current_room_id, capabilities - FROM agents - WHERE current_room_id = $1 AND status != 'offline' - ORDER BY display_name - """ - - rows = await pool.fetch(query, room_id) - return [dict(row) for row in rows] - - -async def get_online_agents() -> List[dict]: - """Отримати всіх онлайн агентів""" - pool = await get_pool() - - query = """ - SELECT id, display_name, kind, avatar_url, color, status, - current_room_id, capabilities - FROM agents - WHERE status IN ('online', 'busy') - ORDER BY display_name - """ - - rows = await pool.fetch(query) - return [dict(row) for row in rows] - - -async def update_agent_status(agent_id: str, status: str, room_id: Optional[str] = None) -> Optional[dict]: - """Оновити статус агента""" - pool = await get_pool() - - if room_id: - query = """ - UPDATE agents - SET status = $2, current_room_id = $3, updated_at = NOW() - WHERE id = $1 - RETURNING id, display_name, kind, status, current_room_id - """ - row = await pool.fetchrow(query, agent_id, status, room_id) - else: - query = """ - UPDATE agents - SET status = $2, updated_at = NOW() - WHERE id = $1 - RETURNING id, display_name, kind, status, current_room_id - """ - row = await pool.fetchrow(query, agent_id, status) - - return dict(row) if row else None - - -async def get_agent_by_id(agent_id: str) -> Optional[dict]: - """Отримати агента по ID або public_slug""" - pool = await get_pool() - - query = """ - SELECT - a.id, - a.display_name, - a.kind, - a.status, - a.node_id, - a.role, - a.avatar_url, - COALESCE(a.color_hint, a.color, 'cyan') AS color, - a.capabilities, - a.primary_room_slug, - a.public_primary_room_slug, - a.public_district, - a.public_title, - a.public_tagline, - a.public_skills, - a.public_slug, - a.is_public, - a.district AS home_district, - a.crew_team_key, - a.dagi_status, - a.last_seen_at, - COALESCE(a.is_node_guardian, false) as is_node_guardian, - COALESCE(a.is_node_steward, false) as is_node_steward - FROM agents a - WHERE a.id = $1 OR a.public_slug = $1 - """ - - row = await pool.fetchrow(query, agent_id) - if not row: - return None - - agent = dict(row) - agent["capabilities"] = _normalize_capabilities(agent.get("capabilities")) - if agent.get("public_skills") is None: - agent["public_skills"] = [] - - # Populate crew_info - if agent.get("crew_team_key"): - agent["crew_info"] = { - "has_crew_team": True, - "crew_team_key": agent["crew_team_key"], - "matrix_room_id": None # Populated later if needed - } - - # If orchestrator, verify if room exists - # For detailed view, let's try to fetch it - if agent.get("primary_room_slug"): - # Just a placeholder check, logic should be outside or specific method - pass - else: - agent["crew_info"] = { - "has_crew_team": False, - "crew_team_key": None, - "matrix_room_id": None - } - - return agent - - -async def get_agent_public_profile(agent_id: str) -> Optional[dict]: - """Отримати публічний профіль агента""" - pool = await get_pool() - - query = """ - SELECT - is_public, - public_slug, - public_title, - public_tagline, - public_skills, - public_district, - public_primary_room_slug - FROM agents - WHERE id = $1 - """ - - row = await pool.fetchrow(query, agent_id) - if not row: - return None - - result = dict(row) - if result.get("public_skills") is None: - result["public_skills"] = [] - return result - - -async def update_agent_public_profile( - agent_id: str, - is_public: bool, - public_slug: Optional[str], - public_title: Optional[str], - public_tagline: Optional[str], - public_skills: Optional[List[str]], - public_district: Optional[str], - public_primary_room_slug: Optional[str] -) -> Optional[dict]: - """Оновити публічний профіль агента""" - pool = await get_pool() - - query = """ - UPDATE agents - SET - is_public = $2, - public_slug = $3, - public_title = $4, - public_tagline = $5, - public_skills = $6, - public_district = $7, - public_primary_room_slug = $8, - updated_at = NOW() - WHERE id = $1 - RETURNING - is_public, - public_slug, - public_title, - public_tagline, - public_skills, - public_district, - public_primary_room_slug - """ - - row = await pool.fetchrow( - query, - agent_id, - is_public, - public_slug, - public_title, - public_tagline, - public_skills, - public_district, - public_primary_room_slug - ) - - if not row: - return None - - result = dict(row) - if result.get("public_skills") is None: - result["public_skills"] = [] - return result - - -async def get_agent_rooms(agent_id: str) -> List[dict]: - """Отримати список кімнат агента (primary/public)""" - pool = await get_pool() - - query = """ - SELECT primary_room_slug, public_primary_room_slug - FROM agents - WHERE id = $1 - """ - - row = await pool.fetchrow(query, agent_id) - if not row: - return [] - - slugs = [] - if row.get("primary_room_slug"): - slugs.append(row["primary_room_slug"]) - if row.get("public_primary_room_slug") and row["public_primary_room_slug"] not in slugs: - slugs.append(row["public_primary_room_slug"]) - - if not slugs: - return [] - - rooms_query = """ - SELECT id, slug, name - FROM city_rooms - WHERE slug = ANY($1::text[]) - """ - - rooms = await pool.fetch(rooms_query, slugs) - return [dict(room) for room in rooms] - - -async def get_agent_matrix_config(agent_id: str) -> Optional[dict]: - """Отримати Matrix-конфіг агента""" - pool = await get_pool() - - query = """ - SELECT agent_id, matrix_user_id, primary_room_id - FROM agent_matrix_config - WHERE agent_id = $1 - """ - - row = await pool.fetchrow(query, agent_id) - return dict(row) if row else None - - -async def get_public_agent_by_slug(slug: str) -> Optional[dict]: - """Отримати базову інформацію про публічного агента""" - pool = await get_pool() - - query = """ - SELECT - id, - display_name, - public_primary_room_slug, - primary_room_slug, - public_district, - public_title, - public_tagline - FROM agents - WHERE public_slug = $1 - AND is_public = true - LIMIT 1 - """ - - row = await pool.fetchrow(query, slug) - return dict(row) if row else None - - -async def get_microdao_for_agent(agent_id: str) -> Optional[dict]: - """Отримати MicroDAO для агента (аліас get_agent_microdao)""" - return await get_agent_microdao(agent_id) - - -# ============================================================================= -# Citizens Repository -# ============================================================================= - -async def get_public_citizens( - district: Optional[str] = None, - kind: Optional[str] = None, - q: Optional[str] = None, - limit: int = 50, - offset: int = 0 -) -> Tuple[List[dict], int]: - """Отримати публічних громадян""" - pool = await get_pool() - - params: List[Any] = [] - where_clauses = [ - "a.is_public = true", - "COALESCE(a.is_archived, false) = false", - "COALESCE(a.is_test, false) = false", - "a.deleted_at IS NULL", - # ROOMS_LAYER_RESTORE: Include agents with gov_level or specific kinds as citizens - "(a.public_slug IS NOT NULL OR a.gov_level IN ('city_governance', 'district_lead', 'orchestrator', 'core_team') OR a.kind IN ('civic', 'governance', 'orchestrator'))" - ] - - if district: - params.append(district) - where_clauses.append(f"a.public_district = ${len(params)}") - - if kind: - params.append(kind) - where_clauses.append(f"a.kind = ${len(params)}") - - if q: - params.append(f"%{q}%") - where_clauses.append( - f"(a.display_name ILIKE ${len(params)} OR a.public_title ILIKE ${len(params)} OR a.public_tagline ILIKE ${len(params)})" - ) - - where_sql = " AND ".join(where_clauses) - - query = f""" - SELECT - a.id, - a.public_slug, - a.display_name, - a.public_title, - a.public_tagline, - a.avatar_url, - a.kind, - a.public_district, - a.public_primary_room_slug, - COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills, - COALESCE(a.status, 'unknown') AS status, - a.node_id, - nc.node_name AS home_node_name, - nc.hostname AS home_node_hostname, - nc.roles AS home_node_roles, - nc.environment AS home_node_environment, - -- MicroDAO info - m.slug AS home_microdao_slug, - m.name AS home_microdao_name, - -- Room info - cr.id AS room_id, - cr.slug AS room_slug, - cr.name AS room_name, - cr.matrix_room_id AS room_matrix_id, - COUNT(*) OVER() AS total_count - FROM agents a - LEFT JOIN node_cache nc ON a.node_id = nc.node_id - -- Join primary MicroDAO - LEFT JOIN LATERAL ( - SELECT ma.agent_id, md.slug, md.name - FROM microdao_agents ma - JOIN microdaos md ON ma.microdao_id = md.id - WHERE ma.agent_id = a.id - ORDER BY ma.is_core DESC, md.name - LIMIT 1 - ) m ON true - -- Join primary room (by public_primary_room_slug) - LEFT JOIN city_rooms cr ON cr.slug = a.public_primary_room_slug - WHERE {where_sql} - ORDER BY a.display_name - LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} - """ - - params.append(limit) - params.append(offset) - - rows = await pool.fetch(query, *params) - if not rows: - return [], 0 - - total = rows[0]["total_count"] - items = [] - for row in rows: - data = dict(row) - data.pop("total_count", None) - data["public_skills"] = list(data.get("public_skills") or []) - data["online_status"] = data.get("status") or "unknown" - # Build home_node object - if data.get("node_id"): - data["home_node"] = { - "id": data.get("node_id"), - "name": data.get("home_node_name"), - "hostname": data.get("home_node_hostname"), - "roles": list(data.get("home_node_roles") or []), - "environment": data.get("home_node_environment") - } - else: - data["home_node"] = None - - # Build primary_city_room object - if data.get("room_id"): - data["primary_city_room"] = { - "id": str(data["room_id"]), - "slug": data["room_slug"], - "name": data["room_name"], - "matrix_room_id": data.get("room_matrix_id") - } - else: - data["primary_city_room"] = None - - # Clean up intermediate fields - for key in ["home_node_name", "home_node_hostname", "home_node_roles", "home_node_environment", - "room_id", "room_slug", "room_name", "room_matrix_id"]: - data.pop(key, None) - items.append(data) - - return items, total - - -async def get_agent_microdao(agent_id: str) -> Optional[dict]: - """Отримати MicroDAO, до якого належить агент (перший збіг)""" - pool = await get_pool() - - query = """ - SELECT - m.id, - m.slug, - m.name, - m.district - FROM microdao_agents ma - JOIN microdaos m ON m.id = ma.microdao_id - WHERE ma.agent_id = $1 - ORDER BY ma.is_core DESC, m.name - LIMIT 1 - """ - - row = await pool.fetchrow(query, agent_id) - return dict(row) if row else None - - -async def get_microdao_public_citizens(microdao_id: str) -> List[dict]: - """Отримати публічних громадян конкретного MicroDAO""" - pool = await get_pool() - - query = """ - SELECT - a.public_slug AS slug, - a.display_name, - a.public_title, - a.public_tagline, - a.avatar_url, - a.public_district, - a.public_primary_room_slug - FROM microdao_agents ma - JOIN agents a ON a.id = ma.agent_id - WHERE ma.microdao_id = $1 - AND a.is_public = true - AND a.public_slug IS NOT NULL - ORDER BY a.display_name - """ - - rows = await pool.fetch(query, microdao_id) - result = [] - for row in rows: - data = dict(row) - result.append(data) - return result - - -async def get_public_citizen_by_slug(slug: str) -> Optional[dict]: - """Отримати детальний профіль громадянина""" - pool = await get_pool() - - query = """ - SELECT - a.id, - a.display_name, - a.kind, - a.status, - a.node_id, - a.avatar_url, - a.public_slug, - a.public_title, - a.public_tagline, - COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills, - a.public_district, - a.public_primary_room_slug, - a.primary_room_slug, - nc.node_name AS home_node_name, - nc.hostname AS home_node_hostname, - nc.roles AS home_node_roles, - nc.environment AS home_node_environment - FROM agents a - LEFT JOIN node_cache nc ON a.node_id = nc.node_id - WHERE a.public_slug = $1 - AND a.is_public = true - LIMIT 1 - """ - - agent_row = await pool.fetchrow(query, slug) - if not agent_row: - return None - - agent = dict(agent_row) - agent["public_skills"] = list(agent.get("public_skills") or []) - - # Build home_node object - home_node = None - if agent.get("node_id"): - home_node = { - "id": agent.get("node_id"), - "name": agent.get("home_node_name"), - "hostname": agent.get("home_node_hostname"), - "roles": list(agent.get("home_node_roles") or []), - "environment": agent.get("home_node_environment") - } - - rooms = await get_agent_rooms(agent["id"]) - primary_room = agent.get("public_primary_room_slug") or agent.get("primary_room_slug") - city_presence = { - "primary_room_slug": primary_room, - "rooms": rooms - } if rooms else { - "primary_room_slug": primary_room, - "rooms": [] - } - - dais_public = { - "core": { - "archetype": agent.get("kind"), - "bio_short": agent.get("public_tagline") - }, - "phenotype": { - "visual": { - "avatar_url": agent.get("avatar_url"), - "color": None - } - }, - "memex": {}, - "economics": {} - } - - interaction = { - "matrix_user": None, - "primary_room_slug": primary_room, - "actions": ["chat", "ask_for_help"] - } - - metrics_public: Dict[str, Any] = {} - - microdao = await get_agent_microdao(agent["id"]) - - return { - "slug": agent["public_slug"], - "display_name": agent["display_name"], - "kind": agent.get("kind"), - "public_title": agent.get("public_title"), - "public_tagline": agent.get("public_tagline"), - "district": agent.get("public_district"), - "avatar_url": agent.get("avatar_url"), - "status": agent.get("status"), - "node_id": agent.get("node_id"), - "public_skills": agent.get("public_skills"), - "city_presence": city_presence, - "dais_public": dais_public, - "interaction": interaction, - "metrics_public": metrics_public, - "microdao": microdao, - "admin_panel_url": f"/agents/{agent['id']}", - "home_node": home_node - } - - -# ============================================================================= -# MicroDAO Membership Repository -# ============================================================================= - -async def get_microdao_options() -> List[dict]: - """Отримати список активних MicroDAO для селектора""" - pool = await get_pool() - - query = """ - SELECT id, slug, name, district, is_active - FROM microdaos - WHERE is_active = true - ORDER BY name - """ - - rows = await pool.fetch(query) - return [dict(row) for row in rows] - - -async def get_agent_microdao_memberships(agent_id: str) -> List[dict]: - """Отримати всі членства агента в MicroDAO""" - pool = await get_pool() - - query = """ - SELECT - ma.microdao_id, - m.slug AS microdao_slug, - m.name AS microdao_name, - m.logo_url, - ma.role, - ma.is_core - FROM microdao_agents ma - JOIN microdaos m ON m.id = ma.microdao_id - WHERE ma.agent_id = $1 - ORDER BY ma.is_core DESC, m.name - """ - - rows = await pool.fetch(query, agent_id) - return [dict(row) for row in rows] - - -async def upsert_agent_microdao_membership( - agent_id: str, - microdao_id: str, - role: Optional[str], - is_core: bool -) -> Optional[dict]: - """Призначити або оновити членство агента в MicroDAO""" - pool = await get_pool() - - query = """ - WITH upsert AS ( - INSERT INTO microdao_agents (microdao_id, agent_id, role, is_core) - VALUES ($1, $2, $3, $4) - ON CONFLICT (microdao_id, agent_id) - DO UPDATE SET role = EXCLUDED.role, is_core = EXCLUDED.is_core - RETURNING microdao_id, agent_id, role, is_core - ) - SELECT - u.microdao_id, - m.slug AS microdao_slug, - m.name AS microdao_name, - u.role, - u.is_core - FROM upsert u - JOIN microdaos m ON m.id = u.microdao_id - """ - - row = await pool.fetchrow(query, microdao_id, agent_id, role, is_core) - return dict(row) if row else None - - -async def remove_agent_microdao_membership(agent_id: str, microdao_id: str) -> bool: - """Видалити членство агента в MicroDAO""" - pool = await get_pool() - - result = await pool.execute( - "DELETE FROM microdao_agents WHERE agent_id = $1 AND microdao_id = $2", - agent_id, - microdao_id - ) - - # asyncpg returns strings like "DELETE 1" - return result.split(" ")[-1] != "0" - - -# ============================================================================= -# MicroDAO Repository -# ============================================================================= - -async def get_microdaos(district: Optional[str] = None, q: Optional[str] = None, limit: int = 50, offset: int = 0) -> List[dict]: - """Отримати список MicroDAOs з агрегованою статистикою""" - pool = await get_pool() - - params = [] - - where_clauses = [ - "m.is_public = true", - "m.is_active = true", - "COALESCE(m.is_archived, false) = false", - "COALESCE(m.is_test, false) = false", - "m.deleted_at IS NULL" - ] - - if district: - params.append(district) - where_clauses.append(f"m.district = ${len(params)}") - - if q: - params.append(f"%{q}%") - where_clauses.append(f"(m.name ILIKE ${len(params)} OR m.description ILIKE ${len(params)})") - - where_sql = " AND ".join(where_clauses) - - query = f""" - SELECT - m.id, - m.slug, - m.name, - m.description, - m.district, - COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id, - oa.display_name as orchestrator_agent_name, - m.is_active, - COALESCE(m.is_public, true) as is_public, - COALESCE(m.is_platform, false) as is_platform, - m.parent_microdao_id, - pm.slug as parent_microdao_slug, - m.logo_url, - m.banner_url, - COUNT(DISTINCT ma.agent_id) AS agents_count, - COUNT(DISTINCT ma.agent_id) AS member_count, - COUNT(DISTINCT mc.id) AS channels_count, - COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count, - COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS room_count - FROM microdaos m - LEFT JOIN microdao_agents ma ON ma.microdao_id = m.id - LEFT JOIN microdao_channels mc ON mc.microdao_id = m.id - LEFT JOIN agents oa ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = oa.id - LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id - WHERE {where_sql} - GROUP BY m.id, oa.display_name, pm.slug - ORDER BY m.name - LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} - """ - - # Append limit and offset to params - params.append(limit) - params.append(offset) - - rows = await pool.fetch(query, *params) - return [dict(row) for row in rows] - - -async def list_microdao_summaries( - *, - is_public: Optional[bool] = None, - is_platform: Optional[bool] = None, - district: Optional[str] = None, - q: Optional[str] = None, - limit: int = 50, - offset: int = 0 -) -> List[dict]: - """ - Unified method to list microDAOs. - Wraps get_microdaos with additional filtering. - """ - pool = await get_pool() - - params = [] - where_clauses = [ - "COALESCE(m.is_archived, false) = false", - "COALESCE(m.is_test, false) = false", - "m.deleted_at IS NULL", - "m.is_active = true" - ] - - if is_public is not None: - params.append(is_public) - where_clauses.append(f"COALESCE(m.is_public, true) = ${len(params)}") - - if is_platform is not None: - params.append(is_platform) - where_clauses.append(f"COALESCE(m.is_platform, false) = ${len(params)}") - - if district: - params.append(district) - where_clauses.append(f"m.district = ${len(params)}") - - if q: - params.append(f"%{q}%") - where_clauses.append(f"(m.name ILIKE ${len(params)} OR m.description ILIKE ${len(params)})") - - where_sql = " AND ".join(where_clauses) - - query = f""" - SELECT - m.id, - m.slug, - m.name, - m.description, - m.district, - COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id, - oa.display_name as orchestrator_agent_name, - m.is_active, - COALESCE(m.is_public, true) as is_public, - COALESCE(m.is_platform, false) as is_platform, - m.parent_microdao_id, - pm.slug as parent_microdao_slug, - m.logo_url, - m.banner_url, - COUNT(DISTINCT ma.agent_id) AS agents_count, - COUNT(DISTINCT ma.agent_id) AS member_count, - COUNT(DISTINCT mc.id) AS channels_count, - COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count, - COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS room_count - FROM microdaos m - LEFT JOIN microdao_agents ma ON ma.microdao_id = m.id - LEFT JOIN microdao_channels mc ON mc.microdao_id = m.id - LEFT JOIN agents oa ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = oa.id - LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id - WHERE {where_sql} - GROUP BY m.id, oa.display_name, pm.slug - ORDER BY m.name - LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} - """ - - params.append(limit) - params.append(offset) - - rows = await pool.fetch(query, *params) - return [dict(row) for row in rows] - - -async def get_microdao_detail(slug: str) -> Optional[dict]: - """ - Get detailed microDAO info including agents, channels, children. - Alias for get_microdao_by_slug with clearer naming. - """ - return await get_microdao_by_slug(slug) - - -async def get_microdao_by_slug(slug: str) -> Optional[dict]: - """Отримати детальну інформацію про MicroDAO""" - pool = await get_pool() - - # 1. Get main DAO info - query_dao = """ - SELECT - m.id, - m.slug, - m.name, - m.description, - m.district, - COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id, - a.display_name as orchestrator_display_name, - m.is_active, - COALESCE(m.is_public, true) as is_public, - COALESCE(m.is_platform, false) as is_platform, - m.parent_microdao_id, - pm.slug as parent_microdao_slug, - m.logo_url, - m.banner_url - FROM microdaos m - LEFT JOIN agents a ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = a.id - LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id - WHERE m.slug = $1 - AND COALESCE(m.is_archived, false) = false - AND COALESCE(m.is_test, false) = false - AND m.deleted_at IS NULL - """ - - dao_row = await pool.fetchrow(query_dao, slug) - if not dao_row: - return None - - result = dict(dao_row) - dao_id = result["id"] - - # 2. Get Agents - query_agents = """ - SELECT - ma.agent_id, - ma.role, - ma.is_core, - a.display_name - FROM microdao_agents ma - JOIN agents a ON ma.agent_id = a.id - WHERE ma.microdao_id = $1 - AND COALESCE(a.is_archived, false) = false - AND COALESCE(a.is_test, false) = false - AND a.deleted_at IS NULL - ORDER BY ma.is_core DESC, ma.role - """ - agents_rows = await pool.fetch(query_agents, dao_id) - result["agents"] = [dict(row) for row in agents_rows] - - # 3. Get Channels - query_channels = """ - SELECT - kind, - ref_id, - display_name, - is_primary - FROM microdao_channels - WHERE microdao_id = $1 - ORDER BY is_primary DESC, kind - """ - channels_rows = await pool.fetch(query_channels, dao_id) - result["channels"] = [dict(row) for row in channels_rows] - - # 4. Get child microDAOs - query_children = """ - SELECT id, slug, name, COALESCE(is_public, true) as is_public, - COALESCE(is_platform, false) as is_platform - FROM microdaos - WHERE parent_microdao_id = $1 - AND COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - AND deleted_at IS NULL - ORDER BY name - """ - children_rows = await pool.fetch(query_children, dao_id) - result["child_microdaos"] = [dict(row) for row in children_rows] - - public_citizens = await get_microdao_public_citizens(dao_id) - result["public_citizens"] = public_citizens - - return result - - -async def update_microdao_branding( - microdao_slug: str, - logo_url: Optional[str] = None, - banner_url: Optional[str] = None -) -> Optional[dict]: - """Оновити брендинг MicroDAO""" - pool = await get_pool() - - set_parts = ["updated_at = NOW()"] - params = [microdao_slug] - - if logo_url is not None: - params.append(logo_url) - set_parts.append(f"logo_url = ${len(params)}") - - if banner_url is not None: - params.append(banner_url) - set_parts.append(f"banner_url = ${len(params)}") - - query = f""" - UPDATE microdaos - SET {', '.join(set_parts)} - WHERE slug = $1 - RETURNING id, slug, name, logo_url, banner_url - """ - - row = await pool.fetchrow(query, *params) - return dict(row) if row else None - - -async def update_room_branding( - room_id: str, - logo_url: Optional[str] = None, - banner_url: Optional[str] = None -) -> Optional[dict]: - """Оновити брендинг кімнати""" - pool = await get_pool() - - set_parts = ["updated_at = NOW()"] - params = [room_id] - - if logo_url is not None: - params.append(logo_url) - set_parts.append(f"logo_url = ${len(params)}") - - if banner_url is not None: - params.append(banner_url) - set_parts.append(f"banner_url = ${len(params)}") - - query = f""" - UPDATE city_rooms - SET {', '.join(set_parts)} - WHERE id = $1 - RETURNING id, slug, name, logo_url, banner_url - """ - - row = await pool.fetchrow(query, *params) - return dict(row) if row else None - - -# ============================================================================= -# Nodes Repository -# ============================================================================= - -async def get_all_nodes() -> List[dict]: - """Отримати список всіх нод з кількістю агентів, Guardian/Steward та метриками. - - ДЖЕРЕЛО ІСТИНИ: - 1. node_registry (якщо існує) + node_cache (метрики) - 2. Fallback: тільки node_cache (для зворотної сумісності) - """ - pool = await get_pool() - - # Перевіримо чи існує node_registry - try: - exists = await pool.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'node_registry' - ) - """) - except Exception: - exists = False - - if exists: - # Використовуємо node_registry як джерело істини - query = """ - SELECT - COALESCE(nr.id, nc.node_id) as node_id, - COALESCE(nr.name, nc.node_name) AS name, - COALESCE(nr.hostname, nc.hostname) as hostname, - COALESCE(nr.roles, nc.roles) as roles, - COALESCE(nr.environment, nc.environment) as environment, - COALESCE(nc.status, 'unknown') as status, - nc.gpu, - COALESCE(nc.last_heartbeat, nc.last_sync) AS last_heartbeat, - nc.guardian_agent_id, - nc.steward_agent_id, - -- Metrics - nc.cpu_model, - nc.cpu_cores, - COALESCE(nc.cpu_usage, 0) as cpu_usage, - nc.gpu_model, - COALESCE(nc.gpu_vram_total, 0) as gpu_vram_total, - COALESCE(nc.gpu_vram_used, 0) as gpu_vram_used, - COALESCE(nc.ram_total, 0) as ram_total, - COALESCE(nc.ram_used, 0) as ram_used, - COALESCE(nc.disk_total, 0) as disk_total, - COALESCE(nc.disk_used, 0) as disk_used, - COALESCE(nc.agent_count_router, 0) as agent_count_router, - COALESCE(nc.agent_count_system, 0) as agent_count_system, - nc.last_heartbeat as metrics_heartbeat, - nc.dagi_router_url, - -- Self-healing status (may not exist yet) - NULL as self_healing_status, - -- Registry info - nr.description as node_description, - nr.is_active as registry_active, - nr.last_self_registration, - -- Agent counts (dynamic) - (SELECT COUNT(*) FROM agents a WHERE a.node_id = COALESCE(nr.id, nc.node_id) AND COALESCE(a.is_archived, false) = false AND a.deleted_at IS NULL) AS agents_total, - (SELECT COUNT(*) FROM agents a WHERE a.node_id = COALESCE(nr.id, nc.node_id) AND a.status = 'online' AND COALESCE(a.is_archived, false) = false) AS agents_online, - ga.display_name AS guardian_name, - ga.public_slug AS guardian_slug, - sa.display_name AS steward_name, - sa.public_slug AS steward_slug - FROM node_registry nr - LEFT JOIN node_cache nc ON nc.node_id = nr.id - LEFT JOIN agents ga ON nc.guardian_agent_id = ga.id - LEFT JOIN agents sa ON nc.steward_agent_id = sa.id - WHERE nr.is_active = true - ORDER BY nr.environment DESC, nr.name - """ - try: - rows = await pool.fetch(query) - except Exception as e: - logger.warning(f"node_registry query failed: {e}") - rows = [] - else: - rows = [] - - # Fallback: якщо node_registry не існує або порожній, використовуємо node_cache - if not rows: - logger.info("Using node_cache as fallback for get_all_nodes") - query_fallback = """ - SELECT - nc.node_id, - nc.node_name AS name, - nc.hostname, - nc.roles, - nc.environment, - nc.status, - nc.gpu, - COALESCE(nc.last_heartbeat, nc.last_sync) AS last_heartbeat, - nc.guardian_agent_id, - nc.steward_agent_id, - nc.cpu_model, - nc.cpu_cores, - COALESCE(nc.cpu_usage, 0) as cpu_usage, - nc.gpu_model, - COALESCE(nc.gpu_vram_total, 0) as gpu_vram_total, - COALESCE(nc.gpu_vram_used, 0) as gpu_vram_used, - COALESCE(nc.ram_total, 0) as ram_total, - COALESCE(nc.ram_used, 0) as ram_used, - COALESCE(nc.disk_total, 0) as disk_total, - COALESCE(nc.disk_used, 0) as disk_used, - COALESCE(nc.agent_count_router, 0) as agent_count_router, - COALESCE(nc.agent_count_system, 0) as agent_count_system, - nc.last_heartbeat as metrics_heartbeat, - nc.dagi_router_url, - NULL as self_healing_status, - NULL as node_description, - true as registry_active, - NULL as last_self_registration, - (SELECT COUNT(*) FROM agents a WHERE a.node_id = nc.node_id AND COALESCE(a.is_archived, false) = false AND a.deleted_at IS NULL) AS agents_total, - (SELECT COUNT(*) FROM agents a WHERE a.node_id = nc.node_id AND a.status = 'online' AND COALESCE(a.is_archived, false) = false) AS agents_online, - ga.display_name AS guardian_name, - ga.public_slug AS guardian_slug, - sa.display_name AS steward_name, - sa.public_slug AS steward_slug - FROM node_cache nc - LEFT JOIN agents ga ON nc.guardian_agent_id = ga.id - LEFT JOIN agents sa ON nc.steward_agent_id = sa.id - ORDER BY nc.environment DESC, nc.node_name - """ - try: - rows = await pool.fetch(query_fallback) - except Exception as e: - logger.error(f"Fallback node_cache query also failed: {e}") - rows = [] - - result = [] - for row in rows: - data = dict(row) - # Build guardian_agent object - if data.get("guardian_agent_id"): - data["guardian_agent"] = { - "id": data.get("guardian_agent_id"), - "name": data.get("guardian_name"), - "slug": data.get("guardian_slug"), - } - else: - data["guardian_agent"] = None - # Build steward_agent object - if data.get("steward_agent_id"): - data["steward_agent"] = { - "id": data.get("steward_agent_id"), - "name": data.get("steward_name"), - "slug": data.get("steward_slug"), - } - else: - data["steward_agent"] = None - - # Build metrics object - data["metrics"] = { - "cpu_model": data.get("cpu_model"), - "cpu_cores": data.get("cpu_cores", 0), - "cpu_usage": float(data.get("cpu_usage", 0)), - "gpu_model": data.get("gpu_model"), - "gpu_vram_total": data.get("gpu_vram_total", 0), - "gpu_vram_used": data.get("gpu_vram_used", 0), - "ram_total": data.get("ram_total", 0), - "ram_used": data.get("ram_used", 0), - "disk_total": data.get("disk_total", 0), - "disk_used": data.get("disk_used", 0), - "agent_count_router": data.get("agent_count_router", 0), - "agent_count_system": data.get("agent_count_system", 0), - "dagi_router_url": data.get("dagi_router_url"), - } - - # Clean up internal fields - data.pop("guardian_name", None) - data.pop("steward_name", None) - data.pop("guardian_slug", None) - data.pop("steward_slug", None) - data.pop("cpu_model", None) - data.pop("cpu_cores", None) - data.pop("cpu_usage", None) - data.pop("gpu_model", None) - data.pop("gpu_vram_total", None) - data.pop("gpu_vram_used", None) - data.pop("ram_total", None) - data.pop("ram_used", None) - data.pop("disk_total", None) - data.pop("disk_used", None) - data.pop("agent_count_router", None) - data.pop("agent_count_system", None) - data.pop("dagi_router_url", None) - data.pop("metrics_heartbeat", None) - - result.append(data) - return result - - -async def get_node_by_id(node_id: str) -> Optional[dict]: - """Отримати ноду по ID з Guardian та Steward агентами""" - pool = await get_pool() - - query = """ - SELECT - nc.node_id, - nc.node_name AS name, - nc.hostname, - nc.roles, - nc.environment, - nc.status, - nc.gpu, - nc.last_sync AS last_heartbeat, - nc.guardian_agent_id, - nc.steward_agent_id, - (SELECT COUNT(*) FROM agents a WHERE a.node_id = nc.node_id) AS agents_total, - (SELECT COUNT(*) FROM agents a WHERE a.node_id = nc.node_id AND a.status = 'online') AS agents_online, - -- Guardian agent info - ga.display_name AS guardian_name, - ga.kind AS guardian_kind, - ga.public_slug AS guardian_slug, - -- Steward agent info - sa.display_name AS steward_name, - sa.kind AS steward_kind, - sa.public_slug AS steward_slug - FROM node_cache nc - LEFT JOIN agents ga ON nc.guardian_agent_id = ga.id - LEFT JOIN agents sa ON nc.steward_agent_id = sa.id - WHERE nc.node_id = $1 - """ - - row = await pool.fetchrow(query, node_id) - if not row: - return None - - data = dict(row) - - # Fetch MicroDAOs where orchestrator is on this node - microdaos = await pool.fetch(""" - SELECT m.id, m.slug, m.name, COUNT(cr.id) as rooms_count - FROM microdaos m - JOIN agents a ON m.orchestrator_agent_id = a.id - LEFT JOIN city_rooms cr ON cr.microdao_id::text = m.id - WHERE a.node_id = $1 - GROUP BY m.id, m.slug, m.name - ORDER BY m.name - """, node_id) - - data["microdaos"] = [dict(m) for m in microdaos] - - # Build guardian_agent object - if data.get("guardian_agent_id"): - data["guardian_agent"] = { - "id": data.get("guardian_agent_id"), - "name": data.get("guardian_name"), - "kind": data.get("guardian_kind"), - "slug": data.get("guardian_slug"), - } - else: - data["guardian_agent"] = None - - # Build steward_agent object - if data.get("steward_agent_id"): - data["steward_agent"] = { - "id": data.get("steward_agent_id"), - "name": data.get("steward_name"), - "kind": data.get("steward_kind"), - "slug": data.get("steward_slug"), - } - else: - data["steward_agent"] = None - - # TASK 038: Dynamic discovery of Node Guardian / Steward if cache is empty - if not data["guardian_agent"] or not data["steward_agent"]: - dynamic_agents = await pool.fetch(""" - SELECT id, display_name, kind, public_slug - FROM agents - WHERE node_id = $1 - AND (kind IN ('node_guardian', 'node_steward') OR kind IN ('infra_monitor', 'infra_ops')) - AND COALESCE(is_archived, false) = false - """, node_id) - - if not data["guardian_agent"]: - # Prefer 'node_guardian', fallback to 'infra_monitor' - guardian = next((a for a in dynamic_agents if a['kind'] == 'node_guardian'), - next((a for a in dynamic_agents if a['kind'] == 'infra_monitor'), None)) - if guardian: - data["guardian_agent"] = { - "id": guardian["id"], - "name": guardian["display_name"], - "kind": guardian["kind"], - "slug": guardian["public_slug"] - } - - if not data["steward_agent"]: - # Prefer 'node_steward', fallback to 'infra_ops' - steward = next((a for a in dynamic_agents if a['kind'] == 'node_steward'), - next((a for a in dynamic_agents if a['kind'] == 'infra_ops'), None)) - if steward: - data["steward_agent"] = { - "id": steward["id"], - "name": steward["display_name"], - "kind": steward["kind"], - "slug": steward["public_slug"] - } - - # Clean up intermediate fields - for key in ["guardian_name", "guardian_kind", "guardian_slug", - "steward_name", "steward_kind", "steward_slug"]: - data.pop(key, None) - - return data - - -# ============================================================================= -# MicroDAO Visibility & Creation (Task 029) -# ============================================================================= - -async def update_microdao_visibility( - microdao_id: str, - *, - is_public: bool, - is_platform: Optional[bool] = None, -) -> Optional[dict]: - """ - Оновити налаштування видимості MicroDAO. - Returns updated MicroDAO data or None if not found. - """ - pool = await get_pool() - - set_parts = ["is_public = $2", "updated_at = NOW()"] - params = [microdao_id, is_public] - - if is_platform is not None: - params.append(is_platform) - set_parts.append(f"is_platform = ${len(params)}") - - query = f""" - UPDATE microdaos - SET {', '.join(set_parts)} - WHERE id = $1 - AND COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - RETURNING id, slug, name, is_public, is_platform - """ - - result = await pool.fetchrow(query, *params) - return dict(result) if result else None - - -async def create_microdao_for_agent( - orchestrator_agent_id: str, - *, - name: str, - slug: str, - description: Optional[str] = None, - make_platform: bool = False, - is_public: bool = True, - parent_microdao_id: Optional[str] = None, -) -> Optional[dict]: - """ - Створює microDAO, прив'язує його до агента-оркестратора. - - 1. INSERT новий microDAO - 2. Додати агента в microdao_agents - 3. Оновити агента: primary_microdao_id, is_orchestrator = true - 4. Повернути створений microDAO - """ - pool = await get_pool() - - import uuid - microdao_id = str(uuid.uuid4()) - - async with pool.acquire() as conn: - async with conn.transaction(): - # 1. Create microDAO - insert_dao_query = """ - INSERT INTO microdaos ( - id, slug, name, description, - orchestrator_agent_id, is_public, is_platform, - parent_microdao_id, is_active, created_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, NOW()) - RETURNING id, slug, name, description, is_public, is_platform - """ - dao_row = await conn.fetchrow( - insert_dao_query, - microdao_id, slug, name, description, - orchestrator_agent_id, is_public, make_platform, - parent_microdao_id - ) - - if not dao_row: - return None - - # 2. Add agent to microdao_agents as orchestrator - insert_member_query = """ - INSERT INTO microdao_agents (microdao_id, agent_id, role, is_core, joined_at) - VALUES ($1, $2, 'orchestrator', true, NOW()) - ON CONFLICT (microdao_id, agent_id) DO UPDATE SET role = 'orchestrator', is_core = true - """ - await conn.execute(insert_member_query, microdao_id, orchestrator_agent_id) - - # 3. Update agent: set primary_microdao_id if empty, set is_orchestrator = true - # Also set public_slug if is_public, so orchestrator becomes a public citizen - update_agent_query = """ - UPDATE agents - SET is_orchestrator = true, - is_public = CASE WHEN $3 THEN true ELSE is_public END, - public_slug = CASE WHEN $3 AND (public_slug IS NULL OR public_slug = '') THEN id ELSE public_slug END, - primary_microdao_id = COALESCE(primary_microdao_id, $2), - updated_at = NOW() - WHERE id = $1 - """ - await conn.execute(update_agent_query, orchestrator_agent_id, microdao_id, is_public) - - return dict(dao_row) - - -async def get_microdao_primary_room(microdao_id: str) -> Optional[dict]: - """ - Отримати основну кімнату MicroDAO для чату. - Пріоритет: room_role='primary' → найнижчий sort_order → перша кімната. - """ - pool = await get_pool() - - query = """ - SELECT - cr.id, - cr.slug, - cr.name, - cr.matrix_room_id, - cr.microdao_id, - cr.room_role, - cr.is_public, - cr.sort_order - FROM city_rooms cr - WHERE cr.microdao_id::text = $1 - ORDER BY - CASE WHEN cr.room_role = 'primary' THEN 0 ELSE 1 END, - cr.sort_order ASC, - cr.name ASC - LIMIT 1 - """ - - row = await pool.fetchrow(query, microdao_id) - if row: - return { - "id": str(row["id"]), - "slug": row["slug"], - "name": row["name"], - "matrix_room_id": row.get("matrix_room_id"), - "microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None, - "room_role": row.get("room_role"), - "is_public": row.get("is_public", True), - "sort_order": row.get("sort_order", 100) - } - return None - - -async def get_microdao_rooms(microdao_id: str) -> List[dict]: - """ - Отримати всі кімнати MicroDAO, впорядковані за sort_order. - Шукає по microdao_id АБО owner_id (для нових кімнат з owner_type='microdao'). - """ - pool = await get_pool() - - query = """ - SELECT - cr.id, - cr.slug, - cr.name, - cr.matrix_room_id, - COALESCE(cr.microdao_id::text, cr.owner_id) AS microdao_id, - cr.room_role, - cr.is_public, - cr.sort_order, - cr.logo_url, - cr.banner_url, - m.slug AS microdao_slug - FROM city_rooms cr - LEFT JOIN microdaos m ON COALESCE(cr.microdao_id::text, cr.owner_id) = m.id - WHERE cr.microdao_id::text = $1 - OR (cr.owner_type = 'microdao' AND cr.owner_id = $1) - ORDER BY - CASE WHEN cr.room_role = 'primary' THEN 0 ELSE 1 END, - cr.sort_order ASC, - cr.name ASC - """ - - rows = await pool.fetch(query, microdao_id) - return [ - { - "id": str(row["id"]), - "slug": row["slug"], - "name": row["name"], - "matrix_room_id": row.get("matrix_room_id"), - "microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None, - "microdao_slug": row.get("microdao_slug"), - "room_role": row.get("room_role"), - "is_public": row.get("is_public", True), - "sort_order": row.get("sort_order", 100), - "logo_url": row.get("logo_url"), - "banner_url": row.get("banner_url") - } - for row in rows - ] - - -async def get_microdao_rooms_by_slug(slug: str) -> Optional[dict]: - """ - Отримати MicroDAO та всі його кімнати за slug. - """ - pool = await get_pool() - - # Get microdao first - microdao_query = """ - SELECT id, slug FROM microdaos - WHERE slug = $1 - AND COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - """ - microdao = await pool.fetchrow(microdao_query, slug) - if not microdao: - return None - - microdao_id = str(microdao["id"]) - rooms = await get_microdao_rooms(microdao_id) - - return { - "microdao_id": microdao_id, - "microdao_slug": microdao["slug"], - "rooms": rooms - } - - -async def attach_room_to_microdao( - microdao_id: str, - room_id: str, - room_role: Optional[str] = None, - is_public: bool = True, - sort_order: int = 100 -) -> Optional[dict]: - """ - Прив'язати існуючу кімнату до MicroDAO. - """ - pool = await get_pool() - - query = """ - UPDATE city_rooms - SET microdao_id = $1, - room_role = $2, - is_public = $3, - sort_order = $4 - WHERE id = $5 - RETURNING id, slug, name, matrix_room_id, microdao_id, room_role, is_public, sort_order, logo_url, banner_url - """ - - row = await pool.fetchrow(query, microdao_id, room_role, is_public, sort_order, room_id) - if row: - return { - "id": str(row["id"]), - "slug": row["slug"], - "name": row["name"], - "matrix_room_id": row.get("matrix_room_id"), - "microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None, - "room_role": row.get("room_role"), - "is_public": row.get("is_public", True), - "sort_order": row.get("sort_order", 100), - "logo_url": row.get("logo_url"), - "banner_url": row.get("banner_url") - } - return None - - -async def update_microdao_room( - microdao_id: str, - room_id: str, - room_role: Optional[str] = None, - is_public: Optional[bool] = None, - sort_order: Optional[int] = None, - set_primary: bool = False -) -> Optional[dict]: - """ - Оновити налаштування кімнати MicroDAO. - Якщо set_primary=True, скидає роль 'primary' з інших кімнат. - """ - pool = await get_pool() - - async with pool.acquire() as conn: - async with conn.transaction(): - # If setting as primary, clear previous primary - if set_primary: - await conn.execute( - """ - UPDATE city_rooms - SET room_role = NULL - WHERE microdao_id = $1 AND room_role = 'primary' - """, - microdao_id - ) - room_role = 'primary' - - # Build update query - set_parts = [] - params = [room_id, microdao_id] - param_idx = 3 - - if room_role is not None: - set_parts.append(f"room_role = ${param_idx}") - params.append(room_role) - param_idx += 1 - - if is_public is not None: - set_parts.append(f"is_public = ${param_idx}") - params.append(is_public) - param_idx += 1 - - if sort_order is not None: - set_parts.append(f"sort_order = ${param_idx}") - params.append(sort_order) - param_idx += 1 - - if not set_parts: - # Nothing to update, just return current state - row = await conn.fetchrow( - "SELECT * FROM city_rooms WHERE id = $1 AND microdao_id = $2", - room_id, microdao_id - ) - else: - query = f""" - UPDATE city_rooms - SET {', '.join(set_parts)} - WHERE id = $1 AND microdao_id = $2 - RETURNING id, slug, name, matrix_room_id, microdao_id, room_role, is_public, sort_order, logo_url, banner_url - """ - row = await conn.fetchrow(query, *params) - - if row: - return { - "id": str(row["id"]), - "slug": row["slug"], - "name": row["name"], - "matrix_room_id": row.get("matrix_room_id"), - "microdao_id": str(row["microdao_id"]) if row.get("microdao_id") else None, - "room_role": row.get("room_role"), - "is_public": row.get("is_public", True), - "sort_order": row.get("sort_order", 100), - "logo_url": row.get("logo_url"), - "banner_url": row.get("banner_url") - } - return None - - -# ============================================================================= -# TASK 044: Orchestrator Crew Team Room -# ============================================================================= - -async def create_matrix_room_for_microdao_orchestrator( - microdao_id: str, - microdao_name: str, - orchestrator_agent_id: str -) -> Optional[dict]: - """ - Викликати Matrix Gateway для створення кімнати команди оркестратора. - """ - # TODO: This should ideally be done with a proper Matrix user (e.g. app bot or the orchestrator agent itself if possible) - # For now, we'll use the system admin user logic in matrix-gateway or a specialized endpoint. - - # Since we are in repo, we don't have the user's token. We rely on matrix-gateway internal API. - async with httpx.AsyncClient(timeout=30.0) as client: - try: - # Ensure matrix room alias is unique - room_alias = f"orchestrator_team_{microdao_id[:8]}" - room_name = f"{microdao_name} — Orchestrator Team" - - # Call Matrix Gateway to create room - # Using /internal/matrix/rooms/create (assuming it exists or we reuse a similar logic) - # If not, we might need to implement it in gateway-bot. - # Let's assume we use a new endpoint or existing one. - # Actually, we can reuse POST /internal/matrix/rooms if it exists or just use bot API. - - # NOTE: In real implementation, we need to authenticate this request or ensure network security. - resp = await client.post( - f"{MATRIX_GATEWAY_URL}/internal/matrix/rooms", - json={ - "alias": room_alias, - "name": room_name, - "topic": "Private team chat for MicroDAO Orchestrator", - "preset": "private_chat", # or public_chat, but team chat usually private - "initial_state": [] - } - ) - - if resp.status_code not in (200, 201): - logger.error(f"Matrix Gateway failed to create room: {resp.text}") - return None - - data = resp.json() - return { - "room_id": data["room_id"], - "room_alias": data.get("room_alias", room_alias) - } - - except Exception as e: - logger.error(f"Failed to create matrix room via gateway: {e}") - return None - - -async def get_or_create_orchestrator_team_room(microdao_id: str) -> Optional[dict]: - """ - Знайти або створити кімнату команди оркестратора для MicroDAO. - """ - pool = await get_pool() - - # 1. Check if room exists in DB - existing_room_query = """ - SELECT - cr.id, cr.slug, cr.name, cr.matrix_room_id, cr.microdao_id, cr.room_role, cr.is_public, cr.sort_order - FROM city_rooms cr - WHERE cr.microdao_id::text = $1 AND cr.room_role = 'orchestrator_team' - LIMIT 1 - """ - room_row = await pool.fetchrow(existing_room_query, microdao_id) - - if room_row: - return dict(room_row) - - # 2. If not, fetch MicroDAO details to create one - microdao_query = """ - SELECT id, name, slug, orchestrator_agent_id - FROM microdaos - WHERE id = $1 - """ - microdao = await pool.fetchrow(microdao_query, microdao_id) - - if not microdao or not microdao["orchestrator_agent_id"]: - logger.warning(f"MicroDAO {microdao_id} not found or has no orchestrator") - return None - - # 3. Create Matrix room - matrix_info = await create_matrix_room_for_microdao_orchestrator( - microdao_id=microdao_id, - microdao_name=microdao["name"], - orchestrator_agent_id=microdao["orchestrator_agent_id"] - ) - - if not matrix_info: - logger.error("Failed to create Matrix room for orchestrator team") - # Fallback: Create DB record without Matrix ID if needed, or fail? - # Let's fail for now as Matrix ID is crucial for this feature. - return None - - # 4. Create DB record - slug = f"{microdao['slug']}-team" - # Ensure unique slug - while True: - check_slug = await pool.fetchrow("SELECT 1 FROM city_rooms WHERE slug = $1", slug) - if not check_slug: - break - slug = f"{slug}-{secrets.token_hex(2)}" - - create_query = """ - INSERT INTO city_rooms ( - id, slug, name, description, created_by, - matrix_room_id, matrix_room_alias, - microdao_id, room_role, is_public, sort_order - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, slug, name, matrix_room_id, microdao_id, room_role, is_public, sort_order - """ - - room_id = f"room_city_{slug}" - - new_room = await pool.fetchrow( - create_query, - room_id, - slug, - f"{microdao['name']} Team", - "Orchestrator Team Chat", - "system", - matrix_info["room_id"], - matrix_info.get("room_alias"), - microdao_id, - "orchestrator_team", - False, # Private by default - 50 # Sort order (high priority) - ) - - return dict(new_room) - - -# ============================================================================= -# Districts Repository (DB-based, no hardcodes) -# ============================================================================= - -async def get_districts() -> List[Dict[str, Any]]: - """ - Отримати всі District-и з БД. - District = microdao з dao_type = 'district' - """ - pool = await get_pool() - query = """ - SELECT id, slug, name, description, dao_type, - orchestrator_agent_id, created_at - FROM microdaos - WHERE dao_type = 'district' - ORDER BY name - """ - rows = await pool.fetch(query) - return [dict(r) for r in rows] - - -async def get_district_by_slug(slug: str) -> Optional[Dict[str, Any]]: - """ - Отримати District за slug. - """ - pool = await get_pool() - query = """ - SELECT id, slug, name, description, dao_type, - orchestrator_agent_id, created_at - FROM microdaos - WHERE slug = $1 - AND dao_type = 'district' - """ - row = await pool.fetchrow(query, slug) - return dict(row) if row else None - - -async def get_district_lead_agent(district_id: str) -> Optional[Dict[str, Any]]: - """ - Отримати lead agent District-а. - Шукаємо спочатку role='district_lead', потім fallback на orchestrator. - """ - pool = await get_pool() - - # Try district_lead first - query = """ - SELECT a.id, a.display_name as name, a.kind, a.status, - a.avatar_url, a.gov_level, - ma.role as membership_role - FROM agents a - JOIN microdao_agents ma ON ma.agent_id = a.id - WHERE ma.microdao_id = $1 - AND ma.role = 'district_lead' - LIMIT 1 - """ - row = await pool.fetchrow(query, district_id) - - if not row: - # Fallback: orchestrator - query = """ - SELECT a.id, a.display_name as name, a.kind, a.status, - a.avatar_url, a.gov_level, - ma.role as membership_role - FROM agents a - JOIN microdao_agents ma ON ma.agent_id = a.id - WHERE ma.microdao_id = $1 - AND (ma.role = 'orchestrator' OR ma.is_core = true) - ORDER BY ma.is_core DESC - LIMIT 1 - """ - row = await pool.fetchrow(query, district_id) - - return dict(row) if row else None - - -async def get_district_core_team(district_id: str) -> List[Dict[str, Any]]: - """ - Отримати core team District-а. - """ - pool = await get_pool() - query = """ - SELECT a.id, a.display_name as name, a.kind, a.status, - a.avatar_url, a.gov_level, - ma.role as membership_role - FROM agents a - JOIN microdao_agents ma ON ma.agent_id = a.id - WHERE ma.microdao_id = $1 - AND (ma.role = 'core_team' OR ma.is_core = true) - AND ma.role != 'district_lead' - AND ma.role != 'orchestrator' - ORDER BY a.display_name - """ - rows = await pool.fetch(query, district_id) - return [dict(r) for r in rows] - - -async def get_district_agents(district_id: str) -> List[Dict[str, Any]]: - """ - Отримати всіх агентів District-а. - """ - pool = await get_pool() - query = """ - SELECT a.id, a.display_name as name, a.kind, a.status, - a.avatar_url, a.gov_level, - ma.role as membership_role, ma.is_core - FROM agents a - JOIN microdao_agents ma ON ma.agent_id = a.id - WHERE ma.microdao_id = $1 - ORDER BY - CASE ma.role - WHEN 'district_lead' THEN 0 - WHEN 'orchestrator' THEN 1 - WHEN 'core_team' THEN 2 - ELSE 3 - END, - ma.is_core DESC, - a.display_name - """ - rows = await pool.fetch(query, district_id) - return [dict(r) for r in rows] - - -async def get_district_rooms(district_slug: str) -> List[Dict[str, Any]]: - """ - Отримати кімнати District-а за slug-префіксом. - Наприклад: soul-lobby, soul-events, greenfood-lobby - """ - pool = await get_pool() - query = """ - SELECT id, slug, name, description, - matrix_room_id, matrix_room_alias, - room_role, is_public - FROM city_rooms - WHERE slug LIKE $1 - ORDER BY sort_order, name - """ - rows = await pool.fetch(query, f"{district_slug}-%") - return [dict(r) for r in rows] - - -async def get_district_nodes(district_id: str) -> List[Dict[str, Any]]: - """ - Отримати ноди District-а. - """ - pool = await get_pool() - query = """ - SELECT n.id, n.display_name as name, n.node_type as kind, - n.status, n.hostname as location, - n.guardian_agent_id, n.steward_agent_id - FROM nodes n - WHERE n.owner_microdao_id = $1 - ORDER BY n.display_name - """ - rows = await pool.fetch(query, district_id) - return [dict(r) for r in rows] - - -async def get_district_stats(district_id: str, district_slug: str) -> Dict[str, Any]: - """ - Отримати статистику District-а. - """ - pool = await get_pool() - - # Count agents - agents_count = await pool.fetchval( - "SELECT COUNT(*) FROM microdao_agents WHERE microdao_id = $1", - district_id - ) - - # Count rooms - rooms_count = await pool.fetchval( - "SELECT COUNT(*) FROM city_rooms WHERE slug LIKE $1", - f"{district_slug}-%" - ) - - # Count nodes - nodes_count = await pool.fetchval( - "SELECT COUNT(*) FROM nodes WHERE owner_microdao_id = $1", - district_id - ) - - return { - "agents_count": agents_count or 0, - "rooms_count": rooms_count or 0, - "nodes_count": nodes_count or 0 - } - - -# ============================================================================= -# DAGI Agent Audit Repository -# ============================================================================= - -async def get_agents_by_node_for_audit(node_id: str) -> List[Dict[str, Any]]: - """ - Отримати агентів для DAGI audit по node_id. - """ - pool = await get_pool() - - query = """ - SELECT - id::text, - external_id, - COALESCE(name, display_name) as name, - kind, - node_id, - status, - COALESCE(is_active, true) as is_active, - last_seen_at, - dagi_status, - created_at, - updated_at - FROM agents - WHERE node_id = $1 - AND COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - AND deleted_at IS NULL - ORDER BY name - """ - - rows = await pool.fetch(query, node_id) - - return [ - { - "id": row["id"], - "external_id": row["external_id"], - "name": row["name"], - "kind": row["kind"], - "node_id": row["node_id"], - "status": row["status"], - "is_active": row["is_active"], - "last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None, - "dagi_status": row["dagi_status"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None - } - for row in rows - ] - - -async def get_all_agents_for_audit() -> List[Dict[str, Any]]: - """ - Отримати всіх активних агентів для DAGI audit. - """ - pool = await get_pool() - - query = """ - SELECT - id::text, - external_id, - COALESCE(name, display_name) as name, - kind, - node_id, - status, - COALESCE(is_active, true) as is_active, - last_seen_at, - dagi_status, - created_at, - updated_at - FROM agents - WHERE COALESCE(is_archived, false) = false - AND COALESCE(is_test, false) = false - AND deleted_at IS NULL - ORDER BY name - """ - - rows = await pool.fetch(query) - - return [ - { - "id": row["id"], - "external_id": row["external_id"], - "name": row["name"], - "kind": row["kind"], - "node_id": row["node_id"], - "status": row["status"], - "is_active": row["is_active"], - "last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None, - "dagi_status": row["dagi_status"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None - } - for row in rows - ] - - -async def update_agents_dagi_status( - agent_ids: List[str], - status: str, - update_last_seen: bool = False -) -> int: - """ - Оновити dagi_status для групи агентів. - Повертає кількість оновлених записів. - """ - if not agent_ids: - return 0 - - pool = await get_pool() - - if update_last_seen: - query = """ - UPDATE agents - SET dagi_status = $2, - last_seen_at = NOW(), - updated_at = NOW() - WHERE id = ANY($1::uuid[]) - """ - else: - query = """ - UPDATE agents - SET dagi_status = $2, - updated_at = NOW() - WHERE id = ANY($1::uuid[]) - """ - - result = await pool.execute(query, agent_ids, status) - # asyncpg returns "UPDATE N" - return int(result.split(" ")[-1]) - - -async def save_dagi_audit_report( - node_id: str, - report_data: Dict[str, Any], - triggered_by: str = "api" -) -> Dict[str, Any]: - """ - Зберегти звіт DAGI audit. - """ - pool = await get_pool() - - import json - - summary = report_data.get("summary", {}) - - row = await pool.fetchrow(""" - INSERT INTO dagi_audit_reports ( - node_id, - router_total, - db_total, - active_count, - phantom_count, - stale_count, - report_data, - triggered_by - ) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) - RETURNING id, node_id, timestamp, router_total, db_total, - active_count, phantom_count, stale_count, triggered_by - """, - node_id, - summary.get("router_total", 0), - summary.get("db_total", 0), - summary.get("active_count", 0), - summary.get("phantom_count", 0), - summary.get("stale_count", 0), - json.dumps(report_data), - triggered_by - ) - - return { - "id": str(row["id"]), - "node_id": row["node_id"], - "timestamp": row["timestamp"].isoformat(), - "router_total": row["router_total"], - "db_total": row["db_total"], - "active_count": row["active_count"], - "phantom_count": row["phantom_count"], - "stale_count": row["stale_count"], - "triggered_by": row["triggered_by"] - } - - -async def get_latest_dagi_audit(node_id: str) -> Optional[Dict[str, Any]]: - """ - Отримати останній DAGI audit звіт для ноди. - """ - pool = await get_pool() - - row = await pool.fetchrow(""" - SELECT id, node_id, timestamp, router_total, db_total, - active_count, phantom_count, stale_count, - report_data, triggered_by - FROM dagi_audit_reports - WHERE node_id = $1 - ORDER BY timestamp DESC - LIMIT 1 - """, node_id) - - if not row: - return None - - return { - "id": str(row["id"]), - "node_id": row["node_id"], - "timestamp": row["timestamp"].isoformat(), - "router_total": row["router_total"], - "db_total": row["db_total"], - "active_count": row["active_count"], - "phantom_count": row["phantom_count"], - "stale_count": row["stale_count"], - "report_data": row["report_data"], - "triggered_by": row["triggered_by"] - } - - -async def get_dagi_audit_history( - node_id: str, - limit: int = 10 -) -> List[Dict[str, Any]]: - """ - Отримати історію DAGI audit звітів для ноди. - """ - pool = await get_pool() - - rows = await pool.fetch(""" - SELECT id, node_id, timestamp, router_total, db_total, - active_count, phantom_count, stale_count, triggered_by - FROM dagi_audit_reports - WHERE node_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - """, node_id, limit) - - return [ - { - "id": str(row["id"]), - "node_id": row["node_id"], - "timestamp": row["timestamp"].isoformat(), - "router_total": row["router_total"], - "db_total": row["db_total"], - "active_count": row["active_count"], - "phantom_count": row["phantom_count"], - "stale_count": row["stale_count"], - "triggered_by": row["triggered_by"] - } - for row in rows - ] - - -# ============================================================================= -# Node Metrics Repository -# ============================================================================= - -async def get_node_metrics_current(node_id: str) -> Optional[Dict[str, Any]]: - """ - Отримати поточні метрики ноди. - """ - pool = await get_pool() - - row = await pool.fetchrow(""" - SELECT - node_id, - node_name, - hostname, - status, - roles, - environment, - cpu_model, - cpu_cores, - COALESCE(cpu_usage, 0) as cpu_usage, - gpu_model, - COALESCE(gpu_vram_total, 0) as gpu_vram_total, - COALESCE(gpu_vram_used, 0) as gpu_vram_used, - COALESCE(ram_total, 0) as ram_total, - COALESCE(ram_used, 0) as ram_used, - COALESCE(disk_total, 0) as disk_total, - COALESCE(disk_used, 0) as disk_used, - COALESCE(agent_count_router, 0) as agent_count_router, - COALESCE(agent_count_system, 0) as agent_count_system, - last_heartbeat, - dagi_router_url, - updated_at - FROM node_cache - WHERE node_id = $1 - """, node_id) - - if not row: - return None - # Count agents from database agent_count = await pool.fetchval(""" SELECT COUNT(*) @@ -3298,662 +8,44 @@ async def get_node_metrics_current(node_id: str) -> Optional[Dict[str, Any]]: AND deleted_at IS NULL """, node_id) - return { - "node_id": row["node_id"], - "node_name": row["node_name"], - "hostname": row["hostname"], - "status": row["status"], - "roles": row["roles"] or [], - "environment": row["environment"], - "cpu_model": row["cpu_model"], - "cpu_cores": row["cpu_cores"] or 0, - "cpu_usage": float(row["cpu_usage"]) if row["cpu_usage"] else 0.0, - "gpu_model": row["gpu_model"], - "gpu_memory_total": row["gpu_vram_total"] or 0, - "gpu_memory_used": row["gpu_vram_used"] or 0, - "ram_total": row["ram_total"] or 0, - "ram_used": row["ram_used"] or 0, - "disk_total": row["disk_total"] or 0, - "disk_used": row["disk_used"] or 0, - "agent_count_router": row["agent_count_router"] or 0, - "agent_count_system": agent_count or 0, - "dagi_router_url": row["dagi_router_url"], - "last_heartbeat": row["last_heartbeat"].isoformat() if row["last_heartbeat"] else None, - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None - } + result = dict(row) + result["agents_total"] = agent_count or row["agent_count_router"] + + # Add GPU info for compatibility + if row["gpu_model"]: + result["gpu_info"] = f"{row['gpu_model']} ({row['gpu_vram_total']}MB)" + else: + result["gpu_info"] = None + + return result -async def update_node_metrics( - node_id: str, - metrics: Dict[str, Any] -) -> bool: +async def get_node_metrics(node_id: str) -> Optional[Dict[str, Any]]: """ - Оновити метрики ноди. + Отримати розширені метрики ноди (включаючи Swapper). """ pool = await get_pool() - result = await pool.execute(""" - UPDATE node_cache SET - cpu_usage = COALESCE($2, cpu_usage), - gpu_vram_used = COALESCE($3, gpu_vram_used), - ram_used = COALESCE($4, ram_used), - disk_used = COALESCE($5, disk_used), - agent_count_router = COALESCE($6, agent_count_router), - agent_count_system = COALESCE($7, agent_count_system), - last_heartbeat = NOW(), - updated_at = NOW() - WHERE node_id = $1 - """, - node_id, - metrics.get("cpu_usage"), - metrics.get("gpu_vram_used"), - metrics.get("ram_used"), - metrics.get("disk_used"), - metrics.get("agent_count_router"), - metrics.get("agent_count_system") - ) - - return "UPDATE 1" in result - - -# ============================================================================= -# DAGI Router Agents Repository -# ============================================================================= - -async def get_dagi_router_agents_for_node(node_id: str) -> Dict[str, Any]: - """ - Отримати агентів DAGI Router для Node Cabinet таблиці. - Поєднує дані з audit report та agents table. - """ - pool = await get_pool() - - # Отримати останній audit - audit = await get_latest_dagi_audit(node_id) - - # Отримати метрики ноди для GPU/CPU info - node_metrics = await get_node_metrics_current(node_id) - - # Отримати всіх агентів з БД для цієї ноди - db_agents_rows = await pool.fetch(""" + row = await pool.fetchrow(""" SELECT - a.id::text, - a.external_id, - COALESCE(a.name, a.display_name) as name, - a.kind, - a.status, - a.node_id, - a.public_slug, - a.dagi_status, - a.last_seen_at, - a.is_public - FROM agents a - WHERE COALESCE(a.is_archived, false) = false - AND COALESCE(a.is_test, false) = false - AND a.deleted_at IS NULL - ORDER BY a.display_name - """) - - # Map db agents by normalized name and external_id - db_agents_map = {} - for row in db_agents_rows: - db_agents_map[row["id"]] = dict(row) - if row["external_id"]: - ext_id = row["external_id"].split(":")[-1].lower() if ":" in row["external_id"] else row["external_id"].lower() - db_agents_map[ext_id] = dict(row) - name_norm = row["name"].lower().replace(" ", "").replace("-", "").replace("_", "") if row["name"] else "" - if name_norm: - db_agents_map[name_norm] = dict(row) - - # Формуємо уніфікований список агентів - agents = [] - active_count = 0 - phantom_count = 0 - stale_count = 0 - - if audit and audit.get("report_data"): - report = audit["report_data"] - - # Active agents - for a in report.get("active_agents", []): - db_agent = db_agents_map.get(a.get("db_id")) - agents.append({ - "id": a.get("db_id") or a.get("router_id"), - "name": a.get("db_name") or a.get("router_name"), - "role": db_agent.get("kind") if db_agent else None, - "status": "active", - "node_id": node_id, - "models": [], # TODO: можна додати з router-config - "gpu": node_metrics.get("gpu_model") if node_metrics else None, - "cpu": f"{node_metrics.get('cpu_cores')} cores" if node_metrics else None, - "last_seen_at": db_agent.get("last_seen_at").isoformat() if db_agent and db_agent.get("last_seen_at") else None, - "has_cabinet": bool(db_agent and db_agent.get("public_slug")), - "cabinet_slug": db_agent.get("public_slug") if db_agent else None - }) - active_count += 1 - - # Phantom agents - for a in report.get("phantom_agents", []): - agents.append({ - "id": a.get("router_id"), - "name": a.get("router_name"), - "role": None, - "status": "phantom", - "node_id": node_id, - "models": [], - "gpu": node_metrics.get("gpu_model") if node_metrics else None, - "cpu": f"{node_metrics.get('cpu_cores')} cores" if node_metrics else None, - "last_seen_at": None, - "has_cabinet": False, - "cabinet_slug": None, - "description": a.get("description") - }) - phantom_count += 1 - - # Stale agents - for a in report.get("stale_agents", []): - db_agent = db_agents_map.get(a.get("db_id")) - agents.append({ - "id": a.get("db_id"), - "name": a.get("db_name"), - "role": db_agent.get("kind") if db_agent else a.get("kind"), - "status": "stale", - "node_id": node_id, - "models": [], - "gpu": node_metrics.get("gpu_model") if node_metrics else None, - "cpu": f"{node_metrics.get('cpu_cores')} cores" if node_metrics else None, - "last_seen_at": db_agent.get("last_seen_at").isoformat() if db_agent and db_agent.get("last_seen_at") else None, - "has_cabinet": bool(db_agent and db_agent.get("public_slug")), - "cabinet_slug": db_agent.get("public_slug") if db_agent else None - }) - stale_count += 1 - - # Check prompts status for all agents - agent_ids = [a["id"] for a in agents if a.get("id")] - prompts_status = await check_agents_prompts_status(agent_ids) if agent_ids else {} - - # Add has_prompts to each agent - for agent in agents: - agent["has_prompts"] = prompts_status.get(agent.get("id"), False) - - return { - "node_id": node_id, - "last_audit_at": audit.get("timestamp") if audit else None, - "summary": { - "active": active_count, - "phantom": phantom_count, - "stale": stale_count, - "router_total": audit.get("router_total", 0) if audit else 0, - "system_total": audit.get("db_total", 0) if audit else len(db_agents_rows) - }, - "agents": agents - } - - -async def sync_phantom_agents( - node_id: str, - agent_ids: List[str], - router_config: Dict[str, Any] -) -> List[Dict[str, Any]]: - """ - Синхронізувати phantom агентів (створити в БД). - """ - pool = await get_pool() - created = [] - - agents_config = router_config.get("agents", {}) - - for agent_id in agent_ids: - if agent_id not in agents_config: - continue - - agent_data = agents_config[agent_id] - - # Створити агента в БД - new_id = str(uuid.uuid4()) - - try: - row = await pool.fetchrow(""" - INSERT INTO agents ( - id, external_id, name, display_name, kind, - status, node_id, dagi_status, last_seen_at, - is_public, public_slug, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), true, $8, NOW(), NOW()) - ON CONFLICT (external_id) DO UPDATE SET - dagi_status = 'active', - last_seen_at = NOW(), - updated_at = NOW() - RETURNING id::text, name, external_id - """, - new_id, - f"agent:{agent_id}", - agent_id, - agent_id.replace("_", " ").title(), - "ai_agent", - "online", - node_id, - agent_id - ) - - if row: - created.append({ - "id": row["id"], - "name": row["name"], - "external_id": row["external_id"] - }) - except Exception as e: - print(f"Error creating agent {agent_id}: {e}") - - return created - - -async def mark_stale_agents(agent_ids: List[str]) -> int: - """ - Позначити агентів як stale. - """ - if not agent_ids: - return 0 - - pool = await get_pool() - - result = await pool.execute(""" - UPDATE agents - SET dagi_status = 'stale', - updated_at = NOW() - WHERE id = ANY($1::uuid[]) - """, agent_ids) - - return int(result.split(" ")[-1]) - - -async def get_node_agents(node_id: str) -> List[Dict[str, Any]]: - """ - Отримати всіх агентів ноди (Guardian, Steward, runtime agents). - """ - pool = await get_pool() - - query = """ - SELECT - a.id, - a.external_id, - COALESCE(a.display_name, a.name) as display_name, - a.kind, - a.status, - a.node_id, - a.public_slug, - a.dagi_status, - a.last_seen_at, - COALESCE(a.is_node_guardian, false) as is_node_guardian, - COALESCE(a.is_node_steward, false) as is_node_steward - FROM agents a - WHERE a.node_id = $1 - AND COALESCE(a.is_archived, false) = false - AND COALESCE(a.is_test, false) = false - AND a.deleted_at IS NULL - ORDER BY - CASE - WHEN a.kind = 'node_guardian' OR a.is_node_guardian THEN 1 - WHEN a.kind = 'node_steward' OR a.is_node_steward THEN 2 - ELSE 3 - END, - a.display_name - """ - - rows = await pool.fetch(query, node_id) - return [dict(row) for row in rows] - - -# ============================================================================== -# Node Self-Registration & Self-Healing -# ============================================================================== - -async def node_self_register( - node_id: str, - name: str, - hostname: Optional[str] = None, - environment: str = "development", - roles: Optional[List[str]] = None, - description: Optional[str] = None -) -> Dict[str, Any]: - """ - Самореєстрація ноди. Викликається з Node Bootstrap або Node Guardian. - - Якщо нода вже існує — оновлює, інакше — створює. - Також забезпечує наявність запису в node_cache. - """ - pool = await get_pool() - roles = roles or [] - - try: - # Використати SQL функцію для атомарної операції - result = await pool.fetchval(""" - SELECT fn_node_self_register($1, $2, $3, $4, $5) - """, node_id, name, hostname, environment, roles) - - if result: - import json - return json.loads(result) - except Exception as e: - # Fallback якщо функція не існує (ще не запущена міграція) - logger.warning(f"fn_node_self_register not available, using fallback: {e}") - - # Fallback: пряма вставка/оновлення - try: - # Check if exists - existing = await pool.fetchval( - "SELECT id FROM node_registry WHERE id = $1", - node_id - ) - is_new = existing is None - - if is_new: - await pool.execute(""" - INSERT INTO node_registry (id, name, hostname, environment, roles, description, is_active, registered_at, updated_at, last_self_registration, self_registration_count) - VALUES ($1, $2, $3, $4, $5, $6, true, NOW(), NOW(), NOW(), 1) - """, node_id, name, hostname, environment, roles, description) - else: - await pool.execute(""" - UPDATE node_registry SET - name = COALESCE(NULLIF($2, ''), name), - hostname = COALESCE($3, hostname), - environment = COALESCE(NULLIF($4, ''), environment), - roles = CASE WHEN array_length($5::text[], 1) > 0 THEN $5 ELSE roles END, - description = COALESCE($6, description), - is_active = true, - updated_at = NOW(), - last_self_registration = NOW(), - self_registration_count = COALESCE(self_registration_count, 0) + 1 - WHERE id = $1 - """, node_id, name, hostname, environment, roles, description) - - # Ensure node_cache entry - await pool.execute(""" - INSERT INTO node_cache (node_id, last_heartbeat, self_healing_status) - VALUES ($1, NOW(), 'healthy') - ON CONFLICT (node_id) DO UPDATE SET - last_heartbeat = NOW(), - self_healing_status = 'healthy' - """, node_id) - - return { - "success": True, - "node_id": node_id, - "is_new": is_new, - "message": "Node registered" if is_new else "Node updated" - } - except Exception as e: - # Ultimate fallback: just update node_cache - logger.warning(f"node_registry insert failed, updating node_cache: {e}") - try: - await pool.execute(""" - INSERT INTO node_cache (node_id, node_name, hostname, environment, roles, last_heartbeat) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (node_id) DO UPDATE SET - node_name = COALESCE(NULLIF($2, ''), node_cache.node_name), - hostname = COALESCE($3, node_cache.hostname), - environment = COALESCE(NULLIF($4, ''), node_cache.environment), - roles = CASE WHEN array_length($5::text[], 1) > 0 THEN $5 ELSE node_cache.roles END, - last_heartbeat = NOW() - """, node_id, name, hostname, environment, roles) - - return { - "success": True, - "node_id": node_id, - "is_new": False, - "message": "Node updated (fallback to node_cache)" - } - except Exception as fallback_error: - logger.error(f"Failed to register node {node_id}: {fallback_error}") - return { - "success": False, - "node_id": node_id, - "error": str(fallback_error) - } - - -async def node_heartbeat( - node_id: str, - metrics: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """ - Heartbeat ноди з оновленням метрик. - - Повертає should_self_register=True якщо нода не зареєстрована. - """ - pool = await get_pool() - metrics = metrics or {} - - try: - # Використати SQL функцію - result = await pool.fetchval(""" - SELECT fn_node_heartbeat($1, $2) - """, node_id, json.dumps(metrics) if metrics else None) - - if result: - return json.loads(result) - except Exception as e: - logger.warning(f"fn_node_heartbeat not available, using fallback: {e}") - - # Fallback - try: - # Check if registered - registered = await pool.fetchval(""" - SELECT EXISTS(SELECT 1 FROM node_registry WHERE id = $1 AND is_active = true) - """, node_id) - - if not registered: - # Check node_cache as fallback - cache_exists = await pool.fetchval(""" - SELECT EXISTS(SELECT 1 FROM node_cache WHERE node_id = $1) - """, node_id) - - if not cache_exists: - return { - "success": False, - "error": "Node not registered", - "should_self_register": True - } - - # Update heartbeat - await pool.execute(""" - UPDATE node_cache SET - last_heartbeat = NOW(), - self_healing_status = 'healthy', - cpu_usage = COALESCE($2::numeric, cpu_usage), - gpu_vram_used = COALESCE($3::integer, gpu_vram_used), - ram_used = COALESCE($4::integer, ram_used), - disk_used = COALESCE($5::integer, disk_used), - agent_count_router = COALESCE($6::integer, agent_count_router), - agent_count_system = COALESCE($7::integer, agent_count_system) - WHERE node_id = $1 - """, node_id, - metrics.get("cpu_usage"), - metrics.get("gpu_vram_used"), - metrics.get("ram_used"), - metrics.get("disk_used"), - metrics.get("agent_count_router"), - metrics.get("agent_count_system") - ) - - return { - "success": True, - "node_id": node_id, - "heartbeat_at": datetime.now(timezone.utc).isoformat() - } - except Exception as e: - logger.error(f"Heartbeat failed for {node_id}: {e}") - return { - "success": False, - "error": str(e) - } - - -async def check_node_in_directory(node_id: str) -> bool: - """ - Перевірити чи нода видима в Node Directory. - Використовується Node Guardian для self-healing. - """ - pool = await get_pool() + swapper_healthy, + swapper_models_loaded, + swapper_models_total, + swapper_state + FROM node_cache + WHERE node_id = $1 + """, node_id) - try: - # Check node_registry first - exists = await pool.fetchval(""" - SELECT EXISTS( - SELECT 1 FROM node_registry - WHERE id = $1 AND is_active = true - ) - """, node_id) - return bool(exists) - except Exception: - # Fallback to node_cache + if not row: + return None + + result = dict(row) + if result.get("swapper_state"): try: - exists = await pool.fetchval(""" - SELECT EXISTS(SELECT 1 FROM node_cache WHERE node_id = $1) - """, node_id) - return bool(exists) + if isinstance(result["swapper_state"], str): + result["swapper_state"] = json.loads(result["swapper_state"]) except Exception: - return False - - -async def get_node_self_healing_status(node_id: str) -> Dict[str, Any]: - """ - Отримати статус self-healing для ноди. - """ - pool = await get_pool() - - try: - row = await pool.fetchrow(""" - SELECT - nr.id, - nr.name, - nr.is_active, - nr.last_self_registration, - nr.self_registration_count, - nc.self_healing_status, - nc.self_healing_last_check, - nc.self_healing_errors, - nc.last_heartbeat, - nc.agent_count_router, - nc.agent_count_system, - nc.guardian_agent_id, - nc.steward_agent_id - FROM node_registry nr - LEFT JOIN node_cache nc ON nc.node_id = nr.id - WHERE nr.id = $1 - """, node_id) - - if not row: - return { - "node_id": node_id, - "registered": False, - "status": "not_found" - } - - return { - "node_id": node_id, - "registered": True, - "is_active": row["is_active"], - "name": row["name"], - "self_healing_status": row["self_healing_status"] or "unknown", - "last_heartbeat": row["last_heartbeat"].isoformat() if row["last_heartbeat"] else None, - "last_self_registration": row["last_self_registration"].isoformat() if row["last_self_registration"] else None, - "self_registration_count": row["self_registration_count"] or 0, - "agent_count_router": row["agent_count_router"] or 0, - "agent_count_system": row["agent_count_system"] or 0, - "has_guardian": bool(row["guardian_agent_id"]), - "has_steward": bool(row["steward_agent_id"]), - "errors": row["self_healing_errors"] or [] - } - except Exception as e: - logger.error(f"Failed to get self-healing status for {node_id}: {e}") - return { - "node_id": node_id, - "registered": False, - "status": "error", - "error": str(e) - } - - -async def update_node_self_healing_status( - node_id: str, - status: str, - error: Optional[str] = None -) -> bool: - """ - Оновити статус self-healing для ноди. - """ - pool = await get_pool() - - try: - if error: - await pool.execute(""" - UPDATE node_cache SET - self_healing_status = $2, - self_healing_last_check = NOW(), - self_healing_errors = COALESCE(self_healing_errors, '[]'::jsonb) || jsonb_build_object( - 'timestamp', NOW(), - 'error', $3 - ) - WHERE node_id = $1 - """, node_id, status, error) - else: - await pool.execute(""" - UPDATE node_cache SET - self_healing_status = $2, - self_healing_last_check = NOW() - WHERE node_id = $1 - """, node_id, status) - - return True - except Exception as e: - logger.error(f"Failed to update self-healing status for {node_id}: {e}") - return False - - -async def get_nodes_needing_healing() -> List[Dict[str, Any]]: - """ - Отримати список нод, які потребують self-healing. - - Критерії: - - heartbeat старший за 10 хвилин - - agent_count_router = 0 - - немає guardian_agent_id - - self_healing_status = 'error' - """ - pool = await get_pool() - - try: - rows = await pool.fetch(""" - SELECT - nr.id as node_id, - nr.name, - nc.last_heartbeat, - nc.agent_count_router, - nc.agent_count_system, - nc.guardian_agent_id, - nc.steward_agent_id, - nc.self_healing_status, - CASE - WHEN nc.last_heartbeat < NOW() - INTERVAL '10 minutes' THEN 'stale_heartbeat' - WHEN nc.agent_count_router = 0 OR nc.agent_count_router IS NULL THEN 'no_router_agents' - WHEN nc.guardian_agent_id IS NULL THEN 'no_guardian' - WHEN nc.self_healing_status = 'error' THEN 'previous_error' - ELSE 'unknown' - END as healing_reason - FROM node_registry nr - LEFT JOIN node_cache nc ON nc.node_id = nr.id - WHERE nr.is_active = true - AND ( - nc.last_heartbeat < NOW() - INTERVAL '10 minutes' - OR nc.agent_count_router = 0 - OR nc.agent_count_router IS NULL - OR nc.guardian_agent_id IS NULL - OR nc.self_healing_status = 'error' - ) - """) - - return [dict(row) for row in rows] - except Exception as e: - logger.error(f"Failed to get nodes needing healing: {e}") - return [] + result["swapper_state"] = {} + + return result diff --git a/services/city-service/repo_city.py_fragment.py b/services/city-service/repo_city.py_fragment.py new file mode 100644 index 00000000..c4cd8536 --- /dev/null +++ b/services/city-service/repo_city.py_fragment.py @@ -0,0 +1,39 @@ + # TASK 038: Dynamic discovery of Node Guardian / Steward if cache is empty + if not data.get("guardian_agent") or not data.get("steward_agent"): + dynamic_agents = await pool.fetch(""" + SELECT id, display_name, kind, public_slug + FROM agents + WHERE node_id = $1 + AND (kind IN ('node_guardian', 'node_steward') OR kind IN ('infra_monitor', 'infra_ops')) + AND COALESCE(is_archived, false) = false + """, node_id) + + if not data.get("guardian_agent"): + # Prefer 'node_guardian', fallback to 'infra_monitor' + guardian_candidates = [a for a in dynamic_agents if a['kind'] == 'node_guardian'] + monitor_candidates = [a for a in dynamic_agents if a['kind'] == 'infra_monitor'] + + guardian = guardian_candidates[0] if guardian_candidates else (monitor_candidates[0] if monitor_candidates else None) + + if guardian: + data["guardian_agent"] = { + "id": guardian["id"], + "name": guardian["display_name"], + "kind": guardian["kind"], + "slug": guardian["public_slug"], + } + + if not data.get("steward_agent"): + # Prefer 'node_steward', fallback to 'infra_ops' + steward_candidates = [a for a in dynamic_agents if a['kind'] == 'node_steward'] + ops_candidates = [a for a in dynamic_agents if a['kind'] == 'infra_ops'] + + steward = steward_candidates[0] if steward_candidates else (ops_candidates[0] if ops_candidates else None) + + if steward: + data["steward_agent"] = { + "id": steward["id"], + "name": steward["display_name"], + "kind": steward["kind"], + "slug": steward["public_slug"], + } \ No newline at end of file diff --git a/services/city-service/repo_city.py_fragment_debug.py b/services/city-service/repo_city.py_fragment_debug.py new file mode 100644 index 00000000..05560e5d --- /dev/null +++ b/services/city-service/repo_city.py_fragment_debug.py @@ -0,0 +1,16 @@ + # Fetch MicroDAOs where orchestrator is on this node + print(f"DEBUG: Fetching microdaos for node {node_id}") + try: + microdaos = await pool.fetch(""" + SELECT m.id, m.slug, m.name, COUNT(cr.id) as rooms_count + FROM microdaos m + JOIN agents a ON m.orchestrator_agent_id = a.id + LEFT JOIN city_rooms cr ON cr.microdao_id::text = m.id + WHERE a.node_id = $1 + GROUP BY m.id, m.slug, m.name + ORDER BY m.name + """, node_id) + print(f"DEBUG: Microdaos fetched: {len(microdaos)}") + except Exception as e: + print(f"DEBUG: Error fetching microdaos: {e}") + raise e diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index 0caf7c28..53ddb84c 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -4057,6 +4057,52 @@ async def get_node_self_healing_status(node_id: str): ) +@router.get("/internal/node/{node_id}/swapper", response_model=NodeSwapperDetail) +async def get_node_swapper_detail(node_id: str): + """ + Get detailed Swapper Service status for a node. + Used by Node Cabinet to show loaded models and health. + """ + try: + # Fetch from node_cache + metrics = await repo_city.get_node_metrics(node_id) + if not metrics: + raise HTTPException(status_code=404, detail="Node not found") + + # Parse swapper state (stored as JSONB) + state = metrics.get("swapper_state") or {} + models_data = state.get("models", []) + + models = [ + SwapperModel( + name=m.get("name", "unknown"), + loaded=m.get("loaded", False), + type=m.get("type"), + vram_gb=m.get("vram_gb") + ) + for m in models_data + ] + + return NodeSwapperDetail( + node_id=node_id, + healthy=metrics.get("swapper_healthy", False), + models_loaded=metrics.get("swapper_models_loaded", 0), + models_total=metrics.get("swapper_models_total", 0), + models=models + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get swapper detail for {node_id}: {e}") + return NodeSwapperDetail( + node_id=node_id, + healthy=False, + models_loaded=0, + models_total=0, + models=[] + ) + + @router.get("/internal/node/{node_id}/directory-check") async def check_node_in_directory(node_id: str): """