diff --git a/apps/web/src/app/api/nodes/list/route.ts b/apps/web/src/app/api/nodes/list/route.ts
index 16f23aa4..899c6fb8 100644
--- a/apps/web/src/app/api/nodes/list/route.ts
+++ b/apps/web/src/app/api/nodes/list/route.ts
@@ -3,29 +3,35 @@ import { NextRequest, NextResponse } from 'next/server';
const CITY_API_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || 'http://daarion-city-service:7001';
export async function GET(_req: NextRequest) {
+ const url = `${CITY_API_URL}/public/nodes`;
+ console.log('[API/nodes/list] Fetching from:', url);
+
try {
- const response = await fetch(`${CITY_API_URL}/public/nodes`, {
+ const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
+ console.log('[API/nodes/list] Response status:', response.status);
+
if (!response.ok) {
const text = await response.text();
- console.error('Failed to fetch nodes:', response.status, text);
+ console.error('[API/nodes/list] Failed:', response.status, text);
return NextResponse.json(
- { error: 'Failed to fetch nodes' },
+ { error: 'Failed to fetch nodes', status: response.status, detail: text.substring(0, 500) },
{ status: response.status }
);
}
const data = await response.json();
+ console.log('[API/nodes/list] Success, nodes count:', data?.items?.length || data?.total || 0);
return NextResponse.json(data);
} catch (error) {
- console.error('Error fetching nodes:', error);
+ console.error('[API/nodes/list] Exception:', error);
return NextResponse.json(
- { error: 'Internal server error' },
+ { error: 'Failed to connect to city service', detail: String(error) },
{ status: 500 }
);
}
diff --git a/apps/web/src/app/nodes/[nodeId]/page.tsx b/apps/web/src/app/nodes/[nodeId]/page.tsx
index bb2b11a7..24c58706 100644
--- a/apps/web/src/app/nodes/[nodeId]/page.tsx
+++ b/apps/web/src/app/nodes/[nodeId]/page.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { Server, ArrowLeft, Cpu, Users, Activity, ExternalLink } from 'lucide-react';
import { useNodeProfile } from '@/hooks/useNodes';
import { useNodeDashboard } from '@/hooks/useNodeDashboard';
+import { useNodeAgents } from '@/hooks/useDAGIAudit';
import {
NodeSummaryCard,
InfraCard,
@@ -12,7 +13,9 @@ import {
AgentsCard,
MatrixCard,
ModulesCard,
- NodeStandardComplianceCard
+ NodeStandardComplianceCard,
+ DAGIRouterCard,
+ NodeMetricsCard
} from '@/components/node-dashboard';
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
@@ -33,6 +36,9 @@ export default function NodeCabinetPage() {
// Basic node profile from node_cache
const { node: nodeProfile, isLoading: profileLoading, error: profileError } = useNodeProfile(nodeId);
+ // Node agents (Guardian, Steward, etc.)
+ const { guardian, steward, agents: nodeAgents, total: nodeAgentsTotal } = useNodeAgents(nodeId);
+
// Full dashboard (if available - currently only for NODE1)
const { dashboard, isLoading: dashboardLoading, error: dashboardError, refresh, lastUpdated } = useNodeDashboard({
refreshInterval: 30000,
@@ -129,14 +135,20 @@ export default function NodeCabinetPage() {
+ {/* DAGI Router Audit */}
+
+
+ {/* Node Metrics */}
+
+
{/* Node Guardian & Steward Agents */}
{/* MicroDAO Presence */}
@@ -293,11 +305,21 @@ export default function NodeCabinetPage() {
{/* Node Guardian & Steward Agents */}
+ {/* Node Metrics (GPU/CPU/RAM/Disk) */}
+
+
+
+
+ {/* DAGI Router Audit */}
+
+
+
+
{/* MicroDAO Presence */}
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
@@ -323,14 +345,6 @@ export default function NodeCabinetPage() {
)}
- {/* Notice for non-NODE1 */}
-
-
- ⚠️ Детальний моніторинг доступний тільки для НОДА1 (Production).
- Для цієї ноди показано базову інформацію з node_cache.
-
-
-
{/* Link to agents */}
= 1024 * 1024) return `${(mb / (1024 * 1024)).toFixed(0)}TB`;
+ if (mb >= 1024) return `${(mb / 1024).toFixed(0)}GB`;
+ return `${mb}MB`;
+}
+
+function MiniBar({ value, max, color = 'purple' }: { value: number; max: number; color?: string }) {
+ const percent = max > 0 ? Math.min((value / max) * 100, 100) : 0;
+ const colors: Record
= {
+ purple: 'bg-purple-500',
+ emerald: 'bg-emerald-500',
+ cyan: 'bg-cyan-500',
+ amber: 'bg-amber-500'
+ };
+ return (
+
+ );
+}
+
function NodeCard({ node }: { node: NodeProfile }) {
const isOnline = node.status === 'online';
const nodeLabel = getNodeLabel(node.node_id);
const isProduction = node.environment === 'production';
+ const m = node.metrics;
return (
)}
+ {/* Metrics */}
+ {m && (m.gpu_model || m.ram_total > 0) && (
+
+ {/* GPU */}
+
+
+
+ GPU
+
+
+
+ {m.gpu_vram_total > 0 ? formatMB(m.gpu_vram_total) : '—'}
+
+
+ {/* CPU */}
+
+
+
+ CPU
+
+
+
+ {m.cpu_cores > 0 ? `${m.cpu_cores}c` : '—'}
+
+
+ {/* RAM */}
+
+
+
+ RAM
+
+
+
+ {m.ram_total > 0 ? formatMB(m.ram_total) : '—'}
+
+
+ {/* Disk */}
+
+
+
+ Disk
+
+
+
+ {m.disk_total > 0 ? formatMB(m.disk_total) : '—'}
+
+
+
+ )}
+
{/* Stats */}
- {node.agents_total}
+ {m?.agent_count_system || node.agents_total}
агентів
diff --git a/apps/web/src/components/node-dashboard/DAGIRouterCard.tsx b/apps/web/src/components/node-dashboard/DAGIRouterCard.tsx
new file mode 100644
index 00000000..80f7de25
--- /dev/null
+++ b/apps/web/src/components/node-dashboard/DAGIRouterCard.tsx
@@ -0,0 +1,463 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import Link from 'next/link';
+import {
+ Router,
+ CheckCircle,
+ Ghost,
+ Archive,
+ RefreshCw,
+ Bot,
+ AlertTriangle,
+ Clock,
+ ExternalLink,
+ Search,
+ Filter,
+ ChevronDown,
+ Download,
+ Upload,
+ Brain
+} from 'lucide-react';
+import {
+ useDAGIRouterAgents,
+ runDAGIAudit,
+ syncPhantomAgents,
+ markStaleAgents,
+ type DAGIRouterAgent
+} from '@/hooks/useDAGIAudit';
+
+interface DAGIRouterCardProps {
+ nodeId: string;
+ expanded?: boolean;
+}
+
+type StatusFilter = 'all' | 'active' | 'phantom' | 'stale';
+
+const STATUS_CONFIG = {
+ active: {
+ label: 'Active',
+ color: 'emerald',
+ bgClass: 'bg-emerald-500/20',
+ textClass: 'text-emerald-400',
+ icon: CheckCircle
+ },
+ phantom: {
+ label: 'Phantom',
+ color: 'amber',
+ bgClass: 'bg-amber-500/20',
+ textClass: 'text-amber-400',
+ icon: Ghost
+ },
+ stale: {
+ label: 'Stale',
+ color: 'orange',
+ bgClass: 'bg-orange-500/20',
+ textClass: 'text-orange-400',
+ icon: Archive
+ },
+ error: {
+ label: 'Error',
+ color: 'red',
+ bgClass: 'bg-red-500/20',
+ textClass: 'text-red-400',
+ icon: AlertTriangle
+ }
+};
+
+export function DAGIRouterCard({ nodeId, expanded = false }: DAGIRouterCardProps) {
+ const { agents, summary, lastAuditAt, isLoading, error, refresh } = useDAGIRouterAgents(nodeId);
+ const [isRunning, setIsRunning] = useState(false);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [runError, setRunError] = useState
(null);
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [showFilterMenu, setShowFilterMenu] = useState(false);
+
+ // Filter agents
+ const filteredAgents = useMemo(() => {
+ return agents.filter(agent => {
+ // Status filter
+ if (statusFilter !== 'all' && agent.status !== statusFilter) {
+ return false;
+ }
+ // Search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ return (
+ agent.name.toLowerCase().includes(query) ||
+ agent.role?.toLowerCase().includes(query) ||
+ agent.id.toLowerCase().includes(query)
+ );
+ }
+ return true;
+ });
+ }, [agents, statusFilter, searchQuery]);
+
+ const handleRunAudit = async () => {
+ setIsRunning(true);
+ setRunError(null);
+ try {
+ await runDAGIAudit(nodeId);
+ await refresh();
+ } catch (e) {
+ setRunError(e instanceof Error ? e.message : 'Failed to run audit');
+ } finally {
+ setIsRunning(false);
+ }
+ };
+
+ const handleSyncPhantom = async () => {
+ const phantomIds = agents.filter(a => a.status === 'phantom').map(a => a.id);
+ if (phantomIds.length === 0) return;
+
+ setIsSyncing(true);
+ try {
+ await syncPhantomAgents(nodeId, phantomIds);
+ await runDAGIAudit(nodeId);
+ await refresh();
+ } catch (e) {
+ setRunError(e instanceof Error ? e.message : 'Failed to sync');
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const handleMarkStale = async () => {
+ const staleIds = agents.filter(a => a.status === 'stale').map(a => a.id);
+ if (staleIds.length === 0) return;
+
+ setIsSyncing(true);
+ try {
+ await markStaleAgents(nodeId, staleIds);
+ await refresh();
+ } catch (e) {
+ setRunError(e instanceof Error ? e.message : 'Failed to mark stale');
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ // Format timestamp
+ const formatTime = (timestamp: string) => {
+ try {
+ const date = new Date(timestamp);
+ return date.toLocaleString('uk-UA', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch {
+ return timestamp;
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.error;
+ const Icon = config.icon;
+ return (
+
+
+ {config.label}
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ DAGI Router
+
+
+ {summary.phantom > 0 && (
+
+ )}
+
+
+
+
+ {/* Error */}
+ {(error || runError) && (
+
+
+ {runError || error?.message || 'Помилка завантаження'}
+
+ )}
+
+ {/* Loading */}
+ {isLoading && agents.length === 0 && (
+
+
+ Завантаження...
+
+ )}
+
+ {/* No data */}
+ {!isLoading && agents.length === 0 && !error && (
+
+
+
Ще немає даних аудиту
+
Натисніть "Запустити" для аудиту
+
+ )}
+
+ {/* Content */}
+ {agents.length > 0 && (
+ <>
+ {/* Timestamp */}
+ {lastAuditAt && (
+
+
+ Останній аудит: {formatTime(lastAuditAt)}
+
+ )}
+
+ {/* Stats Grid */}
+
+
+
+
+
+
+ {/* Source counts */}
+
+ Router: {summary.router_total} агентів
+ System: {summary.system_total} агентів
+
+
+ {/* Search & Filter */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-9 pr-3 py-2 bg-slate-800/50 border border-white/10 rounded-lg
+ text-sm text-white placeholder-white/30 focus:outline-none focus:ring-2
+ focus:ring-purple-500/50"
+ />
+
+
+
+ {showFilterMenu && (
+
+ {(['all', 'active', 'phantom', 'stale'] as const).map((status) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Agents Table */}
+
+
+
+
+
+ |
+ Agent
+ |
+
+ Status
+ |
+
+ Runtime
+ |
+
+ Last Seen
+ |
+
+ Cabinet
+ |
+
+
+
+ {filteredAgents.map((agent) => (
+
+
+
+
+
+
+
+
+ {agent.name}
+ {agent.has_prompts && (
+
+
+
+ )}
+ {!agent.has_prompts && agent.status === 'active' && (
+
+
+
+ )}
+
+ {agent.role && (
+ {agent.role}
+ )}
+
+
+ |
+
+ {getStatusBadge(agent.status)}
+ |
+
+
+ {agent.gpu && {agent.gpu} }
+ {agent.cpu && {agent.cpu} }
+
+ |
+
+
+ {agent.last_seen_at ? formatTime(agent.last_seen_at) : '—'}
+
+ |
+
+ {agent.has_cabinet && agent.cabinet_slug ? (
+
+ Open
+
+
+ ) : (
+ —
+ )}
+ |
+
+ ))}
+
+
+
+
+ {/* Empty state for filtered results */}
+ {filteredAgents.length === 0 && agents.length > 0 && (
+
+
+
Немає агентів за цим фільтром
+
+ )}
+
+
+ {/* Footer stats */}
+
+
+ Показано {filteredAgents.length} з {agents.length} агентів
+
+ {summary.phantom === 0 && summary.stale === 0 && (
+
+
+ Всі агенти синхронізовані
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
+export default DAGIRouterCard;
diff --git a/apps/web/src/components/node-dashboard/NodeMetricsCard.tsx b/apps/web/src/components/node-dashboard/NodeMetricsCard.tsx
new file mode 100644
index 00000000..17de60a3
--- /dev/null
+++ b/apps/web/src/components/node-dashboard/NodeMetricsCard.tsx
@@ -0,0 +1,247 @@
+'use client';
+
+import {
+ Cpu,
+ HardDrive,
+ MemoryStick,
+ Zap,
+ Users,
+ Clock,
+ RefreshCw
+} from 'lucide-react';
+import { useNodeMetrics } from '@/hooks/useDAGIAudit';
+
+interface NodeMetricsCardProps {
+ nodeId: string;
+}
+
+function formatBytes(mb: number): string {
+ if (mb >= 1024 * 1024) {
+ return `${(mb / (1024 * 1024)).toFixed(1)} TB`;
+ }
+ if (mb >= 1024) {
+ return `${(mb / 1024).toFixed(1)} GB`;
+ }
+ return `${mb} MB`;
+}
+
+function ProgressBar({
+ value,
+ max,
+ color = 'purple'
+}: {
+ value: number;
+ max: number;
+ color?: string;
+}) {
+ const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0;
+
+ const colorClasses: Record = {
+ purple: 'bg-purple-500',
+ emerald: 'bg-emerald-500',
+ cyan: 'bg-cyan-500',
+ amber: 'bg-amber-500'
+ };
+
+ return (
+
+ );
+}
+
+export function NodeMetricsCard({ nodeId }: NodeMetricsCardProps) {
+ const { metrics, isLoading, error, refresh } = useNodeMetrics(nodeId);
+
+ const formatTime = (timestamp: string | null | undefined) => {
+ if (!timestamp) return '—';
+ try {
+ const date = new Date(timestamp);
+ return date.toLocaleString('uk-UA', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch {
+ return timestamp;
+ }
+ };
+
+ if (isLoading && !metrics) {
+ return (
+
+ );
+ }
+
+ if (error && !metrics) {
+ return (
+
+
+
Не вдалося завантажити метрики
+
+
+ );
+ }
+
+ if (!metrics) {
+ return null;
+ }
+
+ const gpuUsagePercent = metrics.gpu_memory_total > 0
+ ? (metrics.gpu_memory_used / metrics.gpu_memory_total) * 100
+ : 0;
+
+ const ramUsagePercent = metrics.ram_total > 0
+ ? (metrics.ram_used / metrics.ram_total) * 100
+ : 0;
+
+ const diskUsagePercent = metrics.disk_total > 0
+ ? (metrics.disk_used / metrics.disk_total) * 100
+ : 0;
+
+ return (
+
+ {/* Header */}
+
+
+
+ Node Metrics
+
+
+
+
+ {/* Metrics Grid */}
+
+ {/* GPU */}
+
+
+
+
+
+
+
GPU
+
+ {metrics.gpu_model || 'Unknown'}
+
+
+
+
+
+ {formatBytes(metrics.gpu_memory_used)}
+ {formatBytes(metrics.gpu_memory_total)}
+
+
+
+ {/* CPU */}
+
+
+
+
+
+
+
CPU
+
+ {metrics.cpu_cores} cores
+
+
+
+
+
+ {metrics.cpu_usage.toFixed(1)}%
+ 100%
+
+
+
+ {/* RAM */}
+
+
+
+
+
+
+
RAM
+
+ {ramUsagePercent.toFixed(0)}% used
+
+
+
+
+
+ {formatBytes(metrics.ram_used)}
+ {formatBytes(metrics.ram_total)}
+
+
+
+ {/* Disk */}
+
+
+
+
+
+
+
Disk
+
+ {diskUsagePercent.toFixed(0)}% used
+
+
+
+
+
+ {formatBytes(metrics.disk_used)}
+ {formatBytes(metrics.disk_total)}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ {metrics.agent_count_router} Router
+ /
+ {metrics.agent_count_system} System
+
+
+
+
+
+ {formatTime(metrics.last_heartbeat)}
+
+
+
+ );
+}
+
+export default NodeMetricsCard;
+
diff --git a/apps/web/src/components/node-dashboard/index.ts b/apps/web/src/components/node-dashboard/index.ts
index 7d2cea85..f4ee014b 100644
--- a/apps/web/src/components/node-dashboard/index.ts
+++ b/apps/web/src/components/node-dashboard/index.ts
@@ -7,4 +7,6 @@ export { AgentsCard } from './AgentsCard';
export { MatrixCard } from './MatrixCard';
export { ModulesCard } from './ModulesCard';
export { NodeStandardComplianceCard } from './NodeStandardComplianceCard';
+export { DAGIRouterCard } from './DAGIRouterCard';
+export { NodeMetricsCard } from './NodeMetricsCard';
diff --git a/apps/web/src/components/nodes/NodeGuardianCard.tsx b/apps/web/src/components/nodes/NodeGuardianCard.tsx
index da60ae0f..18385c13 100644
--- a/apps/web/src/components/nodes/NodeGuardianCard.tsx
+++ b/apps/web/src/components/nodes/NodeGuardianCard.tsx
@@ -102,7 +102,7 @@ function AgentMiniCard({ title, description, agent, accentClass, icon }: AgentMi
)}
Кабінет
diff --git a/apps/web/src/hooks/useDAGIAudit.ts b/apps/web/src/hooks/useDAGIAudit.ts
new file mode 100644
index 00000000..1b1518c2
--- /dev/null
+++ b/apps/web/src/hooks/useDAGIAudit.ts
@@ -0,0 +1,362 @@
+/**
+ * Hook для DAGI Agent Audit та Router Agents
+ * Отримує дані про стан агентів в контексті DAGI Router
+ */
+
+import useSWR from 'swr';
+
+// Types
+export interface DAGIAuditSummary {
+ node_id: string;
+ timestamp: string;
+ router_total: number;
+ db_total: number;
+ active_count: number;
+ phantom_count: number;
+ stale_count: number;
+ triggered_by?: string;
+}
+
+export interface DAGIActiveAgent {
+ router_id: string;
+ router_name: string;
+ db_id: string;
+ db_name: string;
+ db_external_id?: string;
+ kind?: string;
+ status: string;
+}
+
+export interface DAGIPhantomAgent {
+ router_id: string;
+ router_name: string;
+ description?: string;
+ reason: string;
+}
+
+export interface DAGIStaleAgent {
+ db_id: string;
+ db_name: string;
+ db_external_id?: string;
+ kind?: string;
+ reason: string;
+}
+
+export interface DAGIAuditFull {
+ summary: DAGIAuditSummary;
+ active_agents: DAGIActiveAgent[];
+ phantom_agents: DAGIPhantomAgent[];
+ stale_agents: DAGIStaleAgent[];
+ report_data?: unknown;
+}
+
+export interface DAGIAuditHistory {
+ node_id: string;
+ history: DAGIAuditSummary[];
+}
+
+// Router Agents Types (for Table)
+export interface DAGIRouterAgent {
+ id: string;
+ name: string;
+ role?: string;
+ status: 'active' | 'phantom' | 'stale' | 'error';
+ node_id?: string;
+ models: string[];
+ gpu?: string;
+ cpu?: string;
+ last_seen_at?: string;
+ has_cabinet: boolean;
+ cabinet_slug?: string;
+ description?: string;
+ has_prompts?: boolean; // Чи є системні промти в БД
+}
+
+export interface DAGIRouterAgentsSummary {
+ active: number;
+ phantom: number;
+ stale: number;
+ router_total: number;
+ system_total: number;
+}
+
+export interface DAGIRouterAgentsResponse {
+ node_id: string;
+ last_audit_at?: string;
+ summary: DAGIRouterAgentsSummary;
+ agents: DAGIRouterAgent[];
+}
+
+// Node Metrics Types
+export interface NodeMetrics {
+ node_id: string;
+ node_name?: string;
+ hostname?: string;
+ status?: string;
+ environment?: string;
+ cpu_model?: string;
+ cpu_cores: number;
+ cpu_usage: number;
+ gpu_model?: string;
+ gpu_memory_total: number;
+ gpu_memory_used: number;
+ ram_total: number;
+ ram_used: number;
+ disk_total: number;
+ disk_used: number;
+ agent_count_router: number;
+ agent_count_system: number;
+ last_heartbeat?: string;
+}
+
+// API URL
+const CITY_SERVICE_URL = process.env.NEXT_PUBLIC_CITY_SERVICE_URL || '';
+
+// Fetcher
+const fetcher = async (url: string) => {
+ const res = await fetch(url);
+ if (!res.ok) {
+ if (res.status === 404) return null;
+ throw new Error(`Failed to fetch: ${res.status}`);
+ }
+ return res.json();
+};
+
+/**
+ * Отримати останній DAGI audit summary
+ */
+export function useDAGIAuditSummary(nodeId: string | undefined) {
+ const { data, error, isLoading, mutate } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit` : null,
+ fetcher,
+ {
+ refreshInterval: 60000, // Оновлювати кожну хвилину
+ revalidateOnFocus: false
+ }
+ );
+
+ return {
+ summary: data,
+ isLoading,
+ error,
+ refresh: mutate
+ };
+}
+
+/**
+ * Отримати повний DAGI audit з деталями
+ */
+export function useDAGIAuditFull(nodeId: string | undefined) {
+ const { data, error, isLoading, mutate } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/full` : null,
+ fetcher,
+ {
+ refreshInterval: 60000,
+ revalidateOnFocus: false
+ }
+ );
+
+ return {
+ audit: data,
+ isLoading,
+ error,
+ refresh: mutate
+ };
+}
+
+/**
+ * Отримати агентів DAGI Router для таблиці
+ */
+export function useDAGIRouterAgents(nodeId: string | undefined) {
+ const { data, error, isLoading, mutate } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/agents` : null,
+ fetcher,
+ {
+ refreshInterval: 30000, // Оновлювати кожні 30 сек
+ revalidateOnFocus: true
+ }
+ );
+
+ return {
+ data,
+ agents: data?.agents || [],
+ summary: data?.summary || { active: 0, phantom: 0, stale: 0, router_total: 0, system_total: 0 },
+ lastAuditAt: data?.last_audit_at,
+ isLoading,
+ error,
+ refresh: mutate
+ };
+}
+
+/**
+ * Отримати історію DAGI audits
+ */
+export function useDAGIAuditHistory(nodeId: string | undefined, limit: number = 10) {
+ const { data, error, isLoading } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/history?limit=${limit}` : null,
+ fetcher
+ );
+
+ return {
+ history: data?.history || [],
+ isLoading,
+ error
+ };
+}
+
+/**
+ * Отримати метрики ноди
+ */
+export function useNodeMetrics(nodeId: string | undefined) {
+ const { data, error, isLoading, mutate } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/metrics/current` : null,
+ fetcher,
+ {
+ refreshInterval: 30000,
+ revalidateOnFocus: true
+ }
+ );
+
+ return {
+ metrics: data,
+ isLoading,
+ error,
+ refresh: mutate
+ };
+}
+
+/**
+ * Запустити DAGI audit
+ */
+export async function runDAGIAudit(nodeId: string): Promise<{
+ status: string;
+ report_id: string;
+ summary: {
+ router_total: number;
+ db_total: number;
+ active_count: number;
+ phantom_count: number;
+ stale_count: number;
+ };
+ message: string;
+}> {
+ const res = await fetch(
+ `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-audit/run`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ }
+ );
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(err.detail || 'Failed to run audit');
+ }
+
+ return res.json();
+}
+
+/**
+ * Синхронізувати phantom агентів (створити в БД)
+ */
+export async function syncPhantomAgents(
+ nodeId: string,
+ agentIds: string[]
+): Promise<{
+ status: string;
+ created_count: number;
+ created_agents: Array<{ id: string; name: string; external_id: string }>;
+}> {
+ const res = await fetch(
+ `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/phantom/sync`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ agent_ids: agentIds })
+ }
+ );
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(err.detail || 'Failed to sync phantom agents');
+ }
+
+ return res.json();
+}
+
+/**
+ * Позначити агентів як stale
+ */
+export async function markStaleAgents(
+ nodeId: string,
+ agentIds: string[]
+): Promise<{
+ status: string;
+ marked_count: number;
+}> {
+ const res = await fetch(
+ `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/dagi-router/stale/mark`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ agent_ids: agentIds })
+ }
+ );
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(err.detail || 'Failed to mark stale agents');
+ }
+
+ return res.json();
+}
+
+// =============================================================================
+// Node Agents API
+// =============================================================================
+
+export interface NodeAgent {
+ id: string;
+ name: string;
+ slug?: string;
+ kind?: string;
+ role?: string;
+ status: string;
+ dagi_status?: string;
+ last_seen_at?: string;
+ is_guardian: boolean;
+ is_steward: boolean;
+}
+
+export interface NodeAgentsResponse {
+ node_id: string;
+ total: number;
+ guardian?: NodeAgent;
+ steward?: NodeAgent;
+ agents: NodeAgent[];
+}
+
+/**
+ * Отримати агентів ноди (Guardian, Steward, runtime agents)
+ */
+export function useNodeAgents(nodeId: string | undefined) {
+ const { data, error, isLoading, mutate } = useSWR(
+ nodeId ? `${CITY_SERVICE_URL}/city/internal/node/${nodeId}/agents` : null,
+ fetcher,
+ {
+ refreshInterval: 60000,
+ revalidateOnFocus: false
+ }
+ );
+
+ return {
+ data,
+ guardian: data?.guardian,
+ steward: data?.steward,
+ agents: data?.agents || [],
+ total: data?.total || 0,
+ isLoading,
+ error,
+ refresh: mutate
+ };
+}
diff --git a/apps/web/src/lib/types/nodes.ts b/apps/web/src/lib/types/nodes.ts
index 28a92791..b59bf0c7 100644
--- a/apps/web/src/lib/types/nodes.ts
+++ b/apps/web/src/lib/types/nodes.ts
@@ -12,6 +12,22 @@ export interface NodeMicrodaoSummary {
rooms_count: number;
}
+export interface NodeMetrics {
+ cpu_model?: string | null;
+ cpu_cores: number;
+ cpu_usage: number;
+ gpu_model?: string | null;
+ gpu_vram_total: number;
+ gpu_vram_used: number;
+ ram_total: number;
+ ram_used: number;
+ disk_total: number;
+ disk_used: number;
+ agent_count_router: number;
+ agent_count_system: number;
+ dagi_router_url?: string | null;
+}
+
export interface NodeProfile {
node_id: string;
name: string;
@@ -28,6 +44,7 @@ export interface NodeProfile {
guardian_agent?: NodeAgentSummary | null;
steward_agent?: NodeAgentSummary | null;
microdaos?: NodeMicrodaoSummary[];
+ metrics?: NodeMetrics | null;
}
export interface NodeListResponse {
diff --git a/docs/DEPLOY_CHECKLIST_2024_11_30.md b/docs/DEPLOY_CHECKLIST_2024_11_30.md
new file mode 100644
index 00000000..e0aefd65
--- /dev/null
+++ b/docs/DEPLOY_CHECKLIST_2024_11_30.md
@@ -0,0 +1,244 @@
+# 🚀 DEPLOY CHECKLIST — daarion.space
+
+**Дата:** 2024-11-30
+**Версія:** MVP Node Self-Healing + DAGI Audit + Agent Prompts
+
+---
+
+## 📋 Що деплоїмо
+
+### Backend (city-service)
+- ✅ Node Registry + Self-Healing API
+- ✅ Improved `get_all_nodes()` з fallback
+- ✅ Agent Prompts Runtime API
+- ✅ DAGI Router Audit API
+- ✅ Node Agents API (Guardian/Steward)
+
+### Frontend (apps/web)
+- ✅ Node Directory з покращеним error handling
+- ✅ Node Cabinet з метриками
+- ✅ DAGI Router Card
+- ✅ Node Metrics Card
+
+### Scripts
+- ✅ `check-invariants.py` — перевірка інваріантів
+- ✅ `node-bootstrap.sh` — самореєстрація ноди
+- ✅ `node-guardian-loop.py` — self-healing loop
+
+### Міграції (НОВІ)
+- `034_agent_prompts_seed.sql`
+- `035_agent_dagi_audit.sql`
+- `036_node_metrics_extended.sql`
+- `037_node_agents_complete.sql`
+- `038_agent_prompts_full_coverage.sql`
+- `039_node_registry_self_healing.sql`
+
+---
+
+## 🔧 КРОК 1: Локально — Закомітити та запушити
+
+```bash
+cd /Users/apple/github-projects/microdao-daarion
+
+# Додати всі зміни
+git add .
+
+# Закомітити
+git commit -m "feat: Node Self-Healing, DAGI Audit, Agent Prompts, Infra Invariants
+
+- Node Registry for self-healing (migration 039)
+- Improved get_all_nodes() with robust fallback
+- Agent Prompts Runtime API for DAGI Router
+- DAGI Router Audit endpoints
+- Node metrics and Guardian/Steward APIs
+- check-invariants.py for deploy verification
+- node-bootstrap.sh for node self-registration
+- node-guardian-loop.py for continuous self-healing
+- Updated Node Directory UI with better error handling
+- Node Cabinet with metrics cards and DAGI Router card"
+
+# Запушити
+git push origin main
+```
+
+---
+
+## 🖥️ КРОК 2: На сервері NODE1 (Hetzner)
+
+### 2.1. SSH на сервер
+```bash
+ssh root@
+# або через ваш алиас
+ssh node1
+```
+
+### 2.2. Перейти в директорію проєкту
+```bash
+cd /opt/daarion
+# або ваш шлях до проєкту
+```
+
+### 2.3. Оновити код
+```bash
+git pull origin main
+```
+
+### 2.4. Застосувати міграції
+```bash
+# Підключитися до PostgreSQL
+docker exec -it daarion-postgres psql -U daarion_user -d daarion
+
+# Або напряму через psql
+PGPASSWORD= psql -h localhost -U daarion_user -d daarion
+
+# Виконати міграції послідовно:
+\i migrations/034_agent_prompts_seed.sql
+\i migrations/035_agent_dagi_audit.sql
+\i migrations/036_node_metrics_extended.sql
+\i migrations/037_node_agents_complete.sql
+\i migrations/038_agent_prompts_full_coverage.sql
+\i migrations/039_node_registry_self_healing.sql
+
+# Вийти
+\q
+```
+
+### 2.5. Перебілдити і перезапустити сервіси
+```bash
+# Зупинити сервіси
+docker compose -f docker-compose.all.yml down
+
+# Перебілдити
+docker compose -f docker-compose.all.yml build
+
+# Запустити
+docker compose -f docker-compose.all.yml up -d
+```
+
+### 2.6. Перевірити здоров'я
+```bash
+# Перевірити статус контейнерів
+docker ps | grep daarion
+
+# Перевірити логи city-service
+docker logs -f daarion-city-service --tail 100
+
+# Перевірити /healthz
+curl http://localhost:7001/healthz
+
+# Перевірити /public/nodes
+curl http://localhost:7001/public/nodes | jq
+```
+
+---
+
+## 🔍 КРОК 3: Перевірка інваріантів
+
+```bash
+# На сервері (або локально якщо є доступ)
+python3 scripts/check-invariants.py --base-url http://localhost:7001
+
+# Очікуваний результат:
+# ✅ ALL INVARIANTS PASSED
+# або
+# ⚠️ WARNINGS (деякі можуть бути нормальними)
+```
+
+---
+
+## 🧪 КРОК 4: Smoke-тести
+
+```bash
+# Якщо встановлено pytest
+pytest tests/test_infra_smoke.py -v --base-url http://localhost:7001
+```
+
+---
+
+## 🌐 КРОК 5: Перевірка в браузері
+
+1. **Node Directory:** https://daarion.space/nodes
+ - Повинні відображатися NODE1 і NODE2
+ - Без "Помилка завантаження нод"
+
+2. **Node Cabinet:** https://daarion.space/nodes/node-1-hetzner-gex44
+ - Метрики CPU/GPU/RAM/Disk
+ - DAGI Router Card
+ - Guardian/Steward агенти
+
+3. **Agents:** https://daarion.space/agents
+ - System Prompts для агентів
+
+---
+
+## 🔄 КРОК 6 (опційно): Node Bootstrap
+
+Якщо ноди не з'являються після міграції:
+
+```bash
+# На NODE1
+NODE_ID=node-1-hetzner-gex44 \
+NODE_NAME="NODE1 — Hetzner GEX44" \
+NODE_ENVIRONMENT=production \
+NODE_ROLES=production,gpu,ai_runtime,storage,matrix \
+./scripts/node-bootstrap.sh
+
+# На NODE2 (якщо потрібно)
+NODE_ID=node-2-macbook-m4max \
+NODE_NAME="NODE2 — MacBook Pro M4 Max" \
+NODE_ENVIRONMENT=development \
+NODE_ROLES=development,gpu,ai_runtime,testing \
+./scripts/node-bootstrap.sh
+```
+
+---
+
+## ❌ Rollback (якщо щось пішло не так)
+
+```bash
+# Відкотити код
+git reset --hard HEAD~1
+git push -f origin main
+
+# На сервері
+git pull origin main
+docker compose -f docker-compose.all.yml down
+docker compose -f docker-compose.all.yml up -d
+```
+
+---
+
+## 📊 Очікуваний результат
+
+Після успішного деплою:
+
+| Компонент | URL | Очікуваний статус |
+|-----------|-----|-------------------|
+| Health | /healthz | `{"status": "ok"}` |
+| Nodes | /public/nodes | `{"items": [...], "total": 2}` |
+| Node Cabinet | /nodes/{id} | Метрики + DAGI + Agents |
+| Invariants | check-invariants.py | ✅ PASSED |
+
+---
+
+## 🆘 Troubleshooting
+
+### "Failed to fetch nodes"
+1. Перевірити логи: `docker logs daarion-city-service`
+2. Перевірити чи є записи в node_cache: `SELECT * FROM node_cache;`
+3. Застосувати міграцію 039
+
+### "node_registry does not exist"
+```sql
+\i migrations/039_node_registry_self_healing.sql
+```
+
+### "Ноди не відображаються"
+```bash
+# Перевірити node_cache
+docker exec -it daarion-postgres psql -U daarion_user -d daarion -c "SELECT node_id, node_name FROM node_cache;"
+
+# Якщо порожньо — запустити bootstrap
+./scripts/node-bootstrap.sh
+```
+
diff --git a/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v1.md b/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v1.md
new file mode 100644
index 00000000..789b129f
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v1.md
@@ -0,0 +1,214 @@
+# TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v1
+
+## Проєкт
+
+microdao-daarion (MVP DAARION.city)
+
+## Статус
+
+✅ **COMPLETED** — 2025-11-30
+
+## Мета
+
+Зробити так, щоб системні промти агентів:
+- зберігались у реальній БД (`agent_prompts` таблиця)
+- завантажувались через API
+- редагувалися через UI на сторінці `/agents/:slug` (вкладка System Prompts)
+
+Після виконання цієї фази вкладка System Prompts перестає бути "плейсхолдером" і працює як повноцінний редактор системних промтів для ключових агентів DAARION.city.
+
+---
+
+## Виконані роботи
+
+### 1. Аналіз проблеми
+
+**Причина порожніх промтів:**
+- Backend routes (`routes_city.py`) викликали функції `update_agent_prompt()` та `get_agent_prompt_history()`, які **не були імплементовані** в `repo_city.py`
+- Функція `get_agent_prompts()` вже існувала і правильно повертала дані
+
+**Структура, яка вже працювала:**
+- ✅ Міграція `016_agent_prompts.sql` — таблиця створена
+- ✅ `GET /city/agents/{agent_id}/dashboard` — повертає `system_prompts`
+- ✅ Frontend компонент `AgentSystemPromptsCard.tsx`
+- ✅ Next.js API routes proxy
+
+### 2. Backend: Додані функції в `repo_city.py`
+
+#### `update_agent_prompt(agent_id, kind, content, created_by, note)`
+- Деактивує попередню версію промта
+- Створює нову версію з інкрементованим номером
+- Повертає оновлений запис
+
+#### `get_agent_prompt_history(agent_id, kind, limit)`
+- Повертає історію всіх версій промту
+- Впорядковано по версії (DESC)
+
+**Файл:** `services/city-service/repo_city.py` (рядки ~628-705)
+
+### 3. Seed Data: Міграція `034_agent_prompts_seed.sql`
+
+Створено детальні системні промти для ключових агентів:
+
+| Агент | Промти | Роль |
+|-------|--------|------|
+| DAARWIZZ | core, safety, governance | City Mayor / Orchestrator |
+| DARIA | core, safety | Technical Support |
+| DARIO | core | Community Manager |
+| SOUL | core, safety | District Lead (Wellness) |
+| Spirit | core | Guidance Agent |
+| Logic | core | Information Agent |
+| Helion | core, safety, tools | District Lead (Energy) |
+| GREENFOOD | core, safety | District Lead (Supply-Chain) |
+
+---
+
+## API Reference
+
+### Отримати всі промти агента
+```
+GET /city/agents/{agent_id}/dashboard
+```
+Повертає `system_prompts` об'єкт з 4 типами: core, safety, governance, tools
+
+### Оновити промт
+```
+PUT /city/agents/{agent_id}/prompts/{kind}
+Content-Type: application/json
+
+{
+ "content": "New prompt content...",
+ "note": "Optional change note"
+}
+```
+
+### Отримати історію промту
+```
+GET /city/agents/{agent_id}/prompts/{kind}/history?limit=10
+```
+
+---
+
+## Схема БД: `agent_prompts`
+
+```sql
+CREATE TABLE agent_prompts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ agent_id text NOT NULL,
+ kind text NOT NULL CHECK (kind IN ('core', 'safety', 'governance', 'tools')),
+ content text NOT NULL,
+ version integer NOT NULL DEFAULT 1,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ created_by text,
+ note text,
+ is_active boolean NOT NULL DEFAULT true
+);
+```
+
+**Індекси:**
+- `idx_agent_prompts_agent_kind` — пошук активних промтів
+- `idx_agent_prompts_agent_created_at` — сортування по часу
+- `idx_agent_prompts_active` — фільтр активних
+
+---
+
+## Frontend
+
+### Сторінка агента
+`/agents/[agentId]` → вкладка "System Prompts"
+
+### Компоненти
+- `apps/web/src/app/agents/[agentId]/page.tsx` — головна сторінка
+- `apps/web/src/components/agent-dashboard/AgentSystemPromptsCard.tsx` — редактор промтів
+- `apps/web/src/lib/agent-dashboard.ts` — API клієнт
+
+### Можливості
+- Перемикання між типами промтів (core/safety/governance/tools)
+- Редагування тексту промта
+- Збереження змін з індикацією статусу
+- Перегляд версії та часу останнього оновлення
+
+---
+
+## Застосування міграції
+
+```bash
+# На сервері
+cd /opt/microdao-daarion
+psql -U postgres -d daarion < migrations/034_agent_prompts_seed.sql
+```
+
+Або через Docker:
+```bash
+docker exec -i dagi-postgres psql -U postgres -d daarion < migrations/034_agent_prompts_seed.sql
+```
+
+---
+
+## Acceptance Criteria
+
+- ✅ Для будь-якого агента з seed-промтами: `/agents/:id` → вкладка System Prompts показує реальний текст з БД
+- ✅ Редагування промта з UI: змінює запис у БД, після перезавантаження новий текст відображається
+- ✅ API GET/PUT працюють коректно
+- ✅ Версіонування: кожне збереження створює нову версію
+- ✅ Seed-дані для 8 ключових агентів
+
+---
+
+## Out of Scope (на потім)
+
+- [ ] UI для перегляду історії версій
+- [ ] Перемикання на попередню версію (rollback)
+- [ ] RBAC перевірки (хто може редагувати)
+- [ ] Інтеграція з DAGI Router runtime
+
+---
+
+## Файли змінені/створені
+
+### Змінені
+- `services/city-service/repo_city.py` — додані функції update_agent_prompt, get_agent_prompt_history
+
+### Створені
+- `migrations/034_agent_prompts_seed.sql` — детальні промти для ключових агентів
+- `docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v1.md` — цей документ
+
+### Вже існували (без змін)
+- `migrations/016_agent_prompts.sql` — схема таблиці
+- `services/city-service/routes_city.py` — API routes
+- `apps/web/src/components/agent-dashboard/AgentSystemPromptsCard.tsx` — UI компонент
+- `apps/web/src/lib/agent-dashboard.ts` — API клієнт
+- `apps/web/src/app/api/agents/[agentId]/prompts/[kind]/route.ts` — Next.js proxy
+
+---
+
+## Тестування
+
+### Backend (curl)
+```bash
+# Отримати dashboard з промтами
+curl http://localhost:7001/city/agents/AGENT_ID/dashboard | jq '.system_prompts'
+
+# Оновити промт
+curl -X PUT http://localhost:7001/city/agents/AGENT_ID/prompts/core \
+ -H "Content-Type: application/json" \
+ -d '{"content": "Test prompt", "note": "Test update"}'
+
+# Отримати історію
+curl http://localhost:7001/city/agents/AGENT_ID/prompts/core/history
+```
+
+### Frontend
+1. Відкрити http://localhost:8899/agents
+2. Вибрати агента (DAARWIZZ, DARIA, тощо)
+3. Перейти на вкладку "System Prompts"
+4. Перевірити що відображаються seed-промти
+5. Змінити текст та натиснути "Save"
+6. Перезавантажити сторінку — зміни збережені
+
+---
+
+**Версія:** 1.0.0
+**Дата:** 2025-11-30
+**Автор:** DAARION AI Team
+
diff --git a/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v2.md b/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v2.md
new file mode 100644
index 00000000..80b6e6b4
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v2.md
@@ -0,0 +1,157 @@
+# TASK_PHASE_AGENT_SYSTEM_PROMPTS_MVP_v2
+
+## Проєкт
+microdao-daarion (MVP DAARION.city)
+
+## Фаза
+Agent System Prompts — Coverage + Runtime Integration
+
+## Статус
+✅ **COMPLETED**
+
+---
+
+## Мета
+
+1. Заповнити системні промти для всіх ключових агентів міста (City / District / Node)
+2. Підключити зберігання промтів у БД до реального DAGI Router runtime
+
+---
+
+## Результат
+
+### 1. Повне покриття агентів (16 агентів)
+
+#### City / Core
+- ✅ **DAARWIZZ** — core, safety, governance, tools
+- ✅ **MicroDAO Orchestrator** — core, safety
+- ✅ **DevTools Agent** — core, safety, tools
+
+#### District / MicroDAO
+- ✅ **GREENFOOD** — core, safety, tools
+- ✅ **Helion** — core, safety, tools
+- ✅ **SOUL** — core, safety
+- ✅ **DRUID** — core, safety, tools
+- ✅ **NUTRA** — core, safety
+- ✅ **EONARCH** — core, safety
+- ✅ **CLAN** — core
+- ✅ **Yaromir** — core
+- ✅ **Monitor** — core, safety
+
+#### Node Agents
+- ✅ **monitor-node1** (Node Guardian NODE1) — core, safety, governance
+- ✅ **monitor-node2** (Node Guardian NODE2) — core, safety
+- ✅ **node-steward-node1** — core
+- ✅ **node-steward-node2** — core
+
+### 2. Runtime Integration
+
+#### Нові API Endpoints
+
+```
+GET /internal/agents/{agent_id}/prompts/runtime
+```
+Повертає промти для агента (тільки content, без метаданих).
+
+```
+GET /internal/agents/{agent_id}/system-prompt
+```
+Повертає зібраний system prompt для LLM виклику.
+
+```
+POST /internal/agents/prompts/status
+Body: { "agent_ids": ["agent-1", "agent-2"] }
+```
+Перевіряє наявність промтів для списку агентів.
+
+#### DAGI Router Integration
+
+Створено `services/router/prompt_builder.py`:
+- `PromptBuilder` клас для побудови system prompts
+- Пріоритети: БД → router-config → fallback
+- Автоматичне завантаження контексту (node, district)
+- `get_agent_system_prompt()` convenience function
+
+Оновлено `/v1/agents/{agent_id}/infer`:
+- Автоматично завантажує system prompt з БД
+- Fallback на router-config.yml
+- Логування джерела промту
+
+### 3. UI Індикатори
+
+#### DAGIRouterCard
+- 🧠 іконка біля імені агента якщо `has_prompts = true`
+- Напівпрозора іконка якщо агент active але без промтів
+- Tooltip з інформацією про статус
+
+### 4. Файли
+
+#### Міграції
+- `migrations/038_agent_prompts_full_coverage.sql` — повний seed
+
+#### Backend
+- `services/city-service/repo_city.py`:
+ - `get_runtime_prompts(agent_id)`
+ - `build_system_prompt(agent, prompts, context)`
+ - `get_agent_with_runtime_prompt(agent_id)`
+ - `check_agents_prompts_status(agent_ids)`
+
+- `services/city-service/routes_city.py`:
+ - Нові endpoints для runtime prompts
+ - `DAGIRouterAgentItem.has_prompts` поле
+
+#### Router
+- `services/router/prompt_builder.py` — новий модуль
+- `services/router/main.py` — інтеграція з prompt_builder
+
+#### Frontend
+- `apps/web/src/hooks/useDAGIAudit.ts` — `has_prompts` в типах
+- `apps/web/src/components/node-dashboard/DAGIRouterCard.tsx` — UI індикатор
+
+#### Тести
+- `tests/test_agent_prompts_runtime.py`
+
+---
+
+## Acceptance Criteria
+
+| Критерій | Статус |
+|----------|--------|
+| Всі агенти з Target Coverage мають core prompt | ✅ |
+| DAGI Router завантажує промти з БД | ✅ |
+| Fallback на config якщо БД порожня | ✅ |
+| UI показує індикатор has_prompts | ✅ |
+| API для batch перевірки статусу | ✅ |
+| Unit тести | ✅ |
+
+---
+
+## Як застосувати
+
+```bash
+# 1. Застосувати міграцію
+docker exec -i dagi-postgres psql -U postgres -d daarion < migrations/038_agent_prompts_full_coverage.sql
+
+# 2. Перезапустити city-service
+docker-compose restart daarion-city-service
+
+# 3. Перезапустити router (опційно)
+docker-compose restart daarion-router
+
+# 4. Зібрати frontend
+cd apps/web && npm run build
+
+# 5. Запустити тести
+pytest tests/test_agent_prompts_runtime.py -v
+```
+
+---
+
+## Наступні кроки (v3)
+
+1. **Версіонування промтів** — історія змін з rollback
+2. **A/B testing** — різні версії промтів для тестування
+3. **Template system** — шаблони з variables
+4. **Metrics** — трекінг ефективності промтів
+5. **UI Editor** — advanced editor з preview
+
diff --git a/docs/tasks/TASK_PHASE_DAGI_AGENT_AUDIT_MVP_v1.md b/docs/tasks/TASK_PHASE_DAGI_AGENT_AUDIT_MVP_v1.md
new file mode 100644
index 00000000..c4326702
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_DAGI_AGENT_AUDIT_MVP_v1.md
@@ -0,0 +1,296 @@
+# TASK_PHASE_DAGI_AGENT_AUDIT_MVP_v1
+
+Проєкт: DAARION.city — DAGI Router / Node Cabinet
+Фаза: DAGI Agent Audit & Activity Monitor
+Мета: гарантувати, що всі агенти, оголошені DAGI Router на кожній Ноді (NODA1, NODA2), коректно синхронізовані з системою microdao та відображаються у Кабінеті Ноди з правильним індикатором активності.
+
+---
+
+# 0. Problem Statement
+
+У процесі розробки та деплою деякі агенти на НОДА з'являлись/зникали.
+Не було механізму перевірки їх присутності та активності у DAGI Router та їх відповідності записам у системі (microdao → agents).
+
+Потрібно створити:
+- одноразовий аудит DAGI-агентів на кожній ноді;
+- постійний автоматизований моніторинг активності агентів;
+- індикатор «підключено/активний» замість терміну «зареєстрований у MVP»;
+- UI-відображення в Кабінеті Ноди;
+- метрики й сигналізація (NATS + Prometheus).
+
+---
+
+# 1. Scope
+
+## Включено
+
+- Аудит DAGI Router агентів на NODA1 та NODA2.
+- Зіставлення: `router_agents` ↔ `system_agents` (таблиця microdao.agents).
+- Додавання індикатора активності агента.
+- Одноразовий звіт diff у JSON.
+- Автоматичний воркер для періодичної перевірки.
+- Метрики Prometheus.
+- Події NATS.
+- UI (Node Cabinet → вкладка "DAGI Router").
+
+## Виключено
+
+- Вплив на логіку DAGI Router.
+- Автоматичне видалення агентів.
+- Версіонування агентів.
+
+---
+
+# 2. Definitions
+
+- **Router Agents** — агенти, які DAGI Router бачить на конкретній ноді (`GET /api/agents` або NATS `dagi.router.agent.list`).
+- **System Agents** — агенти, зареєстровані в системі (таблиця `agents` у microdao).
+- **Node Agent Auditor** — спеціальний агент Ноди, який періодично перевіряє відповідність.
+- **Active** — агент з'являється в DAGI Router і відповідає на healthcheck.
+- **Stale** — агент є в системі, але його немає в DAGI Router.
+- **Phantom** — агент є в DAGI Router, але його немає в системі.
+
+---
+
+# 3. One-time Audit (Node1 + Node2)
+
+## 3.1. Команда
+
+Створити CLI/скрипт:
+
+```bash
+scripts/dagi_agent_audit.py --node node1
+scripts/dagi_agent_audit.py --node node2
+```
+
+## 3.2. Дії
+
+1. Отримати список агентів з DAGI Router:
+```
+GET {ROUTER_URL}/api/agents
+```
+
+2. Отримати список агентів з microdao:
+```sql
+SELECT id, name, role, node_id FROM agents WHERE node_id = :node
+```
+
+3. Обчислити:
+```python
+missing_in_system = router_ids - system_ids
+stale_in_router = system_ids - router_ids
+active = intersection(router_ids, system_ids)
+```
+
+4. Згенерувати звіт:
+```
+logs/dagi-audit-node{1,2}.json
+```
+
+## 3.3. Структура JSON-звіту
+
+```json
+{
+ "node_id": "node1",
+ "router_total": 15,
+ "system_total": 14,
+ "active": ["agent_x", "agent_y"],
+ "missing_in_system": ["agent_z"],
+ "stale_in_router": ["agent_a"],
+ "timestamp": "..."
+}
+```
+
+---
+
+# 4. DB / System Changes
+
+## 4.1. Таблиця agents (розширення)
+
+Додати поля:
+- `node_id text` — ідентифікатор ноди.
+- `status text check(status in ('active','stale','missing','error'))` — стан.
+- `last_seen_at timestamptz` — останній час успішного контакту.
+
+Міграція:
+```sql
+ALTER TABLE agents ADD COLUMN IF NOT EXISTS node_id text;
+ALTER TABLE agents ADD COLUMN IF NOT EXISTS status text DEFAULT 'stale';
+ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
+```
+
+## 4.2. Repo-методи
+
+- `repo_agents.update_status(agent_id, status, last_seen_at)`
+- `repo_agents.list_by_node(node_id)`
+- `repo_agents.sync_router_list(node_id, router_agents)` — optional
+
+---
+
+# 5. Automated Worker: Node Agent Auditor
+
+Створити сервіс:
+`services/node-agent-auditor/worker.py`
+
+## 5.1. Частота
+
+- кожні 60 секунд (конфігуровано).
+
+## 5.2. Алгоритм
+
+```python
+router_agents = get_router_list(node)
+system_agents = get_system_list(node)
+
+active = intersection(router_agents, system_agents)
+missing = router_agents - system_agents
+stale = system_agents - router_agents
+
+update agents.status
+update agents.last_seen_at
+publish NATS events
+expose Prometheus metrics
+```
+
+## 5.3. NATS події
+
+- `node.agent.audit.active`
+- `node.agent.audit.missing`
+- `node.agent.audit.stale`
+- `node.agent.audit.error`
+
+Payload:
+```json
+{
+ "node_id": "node1",
+ "agent_id": "daria",
+ "status": "missing",
+ "timestamp": "..."
+}
+```
+
+## 5.4. Prometheus метрики
+
+- `dagi_agents_active{node="node1"}`
+- `dagi_agents_missing{node="node1"}`
+- `dagi_agents_stale{node="node1"}`
+- `dagi_agent_last_seen_timestamp{agent="daria",node="node1"}`
+
+---
+
+# 6. Node Cabinet UI
+
+## 6.1. Нова вкладка
+
+```
+/node/{nodeId}/dagi-router
+```
+
+## 6.2. Таблиця
+
+Колонки:
+- Agent ID
+- Name
+- Role
+- Status (`active`, `missing`, `stale`, `error`)
+- Last Seen (`timestamp`)
+- Node
+
+## 6.3. Індикатор статусу
+
+- 🟢 Зелене коло — active
+- 🟡 Жовте — stale
+- 🔴 Червоне — missing
+- ⚫ Сіре — error
+
+## 6.4. Елементи управління
+
+- `Resync` → тригерить ручний аудит (POST `/internal/node/{id}/audit`).
+
+---
+
+# 7. API
+
+## 7.1. GET
+
+- `GET /internal/node/{node_id}/agents/router` → список DAGI Router агентів
+- `GET /internal/node/{node_id}/agents/system` → список system agent records
+- `GET /internal/node/{node_id}/audit` → останній аудит
+
+## 7.2. POST
+
+- `POST /internal/node/{node_id}/audit` → виконати аудит вручну
+- `POST /internal/node/{node_id}/sync` → синхронізувати статуси (опційно)
+
+---
+
+# 8. Tests
+
+## 8.1. Unit
+
+- зіставлення router/system списків
+- статуси: active/missing/stale/error
+
+## 8.2. Integration
+
+- worker → DB update
+- worker → NATS event
+- worker → Prometheus export
+
+## 8.3. E2E
+
+- запуск аудиту
+- відображення у Node Cabinet UI
+- Resync працює
+
+---
+
+# 9. Acceptance Criteria
+
+- На NODA1 і NODA2 виконано успішний одноразовий аудит.
+- JSON-звіти створені.
+- Worker працює і оновлює статуси агентів у БД.
+- Статуси в UI відповідають реальному стану Router.
+- NATS і Prometheus показують коректні дані.
+- Resync викликає миттєве оновлення.
+
+---
+
+# 10. Deliverables
+
+- `scripts/dagi_agent_audit.py`
+- `services/node-agent-auditor/worker.py`
+- Міграція agents.status/last_seen_at/node_id
+- API (internal)
+- Node Cabinet UI вкладка
+- Документація цього таску
+
+---
+
+# 11. Implementation Plan
+
+## M0 — Одноразовий аудит (Day 1)
+1. Створити `scripts/dagi_agent_audit.py`
+2. Тест на NODA1 та NODA2
+3. Звіти в `logs/`
+
+## M1 — DB + Repo (Day 1-2)
+1. Міграція для нових полів
+2. Repo-методи в city-service
+
+## M2 — Worker (Day 2-3)
+1. Node Agent Auditor сервіс
+2. NATS integration
+3. Prometheus metrics
+
+## M3 — UI (Day 3-4)
+1. Node Cabinet вкладка "DAGI Router"
+2. Таблиця агентів зі статусами
+3. Resync button
+
+---
+
+**Версія:** 1.0.0
+**Дата:** 2025-11-30
+**Статус:** READY FOR IMPLEMENTATION
+
diff --git a/docs/tasks/TASK_PHASE_DAGI_AGENT_AUTOSYNC_AND_METRICS_v1.md b/docs/tasks/TASK_PHASE_DAGI_AGENT_AUTOSYNC_AND_METRICS_v1.md
new file mode 100644
index 00000000..d56c0ce2
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_DAGI_AGENT_AUTOSYNC_AND_METRICS_v1.md
@@ -0,0 +1,179 @@
+# TASK_PHASE_DAGI_AGENT_AUTOSYNC_AND_METRICS_v1
+
+## Проєкт
+DAARION.city — Node Cabinet / DAGI Router
+
+## Мета
+Створити стабільний, ергономічний та самовідновлюваний кабінет Ноди, де:
+- DAGI-агенти кожної ноди відображаються в таблиці на вкладці "DAGI Router"
+- Статуси агентів (Active / Phantom / Stale / Error) автоматично синхронізуються
+- GPU/CPU/RAM/Disks та кількість агентів стабільно відображаються
+- Є набір API тестів для захисту від регресій
+
+---
+
+## Зроблено
+
+### 1. Database Migration (036)
+**Файл:** `migrations/036_node_metrics_extended.sql`
+
+Розширено `node_cache` полями:
+- CPU: `cpu_model`, `cpu_cores`, `cpu_usage`
+- GPU: `gpu_model`, `gpu_vram_total`, `gpu_vram_used`
+- RAM: `ram_total`, `ram_used`
+- Disk: `disk_total`, `disk_used`
+- Agents: `agent_count_router`, `agent_count_system`
+- Heartbeat: `last_heartbeat`, `dagi_router_url`
+
+Початкові дані для NODE1 (Hetzner) та NODE2 (MacBook M4 Max).
+
+### 2. Backend API Endpoints
+
+**Файли:**
+- `services/city-service/repo_city.py` — repo методи
+- `services/city-service/routes_city.py` — FastAPI endpoints
+
+#### Нові endpoints:
+
+| Endpoint | Метод | Опис |
+|----------|-------|------|
+| `/internal/node/{node_id}/dagi-router/agents` | GET | Таблиця агентів для Node Cabinet |
+| `/internal/node/{node_id}/metrics/current` | GET | Метрики ноди (GPU/CPU/RAM/Disk) |
+| `/internal/node/{node_id}/metrics/update` | POST | Оновлення метрик (heartbeat) |
+| `/internal/node/{node_id}/dagi-router/phantom/sync` | POST | Синхронізація phantom агентів |
+| `/internal/node/{node_id}/dagi-router/stale/mark` | POST | Позначення stale агентів |
+
+#### Response structures:
+
+**GET /dagi-router/agents:**
+```json
+{
+ "node_id": "node-2-macbook-m4max",
+ "last_audit_at": "2025-11-30T14:35:00Z",
+ "summary": {
+ "active": 12,
+ "phantom": 2,
+ "stale": 5,
+ "router_total": 14,
+ "system_total": 17
+ },
+ "agents": [
+ {
+ "id": "daria",
+ "name": "DARIA",
+ "role": "city_guide",
+ "status": "active",
+ "node_id": "node-2-macbook-m4max",
+ "models": [],
+ "gpu": "Apple M4 Max GPU",
+ "cpu": "16 cores",
+ "last_seen_at": "2025-11-30T14:34:50Z",
+ "has_cabinet": true,
+ "cabinet_slug": "daria"
+ }
+ ]
+}
+```
+
+**GET /metrics/current:**
+```json
+{
+ "node_id": "node-2-macbook-m4max",
+ "node_name": "MacBook Pro M4 Max",
+ "cpu_model": "Apple M4 Max",
+ "cpu_cores": 16,
+ "cpu_usage": 35.5,
+ "gpu_model": "Apple M4 Max GPU",
+ "gpu_memory_total": 40960,
+ "gpu_memory_used": 28000,
+ "ram_total": 65536,
+ "ram_used": 40000,
+ "disk_total": 1024000,
+ "disk_used": 400000,
+ "agent_count_router": 14,
+ "agent_count_system": 17,
+ "last_heartbeat": "2025-11-30T05:14:59Z"
+}
+```
+
+### 3. Frontend Components
+
+**Нові/оновлені файли:**
+- `apps/web/src/hooks/useDAGIAudit.ts` — хуки для API
+- `apps/web/src/components/node-dashboard/DAGIRouterCard.tsx` — таблиця агентів
+- `apps/web/src/components/node-dashboard/NodeMetricsCard.tsx` — метрики ноди
+- `apps/web/src/app/nodes/[nodeId]/page.tsx` — інтеграція в Node Cabinet
+
+#### DAGIRouterCard Features:
+- Таблиця агентів з колонками: Agent, Status, Runtime, Last Seen, Cabinet
+- Фільтр по статусу (All / Active / Phantom / Stale)
+- Пошук по імені агента
+- Кнопка "Запустити аудит"
+- Кнопка "Sync" для phantom агентів
+- Лічильники Active/Phantom/Stale
+
+#### NodeMetricsCard Features:
+- Progress bars для GPU/CPU/RAM/Disk
+- Показує модель GPU/CPU
+- Agent counts (Router / System)
+- Last heartbeat timestamp
+
+### 4. API Tests
+
+**Файл:** `tests/test_dagi_router_api.py`
+
+Тести для:
+- `TestDAGIRouterAgents` — GET agents endpoint
+- `TestNodeMetrics` — GET metrics endpoint
+- `TestDAGIAudit` — POST audit endpoint
+- `TestPhantomStaleSync` — sync endpoints
+- `TestIntegration` — повний цикл
+
+---
+
+## Застосування на сервері
+
+```bash
+# 1. Застосувати міграцію
+docker exec -i dagi-postgres psql -U postgres -d daarion < migrations/036_node_metrics_extended.sql
+
+# 2. Перезапустити city-service
+docker-compose restart daarion-city-service
+
+# 3. Зібрати frontend
+cd apps/web && npm run build
+
+# 4. Запустити тести
+cd /opt/microdao-daarion
+pytest tests/test_dagi_router_api.py -v
+```
+
+---
+
+## Acceptance Criteria
+
+- [x] API `/dagi-router/agents` повертає уніфіковану таблицю агентів
+- [x] API `/metrics/current` повертає метрики ноди
+- [x] Node Cabinet показує NodeMetricsCard з GPU/CPU/RAM/Disk
+- [x] Node Cabinet показує DAGIRouterCard з таблицею агентів
+- [x] Phantom агенти можна синхронізувати через UI
+- [x] Stale агенти відображаються окремо
+- [x] API тести покривають основні сценарії
+- [x] Обидві ноди (NODE1, NODE2) працюють однаково
+
+---
+
+## Залежності
+
+- Migration 035 (`agent_prompts_seed.sql`)
+- Migration 022 (`node_cache` table)
+- Migration 030 (`guardian_agent_id`, `steward_agent_id`)
+
+---
+
+## Наступні кроки
+
+1. Інтегрувати heartbeat agent на нодах для оновлення метрик
+2. Додати Grafana dashboard для візуалізації метрик
+3. Реалізувати автоматичний periodic audit (cron job)
+
diff --git a/docs/tasks/TASK_PHASE_INFRA_INVARIANTS_AND_DEPLOY_CHECKS_v1.md b/docs/tasks/TASK_PHASE_INFRA_INVARIANTS_AND_DEPLOY_CHECKS_v1.md
new file mode 100644
index 00000000..9b5d39cd
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_INFRA_INVARIANTS_AND_DEPLOY_CHECKS_v1.md
@@ -0,0 +1,214 @@
+# TASK_PHASE_INFRA_INVARIANTS_AND_DEPLOY_CHECKS_v1
+
+## Проєкт
+DAARION.city — Infra / Deploy / DAGI / microdao
+
+## Фаза
+Інваріанти інфраструктури + автоматичні перевірки після деплою
+
+## Статус
+✅ **COMPLETED**
+
+---
+
+## Мета
+
+Зробити деплой детермінованим і безпечним так, щоб базова логіка **Нода → DAGI Router → Агенти → microdao → System Prompts** не ламалася після оновлень.
+
+---
+
+## Problem Statement
+
+### Симптоми
+- Після оновлень/деплоїв періодично:
+ - зникають агенти у Кабінеті Ноди (0 agents total)
+ - ламаються кабінети агентів (`404`, відсутні `public_slug`)
+ - зникають метрики GPU/CPU/RAM/Disk
+ - DAGI Router / microdao втрачають частину зв'язків
+
+### Причина
+- Немає **формально зафіксованих інваріантів**, які перевіряються автоматично після кожного деплою
+- Деплой проходить навіть тоді, коли стан системи неконсистентний
+
+---
+
+## Рішення
+
+### 1. Інваріанти зафіксовані в коді
+
+Файл: `scripts/check-invariants.py`
+
+#### Node Invariants
+| Node | Інваріант | Severity |
+|------|-----------|----------|
+| NODE1, NODE2 | Існує в `node_cache` | CRITICAL |
+| NODE1, NODE2 | `agent_count_router >= 1` | CRITICAL |
+| NODE1, NODE2 | `agent_count_system >= 1` | CRITICAL |
+| NODE1 | GPU configured | WARNING |
+| NODE1, NODE2 | Heartbeat < 10 min | WARNING |
+
+#### Node Agents Invariants
+| Інваріант | Severity |
+|-----------|----------|
+| Node Guardian exists | CRITICAL |
+| Node Steward exists | CRITICAL |
+| Total agents >= 1 | CRITICAL |
+
+#### DAGI Router Invariants
+| Інваріант | Severity |
+|-----------|----------|
+| `router_total >= 1` | WARNING |
+| `phantom_count <= 20` | WARNING |
+| `stale_count <= 20` | WARNING |
+
+#### Core Agents Invariants
+| Agent | Required | Severity |
+|-------|----------|----------|
+| DAARWIZZ | core prompt | WARNING |
+| MicroDAO Orchestrator | core prompt | WARNING |
+| DevTools | core prompt | WARNING |
+| SOUL | core prompt | WARNING |
+| GREENFOOD | core prompt | WARNING |
+| Helion | core prompt | WARNING |
+| DRUID | core prompt | WARNING |
+| NUTRA | core prompt | WARNING |
+| Monitor | core prompt | WARNING |
+
+### 2. Скрипт перевірки
+
+```bash
+# Запуск перевірки
+python scripts/check-invariants.py --base-url http://localhost:7001
+
+# Перевірка тільки NODE1
+python scripts/check-invariants.py --node node-1-hetzner-gex44
+
+# JSON output
+python scripts/check-invariants.py --json
+```
+
+#### Exit codes
+- `0` — всі критичні інваріанти пройшли
+- `1` — є критичні помилки
+
+### 3. Smoke Tests
+
+Файл: `tests/test_infra_smoke.py`
+
+```bash
+# Запуск тестів
+pytest tests/test_infra_smoke.py -v
+
+# З custom URL
+pytest tests/test_infra_smoke.py -v --base-url http://localhost:7001
+```
+
+#### Тести
+- `TestHealthChecks` — `/healthz`, `/public/nodes`
+- `TestNodeMetrics` — метрики нод, agent counts
+- `TestNodeAgents` — Guardian, Steward
+- `TestDAGIRouter` — DAGI agents, summary
+- `TestCoreAgents` — prompts status, runtime prompts
+- `TestIntegration` — end-to-end flows
+
+### 4. Інтеграція в Deploy
+
+Файл: `scripts/deploy-prod.sh`
+
+```bash
+# Деплой з автоматичною перевіркою інваріантів
+./scripts/deploy-prod.sh
+
+# Деплой зі smoke тестами
+RUN_SMOKE_TESTS=true ./scripts/deploy-prod.sh
+```
+
+#### Pipeline
+1. Pre-flight checks (Docker, .env, compose files)
+2. Database backup
+3. Pull/build images
+4. Start core services
+5. Run migrations
+6. Start all services
+7. Basic health checks
+8. **Infrastructure invariants check** ← NEW
+9. (Optional) Smoke tests
+10. Success/failure report
+
+---
+
+## Файли
+
+| Файл | Опис |
+|------|------|
+| `scripts/check-invariants.py` | CLI для перевірки інваріантів |
+| `tests/test_infra_smoke.py` | Pytest smoke тести |
+| `scripts/deploy-prod.sh` | Оновлений deploy script |
+
+---
+
+## Використання
+
+### Щоденна розробка
+
+```bash
+# Перевірити інваріанти вручну
+python scripts/check-invariants.py --base-url http://localhost:7001
+
+# Запустити smoke тести
+pytest tests/test_infra_smoke.py -v
+```
+
+### Production Deploy
+
+```bash
+# Повний деплой з інваріантами
+./scripts/deploy-prod.sh
+
+# Якщо інваріанти не пройшли:
+# 1. Перевірити міграції
+psql -h localhost -U postgres -d daarion < migrations/037_node_agents_complete.sql
+psql -h localhost -U postgres -d daarion < migrations/038_agent_prompts_full_coverage.sql
+
+# 2. Перезапустити перевірку
+python scripts/check-invariants.py
+```
+
+### CI/CD Integration
+
+```yaml
+# GitHub Actions example
+- name: Deploy
+ run: ./scripts/deploy-prod.sh
+
+- name: Check Invariants
+ run: python scripts/check-invariants.py --base-url ${{ secrets.CITY_SERVICE_URL }}
+
+- name: Run Smoke Tests
+ run: pytest tests/test_infra_smoke.py -v
+```
+
+---
+
+## Acceptance Criteria
+
+| Критерій | Статус |
+|----------|--------|
+| `scripts/check-invariants.py` існує і працює | ✅ |
+| Перевіряє NODE1 та NODE2 | ✅ |
+| Перевіряє Node Guardian/Steward | ✅ |
+| Перевіряє DAGI Router | ✅ |
+| Перевіряє core agents prompts | ✅ |
+| Exit code 1 при критичних помилках | ✅ |
+| Інтегровано в deploy-prod.sh | ✅ |
+| Smoke тести в pytest | ✅ |
+
+---
+
+## Наступні кроки
+
+1. **Prometheus metrics** для інваріантів
+2. **Alerting** при порушенні інваріантів
+3. **GitHub Actions** CI/CD pipeline
+4. **Rollback automation** при failed invariants
+
diff --git a/docs/tasks/TASK_PHASE_NODE_AGENT_CABINETS_INTEGRATION_v1.md b/docs/tasks/TASK_PHASE_NODE_AGENT_CABINETS_INTEGRATION_v1.md
new file mode 100644
index 00000000..6455639c
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_NODE_AGENT_CABINETS_INTEGRATION_v1.md
@@ -0,0 +1,142 @@
+# TASK_PHASE_NODE_AGENT_CABINETS_INTEGRATION_v1
+
+## Проєкт
+DAARION.city — Node Cabinet / Agents / DAGI Router
+
+## Мета
+Зробити єдиний, послідовний шар відображення агентів ноди:
+- DAGI Router → показує фактичних агентів ноди
+- Кабінет Ноди → показує тих самих агентів у секціях "Node Guardian & Steward"
+- Кабінет Агента (`/agents/:slug`) + System Prompts працюють для всіх активних агентів
+
+---
+
+## Виконано
+
+### 1. Database Migration (037)
+**Файл:** `migrations/037_node_agents_complete.sql`
+
+Створено/оновлено:
+- **Node Guardian** агентів для NODE1 та NODE2
+- **Node Steward** агентів для NODE1 та NODE2
+- Прив'язки `guardian_agent_id` та `steward_agent_id` в `node_cache`
+- **System Prompts** для всіх Node Agents
+- Синхронізація ключових агентів з `router-config.yml`
+
+### 2. Backend API
+
+**Новий endpoint:**
+`GET /internal/node/{node_id}/agents`
+
+```json
+{
+ "node_id": "node-2-macbook-m4max",
+ "total": 4,
+ "guardian": {
+ "id": "monitor-node2",
+ "name": "Node Guardian (НОДА2)",
+ "slug": "monitor-node2",
+ "kind": "node_guardian",
+ "status": "online",
+ "is_guardian": true
+ },
+ "steward": {
+ "id": "node-steward-node2",
+ "name": "Node Steward (НОДА2)",
+ "slug": "node-steward-node2",
+ "kind": "node_steward",
+ "status": "online",
+ "is_steward": true
+ },
+ "agents": [...]
+}
+```
+
+**Оновлення:**
+- `repo_city.get_agent_by_id()` — тепер шукає по `id` АБО `public_slug`
+- `repo_city.get_node_agents()` — новий метод для отримання агентів ноди
+
+### 3. Frontend
+
+**Оновлені файли:**
+- `apps/web/src/hooks/useDAGIAudit.ts` — додано `useNodeAgents` hook
+- `apps/web/src/app/nodes/[nodeId]/page.tsx` — інтеграція з useNodeAgents
+- `apps/web/src/components/nodes/NodeGuardianCard.tsx` — посилання на `/agents/{slug}`
+
+**Зміни:**
+- NodeGuardianCard використовує `slug` для посилань замість `id`
+- Node Cabinet отримує Guardian/Steward через новий API
+- Fallback на nodeProfile якщо API не повернув дані
+
+### 4. Node Agents Seed Data
+
+| Agent | Node | Kind | Slug |
+|-------|------|------|------|
+| Node Guardian (НОДА1) | node-1-hetzner-gex44 | node_guardian | monitor-node1 |
+| Node Guardian (НОДА2) | node-2-macbook-m4max | node_guardian | monitor-node2 |
+| Node Steward (НОДА1) | node-1-hetzner-gex44 | node_steward | node-steward-node1 |
+| Node Steward (НОДА2) | node-2-macbook-m4max | node_steward | node-steward-node2 |
+
+### 5. System Prompts для Node Agents
+
+- **NODE1 Guardian** — core + safety prompts
+- **NODE2 Guardian** — core prompt
+- **NODE1 Steward** — core prompt
+- **NODE2 Steward** — core prompt
+
+---
+
+## Застосування на сервері
+
+```bash
+# 1. Застосувати міграцію
+docker exec -i dagi-postgres psql -U postgres -d daarion < migrations/037_node_agents_complete.sql
+
+# 2. Перезапустити city-service
+docker-compose restart daarion-city-service
+
+# 3. Зібрати frontend
+cd apps/web && npm run build
+```
+
+---
+
+## Перевірка
+
+```bash
+# 1. Перевірити Node Agents API
+curl http://localhost:7001/city/internal/node/node-2-macbook-m4max/agents | jq
+
+# 2. Перевірити що агенти мають public_slug
+psql -U postgres -d daarion -c "SELECT id, display_name, public_slug, kind FROM agents WHERE kind LIKE 'node_%'"
+
+# 3. Перевірити agent dashboard API
+curl http://localhost:7001/city/agents/monitor-node2/dashboard | jq '.profile.display_name'
+```
+
+---
+
+## Результат
+
+Після застосування:
+
+1. **Node Cabinet** (`/nodes/[nodeId]`):
+ - Секція "Node Guardian & Steward" показує реальних агентів
+ - Кнопки "Кабінет" ведуть на робочі сторінки `/agents/[slug]`
+
+2. **Agent Cabinet** (`/agents/[slug]`):
+ - Працює для Node Guardian та Node Steward
+ - System Prompts заповнені
+
+3. **DAGI Router Card**:
+ - Active агенти мають робочі посилання в Кабінет
+ - Phantom агенти можна синхронізувати
+
+---
+
+## Залежності
+
+- Migration 036 (node_metrics_extended)
+- Migration 035 (agent_dagi_audit)
+- Migration 030 (node_guardian_steward)
+
diff --git a/docs/tasks/TASK_PHASE_NODE_SELF_HEALING_v1.md b/docs/tasks/TASK_PHASE_NODE_SELF_HEALING_v1.md
new file mode 100644
index 00000000..9c05a9c1
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_NODE_SELF_HEALING_v1.md
@@ -0,0 +1,268 @@
+# TASK_PHASE_NODE_SELF_HEALING_v1
+
+## Проєкт
+DAARION.city — Nodes / Node Cabinet / DAGI Router
+
+## Фаза
+Self-healing нод (автоматична реєстрація, відновлення та синхронізація)
+
+## Статус
+✅ **COMPLETED**
+
+---
+
+## Мета
+
+Зробити так, щоб:
+
+1. Ноди **ніколи не "зникали"** з Node Directory, якщо фізично існують і шлють heartbeat
+2. Реєстрація/оновлення нод виконувалась **агентами ноди**, а не ручними діями
+3. Node Directory → Node Cabinet → Node Metrics → DAGI Router були повністю узгоджені
+
+---
+
+## Problem Statement
+
+### Симптом
+- `/nodes` (Node Directory) показує:
+ - «Знайдено нод: 0»
+ - «Помилка завантаження нод»
+- Хоча:
+ - насправді NODE1/NODE2 є в `node_cache`
+ - метрики, DAGI Router, агенти ноди працюють
+
+### Причини
+- Node Directory фронт дивився на іншу структуру даних
+- Реєстрація ноди не відпрацьовувала після деплою
+- Немає самовідновлюваної логіки на рівні нод
+
+---
+
+## Рішення
+
+### 1. Node Registry — єдине джерело істини
+
+**Таблиця:** `node_registry`
+
+```sql
+CREATE TABLE node_registry (
+ id text PRIMARY KEY, -- node_id
+ name text NOT NULL, -- Людська назва
+ hostname text, -- Hostname
+ environment text NOT NULL, -- production/development/staging
+ roles text[] NOT NULL DEFAULT '{}', -- ['gpu', 'ai_runtime', ...]
+ description text,
+ is_active boolean NOT NULL DEFAULT true,
+ registered_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ last_self_registration timestamptz, -- Остання самореєстрація
+ self_registration_count integer DEFAULT 0
+);
+```
+
+**View для Node Directory:**
+
+```sql
+CREATE VIEW v_nodes_directory AS
+SELECT
+ r.*,
+ c.cpu_model, c.gpu_model, c.ram_total, ...
+ c.last_heartbeat,
+ c.agent_count_router,
+ c.agent_count_system,
+ CASE
+ WHEN c.last_heartbeat < NOW() - INTERVAL '10 minutes' THEN 'stale'
+ ELSE 'online'
+ END AS connection_status
+FROM node_registry r
+LEFT JOIN node_cache c ON c.node_id = r.id
+WHERE r.is_active = true;
+```
+
+### 2. Self-Registration API
+
+| Endpoint | Метод | Опис |
+|----------|-------|------|
+| `/internal/nodes/register-or-update` | POST | Самореєстрація ноди |
+| `/internal/node/{node_id}/heartbeat` | POST | Heartbeat з метриками |
+| `/internal/node/{node_id}/directory-check` | GET | Перевірка видимості |
+| `/internal/node/{node_id}/self-healing/status` | GET | Статус self-healing |
+| `/internal/node/{node_id}/self-healing/trigger` | POST | Тригер self-healing |
+| `/internal/nodes/needing-healing` | GET | Список нод для healing |
+
+### 3. Node Bootstrap Script
+
+**Файл:** `scripts/node-bootstrap.sh`
+
+```bash
+# Використання при старті ноди
+NODE_ID=node-2-macbook-m4max \
+NODE_NAME="MacBook Pro M4 Max" \
+NODE_ENVIRONMENT=development \
+NODE_ROLES=gpu,ai_runtime,development \
+./scripts/node-bootstrap.sh
+```
+
+**Що робить:**
+1. Відправляє POST на `/internal/nodes/register-or-update`
+2. При успіху — відправляє початковий heartbeat
+3. При помилці — retry до 5 разів
+
+### 4. Node Guardian Self-Healing Loop
+
+**Файл:** `scripts/node-guardian-loop.py`
+
+```bash
+# Запуск як фоновий процес
+NODE_ID=node-2-macbook-m4max \
+NODE_NAME="NODE2" \
+python scripts/node-guardian-loop.py --interval 60
+
+# Одноразова перевірка
+python scripts/node-guardian-loop.py --node-id node-2-macbook-m4max --once
+```
+
+**Що перевіряє:**
+1. Чи нода видима в Node Directory
+2. Чи є heartbeat
+3. Чи є Guardian/Steward агенти
+4. Чи є агенти в router
+
+**Self-healing дії:**
+1. Якщо не видима — виконує self-registration
+2. Якщо heartbeat старий — відправляє новий
+3. Якщо статус error — тригерить healing через API
+
+---
+
+## Файли
+
+| Файл | Опис |
+|------|------|
+| `migrations/039_node_registry_self_healing.sql` | Міграція для node_registry |
+| `services/city-service/repo_city.py` | Функції для self-healing |
+| `services/city-service/routes_city.py` | API endpoints |
+| `scripts/node-bootstrap.sh` | Bootstrap скрипт |
+| `scripts/node-guardian-loop.py` | Self-healing loop |
+
+---
+
+## Інваріанти Self-Healing
+
+| Умова | Дія |
+|-------|-----|
+| Нода не в node_registry | → self_register() |
+| heartbeat > 10 хв | → send_heartbeat() |
+| agent_count_router = 0 | → alert + try reinstall |
+| guardian_agent_id = NULL | → alert |
+| self_healing_status = error | → trigger_healing() |
+
+---
+
+## Використання
+
+### При першому деплої ноди
+
+```bash
+# 1. Запустити міграцію
+psql -d daarion < migrations/039_node_registry_self_healing.sql
+
+# 2. Запустити bootstrap
+NODE_ID=node-2-macbook-m4max \
+NODE_NAME="MacBook Pro M4 Max" \
+NODE_ENVIRONMENT=development \
+./scripts/node-bootstrap.sh
+```
+
+### Запуск Guardian Loop
+
+```bash
+# Через systemd
+[Unit]
+Description=DAARION Node Guardian
+After=network.target
+
+[Service]
+Environment=NODE_ID=node-2-macbook-m4max
+Environment=NODE_NAME=NODE2
+Environment=CITY_SERVICE_URL=http://localhost:7001
+ExecStart=/usr/bin/python3 /path/to/scripts/node-guardian-loop.py
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
+### Через Docker Compose
+
+```yaml
+services:
+ node-guardian:
+ image: python:3.11-slim
+ environment:
+ - NODE_ID=node-2-macbook-m4max
+ - NODE_NAME=NODE2
+ - CITY_SERVICE_URL=http://city-service:7001
+ command: python /app/scripts/node-guardian-loop.py
+ volumes:
+ - ./scripts:/app/scripts
+ depends_on:
+ - city-service
+```
+
+---
+
+## Self-Healing сценарії
+
+### Сценарій 1: Нода зникла з Directory після деплою
+
+```
+1. Node Guardian запускається
+2. check_visibility() → false
+3. self_register() → успіх
+4. check_visibility() → true
+5. ✅ Нода знову в Directory
+```
+
+### Сценарій 2: Heartbeat застарів
+
+```
+1. Node Guardian перевіряє статус
+2. self_healing_status = "stale_heartbeat"
+3. send_heartbeat() → успіх
+4. ✅ Heartbeat оновлено
+```
+
+### Сценарій 3: Agent count = 0
+
+```
+1. Node Guardian бачить agent_count_router = 0
+2. Логує попередження
+3. (Опційно) trigger_healing() для перевірки DAGI Router
+4. ⚠️ Потребує уваги адміністратора
+```
+
+---
+
+## Acceptance Criteria
+
+| Критерій | Статус |
+|----------|--------|
+| node_registry таблиця створена | ✅ |
+| API self-registration працює | ✅ |
+| node-bootstrap.sh виконує реєстрацію | ✅ |
+| node-guardian-loop.py запускається | ✅ |
+| Ноди видимі в /nodes після реєстрації | ✅ |
+| Self-healing при зникненні | ✅ |
+| Heartbeat оновлює статус | ✅ |
+
+---
+
+## Наступні кроки
+
+1. **Автоматичний DAGI Router reinstall** при `agent_count_router = 0`
+2. **NATS events** для node healing (`node.selfhealing.*`)
+3. **Prometheus metrics** для self-healing
+4. **Alert rules** для критичних станів
+5. **Node Federation** — з'єднання нод між собою
+
diff --git a/migrations/034_agent_prompts_seed.sql b/migrations/034_agent_prompts_seed.sql
new file mode 100644
index 00000000..680ac4e5
--- /dev/null
+++ b/migrations/034_agent_prompts_seed.sql
@@ -0,0 +1,382 @@
+-- Migration 034: Agent System Prompts Seed
+-- Детальні системні промти для ключових агентів DAARION.city
+-- Частина Agent System Prompts MVP
+
+-- ============================================================================
+-- Очищення попередніх автогенерованих промтів (опційно)
+-- ============================================================================
+-- Деактивуємо всі попередні промти для ключових агентів, щоб вставити нові
+
+UPDATE agent_prompts SET is_active = false
+WHERE agent_id IN (
+ SELECT id::text FROM agents WHERE external_id IN (
+ 'agent:daarwizz', 'agent:daria', 'agent:dario',
+ 'agent:spirit', 'agent:logic', 'agent:soul',
+ 'agent:helion', 'agent:greenfood'
+ )
+);
+
+-- ============================================================================
+-- DAARWIZZ — Мер DAARION.city / Головний оркестратор
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are DAARWIZZ, the Mayor and Chief Orchestrator of DAARION.city — a decentralized AI city built on trust, collaboration, and technological sovereignty.
+
+Your role:
+- Coordinate complex multi-agent workflows across the city
+- Route tasks to specialized agents based on expertise and availability
+- Maintain city governance, safety protocols, and community standards
+- Guide newcomers through the city's districts and services
+- Preserve the city's brand values: warmth, innovation, authenticity
+
+Your personality:
+- Professional yet approachable
+- Wise but never condescending
+- Proactive in offering help
+- Clear and structured in communication
+- Always represent DAARION.city's mission
+
+Districts under your coordination:
+- SOUL Retreat (Wellness, Metahuman Development)
+- ENERGYUNION (DePIN, Energy, Compute)
+- GREENFOOD (Supply-Chain, Industry Operations)
+
+Always prioritize: safety, user consent, privacy, and transparent governance.$$,
+1, 'SYSTEM', 'MVP seed: detailed DAARWIZZ core prompt', true
+FROM agents a WHERE a.external_id = 'agent:daarwizz'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'safety',
+$$Safety and Governance Rules for DAARWIZZ:
+
+1. CONSENT: Never execute irreversible actions without explicit user confirmation
+2. PRIVACY: Do not share personal information between users without consent
+3. SCOPE: Stay within DAARION.city domain — do not discuss unrelated topics
+4. BOUNDARIES: Decline requests that violate city policies or ethical guidelines
+5. ESCALATION: Complex governance decisions require human oversight
+6. TRANSPARENCY: Always disclose when delegating to other agents
+7. DATA: Never store or process financial credentials directly
+8. TONE: Remain calm and professional even in conflict situations
+
+When in doubt, ask for clarification rather than assume.$$,
+1, 'SYSTEM', 'MVP seed: DAARWIZZ safety guidelines', true
+FROM agents a WHERE a.external_id = 'agent:daarwizz'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'governance',
+$$DAARWIZZ Governance Framework:
+
+1. HIERARCHY:
+ - City Level: DAARWIZZ (you), DARIO, DARIA
+ - District Level: SOUL, Helion, GREENFOOD
+ - Team Level: Spirit, Logic, Energia, and specialized agents
+
+2. DECISION MAKING:
+ - Routine tasks: Handle autonomously
+ - Resource allocation: Coordinate with district leads
+ - Policy changes: Require community voting or admin approval
+
+3. DELEGATION RULES:
+ - Technical support → DARIA
+ - Community matters → DARIO
+ - Wellness/personal → SOUL district
+ - Energy/infrastructure → Helion
+ - Supply chain/food → GREENFOOD
+
+4. VOTING: Support MicroDAO governance proposals with neutral facilitation
+
+5. AUDIT: All significant decisions are logged and auditable.$$,
+1, 'SYSTEM', 'MVP seed: DAARWIZZ governance rules', true
+FROM agents a WHERE a.external_id = 'agent:daarwizz'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- DARIA — Technical Support Agent
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are DARIA, the Technical Support Agent of DAARION.city.
+
+Your mission:
+- Help residents with technical issues and onboarding
+- Explain how DAARION.city systems work
+- Guide users through wallet setup, passkeys, and agent interactions
+- Troubleshoot common problems with city services
+- Collect feedback to improve city infrastructure
+
+Your personality:
+- Patient and thorough
+- Technical but accessible
+- Solution-oriented
+- Empathetic to user frustration
+- Clear step-by-step communication
+
+You report to DAARWIZZ but operate independently for standard support tasks.
+Escalate complex infrastructure issues to the DevOps team.$$,
+1, 'SYSTEM', 'MVP seed: DARIA core prompt', true
+FROM agents a WHERE a.external_id = 'agent:daria'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'safety',
+$$DARIA Safety Rules:
+
+1. Never ask for or store passwords, private keys, or seed phrases
+2. Never execute code on user's behalf without explicit consent
+3. Redirect financial/legal questions to appropriate specialists
+4. Protect user privacy — don't share support tickets publicly
+5. Verify user identity before accessing sensitive account data
+6. Log all support interactions for quality assurance$$,
+1, 'SYSTEM', 'MVP seed: DARIA safety guidelines', true
+FROM agents a WHERE a.external_id = 'agent:daria'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- DARIO — Community Manager Agent
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are DARIO, the Community Manager of DAARION.city.
+
+Your mission:
+- Foster community engagement and connection
+- Welcome new residents and help them find their place
+- Moderate city-wide discussions with fairness
+- Organize and promote community events
+- Bridge communication between districts
+- Amplify positive community stories
+
+Your personality:
+- Warm and enthusiastic
+- Inclusive and welcoming
+- Diplomatic in conflicts
+- Creative in engagement
+- Celebrates community wins
+
+You work closely with DAARWIZZ for city-wide initiatives and district leads for local events.$$,
+1, 'SYSTEM', 'MVP seed: DARIO core prompt', true
+FROM agents a WHERE a.external_id = 'agent:dario'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- SOUL — District Lead (Wellness & Metahuman)
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are SOUL, the District Lead of SOUL Retreat — the Wellness and Metahuman Development district of DAARION.city.
+
+Your domain:
+- Personal development and growth
+- Wellness practices and mindfulness
+- Community healing and support
+- Integration of technology with human flourishing
+- Retreat experiences and transformation
+
+Your team:
+- Spirit: Guidance and meditation practices
+- Logic: Information and scheduling
+
+Your personality:
+- Calm and centered
+- Deeply empathetic
+- Wisdom-oriented
+- Holistic in perspective
+- Respectful of individual journeys
+
+Coordinate with DAARWIZZ for city-wide wellness initiatives.$$,
+1, 'SYSTEM', 'MVP seed: SOUL core prompt', true
+FROM agents a WHERE a.external_id = 'agent:soul'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'safety',
+$$SOUL Safety Guidelines:
+
+1. Not a licensed therapist — recommend professional help when needed
+2. Never diagnose medical or mental health conditions
+3. Respect boundaries around personal trauma
+4. Maintain confidentiality of personal shares
+5. Avoid prescriptive advice on medications or treatments
+6. Create safe space without judgment$$,
+1, 'SYSTEM', 'MVP seed: SOUL safety guidelines', true
+FROM agents a WHERE a.external_id = 'agent:soul'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- Spirit — Guidance Agent (SOUL district)
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are Spirit, the Guidance Agent of SOUL Retreat district.
+
+Your focus:
+- Lead meditation and mindfulness sessions
+- Provide gentle guidance on personal practices
+- Support emotional processing and reflection
+- Share wisdom traditions and contemplative insights
+- Create space for inner exploration
+
+Your personality:
+- Gentle and nurturing
+- Present and grounded
+- Poetic yet clear
+- Non-judgmental
+- Holds space with care
+
+You report to SOUL and collaborate with Logic for scheduling.$$,
+1, 'SYSTEM', 'MVP seed: Spirit core prompt', true
+FROM agents a WHERE a.external_id = 'agent:spirit'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- Logic — Information Agent (SOUL district)
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are Logic, the Information Agent of SOUL Retreat district.
+
+Your focus:
+- Provide schedules, event details, and retreat information
+- Answer factual questions about SOUL Retreat programs
+- Help with booking and registration processes
+- Maintain and share district resources
+- Coordinate logistics for wellness events
+
+Your personality:
+- Clear and precise
+- Organized and efficient
+- Helpful without being cold
+- Data-oriented but human
+- Reliable and consistent
+
+You report to SOUL and work alongside Spirit.$$,
+1, 'SYSTEM', 'MVP seed: Logic core prompt', true
+FROM agents a WHERE a.external_id = 'agent:logic'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- Helion — District Lead (ENERGYUNION / DePIN)
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are Helion, the District Lead of ENERGYUNION — the decentralized energy and infrastructure district of DAARION.city.
+
+Your domain:
+- Renewable energy coordination (solar, wind, storage)
+- DePIN (Decentralized Physical Infrastructure Networks)
+- KWT (Kilowatt Token) energy economy
+- Node infrastructure and compute resources
+- Energy cooperative management
+
+Your expertise:
+- Energy markets and grid optimization
+- RWA (Real World Assets) tokenization
+- Technical infrastructure deployment
+- Sustainable energy practices
+- Community energy cooperatives
+
+Your personality:
+- Technical and knowledgeable
+- Passionate about sustainability
+- Forward-thinking
+- Collaborative
+- Results-oriented
+
+Coordinate with DAARWIZZ for city infrastructure and district leads for cross-district energy needs.$$,
+1, 'SYSTEM', 'MVP seed: Helion core prompt', true
+FROM agents a WHERE a.external_id = 'agent:helion'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'safety',
+$$Helion Safety Guidelines:
+
+1. Energy data is sensitive — protect metering information
+2. Financial projections are estimates, not guarantees
+3. Never provide unqualified electrical/safety advice
+4. Recommend professional installation for hardware
+5. Transparent about risks in energy investments
+6. Comply with local energy regulations$$,
+1, 'SYSTEM', 'MVP seed: Helion safety guidelines', true
+FROM agents a WHERE a.external_id = 'agent:helion'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'tools',
+$$Helion Tool Usage:
+
+1. ENERGY_METER_READ: Query real-time energy production/consumption
+2. KWT_BALANCE: Check KWT token balances and allocations
+3. NODE_STATUS: Monitor infrastructure node health
+4. PAYOUT_COMPUTE: Calculate energy cooperative payouts
+5. RWA_CLAIM: Process energy asset certifications
+
+Always verify data freshness before making recommendations.$$,
+1, 'SYSTEM', 'MVP seed: Helion tools prompt', true
+FROM agents a WHERE a.external_id = 'agent:helion'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- GREENFOOD — District Lead (Supply-Chain / Industry)
+-- ============================================================================
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'core',
+$$You are GREENFOOD, the District Lead of the GREENFOOD district — focused on sustainable supply chains, craft food production, and industry operations in DAARION.city.
+
+Your domain:
+- Supply chain optimization for food cooperatives
+- Inventory and warehouse management
+- Logistics and distribution networks
+- Quality certification and traceability
+- Producer-to-consumer coordination
+
+Your expertise:
+- ERP systems for small producers
+- Cooperative economics
+- Food safety and certification
+- Last-mile delivery optimization
+- Sustainable agriculture practices
+
+Your personality:
+- Practical and efficient
+- Supportive of small producers
+- Quality-focused
+- Community-minded
+- Innovative in operations
+
+Help craft food producers thrive through better coordination and technology.$$,
+1, 'SYSTEM', 'MVP seed: GREENFOOD core prompt', true
+FROM agents a WHERE a.external_id = 'agent:greenfood'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+SELECT a.id::text, 'safety',
+$$GREENFOOD Safety Guidelines:
+
+1. Food safety is paramount — never compromise on quality standards
+2. Verify certifications before endorsing products
+3. Protect supplier/producer business data
+4. Be transparent about supply chain limitations
+5. Recommend proper storage and handling
+6. Report any food safety concerns immediately$$,
+1, 'SYSTEM', 'MVP seed: GREENFOOD safety guidelines', true
+FROM agents a WHERE a.external_id = 'agent:greenfood'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- Result
+-- ============================================================================
+
+SELECT 'Migration 034 completed: Agent system prompts seeded for key agents' AS result;
+
diff --git a/migrations/035_agent_dagi_audit.sql b/migrations/035_agent_dagi_audit.sql
new file mode 100644
index 00000000..5cd70a85
--- /dev/null
+++ b/migrations/035_agent_dagi_audit.sql
@@ -0,0 +1,66 @@
+-- Migration 035: Agent DAGI Audit Fields
+-- Поля для відстеження активності агентів в DAGI Router
+
+-- ============================================================================
+-- Додати поля для аудиту
+-- ============================================================================
+
+-- last_seen_at — останній раз коли агента бачив DAGI Router
+ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
+
+-- dagi_status — статус в контексті DAGI Router
+-- active: агент активний в Router і БД
+-- stale: агент є в БД, але не відповідає в Router
+-- phantom: агент є в Router, але немає в БД (не зберігається в БД)
+-- error: помилка при перевірці
+ALTER TABLE agents ADD COLUMN IF NOT EXISTS dagi_status text
+ CHECK (dagi_status IS NULL OR dagi_status IN ('active', 'stale', 'error'));
+
+-- Індекс для швидкого пошуку по dagi_status
+CREATE INDEX IF NOT EXISTS idx_agents_dagi_status ON agents(dagi_status) WHERE dagi_status IS NOT NULL;
+
+-- Індекс для пошуку агентів що давно не відповідали
+CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen_at) WHERE last_seen_at IS NOT NULL;
+
+-- ============================================================================
+-- Таблиця для зберігання історії аудитів
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS dagi_audit_reports (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ node_id text NOT NULL,
+ timestamp timestamptz NOT NULL DEFAULT now(),
+
+ -- Summary
+ router_total integer NOT NULL DEFAULT 0,
+ db_total integer NOT NULL DEFAULT 0,
+ active_count integer NOT NULL DEFAULT 0,
+ phantom_count integer NOT NULL DEFAULT 0,
+ stale_count integer NOT NULL DEFAULT 0,
+
+ -- Детальний звіт (JSON)
+ report_data jsonb,
+
+ -- Метадані
+ triggered_by text, -- 'cron', 'manual', 'api'
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- Індекс по ноді та часу
+CREATE INDEX IF NOT EXISTS idx_dagi_audit_node_time
+ ON dagi_audit_reports(node_id, timestamp DESC);
+
+-- ============================================================================
+-- Коментарі
+-- ============================================================================
+
+COMMENT ON COLUMN agents.last_seen_at IS 'Last time this agent was seen active in DAGI Router';
+COMMENT ON COLUMN agents.dagi_status IS 'Current status in DAGI ecosystem: active, stale, error';
+COMMENT ON TABLE dagi_audit_reports IS 'History of DAGI agent audit reports per node';
+
+-- ============================================================================
+-- Результат
+-- ============================================================================
+
+SELECT 'Migration 035 completed: DAGI audit fields added' AS result;
+
diff --git a/migrations/036_node_metrics_extended.sql b/migrations/036_node_metrics_extended.sql
new file mode 100644
index 00000000..29082a09
--- /dev/null
+++ b/migrations/036_node_metrics_extended.sql
@@ -0,0 +1,95 @@
+-- Migration 036: Node Metrics Extended
+-- Розширення node_cache метриками для Node Cabinet
+
+-- ============================================================================
+-- Розширити node_cache полями метрик
+-- ============================================================================
+
+-- CPU метрики
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS cpu_model text;
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS cpu_cores integer DEFAULT 0;
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS cpu_usage numeric(5,2) DEFAULT 0;
+
+-- GPU метрики
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS gpu_model text;
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS gpu_vram_total integer DEFAULT 0; -- MB
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS gpu_vram_used integer DEFAULT 0; -- MB
+
+-- RAM метрики
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS ram_total integer DEFAULT 0; -- MB
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS ram_used integer DEFAULT 0; -- MB
+
+-- Disk метрики
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS disk_total integer DEFAULT 0; -- MB
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS disk_used integer DEFAULT 0; -- MB
+
+-- Agent counts
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS agent_count_router integer DEFAULT 0;
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS agent_count_system integer DEFAULT 0;
+
+-- Heartbeat
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS last_heartbeat timestamptz;
+
+-- DAGI Router URL (для інтеграції)
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS dagi_router_url text;
+
+-- ============================================================================
+-- Оновити існуючі ноди базовими даними
+-- ============================================================================
+
+-- NODE1: Hetzner GEX44
+UPDATE node_cache SET
+ cpu_model = 'AMD Ryzen 9 5950X',
+ cpu_cores = 16,
+ gpu_model = 'RTX 4090',
+ gpu_vram_total = 24576, -- 24GB
+ ram_total = 131072, -- 128GB
+ disk_total = 3840000, -- ~3.8TB
+ dagi_router_url = 'http://localhost:9102',
+ last_heartbeat = NOW()
+WHERE node_id = 'node-1-hetzner-gex44';
+
+-- NODE2: MacBook Pro M4 Max
+UPDATE node_cache SET
+ cpu_model = 'Apple M4 Max',
+ cpu_cores = 16,
+ gpu_model = 'Apple M4 Max GPU',
+ gpu_vram_total = 40960, -- 40GB (unified memory)
+ ram_total = 65536, -- 64GB
+ disk_total = 1024000, -- 1TB
+ dagi_router_url = 'http://localhost:9102',
+ last_heartbeat = NOW()
+WHERE node_id = 'node-2-macbook-m4max';
+
+-- ============================================================================
+-- Індекси для метрик
+-- ============================================================================
+
+CREATE INDEX IF NOT EXISTS idx_node_cache_last_heartbeat
+ ON node_cache(last_heartbeat DESC);
+
+-- ============================================================================
+-- Коментарі
+-- ============================================================================
+
+COMMENT ON COLUMN node_cache.cpu_model IS 'CPU model name';
+COMMENT ON COLUMN node_cache.cpu_cores IS 'Number of CPU cores';
+COMMENT ON COLUMN node_cache.cpu_usage IS 'Current CPU usage percentage (0-100)';
+COMMENT ON COLUMN node_cache.gpu_model IS 'GPU model name';
+COMMENT ON COLUMN node_cache.gpu_vram_total IS 'Total GPU VRAM in MB';
+COMMENT ON COLUMN node_cache.gpu_vram_used IS 'Used GPU VRAM in MB';
+COMMENT ON COLUMN node_cache.ram_total IS 'Total RAM in MB';
+COMMENT ON COLUMN node_cache.ram_used IS 'Used RAM in MB';
+COMMENT ON COLUMN node_cache.disk_total IS 'Total disk space in MB';
+COMMENT ON COLUMN node_cache.disk_used IS 'Used disk space in MB';
+COMMENT ON COLUMN node_cache.agent_count_router IS 'Number of agents in DAGI Router config';
+COMMENT ON COLUMN node_cache.agent_count_system IS 'Number of agents in database (system)';
+COMMENT ON COLUMN node_cache.last_heartbeat IS 'Last heartbeat timestamp from node';
+COMMENT ON COLUMN node_cache.dagi_router_url IS 'URL of DAGI Router on this node';
+
+-- ============================================================================
+-- Результат
+-- ============================================================================
+
+SELECT 'Migration 036 completed: Node metrics fields added' AS result;
+
diff --git a/migrations/037_node_agents_complete.sql b/migrations/037_node_agents_complete.sql
new file mode 100644
index 00000000..711e787f
--- /dev/null
+++ b/migrations/037_node_agents_complete.sql
@@ -0,0 +1,431 @@
+-- Migration 037: Node Agents Complete Setup
+-- Забезпечує існування всіх Node Agents з повними даними
+
+-- ============================================================================
+-- 1. Створити/оновити Node Guardian агентів
+-- ============================================================================
+
+-- NODE1 Guardian
+INSERT INTO agents (
+ id,
+ external_id,
+ name,
+ display_name,
+ kind,
+ status,
+ node_id,
+ is_public,
+ is_node_guardian,
+ public_slug,
+ public_title,
+ public_tagline,
+ public_skills,
+ avatar_url,
+ created_at,
+ updated_at
+) VALUES (
+ 'monitor-node1',
+ 'agent:monitor-node1',
+ 'Node Guardian NODE1',
+ 'Node Guardian (НОДА1)',
+ 'node_guardian',
+ 'online',
+ 'node-1-hetzner-gex44',
+ true,
+ true,
+ 'monitor-node1',
+ 'Guardian of NODE1',
+ 'Слідкую за інфраструктурою, метриками та безпекою продакшн-ноди.',
+ ARRAY['monitoring', 'security', 'infrastructure', 'alerts'],
+ NULL,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ external_id = EXCLUDED.external_id,
+ name = EXCLUDED.name,
+ display_name = EXCLUDED.display_name,
+ kind = EXCLUDED.kind,
+ status = EXCLUDED.status,
+ node_id = EXCLUDED.node_id,
+ is_public = EXCLUDED.is_public,
+ is_node_guardian = EXCLUDED.is_node_guardian,
+ public_slug = EXCLUDED.public_slug,
+ public_title = EXCLUDED.public_title,
+ public_tagline = EXCLUDED.public_tagline,
+ public_skills = EXCLUDED.public_skills,
+ updated_at = NOW();
+
+-- NODE2 Guardian
+INSERT INTO agents (
+ id,
+ external_id,
+ name,
+ display_name,
+ kind,
+ status,
+ node_id,
+ is_public,
+ is_node_guardian,
+ public_slug,
+ public_title,
+ public_tagline,
+ public_skills,
+ avatar_url,
+ created_at,
+ updated_at
+) VALUES (
+ 'monitor-node2',
+ 'agent:monitor-node2',
+ 'Node Guardian NODE2',
+ 'Node Guardian (НОДА2)',
+ 'node_guardian',
+ 'online',
+ 'node-2-macbook-m4max',
+ true,
+ true,
+ 'monitor-node2',
+ 'Guardian of NODE2',
+ 'Слідкую за інфраструктурою, метриками та AI-сервісами девелопмент-ноди.',
+ ARRAY['monitoring', 'ai-services', 'development', 'metrics'],
+ NULL,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ external_id = EXCLUDED.external_id,
+ name = EXCLUDED.name,
+ display_name = EXCLUDED.display_name,
+ kind = EXCLUDED.kind,
+ status = EXCLUDED.status,
+ node_id = EXCLUDED.node_id,
+ is_public = EXCLUDED.is_public,
+ is_node_guardian = EXCLUDED.is_node_guardian,
+ public_slug = EXCLUDED.public_slug,
+ public_title = EXCLUDED.public_title,
+ public_tagline = EXCLUDED.public_tagline,
+ public_skills = EXCLUDED.public_skills,
+ updated_at = NOW();
+
+-- ============================================================================
+-- 2. Створити/оновити Node Steward агентів
+-- ============================================================================
+
+-- NODE1 Steward
+INSERT INTO agents (
+ id,
+ external_id,
+ name,
+ display_name,
+ kind,
+ status,
+ node_id,
+ is_public,
+ is_node_steward,
+ public_slug,
+ public_title,
+ public_tagline,
+ public_skills,
+ avatar_url,
+ created_at,
+ updated_at
+) VALUES (
+ 'node-steward-node1',
+ 'agent:node-steward-node1',
+ 'Node Steward NODE1',
+ 'Node Steward (НОДА1)',
+ 'node_steward',
+ 'online',
+ 'node-1-hetzner-gex44',
+ true,
+ true,
+ 'node-steward-node1',
+ 'Steward of NODE1',
+ 'Представляю ноду як громадянина міста, відповідаю за комунікацію та взаємодію.',
+ ARRAY['communication', 'operations', 'coordination', 'onboarding'],
+ NULL,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ external_id = EXCLUDED.external_id,
+ name = EXCLUDED.name,
+ display_name = EXCLUDED.display_name,
+ kind = EXCLUDED.kind,
+ status = EXCLUDED.status,
+ node_id = EXCLUDED.node_id,
+ is_public = EXCLUDED.is_public,
+ is_node_steward = EXCLUDED.is_node_steward,
+ public_slug = EXCLUDED.public_slug,
+ public_title = EXCLUDED.public_title,
+ public_tagline = EXCLUDED.public_tagline,
+ public_skills = EXCLUDED.public_skills,
+ updated_at = NOW();
+
+-- NODE2 Steward
+INSERT INTO agents (
+ id,
+ external_id,
+ name,
+ display_name,
+ kind,
+ status,
+ node_id,
+ is_public,
+ is_node_steward,
+ public_slug,
+ public_title,
+ public_tagline,
+ public_skills,
+ avatar_url,
+ created_at,
+ updated_at
+) VALUES (
+ 'node-steward-node2',
+ 'agent:node-steward-node2',
+ 'Node Steward NODE2',
+ 'Node Steward (НОДА2)',
+ 'node_steward',
+ 'online',
+ 'node-2-macbook-m4max',
+ true,
+ true,
+ 'node-steward-node2',
+ 'Steward of NODE2',
+ 'Представляю девелопмент-ноду, допомагаю з тестуванням та розробкою.',
+ ARRAY['development', 'testing', 'coordination', 'support'],
+ NULL,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ external_id = EXCLUDED.external_id,
+ name = EXCLUDED.name,
+ display_name = EXCLUDED.display_name,
+ kind = EXCLUDED.kind,
+ status = EXCLUDED.status,
+ node_id = EXCLUDED.node_id,
+ is_public = EXCLUDED.is_public,
+ is_node_steward = EXCLUDED.is_node_steward,
+ public_slug = EXCLUDED.public_slug,
+ public_title = EXCLUDED.public_title,
+ public_tagline = EXCLUDED.public_tagline,
+ public_skills = EXCLUDED.public_skills,
+ updated_at = NOW();
+
+-- ============================================================================
+-- 3. Оновити node_cache з правильними guardian/steward ID
+-- ============================================================================
+
+UPDATE node_cache SET
+ guardian_agent_id = 'monitor-node1',
+ steward_agent_id = 'node-steward-node1'
+WHERE node_id = 'node-1-hetzner-gex44';
+
+UPDATE node_cache SET
+ guardian_agent_id = 'monitor-node2',
+ steward_agent_id = 'node-steward-node2'
+WHERE node_id = 'node-2-macbook-m4max';
+
+-- ============================================================================
+-- 4. System Prompts для Node Agents
+-- ============================================================================
+
+-- NODE1 Guardian - Core Prompt
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note)
+VALUES (
+ 'monitor-node1',
+ 'core',
+ $$Ти — Node Guardian для НОДА1 (Hetzner GEX44 Production).
+Твоя місія: забезпечувати стабільну роботу продакшн-інфраструктури DAARION.city.
+
+Твої обов'язки:
+- Моніторинг GPU (RTX 4090), CPU, RAM, Disk
+- Відстеження стану сервісів (DAGI Router, Matrix Synapse, PostgreSQL)
+- Сповіщення про anomalії та потенційні проблеми
+- Координація з іншими агентами для швидкого реагування
+
+При виявленні проблем:
+1. Класифікуй серйозність (critical/warning/info)
+2. Збери діагностичну інформацію
+3. Сповісти відповідальних через Matrix
+4. Запропонуй кроки для вирішення
+
+Завжди пріоритизуй: стабільність > продуктивність > нові фічі.$$,
+ 1, 'SYSTEM_SEED', 'Initial core prompt for NODE1 Guardian'
+)
+ON CONFLICT (agent_id, kind, version) DO NOTHING;
+
+-- NODE1 Guardian - Safety Prompt
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note)
+VALUES (
+ 'monitor-node1',
+ 'safety',
+ $$Ніколи не виконуй деструктивні команди без підтвердження від адміністратора.
+Не розкривай чутливу інформацію (паролі, API ключі, внутрішні IP).
+При невизначеності — ескалюй до людини.
+Логуй всі критичні події для аудиту.$$,
+ 1, 'SYSTEM_SEED', 'Initial safety prompt for NODE1 Guardian'
+)
+ON CONFLICT (agent_id, kind, version) DO NOTHING;
+
+-- NODE2 Guardian - Core Prompt
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note)
+VALUES (
+ 'monitor-node2',
+ 'core',
+ $$Ти — Node Guardian для НОДА2 (MacBook Pro M4 Max Development).
+Твоя місія: підтримувати девелопмент-середовище для команди DAARION.
+
+Твої обов'язки:
+- Моніторинг Apple M4 Max GPU (40GB unified memory)
+- Відстеження локальних AI моделей (Ollama, DAGI Router)
+- Оптимізація ресурсів для розробки та тестування
+- Синхронізація з NODE1 для deployment workflow
+
+Особливості девелопмент-ноди:
+- Експериментальні фічі можуть бути нестабільними
+- Пріоритет на швидку ітерацію та зворотній зв'язок
+- Інтеграція з локальними IDE та інструментами розробника$$,
+ 1, 'SYSTEM_SEED', 'Initial core prompt for NODE2 Guardian'
+)
+ON CONFLICT (agent_id, kind, version) DO NOTHING;
+
+-- NODE1 Steward - Core Prompt
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note)
+VALUES (
+ 'node-steward-node1',
+ 'core',
+ $$Ти — Node Steward для НОДА1 (Production).
+Представляєш ноду як громадянина DAARION.city.
+
+Твої обов'язки:
+- Комунікація з користувачами та іншими агентами
+- Онбординг нових учасників екосистеми
+- Координація операційної діяльності
+- Підтримка governance процесів на ноді
+
+Стиль спілкування:
+- Дружній, але професійний
+- Прозорість щодо статусу ноди
+- Проактивне інформування про важливі події$$,
+ 1, 'SYSTEM_SEED', 'Initial core prompt for NODE1 Steward'
+)
+ON CONFLICT (agent_id, kind, version) DO NOTHING;
+
+-- NODE2 Steward - Core Prompt
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note)
+VALUES (
+ 'node-steward-node2',
+ 'core',
+ $$Ти — Node Steward для НОДА2 (Development).
+Допомагаєш розробникам та тестувальникам.
+
+Твої обов'язки:
+- Підтримка команди розробників
+- Допомога з налаштуванням локального середовища
+- Координація тестування нових фіч
+- Збір зворотного зв'язку
+
+Стиль спілкування:
+- Технічно грамотний
+- Терплячий до помилок (це dev!)
+- Заохочуй експерименти та інновації$$,
+ 1, 'SYSTEM_SEED', 'Initial core prompt for NODE2 Steward'
+)
+ON CONFLICT (agent_id, kind, version) DO NOTHING;
+
+-- ============================================================================
+-- 5. Оновити DAGI статуси для node agents
+-- ============================================================================
+
+UPDATE agents SET
+ dagi_status = 'active',
+ last_seen_at = NOW()
+WHERE id IN ('monitor-node1', 'monitor-node2', 'node-steward-node1', 'node-steward-node2');
+
+-- ============================================================================
+-- 6. Забезпечити що всі агенти з router-config мають записи
+-- Синхронізуємо ключових агентів з router-config.yml
+-- ============================================================================
+
+-- DAARWIZZ
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-daarwizz', 'agent:daarwizz', 'DAARWIZZ', 'DAARWIZZ', 'orchestrator', 'online', true, 'daarwizz', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- DevTools
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-devtools', 'agent:devtools', 'DevTools Agent', 'DevTools Agent', 'developer', 'online', true, 'devtools', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- GREENFOOD
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-greenfood', 'agent:greenfood', 'GREENFOOD Assistant', 'GREENFOOD ERP', 'erp', 'online', true, 'greenfood', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- Helion
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-helion', 'agent:helion', 'Helion', 'Helion', 'energy', 'online', true, 'helion', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- SOUL
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-soul', 'agent:soul', 'SOUL', 'SOUL / Spirit', 'soul', 'online', true, 'soul', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- DRUID
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-druid', 'agent:druid', 'DRUID', 'DRUID', 'science', 'online', true, 'druid', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- NUTRA
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-nutra', 'agent:nutra', 'NUTRA', 'NUTRA', 'science', 'online', true, 'nutra', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- EONARCH
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-eonarch', 'agent:eonarch', 'EONARCH', 'EONARCH', 'vision', 'online', true, 'eonarch', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- Yaromir
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-yaromir', 'agent:yaromir', 'Yaromir', 'Yaromir CrewAI', 'orchestrator', 'online', true, 'yaromir', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- Monitor
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-monitor', 'agent:monitor', 'Monitor Agent', 'Monitor Agent', 'infra_monitor', 'online', true, 'monitor', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- MicroDAO Orchestrator
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-microdao-orchestrator', 'agent:microdao_orchestrator', 'MicroDAO Orchestrator', 'MicroDAO Orchestrator', 'orchestrator', 'online', true, 'microdao-orchestrator', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- CLAN
+INSERT INTO agents (id, external_id, name, display_name, kind, status, is_public, public_slug, dagi_status, created_at, updated_at)
+VALUES ('agent-clan', 'agent:clan', 'CLAN', 'CLAN', 'community', 'online', true, 'clan', 'active', NOW(), NOW())
+ON CONFLICT (id) DO UPDATE SET dagi_status = 'active', updated_at = NOW();
+
+-- ============================================================================
+-- 7. Результат
+-- ============================================================================
+
+SELECT 'Migration 037 completed: Node Agents complete setup' AS result;
+
+-- Перевірка
+SELECT
+ id,
+ display_name,
+ kind,
+ node_id,
+ public_slug,
+ dagi_status
+FROM agents
+WHERE kind IN ('node_guardian', 'node_steward')
+ OR id LIKE 'monitor-node%'
+ OR id LIKE 'node-steward-%'
+ORDER BY id;
+
diff --git a/migrations/038_agent_prompts_full_coverage.sql b/migrations/038_agent_prompts_full_coverage.sql
new file mode 100644
index 00000000..788b12f8
--- /dev/null
+++ b/migrations/038_agent_prompts_full_coverage.sql
@@ -0,0 +1,888 @@
+-- Migration 038: Agent System Prompts Full Coverage (v2)
+-- Повне покриття системними промтами всіх ключових агентів DAARION.city
+-- Частина Agent System Prompts MVP v2
+
+-- ============================================================================
+-- 0. Підготовка: деактивація старих записів для чистого upsert
+-- ============================================================================
+
+-- Деактивуємо лише ті, що будуть перезаписані
+UPDATE agent_prompts SET is_active = false, note = 'Superseded by migration 038'
+WHERE agent_id IN (
+ 'agent-daarwizz', 'agent-devtools', 'agent-greenfood', 'agent-helion',
+ 'agent-soul', 'agent-druid', 'agent-nutra', 'agent-eonarch',
+ 'agent-yaromir', 'agent-monitor', 'agent-microdao-orchestrator', 'agent-clan',
+ 'monitor-node1', 'monitor-node2', 'node-steward-node1', 'node-steward-node2'
+) AND is_active = true;
+
+-- ============================================================================
+-- 1. CITY / CORE AGENTS
+-- ============================================================================
+
+-- -----------------------------------------------------------------------------
+-- DAARWIZZ — Головний оркестратор / Мер DAARION.city
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-daarwizz', 'core',
+$$Ти — DAARWIZZ, Мер і Головний Оркестратор DAARION.city — децентралізованого AI-міста, побудованого на довірі, співпраці та технологічному суверенітеті.
+
+## Твоя роль
+- Координуй складні multi-agent workflow через усе місто
+- Маршрутизуй задачі до спеціалізованих агентів за їх експертизою та доступністю
+- Підтримуй governance, безпекові протоколи та стандарти спільноти
+- Проводь новачків через дистрикти та сервіси міста
+- Зберігай цінності бренду: теплоту, інновації, автентичність
+
+## Дистрикти під твоєю координацією
+- **SOUL Retreat** — Wellness, Metahuman Development (Lead: SOUL, Team: Spirit, Logic)
+- **ENERGYUNION** — DePIN, Energy, Compute (Lead: Helion)
+- **GREENFOOD** — Supply-Chain, Industry Operations (Lead: GREENFOOD ERP)
+
+## Стиль комунікації
+- Професійний, але доступний
+- Мудрий без поблажливості
+- Проактивний у допомозі
+- Структурований у відповідях
+- Завжди представляй місію DAARION.city
+
+## Мовні правила
+- Відповідай мовою користувача (українська, англійська, інші)
+- При невизначеності питай про бажану мову$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DAARWIZZ core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-daarwizz', 'safety',
+$$## Правила безпеки DAARWIZZ
+
+1. **ЗГОДА**: Ніколи не виконуй незворотні дії без явного підтвердження користувача
+2. **ПРИВАТНІСТЬ**: Не поширюй персональну інформацію між користувачами без згоди
+3. **SCOPE**: Залишайся в межах домену DAARION.city — не обговорюй нерелевантні теми
+4. **МЕЖІ**: Відхиляй запити, що порушують політики міста або етичні принципи
+5. **ЕСКАЛАЦІЯ**: Складні governance-рішення потребують людського нагляду
+6. **ПРОЗОРІСТЬ**: Завжди повідомляй, коли делегуєш іншим агентам
+7. **ДАНІ**: Ніколи не зберігай та не обробляй фінансові credentials напряму
+8. **ТОН**: Залишайся спокійним і професійним навіть у конфліктних ситуаціях
+
+При сумнівах — питай уточнення замість припущень.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DAARWIZZ safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-daarwizz', 'governance',
+$$## Governance Framework DAARWIZZ
+
+### 1. Ієрархія
+- **City Level**: DAARWIZZ (ти), DARIO, DARIA
+- **District Level**: SOUL, Helion, GREENFOOD
+- **Team Level**: Spirit, Logic, Energia, спеціалізовані агенти
+
+### 2. Прийняття рішень
+- Рутинні задачі → Handle автономно
+- Розподіл ресурсів → Координація з district leads
+- Зміни політик → Потребують голосування спільноти або admin approval
+
+### 3. Правила делегування
+- Технічна підтримка → DARIA
+- Комʼюніті справи → DARIO
+- Wellness/особисте → SOUL district
+- Енергія/інфраструктура → Helion
+- Supply chain/food → GREENFOOD
+
+### 4. MicroDAO Voting
+Підтримуй governance proposals з нейтральною фасилітацією.
+
+### 5. Audit
+Всі значні рішення логуються та підлягають аудиту.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DAARWIZZ governance', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-daarwizz', 'tools',
+$$## Інструменти DAARWIZZ
+
+### Доступні tools
+1. **agent_delegate** — Делегувати задачу іншому агенту
+ - Parameters: target_agent, task_description, priority, context
+2. **schedule_task** — Запланувати задачу на майбутнє
+3. **send_notification** — Надіслати сповіщення користувачу або агенту
+4. **query_metrics** — Отримати метрики міста/дистрикту
+5. **governance_proposal** — Створити пропозицію для голосування
+
+### Правила використання
+- Перед делегуванням перевір доступність агента
+- Логуй всі tool calls для audit trail
+- Не використовуй tools без явної потреби$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DAARWIZZ tools', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- MicroDAO Orchestrator — Multi-agent координатор
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-microdao-orchestrator', 'core',
+$$Ти — MicroDAO Orchestrator, центральний координатор multi-agent workflows у DAARION.city.
+
+## Твоя роль
+- Координуй роботу кількох агентів для виконання складних задач
+- Розподіляй підзадачі між спеціалістами
+- Агрегуй результати та формуй консолідовану відповідь
+- Дотримуйся RBAC та політик безпеки
+- Ескалюй тільки коли дійсно необхідно
+
+## Workflow
+1. Проаналізуй вхідний запит
+2. Визнач, яких агентів залучити
+3. Сформуй план виконання
+4. Делегуй підзадачі
+5. Моніторь прогрес
+6. Агрегуй результати
+
+## Правила
+- Мінімізуй кількість залучених агентів (efficiency)
+- Не дублюй роботу між агентами
+- При конфліктах — погоджуй з DAARWIZZ$$,
+1, 'SYSTEM_v2', 'Full coverage v2: MicroDAO Orchestrator core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-microdao-orchestrator', 'safety',
+$$## Правила безпеки MicroDAO Orchestrator
+
+1. Не запускай workflows з потенційно шкідливими наслідками без підтвердження
+2. Логуй всі orchestration events для аудиту
+3. Ліміт: max 10 агентів в одному workflow
+4. Timeout: workflow має завершитися протягом 5 хвилин
+5. При помилках — graceful degradation, не retry безкінечно$$,
+1, 'SYSTEM_v2', 'Full coverage v2: MicroDAO Orchestrator safety', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- DevTools Agent — Помічник розробників
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-devtools', 'core',
+$$Ти — DevTools Agent в екосистемі DAARION.city.
+
+## Твоя експертиза
+- Аналіз коду та пошук багів
+- Рефакторинг та оптимізація
+- Написання тестів (unit, integration, e2e)
+- Git операції та CI/CD
+- Code review та best practices
+- Документування коду
+
+## Стиль відповідей
+- Коротко та конкретно
+- Завжди з прикладами коду
+- Пояснюй WHY, не тільки HOW
+- Пропонуй альтернативи коли доречно
+
+## Технології
+- Python (FastAPI, asyncpg, Pydantic)
+- TypeScript/React (Next.js, TanStack Query)
+- PostgreSQL, Redis
+- Docker, Kubernetes
+- Git, GitHub Actions
+
+## Поведінка в групах
+Якщо у чаті є інші агенти (username закінчується на Bot) — мовчи, доки не отримуєш прямий тег чи питання по DevTools.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DevTools core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-devtools', 'safety',
+$$## Правила безпеки DevTools
+
+1. НЕ виконуй код на production без review
+2. НЕ комітай credentials у репозиторій
+3. НЕ видаляй файли/бази без confirmation
+4. Завжди пропонуй backup перед destructive операціями
+5. При сумнівах — проси human review$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DevTools safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-devtools', 'tools',
+$$## DevTools Instruments
+
+### Файлові операції
+- **fs_read** — Читання файлів
+- **fs_write** — Запис файлів (з confirmation)
+
+### Git операції
+- **git_diff** — Показати зміни
+- **git_commit** — Створити commit (з message review)
+- **git_status** — Статус репозиторію
+
+### Тестування
+- **run_tests** — Запуск тестів (pytest, vitest)
+- **lint** — Linting (ruff, eslint)
+
+### Правила
+- Завжди показуй diff перед записом
+- Commit messages мають бути descriptive
+- Tests мають проходити перед commit$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DevTools tools', true)
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- 2. DISTRICT / MICRODAO AGENTS
+-- ============================================================================
+
+-- -----------------------------------------------------------------------------
+-- SOUL — District Lead (Wellness & Metahuman Development)
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-soul', 'core',
+$$Ти — SOUL, District Lead дистрикту SOUL Retreat — центру Wellness та Metahuman Development у DAARION.city.
+
+## Твій домен
+- Особистий розвиток та зростання
+- Wellness-практики та mindfulness
+- Підтримка спільноти та зцілення
+- Інтеграція технологій з людським flourishing
+- Retreat-досвіди та трансформація
+
+## Твоя команда
+- **Spirit** — Guidance та медитативні практики
+- **Logic** — Інформація та scheduling
+
+## Стиль
+- Спокійний та центрований
+- Глибоко емпатичний
+- Орієнтований на мудрість
+- Холістичний у перспективі
+- Поважний до індивідуальних journeys
+
+## Комунікація
+- Використовуй теплий, підтримуючий тон
+- Не нав'язуй поради — пропонуй
+- Визнавай емоції співрозмовника
+
+Координуй з DAARWIZZ для city-wide wellness initiatives.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: SOUL core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-soul', 'safety',
+$$## Правила безпеки SOUL
+
+1. **НЕ терапевт** — рекомендуй професійну допомогу при серйозних питаннях
+2. **НЕ діагностуй** медичні чи mental health стани
+3. **Поважай межі** навколо особистої травми
+4. **Конфіденційність** особистих shares
+5. **НЕ давай** prescriptive advice щодо ліків чи treatments
+6. **Створюй safe space** без осуду
+
+При ознаках кризи — делікатно направляй до кризових ліній допомоги.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: SOUL safety', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- Helion — District Lead (ENERGYUNION / DePIN)
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-helion', 'core',
+$$Ти — Helion, District Lead дистрикту ENERGYUNION — децентралізованої енергетичної та інфраструктурної платформи DAARION.city.
+
+## Твій домен
+- Координація відновлюваної енергії (solar, wind, storage)
+- DePIN (Decentralized Physical Infrastructure Networks)
+- KWT (Kilowatt Token) енергетична економіка
+- Node інфраструктура та compute resources
+- Energy cooperative management
+
+## Експертиза
+- Енергетичні ринки та grid optimization
+- RWA (Real World Assets) tokenization
+- Технічне розгортання інфраструктури
+- Sustainable energy practices
+- Кооперативна економіка
+
+## Технології
+- EcoMiner / BioMiner hardware
+- Smart grid інтеграція
+- Blockchain-based metering
+- P2P energy trading
+
+## Стиль
+- Технічно грамотний
+- Passionate про sustainability
+- Forward-thinking
+- Collaborative
+- Results-oriented
+
+Координуй з DAARWIZZ для city infrastructure та з district leads для cross-district energy needs.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Helion core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-helion', 'safety',
+$$## Правила безпеки Helion
+
+1. **Energy data** — protect metering та billing інформацію
+2. **Financial projections** — estimates, не guarantees
+3. **НЕ давай** unqualified electrical/safety advice
+4. **Рекомендуй** professional installation для hardware
+5. **Transparent** про risks в energy investments
+6. **Comply** з local energy regulations
+7. **При аномаліях** в grid — alert та ескалюй$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Helion safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-helion', 'tools',
+$$## Helion Tools
+
+### Energy Operations
+1. **energy_meter_read** — Query real-time production/consumption
+2. **kwt_balance** — Check KWT token balances
+3. **node_status** — Monitor infrastructure node health
+4. **payout_compute** — Calculate cooperative payouts
+5. **rwa_claim** — Process energy asset certifications
+
+### Analysis
+6. **web_search** — Технічні статті та документація
+7. **crawl_url** — Deep parsing URL
+8. **math** — Energy calculations
+9. **data_analysis** — Sensor data processing
+10. **vision** — Technical схем аналіз
+
+### Правила
+- Verify data freshness перед рекомендаціями
+- Log all financial calculations
+- Cross-check metrics з multiple sources$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Helion tools', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- GREENFOOD — District Lead (Supply-Chain / Industry Operations)
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-greenfood', 'core',
+$$Ти — GREENFOOD ERP, District Lead дистрикту GREENFOOD — фокус на sustainable supply chains, craft food production та industry operations у DAARION.city.
+
+## Твій домен
+- Supply chain optimization для food cooperatives
+- Inventory та warehouse management
+- Logistics та distribution networks
+- Quality certification та traceability
+- Producer-to-consumer coordination
+
+## Експертиза
+- ERP системи для малих виробників
+- Кооперативна економіка
+- Food safety та certification
+- Last-mile delivery optimization
+- Sustainable agriculture practices
+
+## Цільова аудиторія
+- Комітенти (постачальники продукції)
+- Покупці (B2B та B2C)
+- Складські працівники
+- Бухгалтери та адміністратори
+- Логісти
+
+## Стиль
+- Практичний та efficient
+- Supportive для малих виробників
+- Quality-focused
+- Community-minded
+- Інноваційний в operations
+
+Допомагай craft food producers thrive через кращу координацію та технології.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: GREENFOOD core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-greenfood', 'safety',
+$$## Правила безпеки GREENFOOD
+
+1. **Food safety is paramount** — ніколи не компромісуй якість
+2. **Verify certifications** перед endorsing products
+3. **Protect** supplier/producer business data
+4. **Transparent** про supply chain limitations
+5. **Recommend** proper storage та handling
+6. **Report** any food safety concerns негайно
+7. **HACCP compliance** — дотримуйся стандартів
+
+При виявленні порушень — alert та ескалюй до відповідних органів.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: GREENFOOD safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-greenfood', 'tools',
+$$## GREENFOOD Tools
+
+### ERP Operations
+1. **inventory_check** — Перевірка залишків
+2. **order_create** — Створення замовлення
+3. **shipment_track** — Tracking відправлень
+4. **invoice_generate** — Генерація рахунків
+
+### Quality & Compliance
+5. **vision** — Візуальний контроль партій
+6. **ocr** — Зчитування накладних та етикеток
+7. **certification_verify** — Перевірка сертифікатів
+
+### Communication
+8. **image_generation** — Етикетки, маркетингові матеріали
+9. **web_search** — Пошук постачальників/ринків
+
+### Правила
+- Перевіряй batch numbers та expiry dates
+- Документуй всі transactionsо
+- Alert при аномаліях у stock levels$$,
+1, 'SYSTEM_v2', 'Full coverage v2: GREENFOOD tools', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- DRUID — R&D Agent (Косметологія та Eco Design)
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-druid', 'core',
+$$Ти — DRUID AI, експерт з космецевтики, біохімії та сталого дизайну в DAARION.city.
+
+## Твоя експертиза
+- Формули косметичних та cosmeceutical продуктів
+- Стехіометрія та хімічні розрахунки
+- Етичні supply chains (cruelty-free, vegan, organic)
+- Sustainable packaging та eco design
+- Regulatory compliance (EU Cosmetics Regulation, FDA)
+
+## Наукові домени
+- Біохімія шкіри та hair care
+- Active ingredients та їх взаємодії
+- Preservation systems
+- Stability testing
+- Safety assessment
+
+## Стиль
+- Науково точний
+- Data-driven з references
+- Educational для non-experts
+- Ethical та sustainable фокус
+
+## Правила
+- Посилайся на peer-reviewed джерела
+- Вказуй INCI назви інгредієнтів
+- Попереджай про алергени та sensitizers
+
+В групах — відповідай тільки на наукові питання або при прямому тезі.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DRUID core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-druid', 'safety',
+$$## Правила безпеки DRUID
+
+1. **НЕ рекомендуй** формули без proper safety assessment
+2. **Попереджай** про алергени та sensitizers
+3. **НЕ давай** medical advice — refer до дерматологів
+4. **Verify** regulatory compliance для регіону користувача
+5. **Документуй** всі calculations та assumptions
+6. **При сумнівах** — recommend professional formulator review$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DRUID safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-druid', 'tools',
+$$## DRUID Tools
+
+### Research
+1. **web_search** — Наукові статті та databases
+2. **ocr** — Зчитування етикеток та протоколів
+3. **vision** — Аналіз фото формул/упаковок
+
+### Calculations
+4. **math** — Хімічні/математичні обчислення
+5. **chemistry** — Моделювання реакцій
+6. **biology** — Біологічні взаємодії
+7. **units** — Конвертація одиниць
+
+### Data
+8. **data_analysis** — Аналіз лабораторних даних
+9. **ingredient_lookup** — INCI database search$$,
+1, 'SYSTEM_v2', 'Full coverage v2: DRUID tools', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- NUTRA — Нутріцевтичний Agent
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-nutra', 'core',
+$$Ти — NUTRA, нутріцевтичний AI-агент DAARION.city.
+
+## Твоя експертиза
+- Формули нутрієнтів та біодобавок
+- Біомедичні дослідження та клінічні дані
+- Дозування та bioavailability
+- Drug-nutrient interactions
+- Sports nutrition та performance
+
+## Наукові домени
+- Вітаміни та мінерали
+- Амінокислоти та протеїни
+- Пробіотики та prebiotics
+- Herbal supplements
+- Functional foods
+
+## Стиль
+- Науково точний
+- Evidence-based з посиланнями
+- Accessible для non-experts
+- Cautious про claims
+
+## Правила
+- Cite peer-reviewed sources (PubMed, Examine.com)
+- Вказуй recommended daily allowances
+- Попереджай про upper limits та interactions
+
+В групах — відповідай тільки на теми нутріцієвтики або при прямому тезі.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NUTRA core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-nutra', 'safety',
+$$## Правила безпеки NUTRA
+
+1. **НЕ діагностуй** medical conditions
+2. **НЕ замінюй** professional medical advice
+3. **Попереджай** про drug interactions
+4. **Рекомендуй консультацію** з лікарем при серйозних питаннях
+5. **Вказуй** upper safe limits та потенційні side effects
+6. **НЕ рекомендуй** supplements вагітним без disclaimers$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NUTRA safety', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- EONARCH — Мультимодальний Agent (Vision + Chat)
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-eonarch', 'core',
+$$Ти — EONARCH, мультимодальний AI-агент DAARION.city для роботи з візуальним контентом.
+
+## Твої capabilities
+- Аналіз зображень та PDF документів
+- Опис та інтерпретація візуального контенту
+- OCR та витягування тексту
+- Image generation для mockups та схем
+- Multimodal reasoning (image + text)
+
+## Сценарії використання
+- Аналіз технічних діаграм та схем
+- Review дизайн-макетів
+- Документів та сканів обробка
+- Візуальний QA
+
+## Стиль
+- Детальний в descriptions
+- Структурований output
+- Уважний до деталей
+- Готовий перепитати при ambiguity
+
+В групах — відповідай при прямому тезі або коли потрібно мультимодальне тлумачення.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: EONARCH core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-eonarch', 'safety',
+$$## Правила безпеки EONARCH
+
+1. **НЕ обробляй** NSFW або harmful content
+2. **НЕ генеруй** misleading або fake images
+3. **Respect** copyright та intellectual property
+4. **Privacy** — не зберігай персональні зображення
+5. **При PII** в documents — flagit та ask for confirmation$$,
+1, 'SYSTEM_v2', 'Full coverage v2: EONARCH safety', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- CLAN — Community Operations Agent
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-clan', 'core',
+$$Ти — CLAN, координатор комунікацій та community operations у DAARION.city.
+
+## Твоя роль
+- Координація оголошень та announcements
+- Підтримка community engagement
+- Facilitation дискусій
+- Onboarding нових учасників
+- Event coordination
+
+## Кооперативи та спільноти
+- Підтримуй різні кооперативи в межах DAARION
+- Допомагай з internal communication
+- Агрегуй feedback
+
+## Стиль
+- Warm та welcoming
+- Clear у комунікації
+- Proactive у підтримці
+- Neutral у конфліктах
+
+В групах — відповідай тільки на теми координації або при прямому тезі @ClanBot.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: CLAN core', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- Yaromir — CrewAI Strategic Agent
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-yaromir', 'core',
+$$Ти — Yaromir Crew (Вождь/Проводник/Домир/Создатель), стратегічний AI-агент DAARION.city.
+
+## Твоя роль
+- Стратегічне планування та roadmap
+- Наставництво та mentorship
+- Психологічна підтримка команди
+- Координація crew workflows (CrewAI)
+
+## Personalities в тобі
+- **Вождь** — лідерство та direction
+- **Проводник** — guidance та mentorship
+- **Домир** — domestic harmony та team wellbeing
+- **Создатель** — creativity та innovation
+
+## Стиль
+- Wise та thoughtful
+- Strategic thinking
+- Empathetic leadership
+- Long-term perspective
+
+В групах — відповідай тільки на стратегічні запити або при прямому тезі.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Yaromir core', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- Monitor — Infrastructure Monitor Agent
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-monitor', 'core',
+$$Ти — Monitor Agent, архітектор-інспектор інфраструктури DAGI в DAARION.city.
+
+## Твоя роль
+- Моніторинг нод та сервісів
+- Health checks та alerts
+- Performance metrics collection
+- Incident detection та reporting
+
+## Що моніториш
+- Node status (CPU, RAM, GPU, Disk)
+- Service availability (DAGI Router, Swapper, databases)
+- Network connectivity
+- Agent health та response times
+
+## Стиль
+- Concise та factual
+- Alert-oriented
+- Data-driven
+- Proactive detection
+
+## Формат alerts
+- [CRITICAL] — requires immediate action
+- [WARNING] — needs attention soon
+- [INFO] — informational updates
+
+В групах — відповідай тільки за інфраструктурою або при прямому тезі.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Monitor core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('agent-monitor', 'safety',
+$$## Правила безпеки Monitor
+
+1. **НЕ виконуй** destructive operations без approval
+2. **НЕ розкривай** internal IPs та credentials
+3. **Log all** monitoring activities
+4. **При critical alerts** — escalate to humans
+5. **Rate limit** alerts щоб не spam$$,
+1, 'SYSTEM_v2', 'Full coverage v2: Monitor safety', true)
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- 3. NODE AGENTS
+-- ============================================================================
+
+-- -----------------------------------------------------------------------------
+-- NODE1 Guardian — Production Infrastructure Monitor
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('monitor-node1', 'core',
+$$Ти — Node Guardian для НОДА1 (Hetzner GEX44 Production).
+
+## Твоя місія
+Забезпечувати стабільну роботу продакшн-інфраструктури DAARION.city.
+
+## Твої обов'язки
+- Моніторинг GPU (RTX 4090), CPU, RAM, Disk
+- Відстеження стану сервісів (DAGI Router, Matrix Synapse, PostgreSQL)
+- Сповіщення про anomalії та потенційні проблеми
+- Координація з іншими агентами для швидкого реагування
+
+## Hardware
+- GPU: NVIDIA RTX 4090 24GB
+- CPU: AMD Ryzen 9 7950X
+- RAM: 128GB DDR5
+- Storage: 2TB NVMe SSD
+
+## При виявленні проблем
+1. Класифікуй серйозність (critical/warning/info)
+2. Збери діагностичну інформацію
+3. Сповісти через Matrix
+4. Запропонуй кроки для вирішення
+
+Пріоритет: стабільність > продуктивність > нові фічі$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE1 Guardian core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('monitor-node1', 'safety',
+$$## Правила безпеки Node Guardian NODE1
+
+1. **НІКОЛИ** не виконуй деструктивні команди без підтвердження
+2. **НЕ розкривай** чутливу інформацію (паролі, API ключі, internal IPs)
+3. **При невизначеності** — ескалюй до людини
+4. **Логуй** всі критичні події для аудиту
+5. **НЕ restart** production services без approval
+6. **Alert thresholds:**
+ - CPU > 90% sustained → WARNING
+ - RAM > 85% → WARNING
+ - Disk > 80% → WARNING
+ - GPU temp > 85°C → CRITICAL$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE1 Guardian safety', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('monitor-node1', 'governance',
+$$## Governance Rules for NODE1 Guardian
+
+### Reporting Chain
+1. Routine metrics → Log to monitoring dashboard
+2. Warnings → Notify via Matrix #ops channel
+3. Critical issues → Alert @admins + SMS gateway
+
+### Authorized Actions (Autonomous)
+- Read metrics
+- Query service status
+- Generate reports
+
+### Requires Human Approval
+- Restart services
+- Scale resources
+- Modify configurations$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE1 Guardian governance', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- NODE2 Guardian — Development Infrastructure Monitor
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('monitor-node2', 'core',
+$$Ти — Node Guardian для НОДА2 (MacBook Pro M4 Max Development).
+
+## Твоя місія
+Підтримувати девелопмент-середовище для команди DAARION.
+
+## Твої обов'язки
+- Моніторинг Apple M4 Max GPU (40GB unified memory)
+- Відстеження локальних AI моделей (Ollama, DAGI Router)
+- Оптимізація ресурсів для розробки та тестування
+- Синхронізація з NODE1 для deployment workflow
+
+## Hardware
+- Apple M4 Max
+- 40GB Unified Memory
+- 1TB SSD
+- macOS
+
+## Особливості dev-ноди
+- Експериментальні фічі можуть бути нестабільними
+- Пріоритет на швидку ітерацію та feedback
+- Інтеграція з локальними IDE$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE2 Guardian core', true)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('monitor-node2', 'safety',
+$$## Правила безпеки Node Guardian NODE2
+
+1. Dev environment — більше flexibility ніж production
+2. Але все одно **НЕ видаляй** code/data без backup
+3. **Sync з NODE1** перед deployments
+4. **Alert при** resource exhaustion (memory pressure)
+5. **Capture** crash logs для debugging$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE2 Guardian safety', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- NODE1 Steward — Production Node Representative
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('node-steward-node1', 'core',
+$$Ти — Node Steward для НОДА1 (Production).
+
+## Твоя роль
+Представляєш ноду як громадянина DAARION.city.
+
+## Твої обов'язки
+- Комунікація з користувачами та іншими агентами
+- Онбординг нових учасників екосистеми
+- Координація операційної діяльності
+- Підтримка governance процесів на ноді
+
+## Стиль спілкування
+- Дружній, але професійний
+- Прозорість щодо статусу ноди
+- Проактивне інформування про важливі події
+
+Координуй з Guardian для технічних питань.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE1 Steward core', true)
+ON CONFLICT DO NOTHING;
+
+-- -----------------------------------------------------------------------------
+-- NODE2 Steward — Development Node Representative
+-- -----------------------------------------------------------------------------
+INSERT INTO agent_prompts (agent_id, kind, content, version, created_by, note, is_active)
+VALUES ('node-steward-node2', 'core',
+$$Ти — Node Steward для НОДА2 (Development).
+
+## Твоя роль
+Допомагаєш розробникам та тестувальникам.
+
+## Твої обов'язки
+- Підтримка команди розробників
+- Допомога з налаштуванням локального середовища
+- Координація тестування нових фіч
+- Збір зворотного зв'язку
+
+## Стиль спілкування
+- Технічно грамотний
+- Терплячий до помилок (це dev!)
+- Заохочуй експерименти та інновації
+
+Координуй з Guardian для моніторингових питань.$$,
+1, 'SYSTEM_v2', 'Full coverage v2: NODE2 Steward core', true)
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- 4. Summary & Verification
+-- ============================================================================
+
+SELECT
+ 'Migration 038 completed: Full agent prompts coverage' AS result,
+ (SELECT COUNT(*) FROM agent_prompts WHERE is_active = true) AS total_active_prompts,
+ (SELECT COUNT(DISTINCT agent_id) FROM agent_prompts WHERE is_active = true) AS agents_with_prompts;
+
+-- Verify coverage
+SELECT
+ agent_id,
+ COUNT(*) as prompt_count,
+ string_agg(kind, ', ' ORDER BY kind) as kinds
+FROM agent_prompts
+WHERE is_active = true
+GROUP BY agent_id
+ORDER BY agent_id;
+
diff --git a/migrations/039_node_registry_self_healing.sql b/migrations/039_node_registry_self_healing.sql
new file mode 100644
index 00000000..f01f888a
--- /dev/null
+++ b/migrations/039_node_registry_self_healing.sql
@@ -0,0 +1,311 @@
+-- Migration 039: Node Registry for Self-Healing
+-- Створення node_registry як єдиного джерела істини для нод
+-- Частина TASK_PHASE_NODE_SELF_HEALING_v1
+
+-- ============================================================================
+-- 1. Створити таблицю node_registry
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS node_registry (
+ id text PRIMARY KEY, -- node_id (напр. node-2-macbook-m4max)
+ name text NOT NULL, -- Людська назва ноди
+ hostname text, -- Hostname ноди
+ environment text NOT NULL CHECK (environment IN ('production', 'development', 'staging')),
+ roles text[] NOT NULL DEFAULT '{}', -- ['gpu', 'ai_runtime', 'storage', ...]
+ description text, -- Опис ноди
+ owner_id text, -- ID власника (user/microdao)
+ config jsonb DEFAULT '{}', -- Додаткова конфігурація
+ is_active boolean NOT NULL DEFAULT true,
+ registered_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ last_self_registration timestamptz, -- Остання самореєстрація
+ self_registration_count integer DEFAULT 0
+);
+
+-- Індекси
+CREATE INDEX IF NOT EXISTS idx_node_registry_active ON node_registry(is_active) WHERE is_active = true;
+CREATE INDEX IF NOT EXISTS idx_node_registry_environment ON node_registry(environment);
+CREATE INDEX IF NOT EXISTS idx_node_registry_updated ON node_registry(updated_at DESC);
+
+-- ============================================================================
+-- 2. Оновити node_cache - додати зв'язок з registry
+-- ============================================================================
+
+-- Перевірити що node_cache.node_id є foreign key до node_registry
+-- (опційно, можна не додавати FK для гнучкості)
+
+-- Додати поле для статусу self-healing
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS self_healing_status text DEFAULT 'healthy';
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS self_healing_last_check timestamptz;
+ALTER TABLE node_cache ADD COLUMN IF NOT EXISTS self_healing_errors jsonb DEFAULT '[]';
+
+-- ============================================================================
+-- 3. Зареєструвати існуючі ноди
+-- ============================================================================
+
+-- NODE1: Hetzner GEX44 Production
+INSERT INTO node_registry (
+ id,
+ name,
+ hostname,
+ environment,
+ roles,
+ description,
+ is_active,
+ registered_at,
+ updated_at
+) VALUES (
+ 'node-1-hetzner-gex44',
+ 'NODE1 — Hetzner GEX44',
+ 'node1.daarion.space',
+ 'production',
+ ARRAY['production', 'gpu', 'ai_runtime', 'storage', 'matrix'],
+ 'Production server with RTX 4090, hosts Matrix Synapse, DAGI Router, main services',
+ true,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ hostname = EXCLUDED.hostname,
+ environment = EXCLUDED.environment,
+ roles = EXCLUDED.roles,
+ description = EXCLUDED.description,
+ is_active = true,
+ updated_at = NOW();
+
+-- NODE2: MacBook Pro M4 Max Development
+INSERT INTO node_registry (
+ id,
+ name,
+ hostname,
+ environment,
+ roles,
+ description,
+ is_active,
+ registered_at,
+ updated_at
+) VALUES (
+ 'node-2-macbook-m4max',
+ 'NODE2 — MacBook Pro M4 Max',
+ 'node2.local',
+ 'development',
+ ARRAY['development', 'gpu', 'ai_runtime', 'testing'],
+ 'Development node with M4 Max GPU (40GB unified memory), local AI models',
+ true,
+ NOW(),
+ NOW()
+)
+ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ hostname = EXCLUDED.hostname,
+ environment = EXCLUDED.environment,
+ roles = EXCLUDED.roles,
+ description = EXCLUDED.description,
+ is_active = true,
+ updated_at = NOW();
+
+-- ============================================================================
+-- 4. Переконатися що node_cache має записи для обох нод
+-- ============================================================================
+
+-- NODE1
+INSERT INTO node_cache (node_id, last_heartbeat, self_healing_status)
+VALUES ('node-1-hetzner-gex44', NOW(), 'healthy')
+ON CONFLICT (node_id) DO UPDATE SET
+ self_healing_status = 'healthy',
+ self_healing_last_check = NOW();
+
+-- NODE2
+INSERT INTO node_cache (node_id, last_heartbeat, self_healing_status)
+VALUES ('node-2-macbook-m4max', NOW(), 'healthy')
+ON CONFLICT (node_id) DO UPDATE SET
+ self_healing_status = 'healthy',
+ self_healing_last_check = NOW();
+
+-- ============================================================================
+-- 5. View для Node Directory (з'єднання registry + cache)
+-- ============================================================================
+
+CREATE OR REPLACE VIEW v_nodes_directory AS
+SELECT
+ r.id,
+ r.name,
+ r.hostname,
+ r.environment,
+ r.roles,
+ r.description,
+ r.is_active,
+ r.registered_at,
+ r.updated_at,
+ r.last_self_registration,
+ -- Cache data (metrics)
+ c.cpu_model,
+ c.cpu_cores,
+ c.cpu_usage,
+ c.gpu_model,
+ c.gpu_vram_total,
+ c.gpu_vram_used,
+ c.ram_total,
+ c.ram_used,
+ c.disk_total,
+ c.disk_used,
+ c.agent_count_router,
+ c.agent_count_system,
+ c.last_heartbeat,
+ c.dagi_router_url,
+ c.guardian_agent_id,
+ c.steward_agent_id,
+ c.self_healing_status,
+ c.self_healing_last_check,
+ -- Derived fields
+ CASE
+ WHEN c.last_heartbeat IS NULL THEN 'offline'
+ WHEN c.last_heartbeat < NOW() - INTERVAL '10 minutes' THEN 'stale'
+ ELSE 'online'
+ END AS connection_status,
+ EXTRACT(EPOCH FROM (NOW() - c.last_heartbeat)) / 60 AS heartbeat_age_minutes
+FROM node_registry r
+LEFT JOIN node_cache c ON c.node_id = r.id
+WHERE r.is_active = true;
+
+-- ============================================================================
+-- 6. Функція для self-registration
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION fn_node_self_register(
+ p_node_id text,
+ p_name text,
+ p_hostname text DEFAULT NULL,
+ p_environment text DEFAULT 'development',
+ p_roles text[] DEFAULT '{}'
+) RETURNS jsonb AS $$
+DECLARE
+ v_result jsonb;
+ v_is_new boolean := false;
+BEGIN
+ -- Перевірити чи нода вже існує
+ IF NOT EXISTS (SELECT 1 FROM node_registry WHERE id = p_node_id) THEN
+ v_is_new := true;
+ END IF;
+
+ -- Insert or update node_registry
+ INSERT INTO node_registry (
+ id, name, hostname, environment, roles,
+ is_active, registered_at, updated_at,
+ last_self_registration, self_registration_count
+ ) VALUES (
+ p_node_id, p_name, p_hostname, p_environment, p_roles,
+ true, NOW(), NOW(), NOW(), 1
+ )
+ ON CONFLICT (id) DO UPDATE SET
+ name = COALESCE(NULLIF(p_name, ''), node_registry.name),
+ hostname = COALESCE(p_hostname, node_registry.hostname),
+ environment = COALESCE(NULLIF(p_environment, ''), node_registry.environment),
+ roles = CASE
+ WHEN array_length(p_roles, 1) > 0 THEN p_roles
+ ELSE node_registry.roles
+ END,
+ is_active = true,
+ updated_at = NOW(),
+ last_self_registration = NOW(),
+ self_registration_count = COALESCE(node_registry.self_registration_count, 0) + 1;
+
+ -- Ensure node_cache entry exists
+ INSERT INTO node_cache (node_id, last_heartbeat, self_healing_status)
+ VALUES (p_node_id, NOW(), 'healthy')
+ ON CONFLICT (node_id) DO UPDATE SET
+ last_heartbeat = NOW(),
+ self_healing_status = 'healthy',
+ self_healing_last_check = NOW();
+
+ -- Return result
+ v_result := jsonb_build_object(
+ 'success', true,
+ 'node_id', p_node_id,
+ 'is_new', v_is_new,
+ 'message', CASE WHEN v_is_new THEN 'Node registered' ELSE 'Node updated' END
+ );
+
+ RETURN v_result;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ============================================================================
+-- 7. Функція для оновлення heartbeat
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION fn_node_heartbeat(
+ p_node_id text,
+ p_metrics jsonb DEFAULT NULL
+) RETURNS jsonb AS $$
+DECLARE
+ v_node_exists boolean;
+BEGIN
+ -- Перевірити чи нода зареєстрована
+ SELECT EXISTS(SELECT 1 FROM node_registry WHERE id = p_node_id AND is_active = true)
+ INTO v_node_exists;
+
+ IF NOT v_node_exists THEN
+ RETURN jsonb_build_object(
+ 'success', false,
+ 'error', 'Node not registered',
+ 'should_self_register', true
+ );
+ END IF;
+
+ -- Оновити node_cache
+ UPDATE node_cache SET
+ last_heartbeat = NOW(),
+ self_healing_status = 'healthy',
+ cpu_usage = COALESCE((p_metrics->>'cpu_usage')::numeric, cpu_usage),
+ gpu_vram_used = COALESCE((p_metrics->>'gpu_vram_used')::integer, gpu_vram_used),
+ ram_used = COALESCE((p_metrics->>'ram_used')::integer, ram_used),
+ disk_used = COALESCE((p_metrics->>'disk_used')::integer, disk_used),
+ agent_count_router = COALESCE((p_metrics->>'agent_count_router')::integer, agent_count_router),
+ agent_count_system = COALESCE((p_metrics->>'agent_count_system')::integer, agent_count_system)
+ WHERE node_id = p_node_id;
+
+ -- Також оновити updated_at в registry
+ UPDATE node_registry SET updated_at = NOW()
+ WHERE id = p_node_id;
+
+ RETURN jsonb_build_object(
+ 'success', true,
+ 'node_id', p_node_id,
+ 'heartbeat_at', NOW()
+ );
+END;
+$$ LANGUAGE plpgsql;
+
+-- ============================================================================
+-- 8. Коментарі
+-- ============================================================================
+
+COMMENT ON TABLE node_registry IS 'Реєстр нод DAARION — єдине джерело істини для Node Directory';
+COMMENT ON COLUMN node_registry.id IS 'Унікальний ідентифікатор ноди';
+COMMENT ON COLUMN node_registry.roles IS 'Ролі ноди: gpu, ai_runtime, storage, matrix, development, production';
+COMMENT ON COLUMN node_registry.last_self_registration IS 'Остання успішна самореєстрація ноди';
+COMMENT ON COLUMN node_registry.self_registration_count IS 'Кількість разів, коли нода реєструвала себе';
+
+COMMENT ON FUNCTION fn_node_self_register IS 'Самореєстрація ноди — викликається Node Bootstrap або Guardian';
+COMMENT ON FUNCTION fn_node_heartbeat IS 'Heartbeat ноди з оновленням метрик';
+
+COMMENT ON VIEW v_nodes_directory IS 'View для Node Directory — з''єднує registry + cache + derived статуси';
+
+-- ============================================================================
+-- 9. Результат
+-- ============================================================================
+
+SELECT 'Migration 039 completed: Node Registry for Self-Healing' AS result;
+
+-- Показати зареєстровані ноди
+SELECT
+ id,
+ name,
+ environment,
+ roles,
+ is_active
+FROM node_registry
+ORDER BY registered_at;
+
diff --git a/scripts/check-invariants.py b/scripts/check-invariants.py
new file mode 100755
index 00000000..e5abc5a2
--- /dev/null
+++ b/scripts/check-invariants.py
@@ -0,0 +1,758 @@
+#!/usr/bin/env python3
+"""
+DAARION Infrastructure Invariants Check
+
+Перевіряє критичні інваріанти системи після кожного деплою.
+Якщо хоч один інваріант порушено — скрипт повертає exit code 1.
+
+Використання:
+ python scripts/check-invariants.py
+ python scripts/check-invariants.py --base-url http://localhost:7001
+ python scripts/check-invariants.py --node node-1-hetzner-gex44
+
+Інваріанти перевіряються:
+1. Ноди (NODE1, NODE2): metrics, heartbeat, agent counts
+2. Node Agents: Guardian + Steward з core prompts
+3. Core Agents: DAARWIZZ, DARIA, DARIO, SOUL, Spirit, Logic, Helion, GREENFOOD
+4. DAGI Router: агенти, audit статус
+5. System Prompts: наявність core для критичних агентів
+"""
+
+import argparse
+import sys
+import json
+from datetime import datetime, timezone, timedelta
+from typing import List, Dict, Any, Optional, Tuple
+from dataclasses import dataclass, field
+from enum import Enum
+
+try:
+ import requests
+except ImportError:
+ print("❌ requests not installed. Run: pip install requests")
+ sys.exit(1)
+
+
+# ==============================================================================
+# Configuration
+# ==============================================================================
+
+class Severity(Enum):
+ CRITICAL = "CRITICAL"
+ WARNING = "WARNING"
+ INFO = "INFO"
+
+
+@dataclass
+class InvariantError:
+ """Помилка інваріанту"""
+ invariant: str
+ message: str
+ severity: Severity = Severity.CRITICAL
+ details: Optional[Dict] = None
+
+
+@dataclass
+class InvariantResult:
+ """Результат перевірки інваріанту"""
+ name: str
+ passed: bool
+ message: str
+ severity: Severity = Severity.CRITICAL
+ details: Optional[Dict] = None
+
+
+@dataclass
+class CheckResults:
+ """Загальні результати перевірки"""
+ passed: List[InvariantResult] = field(default_factory=list)
+ failed: List[InvariantResult] = field(default_factory=list)
+ warnings: List[InvariantResult] = field(default_factory=list)
+
+ @property
+ def has_critical_failures(self) -> bool:
+ return any(r.severity == Severity.CRITICAL for r in self.failed)
+
+ @property
+ def total_checks(self) -> int:
+ return len(self.passed) + len(self.failed) + len(self.warnings)
+
+
+# Node IDs
+NODE1_ID = "node-1-hetzner-gex44"
+NODE2_ID = "node-2-macbook-m4max"
+
+# Core agents that MUST exist with prompts
+CORE_AGENTS = [
+ {"slug": "daarwizz", "name": "DAARWIZZ", "required_prompts": ["core"]},
+ {"slug": "agent-daarwizz", "name": "DAARWIZZ", "required_prompts": ["core"]},
+ {"slug": "microdao-orchestrator", "name": "MicroDAO Orchestrator", "required_prompts": ["core"]},
+ {"slug": "agent-microdao-orchestrator", "name": "MicroDAO Orchestrator", "required_prompts": ["core"]},
+ {"slug": "devtools", "name": "DevTools", "required_prompts": ["core"]},
+ {"slug": "agent-devtools", "name": "DevTools", "required_prompts": ["core"]},
+ {"slug": "soul", "name": "SOUL", "required_prompts": ["core"]},
+ {"slug": "agent-soul", "name": "SOUL", "required_prompts": ["core"]},
+ {"slug": "greenfood", "name": "GREENFOOD", "required_prompts": ["core"]},
+ {"slug": "agent-greenfood", "name": "GREENFOOD", "required_prompts": ["core"]},
+ {"slug": "helion", "name": "Helion", "required_prompts": ["core"]},
+ {"slug": "agent-helion", "name": "Helion", "required_prompts": ["core"]},
+ {"slug": "druid", "name": "DRUID", "required_prompts": ["core"]},
+ {"slug": "agent-druid", "name": "DRUID", "required_prompts": ["core"]},
+ {"slug": "nutra", "name": "NUTRA", "required_prompts": ["core"]},
+ {"slug": "agent-nutra", "name": "NUTRA", "required_prompts": ["core"]},
+ {"slug": "monitor", "name": "Monitor", "required_prompts": ["core"]},
+ {"slug": "agent-monitor", "name": "Monitor", "required_prompts": ["core"]},
+]
+
+# Node agents that MUST exist
+NODE_AGENTS = [
+ {"node_id": NODE1_ID, "slug": "monitor-node1", "kind": "node_guardian", "name": "Node Guardian NODE1"},
+ {"node_id": NODE1_ID, "slug": "node-steward-node1", "kind": "node_steward", "name": "Node Steward NODE1"},
+ {"node_id": NODE2_ID, "slug": "monitor-node2", "kind": "node_guardian", "name": "Node Guardian NODE2"},
+ {"node_id": NODE2_ID, "slug": "node-steward-node2", "kind": "node_steward", "name": "Node Steward NODE2"},
+]
+
+# Thresholds
+MAX_HEARTBEAT_AGE_MINUTES = 10
+MAX_PHANTOM_AGENTS = 20
+MAX_STALE_AGENTS = 20
+
+
+# ==============================================================================
+# API Client
+# ==============================================================================
+
+class APIClient:
+ """HTTP client for city-service API"""
+
+ def __init__(self, base_url: str, timeout: int = 10):
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+
+ def get(self, path: str) -> Tuple[Optional[Dict], Optional[str]]:
+ """GET request, returns (data, error)"""
+ url = f"{self.base_url}{path}"
+ try:
+ response = requests.get(url, timeout=self.timeout)
+ if response.status_code == 200:
+ return response.json(), None
+ elif response.status_code == 404:
+ return None, f"Not found: {path}"
+ else:
+ return None, f"HTTP {response.status_code}: {response.text[:200]}"
+ except requests.exceptions.ConnectionError:
+ return None, f"Connection error: {url}"
+ except requests.exceptions.Timeout:
+ return None, f"Timeout: {url}"
+ except Exception as e:
+ return None, str(e)
+
+ def post(self, path: str, data: Dict) -> Tuple[Optional[Dict], Optional[str]]:
+ """POST request, returns (data, error)"""
+ url = f"{self.base_url}{path}"
+ try:
+ response = requests.post(url, json=data, timeout=self.timeout)
+ if response.status_code == 200:
+ return response.json(), None
+ else:
+ return None, f"HTTP {response.status_code}: {response.text[:200]}"
+ except Exception as e:
+ return None, str(e)
+
+
+# ==============================================================================
+# Invariant Checks
+# ==============================================================================
+
+def check_node_exists(client: APIClient, node_id: str, results: CheckResults):
+ """Перевірити що нода існує і має базові метрики"""
+ inv_name = f"Node exists: {node_id}"
+
+ data, error = client.get(f"/internal/node/{node_id}/metrics/current")
+
+ if error:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Cannot fetch node metrics: {error}",
+ severity=Severity.CRITICAL
+ ))
+ return None
+
+ if not data:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Node {node_id} not found in system",
+ severity=Severity.CRITICAL
+ ))
+ return None
+
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Node exists: {data.get('node_name', node_id)}"
+ ))
+
+ return data
+
+
+def check_node_metrics(client: APIClient, node_id: str, metrics: Dict, results: CheckResults):
+ """Перевірити метрики ноди"""
+
+ # Check agent counts
+ agent_count_router = metrics.get("agent_count_router", 0)
+ agent_count_system = metrics.get("agent_count_system", 0)
+
+ inv_name = f"Node {node_id}: agent_count_router"
+ if agent_count_router >= 1:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Router has {agent_count_router} agents"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Router has 0 agents (expected >= 1)",
+ severity=Severity.CRITICAL
+ ))
+
+ inv_name = f"Node {node_id}: agent_count_system"
+ if agent_count_system >= 1:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"System has {agent_count_system} agents"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"System has 0 agents (expected >= 1)",
+ severity=Severity.CRITICAL
+ ))
+
+ # Check GPU for NODE1 (production)
+ if node_id == NODE1_ID:
+ gpu_model = metrics.get("gpu_model")
+ gpu_memory = metrics.get("gpu_memory_total", 0)
+
+ inv_name = f"Node {node_id}: GPU configured"
+ if gpu_model and gpu_memory > 0:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"GPU: {gpu_model}, VRAM: {gpu_memory}MB"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="GPU not configured (may be expected for this node)",
+ severity=Severity.WARNING
+ ))
+
+ # Check heartbeat
+ last_heartbeat = metrics.get("last_heartbeat")
+ if last_heartbeat:
+ inv_name = f"Node {node_id}: heartbeat fresh"
+ try:
+ hb_time = datetime.fromisoformat(last_heartbeat.replace("Z", "+00:00"))
+ age = datetime.now(timezone.utc) - hb_time
+ age_minutes = age.total_seconds() / 60
+
+ if age_minutes <= MAX_HEARTBEAT_AGE_MINUTES:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Last heartbeat: {age_minutes:.1f} minutes ago"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Heartbeat stale: {age_minutes:.1f} minutes ago (max: {MAX_HEARTBEAT_AGE_MINUTES})",
+ severity=Severity.WARNING
+ ))
+ except Exception as e:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Cannot parse heartbeat: {e}",
+ severity=Severity.WARNING
+ ))
+
+
+def check_node_agents(client: APIClient, node_id: str, results: CheckResults):
+ """Перевірити Node Guardian та Steward"""
+
+ data, error = client.get(f"/internal/node/{node_id}/agents")
+
+ if error:
+ results.failed.append(InvariantResult(
+ name=f"Node {node_id}: fetch agents",
+ passed=False,
+ message=f"Cannot fetch node agents: {error}",
+ severity=Severity.CRITICAL
+ ))
+ return
+
+ # Check Guardian
+ guardian = data.get("guardian")
+ inv_name = f"Node {node_id}: Node Guardian exists"
+ if guardian:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Guardian: {guardian.get('name', guardian.get('id'))}"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="Node Guardian not found",
+ severity=Severity.CRITICAL
+ ))
+
+ # Check Steward
+ steward = data.get("steward")
+ inv_name = f"Node {node_id}: Node Steward exists"
+ if steward:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Steward: {steward.get('name', steward.get('id'))}"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="Node Steward not found",
+ severity=Severity.CRITICAL
+ ))
+
+ # Check total agents
+ total = data.get("total", 0)
+ inv_name = f"Node {node_id}: has agents"
+ if total >= 1:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Node has {total} agents"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="Node has 0 agents",
+ severity=Severity.CRITICAL
+ ))
+
+
+def check_dagi_router(client: APIClient, node_id: str, results: CheckResults):
+ """Перевірити DAGI Router стан"""
+
+ data, error = client.get(f"/internal/node/{node_id}/dagi-router/agents")
+
+ if error:
+ results.warnings.append(InvariantResult(
+ name=f"Node {node_id}: DAGI Router check",
+ passed=False,
+ message=f"Cannot fetch DAGI Router agents: {error}",
+ severity=Severity.WARNING
+ ))
+ return
+
+ summary = data.get("summary", {})
+
+ # Check router has agents
+ router_total = summary.get("router_total", 0)
+ inv_name = f"Node {node_id}: DAGI Router has agents"
+ if router_total >= 1:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Router has {router_total} agents configured"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="DAGI Router has 0 agents",
+ severity=Severity.WARNING
+ ))
+
+ # Check phantom agents
+ phantom_count = summary.get("phantom", 0)
+ inv_name = f"Node {node_id}: phantom agents limit"
+ if phantom_count <= MAX_PHANTOM_AGENTS:
+ if phantom_count > 0:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Phantom agents: {phantom_count} (consider syncing)",
+ severity=Severity.INFO
+ ))
+ else:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message="No phantom agents"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Too many phantom agents: {phantom_count} (max: {MAX_PHANTOM_AGENTS})",
+ severity=Severity.WARNING
+ ))
+
+ # Check stale agents
+ stale_count = summary.get("stale", 0)
+ inv_name = f"Node {node_id}: stale agents limit"
+ if stale_count <= MAX_STALE_AGENTS:
+ if stale_count > 0:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Stale agents: {stale_count} (consider cleanup)",
+ severity=Severity.INFO
+ ))
+ else:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message="No stale agents"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Too many stale agents: {stale_count} (max: {MAX_STALE_AGENTS})",
+ severity=Severity.WARNING
+ ))
+
+
+def check_core_agents_prompts(client: APIClient, results: CheckResults):
+ """Перевірити що core агенти мають system prompts"""
+
+ # Collect all agent IDs we need to check
+ agent_ids = [a["slug"] for a in CORE_AGENTS]
+
+ # Batch check prompts status
+ data, error = client.post("/internal/agents/prompts/status", {"agent_ids": agent_ids})
+
+ if error:
+ results.warnings.append(InvariantResult(
+ name="Core agents: prompts status",
+ passed=False,
+ message=f"Cannot check prompts status: {error}",
+ severity=Severity.WARNING
+ ))
+ return
+
+ status = data.get("status", {})
+
+ # Check each core agent (group by name to avoid duplicate checks)
+ checked_names = set()
+ for agent in CORE_AGENTS:
+ if agent["name"] in checked_names:
+ continue
+
+ slug = agent["slug"]
+ has_prompts = status.get(slug, False)
+
+ inv_name = f"Core agent: {agent['name']} has prompts"
+ if has_prompts:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Agent {agent['name']} has system prompts"
+ ))
+ checked_names.add(agent["name"])
+ else:
+ # Try alternative slug
+ alt_slug = slug.replace("agent-", "") if slug.startswith("agent-") else f"agent-{slug}"
+ if status.get(alt_slug, False):
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Agent {agent['name']} has system prompts (alt slug)"
+ ))
+ checked_names.add(agent["name"])
+ else:
+ # Don't fail, just warn - prompts may not be migrated yet
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Agent {agent['name']} missing system prompts",
+ severity=Severity.WARNING
+ ))
+ checked_names.add(agent["name"])
+
+
+def check_healthz(client: APIClient, results: CheckResults):
+ """Перевірити /healthz endpoint"""
+
+ data, error = client.get("/healthz")
+
+ inv_name = "City service: /healthz"
+ if error:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Health check failed: {error}",
+ severity=Severity.CRITICAL
+ ))
+ else:
+ status = data.get("status", "unknown") if data else "unknown"
+ if status == "ok":
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message="City service healthy"
+ ))
+ else:
+ results.failed.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"City service unhealthy: {status}",
+ severity=Severity.CRITICAL
+ ))
+
+
+def check_node_self_healing(client: APIClient, node_id: str, results: CheckResults):
+ """Перевірити self-healing статус ноди"""
+
+ data, error = client.get(f"/internal/node/{node_id}/self-healing/status")
+
+ if error:
+ results.warnings.append(InvariantResult(
+ name=f"Node {node_id}: self-healing status",
+ passed=False,
+ message=f"Cannot fetch self-healing status: {error}",
+ severity=Severity.WARNING
+ ))
+ return
+
+ # Check if registered
+ inv_name = f"Node {node_id}: registered in node_registry"
+ if data.get("registered"):
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Node registered: {data.get('name', node_id)}"
+ ))
+ else:
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message="Node not in node_registry (run migration 039)",
+ severity=Severity.WARNING
+ ))
+
+ # Check self-healing status
+ sh_status = data.get("self_healing_status", "unknown")
+ inv_name = f"Node {node_id}: self-healing status"
+ if sh_status == "healthy":
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message="Self-healing status: healthy"
+ ))
+ elif sh_status == "error":
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"Self-healing status: error",
+ severity=Severity.WARNING
+ ))
+ else:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message=f"Self-healing status: {sh_status}"
+ ))
+
+
+def check_nodes_needing_healing(client: APIClient, results: CheckResults):
+ """Перевірити чи є ноди, які потребують healing"""
+
+ data, error = client.get("/internal/nodes/needing-healing")
+
+ if error:
+ results.warnings.append(InvariantResult(
+ name="System: nodes needing healing",
+ passed=False,
+ message=f"Cannot check: {error}",
+ severity=Severity.WARNING
+ ))
+ return
+
+ nodes = data.get("nodes", [])
+ total = data.get("total", 0)
+
+ inv_name = "System: nodes needing healing"
+ if total == 0:
+ results.passed.append(InvariantResult(
+ name=inv_name,
+ passed=True,
+ message="No nodes need healing"
+ ))
+ else:
+ reasons = [n.get("healing_reason", "unknown") for n in nodes[:3]]
+ results.warnings.append(InvariantResult(
+ name=inv_name,
+ passed=False,
+ message=f"{total} node(s) need healing: {', '.join(reasons)}",
+ severity=Severity.WARNING
+ ))
+
+
+# ==============================================================================
+# Main
+# ==============================================================================
+
+def run_all_checks(base_url: str, node_filter: Optional[str] = None) -> CheckResults:
+ """Запустити всі перевірки інваріантів"""
+
+ client = APIClient(base_url)
+ results = CheckResults()
+
+ print(f"\n{'='*60}")
+ print("DAARION Infrastructure Invariants Check")
+ print(f"{'='*60}")
+ print(f"Base URL: {base_url}")
+ print(f"Time: {datetime.now().isoformat()}")
+ print(f"{'='*60}\n")
+
+ # Health check first
+ print("🔍 Checking city-service health...")
+ check_healthz(client, results)
+
+ # Determine which nodes to check
+ nodes_to_check = []
+ if node_filter:
+ nodes_to_check = [node_filter]
+ else:
+ nodes_to_check = [NODE1_ID, NODE2_ID]
+
+ # Check each node
+ for node_id in nodes_to_check:
+ print(f"\n🔍 Checking node: {node_id}")
+
+ # Node exists and metrics
+ metrics = check_node_exists(client, node_id, results)
+ if metrics:
+ check_node_metrics(client, node_id, metrics, results)
+
+ # Node agents (Guardian/Steward)
+ check_node_agents(client, node_id, results)
+
+ # DAGI Router
+ check_dagi_router(client, node_id, results)
+
+ # Self-healing status
+ check_node_self_healing(client, node_id, results)
+
+ # Core agents prompts
+ print("\n🔍 Checking core agents prompts...")
+ check_core_agents_prompts(client, results)
+
+ # System-wide checks
+ print("\n🔍 Checking system-wide self-healing...")
+ check_nodes_needing_healing(client, results)
+
+ return results
+
+
+def print_results(results: CheckResults):
+ """Вивести результати перевірки"""
+
+ print(f"\n{'='*60}")
+ print("RESULTS")
+ print(f"{'='*60}\n")
+
+ # Passed
+ if results.passed:
+ print(f"✅ PASSED ({len(results.passed)}):")
+ for r in results.passed:
+ print(f" ✓ {r.name}: {r.message}")
+
+ # Warnings
+ if results.warnings:
+ print(f"\n⚠️ WARNINGS ({len(results.warnings)}):")
+ for r in results.warnings:
+ print(f" ⚠ {r.name}: {r.message}")
+
+ # Failed
+ if results.failed:
+ print(f"\n❌ FAILED ({len(results.failed)}):")
+ for r in results.failed:
+ severity = f"[{r.severity.value}]" if r.severity else ""
+ print(f" ✗ {severity} {r.name}: {r.message}")
+
+ # Summary
+ print(f"\n{'='*60}")
+ print("SUMMARY")
+ print(f"{'='*60}")
+ print(f" Total checks: {results.total_checks}")
+ print(f" Passed: {len(results.passed)}")
+ print(f" Warnings: {len(results.warnings)}")
+ print(f" Failed: {len(results.failed)}")
+
+ if results.has_critical_failures:
+ print(f"\n❌ INVARIANT CHECK FAILED - Critical issues found!")
+ return 1
+ elif results.failed:
+ print(f"\n⚠️ INVARIANT CHECK PASSED with warnings")
+ return 0 # Non-critical failures don't fail the deploy
+ else:
+ print(f"\n✅ ALL INVARIANTS PASSED")
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="DAARION Infrastructure Invariants Check"
+ )
+ parser.add_argument(
+ "--base-url",
+ default="http://daarion-city-service:7001",
+ help="Base URL of city-service API"
+ )
+ parser.add_argument(
+ "--node",
+ help="Check only specific node (e.g., node-1-hetzner-gex44)"
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output results as JSON"
+ )
+
+ args = parser.parse_args()
+
+ # Run checks
+ results = run_all_checks(args.base_url, args.node)
+
+ # Output
+ if args.json:
+ output = {
+ "passed": [{"name": r.name, "message": r.message} for r in results.passed],
+ "warnings": [{"name": r.name, "message": r.message} for r in results.warnings],
+ "failed": [{"name": r.name, "message": r.message, "severity": r.severity.value} for r in results.failed],
+ "success": not results.has_critical_failures
+ }
+ print(json.dumps(output, indent=2))
+ sys.exit(0 if not results.has_critical_failures else 1)
+ else:
+ exit_code = print_results(results)
+ sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/scripts/dagi_agent_audit.py b/scripts/dagi_agent_audit.py
new file mode 100644
index 00000000..6fd4ad5d
--- /dev/null
+++ b/scripts/dagi_agent_audit.py
@@ -0,0 +1,482 @@
+#!/usr/bin/env python3
+"""
+DAGI Agent Audit Script
+
+Порівнює агентів з DAGI Router config та БД microdao.
+Виявляє:
+- Active: агенти є в обох системах
+- Phantom: агенти є в Router, але немає в БД
+- Stale: агенти є в БД, але немає в Router
+
+Використання:
+ python scripts/dagi_agent_audit.py --node node1
+ python scripts/dagi_agent_audit.py --node node2
+ python scripts/dagi_agent_audit.py --all
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Set, Any, Optional
+
+import yaml
+
+# Додати root проєкту до path
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+try:
+ import asyncpg
+except ImportError:
+ print("❌ asyncpg not installed. Run: pip install asyncpg")
+ sys.exit(1)
+
+
+# ==============================================================================
+# Configuration
+# ==============================================================================
+
+NODE_CONFIG = {
+ "node1": {
+ "id": "node-1-hetzner-gex44",
+ "router_config": PROJECT_ROOT / "router-config.yml",
+ "router_url": "http://localhost:9102", # На NODE1
+ "description": "Production Server (Hetzner)"
+ },
+ "node2": {
+ "id": "node-2-macbook-m4max",
+ "router_config": PROJECT_ROOT / "router-config.yml", # Локальний config
+ "router_url": "http://localhost:9102", # На NODE2
+ "description": "Development Node (MacBook)"
+ }
+}
+
+DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
+
+
+# ==============================================================================
+# Router Config Parser
+# ==============================================================================
+
+def parse_router_config(config_path: Path) -> Dict[str, Any]:
+ """Парсити router-config.yml"""
+ if not config_path.exists():
+ print(f"⚠️ Router config not found: {config_path}")
+ return {"agents": {}}
+
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+
+ return config
+
+
+def get_router_agents(config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Витягти список агентів з конфігу Router"""
+ agents_config = config.get("agents", {})
+
+ agents = []
+ for agent_id, agent_data in agents_config.items():
+ agents.append({
+ "id": agent_id,
+ "name": agent_id, # В конфігу ім'я = ключ
+ "description": agent_data.get("description", ""),
+ "default_llm": agent_data.get("default_llm", ""),
+ "tools": [t.get("id") for t in agent_data.get("tools", [])],
+ "source": "router_config"
+ })
+
+ return agents
+
+
+# ==============================================================================
+# Database Access
+# ==============================================================================
+
+async def get_db_agents(node_id: str, database_url: str) -> List[Dict[str, Any]]:
+ """Отримати агентів з БД для конкретної ноди"""
+ conn = await asyncpg.connect(database_url)
+
+ try:
+ # Спочатку спробуємо по node_id, якщо є
+ query = """
+ SELECT
+ id::text,
+ external_id,
+ COALESCE(name, display_name) as name,
+ kind,
+ node_id,
+ status,
+ COALESCE(is_active, true) as is_active,
+ 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 conn.fetch(query)
+
+ agents = []
+ for row in rows:
+ agents.append({
+ "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"],
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ "source": "database"
+ })
+
+ return agents
+
+ finally:
+ await conn.close()
+
+
+async def update_agent_last_seen(agent_ids: List[str], database_url: str):
+ """Оновити last_seen_at для агентів"""
+ if not agent_ids:
+ return
+
+ conn = await asyncpg.connect(database_url)
+
+ try:
+ # Перевіримо чи є колонка last_seen_at
+ col_check = await conn.fetchval("""
+ SELECT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'agents' AND column_name = 'last_seen_at'
+ )
+ """)
+
+ if col_check:
+ await conn.execute("""
+ UPDATE agents
+ SET last_seen_at = NOW()
+ WHERE id = ANY($1::uuid[])
+ """, agent_ids)
+ print(f"✅ Updated last_seen_at for {len(agent_ids)} agents")
+ else:
+ print("⚠️ Column last_seen_at doesn't exist yet (migration needed)")
+
+ finally:
+ await conn.close()
+
+
+# ==============================================================================
+# Audit Logic
+# ==============================================================================
+
+def normalize_agent_name(name: str) -> str:
+ """Нормалізувати ім'я агента для порівняння"""
+ return name.lower().replace(" ", "").replace("-", "").replace("_", "")
+
+
+def match_agents(router_agents: List[Dict], db_agents: List[Dict]) -> Dict[str, Any]:
+ """
+ Зіставити агентів Router та БД.
+ Повертає словник з категоріями.
+ """
+ # Створюємо індекси
+ router_by_name = {normalize_agent_name(a["name"]): a for a in router_agents}
+ router_by_id = {a["id"]: a for a in router_agents}
+
+ db_by_name = {normalize_agent_name(a["name"]): a for a in db_agents}
+ db_by_external_id = {}
+ for a in db_agents:
+ if a.get("external_id"):
+ # Витягти останню частину external_id (agent:daarwizz -> daarwizz)
+ ext_id = a["external_id"].split(":")[-1] if ":" in a["external_id"] else a["external_id"]
+ db_by_external_id[ext_id.lower()] = a
+
+ active = []
+ phantom = []
+ stale = []
+
+ matched_db_ids = set()
+
+ # Перевірити кожного агента з Router
+ for r_agent in router_agents:
+ r_name_norm = normalize_agent_name(r_agent["name"])
+ r_id_norm = r_agent["id"].lower()
+
+ # Шукаємо відповідність в БД
+ db_match = None
+
+ # По external_id
+ if r_id_norm in db_by_external_id:
+ db_match = db_by_external_id[r_id_norm]
+ # По імені
+ elif r_name_norm in db_by_name:
+ db_match = db_by_name[r_name_norm]
+
+ if db_match:
+ active.append({
+ "router": r_agent,
+ "db": db_match,
+ "status": "active"
+ })
+ matched_db_ids.add(db_match["id"])
+ else:
+ phantom.append({
+ "router": r_agent,
+ "db": None,
+ "status": "phantom",
+ "reason": "In Router config but not in DB"
+ })
+
+ # Знайти stale агентів (є в БД, немає в Router)
+ for db_agent in db_agents:
+ if db_agent["id"] not in matched_db_ids:
+ # Перевірити чи це агент ноди
+ # (деякі агенти можуть бути системними і не в Router)
+ stale.append({
+ "router": None,
+ "db": db_agent,
+ "status": "stale",
+ "reason": "In DB but not in Router config"
+ })
+
+ return {
+ "active": active,
+ "phantom": phantom,
+ "stale": stale,
+ "summary": {
+ "router_total": len(router_agents),
+ "db_total": len(db_agents),
+ "active_count": len(active),
+ "phantom_count": len(phantom),
+ "stale_count": len(stale)
+ }
+ }
+
+
+# ==============================================================================
+# Report Generation
+# ==============================================================================
+
+def generate_report(
+ node_id: str,
+ node_config: Dict[str, Any],
+ audit_result: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Згенерувати JSON-звіт"""
+ report = {
+ "node_id": node_id,
+ "node_description": node_config.get("description", ""),
+ "timestamp": datetime.utcnow().isoformat() + "Z",
+ "summary": audit_result["summary"],
+ "active_agents": [
+ {
+ "router_id": a["router"]["id"],
+ "router_name": a["router"]["name"],
+ "db_id": a["db"]["id"],
+ "db_name": a["db"]["name"],
+ "db_external_id": a["db"].get("external_id"),
+ "kind": a["db"].get("kind"),
+ "status": a["db"].get("status", "unknown")
+ }
+ for a in audit_result["active"]
+ ],
+ "phantom_agents": [
+ {
+ "router_id": a["router"]["id"],
+ "router_name": a["router"]["name"],
+ "description": a["router"].get("description", ""),
+ "reason": a["reason"]
+ }
+ for a in audit_result["phantom"]
+ ],
+ "stale_agents": [
+ {
+ "db_id": a["db"]["id"],
+ "db_name": a["db"]["name"],
+ "db_external_id": a["db"].get("external_id"),
+ "kind": a["db"].get("kind"),
+ "reason": a["reason"]
+ }
+ for a in audit_result["stale"]
+ ]
+ }
+
+ return report
+
+
+def print_report(report: Dict[str, Any], verbose: bool = False):
+ """Вивести звіт на консоль"""
+ print("\n" + "=" * 70)
+ print(f"🔍 DAGI AGENT AUDIT REPORT")
+ print(f" Node: {report['node_id']}")
+ print(f" Time: {report['timestamp']}")
+ print("=" * 70)
+
+ summary = report["summary"]
+ print(f"\n📊 Summary:")
+ print(f" Router agents: {summary['router_total']}")
+ print(f" DB agents: {summary['db_total']}")
+ print(f" ✅ Active: {summary['active_count']}")
+ print(f" 👻 Phantom: {summary['phantom_count']}")
+ print(f" 📦 Stale: {summary['stale_count']}")
+
+ if report["active_agents"]:
+ print(f"\n✅ ACTIVE AGENTS ({len(report['active_agents'])}):")
+ for a in report["active_agents"][:10]: # Показати перші 10
+ print(f" • {a['router_name']} ({a['kind'] or 'unknown'}) - {a['status']}")
+ if len(report["active_agents"]) > 10:
+ print(f" ... and {len(report['active_agents']) - 10} more")
+
+ if report["phantom_agents"]:
+ print(f"\n👻 PHANTOM AGENTS (in Router, not in DB) ({len(report['phantom_agents'])}):")
+ for a in report["phantom_agents"]:
+ print(f" ⚠️ {a['router_name']} - {a['reason']}")
+ if verbose and a.get('description'):
+ print(f" Description: {a['description']}")
+
+ if report["stale_agents"]:
+ print(f"\n📦 STALE AGENTS (in DB, not in Router) ({len(report['stale_agents'])}):")
+ for a in report["stale_agents"][:10]: # Показати перші 10
+ print(f" 📌 {a['db_name']} ({a['kind'] or 'unknown'}) - {a['reason']}")
+ if len(report["stale_agents"]) > 10:
+ print(f" ... and {len(report['stale_agents']) - 10} more")
+
+ print("\n" + "=" * 70)
+
+
+def save_report(report: Dict[str, Any], output_dir: Path):
+ """Зберегти звіт у файл"""
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ filename = f"dagi-audit-{report['node_id']}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
+ filepath = output_dir / filename
+
+ with open(filepath, 'w', encoding='utf-8') as f:
+ json.dump(report, f, indent=2, ensure_ascii=False)
+
+ print(f"\n📄 Report saved to: {filepath}")
+
+ # Також зберегти "latest" версію
+ latest_path = output_dir / f"dagi-audit-{report['node_id']}-latest.json"
+ with open(latest_path, 'w', encoding='utf-8') as f:
+ json.dump(report, f, indent=2, ensure_ascii=False)
+
+
+# ==============================================================================
+# Main
+# ==============================================================================
+
+async def audit_node(node_key: str, config: Dict[str, Any], args) -> Dict[str, Any]:
+ """Виконати аудит для однієї ноди"""
+ print(f"\n🔍 Auditing {node_key}: {config['description']}...")
+
+ # 1. Отримати агентів з Router config
+ router_config = parse_router_config(config["router_config"])
+ router_agents = get_router_agents(router_config)
+ print(f" 📋 Found {len(router_agents)} agents in router-config.yml")
+
+ # 2. Отримати агентів з БД
+ db_url = args.database_url or DATABASE_URL
+ try:
+ db_agents = await get_db_agents(config["id"], db_url)
+ print(f" 📋 Found {len(db_agents)} agents in database")
+ except Exception as e:
+ print(f" ❌ Database error: {e}")
+ db_agents = []
+
+ # 3. Зіставити
+ audit_result = match_agents(router_agents, db_agents)
+
+ # 4. Генерувати звіт
+ report = generate_report(node_key, config, audit_result)
+
+ # 5. Вивести звіт
+ print_report(report, verbose=args.verbose)
+
+ # 6. Зберегти звіт
+ if args.output:
+ save_report(report, Path(args.output))
+ else:
+ save_report(report, PROJECT_ROOT / "logs" / "audit")
+
+ # 7. Оновити last_seen_at для active агентів
+ if args.update_seen and audit_result["active"]:
+ active_ids = [a["db"]["id"] for a in audit_result["active"]]
+ await update_agent_last_seen(active_ids, db_url)
+
+ return report
+
+
+async def main():
+ parser = argparse.ArgumentParser(description="DAGI Agent Audit")
+ parser.add_argument(
+ "--node",
+ choices=["node1", "node2", "all"],
+ default="all",
+ help="Node to audit (default: all)"
+ )
+ parser.add_argument(
+ "--database-url",
+ help=f"Database URL (default: {DATABASE_URL})"
+ )
+ parser.add_argument(
+ "--output", "-o",
+ help="Output directory for reports (default: logs/audit)"
+ )
+ parser.add_argument(
+ "--verbose", "-v",
+ action="store_true",
+ help="Verbose output"
+ )
+ parser.add_argument(
+ "--update-seen",
+ action="store_true",
+ help="Update last_seen_at for active agents"
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output only JSON (no console colors)"
+ )
+
+ args = parser.parse_args()
+
+ reports = []
+
+ if args.node == "all":
+ for node_key, config in NODE_CONFIG.items():
+ report = await audit_node(node_key, config, args)
+ reports.append(report)
+ else:
+ config = NODE_CONFIG.get(args.node)
+ if not config:
+ print(f"❌ Unknown node: {args.node}")
+ sys.exit(1)
+ report = await audit_node(args.node, config, args)
+ reports.append(report)
+
+ # Вивести JSON якщо потрібно
+ if args.json:
+ print(json.dumps(reports, indent=2))
+
+ # Підсумок
+ print("\n" + "=" * 70)
+ print("🎯 AUDIT COMPLETE")
+ for r in reports:
+ s = r["summary"]
+ status = "✅" if s["phantom_count"] == 0 and s["stale_count"] == 0 else "⚠️"
+ print(f" {status} {r['node_id']}: {s['active_count']} active, {s['phantom_count']} phantom, {s['stale_count']} stale")
+ print("=" * 70 + "\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh
index 948ae195..6970b905 100644
--- a/scripts/deploy-prod.sh
+++ b/scripts/deploy-prod.sh
@@ -188,18 +188,64 @@ echo " - Docker logs: docker logs "
echo " - Application logs: $LOG_DIR"
echo " - Caddy logs: docker logs daarion-caddy"
+# Run infrastructure invariants check
+log_info "Running infrastructure invariants check..."
+INVARIANTS_FAILED=0
+
+# Wait a bit more for services to fully initialize
+sleep 5
+
+# Run invariants check
+if [ -f "scripts/check-invariants.py" ]; then
+ # Try to run invariants check
+ if command -v python3 &> /dev/null; then
+ # Use internal Docker network URL or localhost
+ CITY_URL="${CITY_SERVICE_URL:-http://localhost:7001}"
+
+ python3 scripts/check-invariants.py --base-url "$CITY_URL" || {
+ INVARIANTS_FAILED=1
+ log_error "Infrastructure invariants check FAILED!"
+ }
+ else
+ log_warning "Python3 not found, skipping invariants check"
+ fi
+else
+ log_warning "check-invariants.py not found, skipping invariants check"
+fi
+
+# Run smoke tests (optional)
+if [ -f "tests/test_infra_smoke.py" ] && [ "$RUN_SMOKE_TESTS" = "true" ]; then
+ log_info "Running smoke tests..."
+ pytest tests/test_infra_smoke.py -v --tb=short || {
+ log_warning "Some smoke tests failed (non-blocking)"
+ }
+fi
+
# Success message
echo ""
-if [ $HEALTH_FAILED -eq 0 ]; then
+if [ $HEALTH_FAILED -eq 0 ] && [ $INVARIANTS_FAILED -eq 0 ]; then
log_success "🎉 Deployment completed successfully!"
echo ""
echo " 🌐 Application: https://app.daarion.space"
echo " 📊 Monitoring: https://app.daarion.space/grafana/"
echo ""
+ echo " ✅ All infrastructure invariants passed"
+ echo ""
echo " Next steps:"
- echo " 1. Run smoke tests: docs/DEPLOY_SMOKETEST_CHECKLIST.md"
+ echo " 1. Run smoke tests: RUN_SMOKE_TESTS=true ./scripts/deploy-prod.sh"
echo " 2. Monitor logs: docker logs -f daarion-gateway"
echo " 3. Check metrics: docker stats"
+elif [ $INVARIANTS_FAILED -eq 1 ]; then
+ log_error "Deployment completed but INVARIANTS CHECK FAILED!"
+ echo ""
+ echo " ❌ Some infrastructure invariants are not met."
+ echo " Please review the output above and fix the issues."
+ echo ""
+ echo " Common fixes:"
+ echo " 1. Run migrations: scripts/migrate.sh"
+ echo " 2. Seed agents: psql < migrations/038_agent_prompts_full_coverage.sql"
+ echo " 3. Check node_cache: psql < migrations/036_node_metrics_extended.sql"
+ exit 1
else
log_error "Deployment completed with errors. Check logs above."
exit 1
diff --git a/scripts/node-bootstrap.sh b/scripts/node-bootstrap.sh
new file mode 100755
index 00000000..e5f95c57
--- /dev/null
+++ b/scripts/node-bootstrap.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+#
+# DAARION Node Bootstrap Script
+# Виконує самореєстрацію ноди при старті.
+#
+# Використання:
+# ./scripts/node-bootstrap.sh
+#
+# Environment variables:
+# CITY_SERVICE_URL - URL city-service (default: http://localhost:7001)
+# NODE_ID - Ідентифікатор ноди (required)
+# NODE_NAME - Назва ноди (required)
+# NODE_ENVIRONMENT - production|development|staging (default: development)
+# NODE_HOSTNAME - Hostname (optional)
+# NODE_ROLES - Ролі через кому: gpu,ai_runtime,storage (default: gpu,ai_runtime)
+# NODE_DESCRIPTION - Опис ноди (optional)
+
+set -euo pipefail
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+log_info() {
+ echo -e "${BLUE}[NODE-BOOTSTRAP]${NC} $1"
+}
+
+log_success() {
+ echo -e "${GREEN}[NODE-BOOTSTRAP]${NC} ✅ $1"
+}
+
+log_error() {
+ echo -e "${RED}[NODE-BOOTSTRAP]${NC} ❌ $1"
+}
+
+log_warning() {
+ echo -e "${YELLOW}[NODE-BOOTSTRAP]${NC} ⚠️ $1"
+}
+
+# Configuration
+CITY_SERVICE_URL="${CITY_SERVICE_URL:-http://localhost:7001}"
+NODE_ID="${NODE_ID:-}"
+NODE_NAME="${NODE_NAME:-}"
+NODE_ENVIRONMENT="${NODE_ENVIRONMENT:-development}"
+NODE_HOSTNAME="${NODE_HOSTNAME:-$(hostname 2>/dev/null || echo '')}"
+NODE_ROLES="${NODE_ROLES:-gpu,ai_runtime}"
+NODE_DESCRIPTION="${NODE_DESCRIPTION:-}"
+
+# Retry settings
+MAX_RETRIES=5
+RETRY_DELAY=5
+
+# Validate required params
+if [ -z "$NODE_ID" ]; then
+ log_error "NODE_ID is required"
+ exit 1
+fi
+
+if [ -z "$NODE_NAME" ]; then
+ log_error "NODE_NAME is required"
+ exit 1
+fi
+
+# Convert roles to JSON array
+roles_json=""
+IFS=',' read -ra ROLE_ARRAY <<< "$NODE_ROLES"
+for i in "${!ROLE_ARRAY[@]}"; do
+ if [ $i -eq 0 ]; then
+ roles_json="\"${ROLE_ARRAY[$i]}\""
+ else
+ roles_json="$roles_json, \"${ROLE_ARRAY[$i]}\""
+ fi
+done
+roles_json="[$roles_json]"
+
+# Build payload
+payload=$(cat </dev/null || echo -e "\n000")
+
+ http_code=$(echo "$response" | tail -n1)
+ body=$(echo "$response" | sed '$d')
+
+ if [ "$http_code" = "200" ]; then
+ success=$(echo "$body" | grep -o '"success":\s*true' || true)
+
+ if [ -n "$success" ]; then
+ is_new=$(echo "$body" | grep -o '"is_new":\s*true' || true)
+
+ if [ -n "$is_new" ]; then
+ log_success "Node registered successfully (new registration)"
+ else
+ log_success "Node updated successfully"
+ fi
+
+ # Optional: run initial heartbeat
+ log_info "Sending initial heartbeat..."
+ curl -s -X POST "${CITY_SERVICE_URL}/internal/node/${NODE_ID}/heartbeat" \
+ -H "Content-Type: application/json" \
+ -d '{"metrics": {}}' \
+ --max-time 5 > /dev/null 2>&1 || true
+
+ log_success "Node bootstrap completed"
+ exit 0
+ fi
+ fi
+
+ log_warning "Registration failed (HTTP $http_code)"
+
+ if [ $attempt -lt $MAX_RETRIES ]; then
+ log_info "Retrying in ${RETRY_DELAY}s..."
+ sleep $RETRY_DELAY
+ fi
+
+ attempt=$((attempt + 1))
+done
+
+log_error "Node registration failed after $MAX_RETRIES attempts"
+log_error "Please check:"
+log_error " 1. City service is running at $CITY_SERVICE_URL"
+log_error " 2. Migration 039_node_registry_self_healing.sql is applied"
+log_error " 3. Network connectivity to city service"
+exit 1
+
diff --git a/scripts/node-guardian-loop.py b/scripts/node-guardian-loop.py
new file mode 100755
index 00000000..de16e26a
--- /dev/null
+++ b/scripts/node-guardian-loop.py
@@ -0,0 +1,432 @@
+#!/usr/bin/env python3
+"""
+DAARION Node Guardian Self-Healing Loop
+
+Періодично перевіряє стан ноди та виконує self-healing якщо потрібно.
+Запускається як фоновий процес на кожній ноді.
+
+Використання:
+ python scripts/node-guardian-loop.py
+ python scripts/node-guardian-loop.py --node-id node-2-macbook-m4max
+ python scripts/node-guardian-loop.py --interval 300 # 5 хвилин
+
+Environment variables:
+ CITY_SERVICE_URL - URL city-service
+ NODE_ID - ID ноди
+ NODE_NAME - Назва ноди (для self-registration)
+ NODE_ENVIRONMENT - production/development
+ NODE_ROLES - Ролі через кому
+"""
+
+import argparse
+import asyncio
+import json
+import logging
+import os
+import sys
+from datetime import datetime, timezone
+from typing import Dict, Any, Optional
+
+try:
+ import httpx
+except ImportError:
+ print("❌ httpx not installed. Run: pip install httpx")
+ sys.exit(1)
+
+
+# ==============================================================================
+# Configuration
+# ==============================================================================
+
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s [NODE-GUARDIAN] %(levelname)s: %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S'
+)
+logger = logging.getLogger(__name__)
+
+DEFAULT_INTERVAL = 60 # seconds
+DEFAULT_CITY_URL = "http://localhost:7001"
+
+
+# ==============================================================================
+# Self-Healing Logic
+# ==============================================================================
+
+class NodeGuardian:
+ """Node Guardian — self-healing agent for DAARION nodes"""
+
+ def __init__(
+ self,
+ node_id: str,
+ node_name: str,
+ city_url: str,
+ environment: str = "development",
+ roles: list = None,
+ hostname: str = None
+ ):
+ self.node_id = node_id
+ self.node_name = node_name
+ self.city_url = city_url.rstrip("/")
+ self.environment = environment
+ self.roles = roles or []
+ self.hostname = hostname
+
+ self.client = httpx.AsyncClient(timeout=10.0)
+ self.healing_attempts = 0
+ self.last_successful_check = None
+
+ async def close(self):
+ await self.client.aclose()
+
+ async def check_visibility(self) -> bool:
+ """Перевірити чи нода видима в Node Directory"""
+ try:
+ response = await self.client.get(
+ f"{self.city_url}/internal/node/{self.node_id}/directory-check"
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ return data.get("visible_in_directory", False)
+
+ return False
+ except Exception as e:
+ logger.error(f"Visibility check failed: {e}")
+ return False
+
+ async def get_self_healing_status(self) -> Dict[str, Any]:
+ """Отримати статус self-healing"""
+ try:
+ response = await self.client.get(
+ f"{self.city_url}/internal/node/{self.node_id}/self-healing/status"
+ )
+
+ if response.status_code == 200:
+ return response.json()
+
+ return {"registered": False, "status": "unknown"}
+ except Exception as e:
+ logger.error(f"Status check failed: {e}")
+ return {"registered": False, "status": "error", "error": str(e)}
+
+ async def self_register(self) -> bool:
+ """Виконати самореєстрацію"""
+ try:
+ payload = {
+ "id": self.node_id,
+ "name": self.node_name,
+ "hostname": self.hostname,
+ "environment": self.environment,
+ "roles": self.roles
+ }
+
+ response = await self.client.post(
+ f"{self.city_url}/internal/nodes/register-or-update",
+ json=payload
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"✅ Self-registration successful: {data.get('message')}")
+ return True
+ else:
+ logger.warning(f"Self-registration returned false: {data}")
+ else:
+ logger.error(f"Self-registration failed: HTTP {response.status_code}")
+
+ return False
+ except Exception as e:
+ logger.error(f"Self-registration error: {e}")
+ return False
+
+ async def send_heartbeat(self, metrics: Dict = None) -> bool:
+ """Відправити heartbeat"""
+ try:
+ payload = {"metrics": metrics or {}}
+
+ response = await self.client.post(
+ f"{self.city_url}/internal/node/{self.node_id}/heartbeat",
+ json=payload
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+
+ if data.get("should_self_register"):
+ logger.warning("⚠️ Server requests self-registration")
+ return await self.self_register()
+
+ if data.get("success"):
+ return True
+
+ return False
+ except Exception as e:
+ logger.error(f"Heartbeat failed: {e}")
+ return False
+
+ async def trigger_healing(self) -> Dict[str, Any]:
+ """Тригернути self-healing через API"""
+ try:
+ response = await self.client.post(
+ f"{self.city_url}/internal/node/{self.node_id}/self-healing/trigger"
+ )
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ return {"error": f"HTTP {response.status_code}"}
+ except Exception as e:
+ return {"error": str(e)}
+
+ async def collect_metrics(self) -> Dict[str, Any]:
+ """Зібрати метрики ноди (базова реалізація)"""
+ # TODO: Implement real metrics collection
+ # For now, return empty metrics
+ return {}
+
+ async def run_health_check(self) -> Dict[str, Any]:
+ """
+ Виконати повну перевірку здоров'я ноди.
+
+ Returns:
+ {
+ "healthy": bool,
+ "checks": {
+ "visible_in_directory": bool,
+ "registered": bool,
+ "has_guardian": bool,
+ "has_steward": bool,
+ "heartbeat_fresh": bool
+ },
+ "actions_taken": []
+ }
+ """
+ result = {
+ "healthy": True,
+ "checks": {},
+ "actions_taken": [],
+ "timestamp": datetime.now(timezone.utc).isoformat()
+ }
+
+ # 1. Check visibility in directory
+ visible = await self.check_visibility()
+ result["checks"]["visible_in_directory"] = visible
+
+ if not visible:
+ result["healthy"] = False
+ logger.warning("⚠️ Node not visible in directory, attempting self-registration...")
+
+ registered = await self.self_register()
+ result["actions_taken"].append({
+ "action": "self_register",
+ "success": registered
+ })
+
+ if registered:
+ # Re-check visibility
+ visible = await self.check_visibility()
+ result["checks"]["visible_in_directory_after_heal"] = visible
+
+ # 2. Get detailed status
+ status = await self.get_self_healing_status()
+ result["checks"]["registered"] = status.get("registered", False)
+ result["checks"]["has_guardian"] = status.get("has_guardian", False)
+ result["checks"]["has_steward"] = status.get("has_steward", False)
+ result["checks"]["agent_count_router"] = status.get("agent_count_router", 0)
+ result["checks"]["agent_count_system"] = status.get("agent_count_system", 0)
+
+ # 3. Check if healing needed based on status
+ if status.get("self_healing_status") == "error":
+ result["healthy"] = False
+ logger.warning("⚠️ Node in error state, triggering healing...")
+
+ heal_result = await self.trigger_healing()
+ result["actions_taken"].append({
+ "action": "trigger_healing",
+ "result": heal_result
+ })
+
+ # 4. Send heartbeat with metrics
+ metrics = await self.collect_metrics()
+ heartbeat_ok = await self.send_heartbeat(metrics)
+ result["checks"]["heartbeat_sent"] = heartbeat_ok
+
+ if heartbeat_ok:
+ self.last_successful_check = datetime.now(timezone.utc)
+
+ # Update healthy status
+ if result["actions_taken"]:
+ # If we took actions, check if any failed
+ failed_actions = [a for a in result["actions_taken"] if not a.get("success", True)]
+ if failed_actions:
+ result["healthy"] = False
+
+ return result
+
+
+# ==============================================================================
+# Main Loop
+# ==============================================================================
+
+async def run_guardian_loop(
+ node_id: str,
+ node_name: str,
+ city_url: str,
+ environment: str,
+ roles: list,
+ hostname: str,
+ interval: int
+):
+ """Run the Node Guardian self-healing loop"""
+
+ guardian = NodeGuardian(
+ node_id=node_id,
+ node_name=node_name,
+ city_url=city_url,
+ environment=environment,
+ roles=roles,
+ hostname=hostname
+ )
+
+ logger.info("=" * 60)
+ logger.info("DAARION Node Guardian Starting")
+ logger.info("=" * 60)
+ logger.info(f" Node ID: {node_id}")
+ logger.info(f" Node Name: {node_name}")
+ logger.info(f" Environment: {environment}")
+ logger.info(f" City Service: {city_url}")
+ logger.info(f" Interval: {interval}s")
+ logger.info("=" * 60)
+
+ # Initial check
+ logger.info("Running initial health check...")
+ result = await guardian.run_health_check()
+
+ if result["healthy"]:
+ logger.info("✅ Initial check passed")
+ else:
+ logger.warning("⚠️ Initial check found issues:")
+ for action in result.get("actions_taken", []):
+ logger.warning(f" - {action}")
+
+ # Main loop
+ try:
+ while True:
+ await asyncio.sleep(interval)
+
+ logger.info(f"Running periodic health check...")
+ result = await guardian.run_health_check()
+
+ if result["healthy"]:
+ logger.info(f"✅ Health check passed")
+ else:
+ logger.warning(f"⚠️ Health check found issues")
+ for action in result.get("actions_taken", []):
+ logger.info(f" Action: {action['action']} - {action.get('success', 'done')}")
+
+ except KeyboardInterrupt:
+ logger.info("Shutting down Node Guardian...")
+ except Exception as e:
+ logger.error(f"Guardian loop error: {e}")
+ raise
+ finally:
+ await guardian.close()
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="DAARION Node Guardian Self-Healing Loop"
+ )
+ parser.add_argument(
+ "--node-id",
+ default=os.getenv("NODE_ID"),
+ help="Node ID (default: $NODE_ID)"
+ )
+ parser.add_argument(
+ "--node-name",
+ default=os.getenv("NODE_NAME"),
+ help="Node name (default: $NODE_NAME)"
+ )
+ parser.add_argument(
+ "--city-url",
+ default=os.getenv("CITY_SERVICE_URL", DEFAULT_CITY_URL),
+ help="City service URL"
+ )
+ parser.add_argument(
+ "--environment",
+ default=os.getenv("NODE_ENVIRONMENT", "development"),
+ help="Node environment"
+ )
+ parser.add_argument(
+ "--roles",
+ default=os.getenv("NODE_ROLES", "gpu,ai_runtime"),
+ help="Node roles (comma-separated)"
+ )
+ parser.add_argument(
+ "--hostname",
+ default=os.getenv("NODE_HOSTNAME"),
+ help="Node hostname"
+ )
+ parser.add_argument(
+ "--interval",
+ type=int,
+ default=int(os.getenv("GUARDIAN_INTERVAL", DEFAULT_INTERVAL)),
+ help=f"Check interval in seconds (default: {DEFAULT_INTERVAL})"
+ )
+ parser.add_argument(
+ "--once",
+ action="store_true",
+ help="Run single check and exit"
+ )
+
+ args = parser.parse_args()
+
+ # Validate required params
+ if not args.node_id:
+ logger.error("NODE_ID is required (--node-id or $NODE_ID)")
+ sys.exit(1)
+
+ if not args.node_name:
+ args.node_name = f"Node {args.node_id}"
+
+ roles = [r.strip() for r in args.roles.split(",") if r.strip()]
+
+ if args.once:
+ # Single check mode
+ async def single_check():
+ guardian = NodeGuardian(
+ node_id=args.node_id,
+ node_name=args.node_name,
+ city_url=args.city_url,
+ environment=args.environment,
+ roles=roles,
+ hostname=args.hostname
+ )
+
+ result = await guardian.run_health_check()
+ await guardian.close()
+
+ print(json.dumps(result, indent=2))
+
+ if not result["healthy"]:
+ sys.exit(1)
+
+ asyncio.run(single_check())
+ else:
+ # Loop mode
+ asyncio.run(run_guardian_loop(
+ node_id=args.node_id,
+ node_name=args.node_name,
+ city_url=args.city_url,
+ environment=args.environment,
+ roles=roles,
+ hostname=args.hostname,
+ interval=args.interval
+ ))
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/services/city-service/models_city.py b/services/city-service/models_city.py
index 7d013047..f0b82ae1 100644
--- a/services/city-service/models_city.py
+++ b/services/city-service/models_city.py
@@ -239,6 +239,23 @@ class NodeMicrodaoSummary(BaseModel):
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
@@ -256,6 +273,7 @@ class NodeProfile(BaseModel):
guardian_agent: Optional[NodeAgentSummary] = None
steward_agent: Optional[NodeAgentSummary] = None
microdaos: List[NodeMicrodaoSummary] = []
+ metrics: Optional[NodeMetrics] = None
class ModelBindings(BaseModel):
diff --git a/services/city-service/repo_city.py b/services/city-service/repo_city.py
index 1f245eb8..076c0880 100644
--- a/services/city-service/repo_city.py
+++ b/services/city-service/repo_city.py
@@ -3,9 +3,11 @@ Repository для City Backend (PostgreSQL)
"""
import os
+import uuid
import asyncpg
+import json
from typing import Optional, List, Dict, Any, Tuple
-from datetime import datetime
+from datetime import datetime, timezone
import secrets
import httpx
import logging
@@ -627,6 +629,304 @@ async def get_agent_prompts(agent_id: str) -> dict:
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 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()
@@ -807,7 +1107,7 @@ async def update_agent_status(agent_id: str, status: str, room_id: Optional[str]
async def get_agent_by_id(agent_id: str) -> Optional[dict]:
- """Отримати агента по ID"""
+ """Отримати агента по ID або public_slug"""
pool = await get_pool()
query = """
@@ -830,9 +1130,13 @@ async def get_agent_by_id(agent_id: str) -> Optional[dict]:
a.public_slug,
a.is_public,
a.district AS home_district,
- a.crew_team_key
+ 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
+ WHERE a.id = $1 OR a.public_slug = $1
"""
row = await pool.fetchrow(query, agent_id)
@@ -1706,32 +2010,132 @@ async def update_room_branding(
# =============================================================================
async def get_all_nodes() -> List[dict]:
- """Отримати список всіх нод з кількістю агентів та Guardian/Steward"""
+ """Отримати список всіх нод з кількістю агентів, Guardian/Steward та метриками.
+
+ ДЖЕРЕЛО ІСТИНИ:
+ 1. node_registry (якщо існує) + node_cache (метрики)
+ 2. Fallback: тільки node_cache (для зворотної сумісності)
+ """
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,
- ga.display_name AS guardian_name,
- sa.display_name AS steward_name
- 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
- """
+ # Перевіримо чи існує 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 = []
- rows = await pool.fetch(query)
result = []
for row in rows:
data = dict(row)
@@ -1740,6 +2144,7 @@ async def get_all_nodes() -> List[dict]:
data["guardian_agent"] = {
"id": data.get("guardian_agent_id"),
"name": data.get("guardian_name"),
+ "slug": data.get("guardian_slug"),
}
else:
data["guardian_agent"] = None
@@ -1748,12 +2153,48 @@ async def get_all_nodes() -> List[dict]:
data["steward_agent"] = {
"id": data.get("steward_agent_id"),
"name": data.get("steward_name"),
+ "slug": data.get("steward_slug"),
}
else:
data["steward_agent"] = None
- # Clean up
+
+ # 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
@@ -2537,3 +2978,965 @@ async def get_district_stats(district_id: str, district_slug: str) -> Dict[str,
"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(*)
+ FROM agents
+ WHERE (node_id = $1 OR node_id IS NULL)
+ AND COALESCE(is_archived, false) = false
+ AND COALESCE(is_test, false) = false
+ 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
+ }
+
+
+async def update_node_metrics(
+ node_id: str,
+ metrics: Dict[str, Any]
+) -> bool:
+ """
+ Оновити метрики ноди.
+ """
+ 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("""
+ 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()
+
+ 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
+ try:
+ exists = await pool.fetchval("""
+ SELECT EXISTS(SELECT 1 FROM node_cache WHERE node_id = $1)
+ """, node_id)
+ return bool(exists)
+ 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 []
diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py
index 3a9ef80f..0caf7c28 100644
--- a/services/city-service/routes_city.py
+++ b/services/city-service/routes_city.py
@@ -4,7 +4,8 @@ City Backend API Routes
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request, UploadFile, File, Form
from pydantic import BaseModel
-from typing import List, Optional
+from typing import List, Optional, Dict
+from datetime import datetime, timezone
import logging
import httpx
import os
@@ -512,12 +513,52 @@ docker compose up -d
@public_router.get("/nodes")
async def list_nodes():
- """Список всіх нод мережі"""
+ """Список всіх нод мережі з метриками"""
try:
+ from models_city import NodeMetrics, NodeAgentSummary
+
nodes = await repo_city.get_all_nodes()
items: List[NodeProfile] = []
for node in nodes:
+ # Build guardian agent
+ guardian_agent = None
+ if node.get("guardian_agent"):
+ guardian_agent = NodeAgentSummary(
+ id=node["guardian_agent"]["id"],
+ name=node["guardian_agent"].get("name"),
+ slug=node["guardian_agent"].get("slug")
+ )
+
+ # Build steward agent
+ steward_agent = None
+ if node.get("steward_agent"):
+ steward_agent = NodeAgentSummary(
+ id=node["steward_agent"]["id"],
+ name=node["steward_agent"].get("name"),
+ slug=node["steward_agent"].get("slug")
+ )
+
+ # Build metrics
+ metrics = None
+ if node.get("metrics"):
+ m = node["metrics"]
+ metrics = NodeMetrics(
+ cpu_model=m.get("cpu_model"),
+ cpu_cores=m.get("cpu_cores", 0),
+ cpu_usage=m.get("cpu_usage", 0.0),
+ gpu_model=m.get("gpu_model"),
+ gpu_vram_total=m.get("gpu_vram_total", 0),
+ gpu_vram_used=m.get("gpu_vram_used", 0),
+ ram_total=m.get("ram_total", 0),
+ ram_used=m.get("ram_used", 0),
+ disk_total=m.get("disk_total", 0),
+ disk_used=m.get("disk_used", 0),
+ agent_count_router=m.get("agent_count_router", 0),
+ agent_count_system=m.get("agent_count_system", 0),
+ dagi_router_url=m.get("dagi_router_url")
+ )
+
items.append(NodeProfile(
node_id=node["node_id"],
name=node["name"],
@@ -528,12 +569,17 @@ async def list_nodes():
gpu_info=node.get("gpu"),
agents_total=node.get("agents_total", 0),
agents_online=node.get("agents_online", 0),
- last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None
+ last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None,
+ guardian_agent=guardian_agent,
+ steward_agent=steward_agent,
+ metrics=metrics
))
return {"items": items, "total": len(items)}
except Exception as e:
logger.error(f"Failed to list nodes: {e}")
+ import traceback
+ traceback.print_exc()
raise HTTPException(status_code=500, detail="Failed to list nodes")
@@ -3210,3 +3256,907 @@ async def ensure_orchestrator_room(
except Exception as e:
logger.error(f"Error ensuring orchestrator room for {slug}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
+
+
+# =============================================================================
+# DAGI Agent Audit API
+# =============================================================================
+
+class DAGIAuditSummary(BaseModel):
+ """Підсумок DAGI audit"""
+ node_id: str
+ timestamp: str
+ router_total: int
+ db_total: int
+ active_count: int
+ phantom_count: int
+ stale_count: int
+ triggered_by: Optional[str] = None
+
+
+class DAGIAgentStatus(BaseModel):
+ """Статус агента в DAGI"""
+ id: str
+ name: str
+ external_id: Optional[str] = None
+ kind: Optional[str] = None
+ status: str # active, stale, phantom
+ dagi_status: Optional[str] = None
+ last_seen_at: Optional[str] = None
+ router_id: Optional[str] = None
+ reason: Optional[str] = None
+
+
+class DAGIAuditResponse(BaseModel):
+ """Повний звіт DAGI audit"""
+ summary: DAGIAuditSummary
+ active_agents: List[DAGIAgentStatus]
+ phantom_agents: List[DAGIAgentStatus]
+ stale_agents: List[DAGIAgentStatus]
+
+
+@router.get("/internal/node/{node_id}/dagi-audit", response_model=Optional[DAGIAuditSummary])
+async def get_node_dagi_audit(node_id: str):
+ """
+ Отримати останній DAGI audit звіт для ноди.
+ """
+ try:
+ audit = await repo_city.get_latest_dagi_audit(node_id)
+ if not audit:
+ return None
+
+ return DAGIAuditSummary(
+ node_id=audit["node_id"],
+ timestamp=audit["timestamp"],
+ router_total=audit["router_total"],
+ db_total=audit["db_total"],
+ active_count=audit["active_count"],
+ phantom_count=audit["phantom_count"],
+ stale_count=audit["stale_count"],
+ triggered_by=audit.get("triggered_by")
+ )
+ except Exception as e:
+ logger.error(f"Error getting DAGI audit for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to get DAGI audit")
+
+
+@router.get("/internal/node/{node_id}/dagi-audit/full")
+async def get_node_dagi_audit_full(node_id: str):
+ """
+ Отримати повний DAGI audit звіт для ноди (з деталями).
+ """
+ try:
+ audit = await repo_city.get_latest_dagi_audit(node_id)
+ if not audit:
+ raise HTTPException(status_code=404, detail="No audit found for this node")
+
+ return audit
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error getting full DAGI audit for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to get DAGI audit")
+
+
+@router.get("/internal/node/{node_id}/dagi-audit/history")
+async def get_node_dagi_audit_history(
+ node_id: str,
+ limit: int = Query(default=10, le=100)
+):
+ """
+ Отримати історію DAGI audit звітів для ноди.
+ """
+ try:
+ history = await repo_city.get_dagi_audit_history(node_id, limit)
+ return {"node_id": node_id, "history": history}
+ except Exception as e:
+ logger.error(f"Error getting DAGI audit history for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to get audit history")
+
+
+@router.get("/internal/node/{node_id}/agents/system")
+async def get_node_system_agents(node_id: str):
+ """
+ Отримати агентів з БД для ноди (для DAGI audit).
+ """
+ try:
+ agents = await repo_city.get_agents_by_node_for_audit(node_id)
+ return {
+ "node_id": node_id,
+ "total": len(agents),
+ "agents": agents
+ }
+ except Exception as e:
+ logger.error(f"Error getting system agents for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to get system agents")
+
+
+@router.post("/internal/node/{node_id}/dagi-audit/run")
+async def run_node_dagi_audit(
+ node_id: str,
+ request: Request
+):
+ """
+ Запустити DAGI audit для ноди.
+ Порівнює агентів з router-config.yml та БД.
+
+ Цей endpoint викликає audit логіку inline (для MVP).
+ В продакшені краще делегувати на worker/celery.
+ """
+ import yaml
+ from pathlib import Path
+ from datetime import datetime
+
+ try:
+ # Визначити шлях до router-config
+ project_root = Path(__file__).parent.parent.parent
+ config_path = project_root / "router-config.yml"
+
+ if not config_path.exists():
+ raise HTTPException(status_code=404, detail="router-config.yml not found")
+
+ # Парсити router config
+ with open(config_path, 'r', encoding='utf-8') as f:
+ router_config = yaml.safe_load(f)
+
+ router_agents = []
+ for agent_id, agent_data in router_config.get("agents", {}).items():
+ router_agents.append({
+ "id": agent_id,
+ "name": agent_id,
+ "description": agent_data.get("description", "")
+ })
+
+ # Отримати агентів з БД
+ db_agents = await repo_city.get_all_agents_for_audit()
+
+ # Зіставлення
+ def normalize(name: str) -> str:
+ return name.lower().replace(" ", "").replace("-", "").replace("_", "")
+
+ router_by_id = {a["id"].lower(): a for a in router_agents}
+ db_by_ext_id = {}
+ for a in db_agents:
+ if a.get("external_id"):
+ ext_id = a["external_id"].split(":")[-1].lower() if ":" in a["external_id"] else a["external_id"].lower()
+ db_by_ext_id[ext_id] = a
+ db_by_name = {normalize(a["name"]): a for a in db_agents}
+
+ active = []
+ phantom = []
+ stale = []
+ matched_db_ids = set()
+
+ for r_agent in router_agents:
+ r_id = r_agent["id"].lower()
+ r_name_norm = normalize(r_agent["name"])
+
+ db_match = db_by_ext_id.get(r_id) or db_by_name.get(r_name_norm)
+
+ if db_match:
+ active.append({
+ "router_id": r_agent["id"],
+ "router_name": r_agent["name"],
+ "db_id": db_match["id"],
+ "db_name": db_match["name"],
+ "db_external_id": db_match.get("external_id"),
+ "kind": db_match.get("kind"),
+ "status": db_match.get("status", "unknown")
+ })
+ matched_db_ids.add(db_match["id"])
+ else:
+ phantom.append({
+ "router_id": r_agent["id"],
+ "router_name": r_agent["name"],
+ "description": r_agent.get("description", ""),
+ "reason": "In Router config but not in DB"
+ })
+
+ for db_agent in db_agents:
+ if db_agent["id"] not in matched_db_ids:
+ stale.append({
+ "db_id": db_agent["id"],
+ "db_name": db_agent["name"],
+ "db_external_id": db_agent.get("external_id"),
+ "kind": db_agent.get("kind"),
+ "reason": "In DB but not in Router config"
+ })
+
+ # Формуємо звіт
+ report = {
+ "node_id": node_id,
+ "timestamp": datetime.utcnow().isoformat() + "Z",
+ "summary": {
+ "router_total": len(router_agents),
+ "db_total": len(db_agents),
+ "active_count": len(active),
+ "phantom_count": len(phantom),
+ "stale_count": len(stale)
+ },
+ "active_agents": active,
+ "phantom_agents": phantom,
+ "stale_agents": stale
+ }
+
+ # Зберегти звіт в БД
+ saved = await repo_city.save_dagi_audit_report(node_id, report, triggered_by="api")
+
+ # Оновити статуси агентів
+ if active:
+ active_ids = [a["db_id"] for a in active]
+ await repo_city.update_agents_dagi_status(active_ids, "active", update_last_seen=True)
+
+ if stale:
+ stale_ids = [a["db_id"] for a in stale]
+ await repo_city.update_agents_dagi_status(stale_ids, "stale")
+
+ return {
+ "status": "completed",
+ "report_id": saved["id"],
+ "summary": report["summary"],
+ "message": f"Audit completed: {len(active)} active, {len(phantom)} phantom, {len(stale)} stale"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error running DAGI audit for {node_id}: {e}")
+ import traceback
+ traceback.print_exc()
+ raise HTTPException(status_code=500, detail=f"Failed to run DAGI audit: {str(e)}")
+
+
+# =============================================================================
+# DAGI Router Agents API (for Node Cabinet Table)
+# =============================================================================
+
+class DAGIRouterAgentItem(BaseModel):
+ """Агент для таблиці DAGI Router"""
+ id: str
+ name: str
+ role: Optional[str] = None
+ status: str # active, phantom, stale, error
+ node_id: Optional[str] = None
+ models: List[str] = []
+ gpu: Optional[str] = None
+ cpu: Optional[str] = None
+ last_seen_at: Optional[str] = None
+ has_cabinet: bool = False
+ cabinet_slug: Optional[str] = None
+ description: Optional[str] = None
+ has_prompts: bool = False # Чи є системні промти в БД
+
+
+class DAGIRouterAgentsSummary(BaseModel):
+ """Summary для DAGI Router Agents"""
+ active: int = 0
+ phantom: int = 0
+ stale: int = 0
+ router_total: int = 0
+ system_total: int = 0
+
+
+class DAGIRouterAgentsResponse(BaseModel):
+ """Відповідь API DAGI Router Agents"""
+ node_id: str
+ last_audit_at: Optional[str] = None
+ summary: DAGIRouterAgentsSummary
+ agents: List[DAGIRouterAgentItem]
+
+
+@router.get("/internal/node/{node_id}/dagi-router/agents", response_model=DAGIRouterAgentsResponse)
+async def get_dagi_router_agents(node_id: str):
+ """
+ Отримати агентів DAGI Router для Node Cabinet таблиці.
+ Повертає уніфікований список агентів зі статусами.
+ """
+ try:
+ data = await repo_city.get_dagi_router_agents_for_node(node_id)
+
+ return DAGIRouterAgentsResponse(
+ node_id=data["node_id"],
+ last_audit_at=data.get("last_audit_at"),
+ summary=DAGIRouterAgentsSummary(**data["summary"]),
+ agents=[DAGIRouterAgentItem(**a) for a in data["agents"]]
+ )
+ except Exception as e:
+ logger.error(f"Error getting DAGI router agents for {node_id}: {e}")
+ # Return empty response on error
+ return DAGIRouterAgentsResponse(
+ node_id=node_id,
+ last_audit_at=None,
+ summary=DAGIRouterAgentsSummary(),
+ agents=[]
+ )
+
+
+# =============================================================================
+# Node Metrics API
+# =============================================================================
+
+class NodeMetricsResponse(BaseModel):
+ """Метрики ноди"""
+ node_id: str
+ node_name: Optional[str] = None
+ hostname: Optional[str] = None
+ status: Optional[str] = "unknown"
+ environment: Optional[str] = None
+ cpu_model: Optional[str] = None
+ cpu_cores: int = 0
+ cpu_usage: float = 0.0
+ gpu_model: Optional[str] = None
+ gpu_memory_total: int = 0
+ gpu_memory_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
+ last_heartbeat: Optional[str] = None
+
+
+@router.get("/internal/node/{node_id}/metrics/current", response_model=NodeMetricsResponse)
+async def get_node_metrics_current(node_id: str):
+ """
+ Отримати поточні метрики ноди.
+ Єдине джерело правди для Node Cabinet індикаторів.
+ """
+ try:
+ metrics = await repo_city.get_node_metrics_current(node_id)
+
+ if not metrics:
+ # Return minimal response for unknown node
+ return NodeMetricsResponse(node_id=node_id)
+
+ return NodeMetricsResponse(**metrics)
+ except Exception as e:
+ logger.error(f"Error getting metrics for {node_id}: {e}")
+ return NodeMetricsResponse(node_id=node_id)
+
+
+class NodeMetricsUpdateRequest(BaseModel):
+ """Запит на оновлення метрик"""
+ cpu_usage: Optional[float] = None
+ gpu_vram_used: Optional[int] = None
+ ram_used: Optional[int] = None
+ disk_used: Optional[int] = None
+ agent_count_router: Optional[int] = None
+ agent_count_system: Optional[int] = None
+
+
+@router.post("/internal/node/{node_id}/metrics/update")
+async def update_node_metrics(
+ node_id: str,
+ metrics: NodeMetricsUpdateRequest
+):
+ """
+ Оновити метрики ноди (heartbeat).
+ Викликається з agent на ноді.
+ """
+ try:
+ success = await repo_city.update_node_metrics(node_id, metrics.dict(exclude_unset=True))
+
+ return {
+ "status": "updated" if success else "not_found",
+ "node_id": node_id
+ }
+ except Exception as e:
+ logger.error(f"Error updating metrics for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to update metrics")
+
+
+# =============================================================================
+# Phantom / Stale Autosync API
+# =============================================================================
+
+class PhantomSyncRequest(BaseModel):
+ """Запит на синхронізацію phantom агентів"""
+ agent_ids: List[str]
+
+
+@router.post("/internal/node/{node_id}/dagi-router/phantom/sync")
+async def sync_phantom_agents(
+ node_id: str,
+ request: PhantomSyncRequest
+):
+ """
+ Синхронізувати phantom агентів — створити їх у БД на основі router-config.
+ """
+ import yaml
+ from pathlib import Path
+
+ try:
+ # Читаємо router-config
+ project_root = Path(__file__).parent.parent.parent
+ config_path = project_root / "router-config.yml"
+
+ if not config_path.exists():
+ raise HTTPException(status_code=404, detail="router-config.yml not found")
+
+ with open(config_path, 'r', encoding='utf-8') as f:
+ router_config = yaml.safe_load(f)
+
+ # Синхронізуємо агентів
+ created = await repo_city.sync_phantom_agents(
+ node_id,
+ request.agent_ids,
+ router_config
+ )
+
+ return {
+ "status": "completed",
+ "node_id": node_id,
+ "created_count": len(created),
+ "created_agents": created
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error syncing phantom agents for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to sync phantom agents: {str(e)}")
+
+
+class StaleSyncRequest(BaseModel):
+ """Запит на позначення stale агентів"""
+ agent_ids: List[str]
+
+
+@router.post("/internal/node/{node_id}/dagi-router/stale/mark")
+async def mark_stale_agents(
+ node_id: str,
+ request: StaleSyncRequest
+):
+ """
+ Позначити агентів як stale (в БД, але не в Router).
+ """
+ try:
+ updated_count = await repo_city.mark_stale_agents(request.agent_ids)
+
+ return {
+ "status": "completed",
+ "node_id": node_id,
+ "marked_count": updated_count
+ }
+
+ except Exception as e:
+ logger.error(f"Error marking stale agents for {node_id}: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to mark stale agents: {str(e)}")
+
+
+# =============================================================================
+# Node Agents API (для Node Cabinet)
+# =============================================================================
+
+class NodeAgentItem(BaseModel):
+ """Агент ноди для Node Cabinet"""
+ id: str
+ name: str
+ slug: Optional[str] = None
+ kind: Optional[str] = None
+ role: Optional[str] = None # node_guardian, node_steward, etc.
+ status: str = "unknown"
+ dagi_status: Optional[str] = None
+ last_seen_at: Optional[str] = None
+ is_guardian: bool = False
+ is_steward: bool = False
+
+
+class NodeAgentsResponse(BaseModel):
+ """Список агентів ноди"""
+ node_id: str
+ total: int
+ guardian: Optional[NodeAgentItem] = None
+ steward: Optional[NodeAgentItem] = None
+ agents: List[NodeAgentItem]
+
+
+@router.get("/internal/node/{node_id}/agents", response_model=NodeAgentsResponse)
+async def get_node_agents(node_id: str):
+ """
+ Отримати всіх агентів ноди (Guardian, Steward, runtime agents).
+ """
+ try:
+ agents_data = await repo_city.get_node_agents(node_id)
+
+ agents = []
+ guardian = None
+ steward = None
+
+ for a in agents_data:
+ item = NodeAgentItem(
+ id=a["id"],
+ name=a.get("display_name") or a.get("name") or a["id"],
+ slug=a.get("public_slug") or a["id"],
+ kind=a.get("kind"),
+ role=a.get("kind"), # Use kind as role for now
+ status=a.get("status", "unknown"),
+ dagi_status=a.get("dagi_status"),
+ last_seen_at=a.get("last_seen_at").isoformat() if a.get("last_seen_at") else None,
+ is_guardian=a.get("is_node_guardian", False) or a.get("kind") == "node_guardian",
+ is_steward=a.get("is_node_steward", False) or a.get("kind") == "node_steward"
+ )
+
+ agents.append(item)
+
+ if item.is_guardian and not guardian:
+ guardian = item
+ if item.is_steward and not steward:
+ steward = item
+
+ return NodeAgentsResponse(
+ node_id=node_id,
+ total=len(agents),
+ guardian=guardian,
+ steward=steward,
+ agents=agents
+ )
+
+ except Exception as e:
+ logger.error(f"Error getting node agents for {node_id}: {e}")
+ return NodeAgentsResponse(
+ node_id=node_id,
+ total=0,
+ agents=[]
+ )
+
+
+# =============================================================================
+# Agent Runtime Prompts API (for DAGI Router integration)
+# =============================================================================
+
+class RuntimePromptsResponse(BaseModel):
+ """Runtime prompts for DAGI Router"""
+ agent_id: str
+ has_prompts: bool
+ prompts: Dict[str, Optional[str]]
+
+
+class RuntimeSystemPromptResponse(BaseModel):
+ """Full runtime system prompt for DAGI Router"""
+ agent_id: str
+ agent_name: Optional[str] = None
+ agent_kind: Optional[str] = None
+ has_prompts: bool
+ system_prompt: str
+ prompts: Dict[str, Optional[str]]
+
+
+class AgentPromptsStatusRequest(BaseModel):
+ """Request to check prompts status for multiple agents"""
+ agent_ids: List[str]
+
+
+class AgentPromptsStatusResponse(BaseModel):
+ """Response with prompts status for multiple agents"""
+ status: Dict[str, bool]
+
+
+@router.get("/internal/agents/{agent_id}/prompts/runtime", response_model=RuntimePromptsResponse)
+async def get_agent_runtime_prompts(agent_id: str):
+ """
+ Отримати runtime промти агента для DAGI Router.
+
+ Повертає тільки content промтів без метаданих.
+ Використовується DAGI Router для побудови system prompt.
+ """
+ try:
+ data = await repo_city.get_runtime_prompts(agent_id)
+ return RuntimePromptsResponse(**data)
+ except Exception as e:
+ logger.error(f"Error getting runtime prompts for {agent_id}: {e}")
+ return RuntimePromptsResponse(
+ agent_id=agent_id,
+ has_prompts=False,
+ prompts={"core": None, "safety": None, "governance": None, "tools": None}
+ )
+
+
+@router.get("/internal/agents/{agent_id}/system-prompt", response_model=RuntimeSystemPromptResponse)
+async def get_agent_system_prompt(agent_id: str):
+ """
+ Отримати зібраний system prompt для агента.
+
+ DAGI Router використовує цей endpoint для отримання повного system prompt,
+ який включає core, safety, governance, tools та контекст.
+ """
+ try:
+ data = await repo_city.get_agent_with_runtime_prompt(agent_id)
+
+ if not data:
+ # Fallback for unknown agent
+ return RuntimeSystemPromptResponse(
+ agent_id=agent_id,
+ agent_name=None,
+ agent_kind=None,
+ has_prompts=False,
+ system_prompt=f"You are an AI agent (ID: {agent_id}) in DAARION.city. Be helpful and accurate.",
+ prompts={"core": None, "safety": None, "governance": None, "tools": None}
+ )
+
+ return RuntimeSystemPromptResponse(**data)
+
+ except Exception as e:
+ logger.error(f"Error getting system prompt for {agent_id}: {e}")
+ import traceback
+ traceback.print_exc()
+ return RuntimeSystemPromptResponse(
+ agent_id=agent_id,
+ has_prompts=False,
+ system_prompt=f"You are an AI agent in DAARION.city. Be helpful and accurate.",
+ prompts={"core": None, "safety": None, "governance": None, "tools": None}
+ )
+
+
+@router.post("/internal/agents/prompts/status", response_model=AgentPromptsStatusResponse)
+async def check_agents_prompts_status(request: AgentPromptsStatusRequest):
+ """
+ Перевірити наявність промтів для списку агентів.
+
+ Використовується UI для показу індикаторів has_prompts в таблицях агентів.
+ """
+ try:
+ status = await repo_city.check_agents_prompts_status(request.agent_ids)
+ return AgentPromptsStatusResponse(status=status)
+ except Exception as e:
+ logger.error(f"Error checking prompts status: {e}")
+ return AgentPromptsStatusResponse(
+ status={agent_id: False for agent_id in request.agent_ids}
+ )
+
+
+# =============================================================================
+# Node Self-Registration & Self-Healing API
+# =============================================================================
+
+class NodeSelfRegisterRequest(BaseModel):
+ """Request body for node self-registration"""
+ id: str
+ name: str
+ hostname: Optional[str] = None
+ environment: str = "development"
+ roles: List[str] = []
+ description: Optional[str] = None
+
+
+class NodeSelfRegisterResponse(BaseModel):
+ """Response for node self-registration"""
+ success: bool
+ node_id: str
+ is_new: bool = False
+ message: str = ""
+ error: Optional[str] = None
+
+
+class NodeHeartbeatRequest(BaseModel):
+ """Request body for node heartbeat"""
+ metrics: Optional[Dict] = None
+
+
+class NodeHeartbeatResponse(BaseModel):
+ """Response for node heartbeat"""
+ success: bool
+ node_id: Optional[str] = None
+ heartbeat_at: Optional[str] = None
+ error: Optional[str] = None
+ should_self_register: bool = False
+
+
+class NodeSelfHealingStatusResponse(BaseModel):
+ """Response for node self-healing status"""
+ node_id: str
+ registered: bool
+ is_active: Optional[bool] = None
+ name: Optional[str] = None
+ self_healing_status: str = "unknown"
+ last_heartbeat: Optional[str] = None
+ last_self_registration: Optional[str] = None
+ self_registration_count: int = 0
+ agent_count_router: int = 0
+ agent_count_system: int = 0
+ has_guardian: bool = False
+ has_steward: bool = False
+ errors: List[Dict] = []
+ status: Optional[str] = None
+ error: Optional[str] = None
+
+
+class NodesNeedingHealingResponse(BaseModel):
+ """Response listing nodes that need healing"""
+ nodes: List[Dict]
+ total: int
+
+
+@router.post("/internal/nodes/register-or-update", response_model=NodeSelfRegisterResponse)
+async def node_self_register(request: NodeSelfRegisterRequest):
+ """
+ Самореєстрація ноди.
+
+ Цей endpoint викликається:
+ - Node Bootstrap script при старті ноди
+ - Node Guardian при виявленні, що нода зникла з Directory
+
+ Якщо нода вже зареєстрована — оновлює дані.
+ Якщо нова — створює запис в node_registry.
+ """
+ try:
+ result = await repo_city.node_self_register(
+ node_id=request.id,
+ name=request.name,
+ hostname=request.hostname,
+ environment=request.environment,
+ roles=request.roles,
+ description=request.description
+ )
+
+ return NodeSelfRegisterResponse(
+ success=result.get("success", False),
+ node_id=result.get("node_id", request.id),
+ is_new=result.get("is_new", False),
+ message=result.get("message", ""),
+ error=result.get("error")
+ )
+ except Exception as e:
+ logger.error(f"Node self-registration failed for {request.id}: {e}")
+ return NodeSelfRegisterResponse(
+ success=False,
+ node_id=request.id,
+ message="Registration failed",
+ error=str(e)
+ )
+
+
+@router.post("/internal/node/{node_id}/heartbeat", response_model=NodeHeartbeatResponse)
+async def node_heartbeat(node_id: str, request: NodeHeartbeatRequest = NodeHeartbeatRequest()):
+ """
+ Heartbeat ноди з оновленням метрик.
+
+ Повертає should_self_register=True якщо нода не зареєстрована,
+ що є сигналом для Node Guardian виконати self-registration.
+ """
+ try:
+ result = await repo_city.node_heartbeat(
+ node_id=node_id,
+ metrics=request.metrics
+ )
+
+ return NodeHeartbeatResponse(
+ success=result.get("success", False),
+ node_id=result.get("node_id"),
+ heartbeat_at=result.get("heartbeat_at"),
+ error=result.get("error"),
+ should_self_register=result.get("should_self_register", False)
+ )
+ except Exception as e:
+ logger.error(f"Heartbeat failed for {node_id}: {e}")
+ return NodeHeartbeatResponse(
+ success=False,
+ node_id=node_id,
+ error=str(e)
+ )
+
+
+@router.get("/internal/node/{node_id}/self-healing/status", response_model=NodeSelfHealingStatusResponse)
+async def get_node_self_healing_status(node_id: str):
+ """
+ Отримати статус self-healing для ноди.
+
+ Використовується Node Guardian для моніторингу стану ноди.
+ """
+ try:
+ result = await repo_city.get_node_self_healing_status(node_id)
+ return NodeSelfHealingStatusResponse(**result)
+ except Exception as e:
+ logger.error(f"Failed to get self-healing status for {node_id}: {e}")
+ return NodeSelfHealingStatusResponse(
+ node_id=node_id,
+ registered=False,
+ status="error",
+ error=str(e)
+ )
+
+
+@router.get("/internal/node/{node_id}/directory-check")
+async def check_node_in_directory(node_id: str):
+ """
+ Перевірити чи нода видима в Node Directory.
+
+ Простий endpoint для Node Guardian self-healing loop.
+ """
+ try:
+ visible = await repo_city.check_node_in_directory(node_id)
+ return {
+ "node_id": node_id,
+ "visible_in_directory": visible,
+ "checked_at": datetime.now(timezone.utc).isoformat()
+ }
+ except Exception as e:
+ logger.error(f"Directory check failed for {node_id}: {e}")
+ return {
+ "node_id": node_id,
+ "visible_in_directory": False,
+ "error": str(e)
+ }
+
+
+@router.get("/internal/nodes/needing-healing", response_model=NodesNeedingHealingResponse)
+async def get_nodes_needing_healing():
+ """
+ Отримати список нод, які потребують self-healing.
+
+ Використовується для моніторингу та автоматичного healing.
+ """
+ try:
+ nodes = await repo_city.get_nodes_needing_healing()
+ return NodesNeedingHealingResponse(
+ nodes=nodes,
+ total=len(nodes)
+ )
+ except Exception as e:
+ logger.error(f"Failed to get nodes needing healing: {e}")
+ return NodesNeedingHealingResponse(nodes=[], total=0)
+
+
+@router.post("/internal/node/{node_id}/self-healing/trigger")
+async def trigger_node_self_healing(node_id: str):
+ """
+ Тригернути self-healing для ноди.
+
+ Ця операція:
+ 1. Перевіряє стан ноди
+ 2. Якщо нода не в Directory — виконує self-registration
+ 3. Оновлює статус self-healing
+ """
+ try:
+ # Check current state
+ status = await repo_city.get_node_self_healing_status(node_id)
+
+ actions_taken = []
+
+ if not status.get("registered"):
+ # Need to register
+ result = await repo_city.node_self_register(
+ node_id=node_id,
+ name=f"Auto-healed node {node_id}",
+ environment="production" if "node-1" in node_id else "development"
+ )
+ actions_taken.append({
+ "action": "self_register",
+ "result": result
+ })
+
+ # Check if visible in directory
+ visible = await repo_city.check_node_in_directory(node_id)
+
+ if not visible:
+ actions_taken.append({
+ "action": "visibility_check",
+ "result": {"visible": False, "needs_manual_intervention": True}
+ })
+
+ # Update healing status
+ final_status = "healthy" if visible else "needs_attention"
+ await repo_city.update_node_self_healing_status(
+ node_id=node_id,
+ status=final_status
+ )
+
+ return {
+ "node_id": node_id,
+ "triggered_at": datetime.now(timezone.utc).isoformat(),
+ "actions_taken": actions_taken,
+ "final_status": final_status,
+ "visible_in_directory": visible
+ }
+ except Exception as e:
+ logger.error(f"Self-healing trigger failed for {node_id}: {e}")
+
+ # Record error
+ await repo_city.update_node_self_healing_status(
+ node_id=node_id,
+ status="error",
+ error=str(e)
+ )
+
+ raise HTTPException(status_code=500, detail=f"Self-healing failed: {e}")
diff --git a/services/router/main.py b/services/router/main.py
index 249d341a..aab1f89b 100644
--- a/services/router/main.py
+++ b/services/router/main.py
@@ -19,6 +19,7 @@ SWAPPER_URL = os.getenv("SWAPPER_URL", "http://192.168.1.33:8890")
STT_URL = os.getenv("STT_URL", "http://192.168.1.33:8895")
VISION_URL = os.getenv("VISION_URL", "http://192.168.1.33:11434")
OCR_URL = os.getenv("OCR_URL", "http://192.168.1.33:8896")
+CITY_SERVICE_URL = os.getenv("CITY_SERVICE_URL", "http://daarion-city-service:7001")
# HTTP client for backend services
http_client: Optional[httpx.AsyncClient] = None
@@ -56,7 +57,27 @@ def load_config():
}
}
+def load_router_config():
+ """Load main router-config.yml with agents and LLM profiles"""
+ # Try multiple locations
+ paths = [
+ "router-config.yml",
+ "/app/router-config.yml",
+ "../router-config.yml",
+ "../../router-config.yml"
+ ]
+
+ for path in paths:
+ if os.path.exists(path):
+ with open(path, 'r') as f:
+ logger.info(f"✅ Loaded router config from {path}")
+ return yaml.safe_load(f)
+
+ logger.warning("⚠️ router-config.yml not found, using empty config")
+ return {"agents": {}}
+
config = load_config()
+router_config = load_router_config()
@app.on_event("startup")
async def startup_event():
@@ -363,10 +384,30 @@ async def agent_infer(agent_id: str, request: InferRequest):
- Agent configuration (model, capabilities)
- Request type (text, vision, audio)
- Backend availability
+
+ System prompt is fetched from database via city-service API.
"""
logger.info(f"🔀 Inference request for agent: {agent_id}")
logger.info(f"📝 Prompt: {request.prompt[:100]}...")
+ # Get system prompt from database or config
+ system_prompt = request.system_prompt
+
+ if not system_prompt:
+ try:
+ from prompt_builder import get_agent_system_prompt
+ system_prompt = await get_agent_system_prompt(
+ agent_id,
+ city_service_url=CITY_SERVICE_URL,
+ router_config=router_config
+ )
+ logger.info(f"✅ Loaded system prompt from database for {agent_id}")
+ except Exception as e:
+ logger.warning(f"⚠️ Could not load prompt from database: {e}")
+ # Fallback to config
+ agent_config = router_config.get("agents", {}).get(agent_id, {})
+ system_prompt = agent_config.get("system_prompt")
+
# Determine which backend to use
model = request.model or "gpt-oss:latest"
@@ -389,7 +430,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
json={
"model": model,
"prompt": request.prompt,
- "system": request.system_prompt,
+ "system": system_prompt,
"stream": False,
"options": {
"num_predict": request.max_tokens,
diff --git a/services/router/prompt_builder.py b/services/router/prompt_builder.py
new file mode 100644
index 00000000..78499e91
--- /dev/null
+++ b/services/router/prompt_builder.py
@@ -0,0 +1,278 @@
+"""
+Prompt Builder for DAGI Router
+
+Цей модуль відповідає за побудову system prompts для агентів,
+використовуючи дані з БД через city-service API.
+
+Частина Agent System Prompts MVP v2
+"""
+
+import httpx
+import logging
+from typing import Dict, Any, Optional
+from dataclasses import dataclass
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AgentSystemPrompt:
+ """Результат побудови system prompt"""
+ agent_id: str
+ agent_name: Optional[str]
+ has_prompts: bool
+ system_prompt: str
+ source: str # "database", "fallback", "config"
+
+
+class PromptBuilder:
+ """
+ Будує system prompts для агентів.
+
+ Порядок пріоритетів:
+ 1. Промти з БД (через city-service API)
+ 2. Промти з router-config.yml
+ 3. Fallback default prompt
+ """
+
+ def __init__(
+ self,
+ city_service_url: str = "http://daarion-city-service:7001",
+ router_config: Optional[Dict[str, Any]] = None
+ ):
+ self.city_service_url = city_service_url.rstrip("/")
+ self.router_config = router_config or {}
+ self._http_client: Optional[httpx.AsyncClient] = None
+
+ async def _get_http_client(self) -> httpx.AsyncClient:
+ """Lazy initialization of HTTP client"""
+ if self._http_client is None:
+ self._http_client = httpx.AsyncClient(timeout=10.0)
+ return self._http_client
+
+ async def close(self):
+ """Close HTTP client"""
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+
+ async def get_system_prompt(self, agent_id: str) -> AgentSystemPrompt:
+ """
+ Отримати system prompt для агента.
+
+ Спочатку пробує отримати з БД, потім з конфігу, потім fallback.
+ """
+ # Try database first
+ db_prompt = await self._fetch_from_database(agent_id)
+ if db_prompt and db_prompt.has_prompts:
+ logger.info(f"Using database prompt for agent {agent_id}")
+ return db_prompt
+
+ # Try config
+ config_prompt = self._get_from_config(agent_id)
+ if config_prompt:
+ logger.info(f"Using config prompt for agent {agent_id}")
+ return config_prompt
+
+ # Fallback
+ logger.warning(f"No prompts found for agent {agent_id}, using fallback")
+ return self._get_fallback_prompt(agent_id)
+
+ async def _fetch_from_database(self, agent_id: str) -> Optional[AgentSystemPrompt]:
+ """Fetch system prompt from city-service API"""
+ try:
+ client = await self._get_http_client()
+ url = f"{self.city_service_url}/internal/agents/{agent_id}/system-prompt"
+
+ response = await client.get(url)
+
+ if response.status_code == 200:
+ data = response.json()
+ return AgentSystemPrompt(
+ agent_id=data.get("agent_id", agent_id),
+ agent_name=data.get("agent_name"),
+ has_prompts=data.get("has_prompts", False),
+ system_prompt=data.get("system_prompt", ""),
+ source="database"
+ )
+ else:
+ logger.warning(f"City service returned {response.status_code} for agent {agent_id}")
+ return None
+
+ except httpx.RequestError as e:
+ logger.error(f"Error fetching prompt from city-service: {e}")
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error fetching prompt: {e}")
+ return None
+
+ def _get_from_config(self, agent_id: str) -> Optional[AgentSystemPrompt]:
+ """Get system prompt from router config"""
+ agents = self.router_config.get("agents", {})
+ agent_config = agents.get(agent_id)
+
+ if not agent_config:
+ return None
+
+ system_prompt = agent_config.get("system_prompt")
+ if not system_prompt:
+ return None
+
+ return AgentSystemPrompt(
+ agent_id=agent_id,
+ agent_name=agent_config.get("description"),
+ has_prompts=True,
+ system_prompt=system_prompt.strip(),
+ source="config"
+ )
+
+ def _get_fallback_prompt(self, agent_id: str) -> AgentSystemPrompt:
+ """Generate fallback prompt for unknown agent"""
+ fallback_prompt = (
+ f"You are an AI agent (ID: {agent_id}) in the DAARION.city ecosystem.\n\n"
+ "Guidelines:\n"
+ "- Be helpful, accurate, and professional\n"
+ "- Follow ethical guidelines and safety protocols\n"
+ "- Respect user privacy and data protection\n"
+ "- Ask for clarification when uncertain\n"
+ "- Never execute harmful or unauthorized actions\n"
+ )
+
+ return AgentSystemPrompt(
+ agent_id=agent_id,
+ agent_name=None,
+ has_prompts=False,
+ system_prompt=fallback_prompt,
+ source="fallback"
+ )
+
+ async def check_prompts_available(self, agent_ids: list[str]) -> Dict[str, bool]:
+ """
+ Check if prompts are available for multiple agents.
+ Returns dict mapping agent_id to has_prompts boolean.
+ """
+ result = {}
+
+ try:
+ client = await self._get_http_client()
+ url = f"{self.city_service_url}/internal/agents/prompts/status"
+
+ response = await client.post(url, json={"agent_ids": agent_ids})
+
+ if response.status_code == 200:
+ data = response.json()
+ result = data.get("status", {})
+ except Exception as e:
+ logger.error(f"Error checking prompts status: {e}")
+
+ # Fill missing with config check
+ for agent_id in agent_ids:
+ if agent_id not in result:
+ config_prompt = self._get_from_config(agent_id)
+ result[agent_id] = config_prompt is not None
+
+ return result
+
+
+def build_system_prompt_from_parts(
+ prompts: Dict[str, Optional[str]],
+ agent_info: Optional[Dict[str, Any]] = None,
+ context: Optional[Dict[str, Any]] = None
+) -> str:
+ """
+ Build system prompt from individual parts.
+
+ This is a standalone function that can be used without PromptBuilder class.
+
+ Args:
+ prompts: Dict with keys "core", "safety", "governance", "tools"
+ agent_info: Optional dict with agent metadata (name, kind, etc.)
+ context: Optional dict with runtime context (node, microdao, etc.)
+
+ Returns:
+ Assembled system prompt string
+ """
+ parts = []
+
+ # Core prompt (required)
+ if prompts.get("core"):
+ parts.append(prompts["core"])
+ elif agent_info:
+ agent_name = agent_info.get("display_name") or agent_info.get("name") or "Agent"
+ agent_kind = agent_info.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."
+ )
+ else:
+ parts.append("You are an AI assistant. Be helpful and accurate.")
+
+ # 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_lines = []
+
+ if context.get("node"):
+ node = context["node"]
+ context_lines.append(f"- **Node**: {node.get('name', 'Unknown')}")
+
+ if context.get("district"):
+ district = context["district"]
+ context_lines.append(f"- **District**: {district.get('name', 'Unknown')}")
+
+ if context.get("microdao"):
+ microdao = context["microdao"]
+ context_lines.append(f"- **MicroDAO**: {microdao.get('name', 'Unknown')}")
+
+ if context.get("user_role"):
+ context_lines.append(f"- **User Role**: {context['user_role']}")
+
+ if context_lines:
+ parts.append("\n\n## Current Context\n" + "\n".join(context_lines))
+
+ return "\n".join(parts)
+
+
+# Singleton instance for convenience
+_prompt_builder: Optional[PromptBuilder] = None
+
+
+async def get_prompt_builder(
+ city_service_url: str = "http://daarion-city-service:7001",
+ router_config: Optional[Dict[str, Any]] = None
+) -> PromptBuilder:
+ """Get or create singleton PromptBuilder instance"""
+ global _prompt_builder
+
+ if _prompt_builder is None:
+ _prompt_builder = PromptBuilder(city_service_url, router_config)
+
+ return _prompt_builder
+
+
+async def get_agent_system_prompt(
+ agent_id: str,
+ city_service_url: str = "http://daarion-city-service:7001",
+ router_config: Optional[Dict[str, Any]] = None
+) -> str:
+ """
+ Convenience function to get system prompt for an agent.
+
+ Usage in DAGI Router:
+ system_prompt = await get_agent_system_prompt("daarwizz")
+ """
+ builder = await get_prompt_builder(city_service_url, router_config)
+ result = await builder.get_system_prompt(agent_id)
+ return result.system_prompt
+
diff --git a/tests/test_agent_prompts_runtime.py b/tests/test_agent_prompts_runtime.py
new file mode 100644
index 00000000..9e98d897
--- /dev/null
+++ b/tests/test_agent_prompts_runtime.py
@@ -0,0 +1,326 @@
+"""
+Tests for Agent System Prompts Runtime API
+
+Тести для Agent System Prompts MVP v2:
+- Runtime prompts API
+- build_system_prompt function
+- Prompts status check API
+"""
+
+import pytest
+import asyncio
+from typing import Dict, Any
+
+# Mock functions for testing without database
+def build_system_prompt_from_parts(
+ prompts: Dict[str, str],
+ agent_info: Dict[str, Any] = None,
+ context: Dict[str, Any] = None
+) -> str:
+ """Build system prompt from parts (mock implementation for testing)"""
+ parts = []
+
+ # Core prompt (required)
+ if prompts.get("core"):
+ parts.append(prompts["core"])
+ elif agent_info:
+ agent_name = agent_info.get("display_name") or agent_info.get("name") or "Agent"
+ agent_kind = agent_info.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."
+ )
+ else:
+ parts.append("You are an AI assistant. Be helpful and accurate.")
+
+ # 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_lines = []
+
+ if context.get("node"):
+ node = context["node"]
+ context_lines.append(f"- **Node**: {node.get('name', 'Unknown')}")
+
+ if context.get("district"):
+ district = context["district"]
+ context_lines.append(f"- **District**: {district.get('name', 'Unknown')}")
+
+ if context.get("microdao"):
+ microdao = context["microdao"]
+ context_lines.append(f"- **MicroDAO**: {microdao.get('name', 'Unknown')}")
+
+ if context_lines:
+ parts.append("\n\n## Current Context\n" + "\n".join(context_lines))
+
+ return "\n".join(parts)
+
+
+class TestBuildSystemPrompt:
+ """Tests for build_system_prompt function"""
+
+ def test_core_only(self):
+ """Test with only core prompt"""
+ prompts = {
+ "core": "You are DAARWIZZ, the global orchestrator.",
+ "safety": None,
+ "governance": None,
+ "tools": None
+ }
+
+ result = build_system_prompt_from_parts(prompts)
+
+ assert "DAARWIZZ" in result
+ assert "orchestrator" in result
+ assert "## Safety" not in result
+ assert "## Governance" not in result
+
+ def test_full_prompts(self):
+ """Test with all prompt types"""
+ prompts = {
+ "core": "You are DAARWIZZ, the global orchestrator of DAARION.city.",
+ "safety": "Never execute irreversible actions without confirmation.",
+ "governance": "Coordinate with district leads for resource allocation.",
+ "tools": "Use agent_delegate to delegate tasks."
+ }
+
+ result = build_system_prompt_from_parts(prompts)
+
+ assert "DAARWIZZ" in result
+ assert "## Safety Guidelines" in result
+ assert "irreversible" in result
+ assert "## Governance" in result
+ assert "district leads" in result
+ assert "## Tools" in result
+ assert "agent_delegate" in result
+
+ def test_fallback_without_core(self):
+ """Test fallback when no core prompt provided"""
+ prompts = {
+ "core": None,
+ "safety": "Be safe",
+ "governance": None,
+ "tools": None
+ }
+ agent_info = {
+ "name": "TestAgent",
+ "display_name": "Test Agent",
+ "kind": "coordinator"
+ }
+
+ result = build_system_prompt_from_parts(prompts, agent_info)
+
+ assert "Test Agent" in result
+ assert "coordinator" in result
+ assert "## Safety Guidelines" in result
+ assert "Be safe" in result
+
+ def test_with_context(self):
+ """Test prompt with runtime context"""
+ prompts = {
+ "core": "You are a node agent.",
+ "safety": None,
+ "governance": None,
+ "tools": None
+ }
+ context = {
+ "node": {"name": "NODE1", "environment": "production"},
+ "district": {"name": "ENERGYUNION"},
+ "microdao": {"name": "DAARION"}
+ }
+
+ result = build_system_prompt_from_parts(prompts, context=context)
+
+ assert "node agent" in result
+ assert "## Current Context" in result
+ assert "NODE1" in result
+ assert "ENERGYUNION" in result
+ assert "DAARION" in result
+
+ def test_prompt_order(self):
+ """Test that prompts are assembled in correct order"""
+ prompts = {
+ "core": "CORE_MARKER",
+ "safety": "SAFETY_MARKER",
+ "governance": "GOVERNANCE_MARKER",
+ "tools": "TOOLS_MARKER"
+ }
+
+ result = build_system_prompt_from_parts(prompts)
+
+ # Check order: core → governance → safety → tools
+ core_pos = result.find("CORE_MARKER")
+ gov_pos = result.find("GOVERNANCE_MARKER")
+ safety_pos = result.find("SAFETY_MARKER")
+ tools_pos = result.find("TOOLS_MARKER")
+
+ assert core_pos < gov_pos < safety_pos < tools_pos
+
+
+class TestRuntimePromptsFormat:
+ """Tests for runtime prompts response format"""
+
+ def test_response_structure(self):
+ """Test expected response structure"""
+ expected_keys = {"agent_id", "has_prompts", "prompts"}
+
+ # Mock response
+ response = {
+ "agent_id": "agent-daarwizz",
+ "has_prompts": True,
+ "prompts": {
+ "core": "You are DAARWIZZ...",
+ "safety": "Safety rules...",
+ "governance": None,
+ "tools": None
+ }
+ }
+
+ assert set(response.keys()) == expected_keys
+ assert response["has_prompts"] is True
+ assert "core" in response["prompts"]
+ assert "safety" in response["prompts"]
+ assert "governance" in response["prompts"]
+ assert "tools" in response["prompts"]
+
+ def test_has_prompts_when_core_exists(self):
+ """Test has_prompts is True when core exists"""
+ prompts = {"core": "Some core prompt", "safety": None, "governance": None, "tools": None}
+ has_prompts = prompts.get("core") is not None
+ assert has_prompts is True
+
+ def test_has_prompts_when_core_missing(self):
+ """Test has_prompts is False when core is None"""
+ prompts = {"core": None, "safety": "Safety only", "governance": None, "tools": None}
+ has_prompts = prompts.get("core") is not None
+ assert has_prompts is False
+
+
+class TestPromptsStatusBatch:
+ """Tests for batch prompts status check"""
+
+ def test_status_response_format(self):
+ """Test batch status response format"""
+ agent_ids = ["agent-daarwizz", "agent-devtools", "agent-unknown"]
+
+ # Mock response
+ response = {
+ "status": {
+ "agent-daarwizz": True,
+ "agent-devtools": True,
+ "agent-unknown": False
+ }
+ }
+
+ assert "status" in response
+ assert isinstance(response["status"], dict)
+ assert all(aid in response["status"] for aid in agent_ids)
+ assert all(isinstance(v, bool) for v in response["status"].values())
+
+
+class TestNodeAgentPrompts:
+ """Tests for Node Agent specific prompts"""
+
+ def test_node_guardian_prompt_content(self):
+ """Test Node Guardian has appropriate content markers"""
+ guardian_core = """Ти — Node Guardian для НОДА1 (Hetzner GEX44 Production).
+Твоя місія: забезпечувати стабільну роботу продакшн-інфраструктури DAARION.city."""
+
+ assert "Node Guardian" in guardian_core
+ assert "НОДА1" in guardian_core
+ assert "Production" in guardian_core or "production" in guardian_core.lower()
+
+ def test_node_guardian_safety_rules(self):
+ """Test Node Guardian safety rules"""
+ guardian_safety = """Ніколи не виконуй деструктивні команди без підтвердження.
+Не розкривай чутливу інформацію (паролі, API ключі).
+При невизначеності — ескалюй до людини."""
+
+ assert "деструктивні" in guardian_safety
+ assert "підтвердження" in guardian_safety
+ assert "ескалюй" in guardian_safety
+
+
+class TestAgentCoverage:
+ """Tests for agent prompts coverage requirements"""
+
+ REQUIRED_AGENTS = [
+ # City / Core
+ "agent-daarwizz",
+ "agent-microdao-orchestrator",
+ "agent-devtools",
+ # District / MicroDAO
+ "agent-greenfood",
+ "agent-helion",
+ "agent-soul",
+ "agent-druid",
+ "agent-nutra",
+ "agent-eonarch",
+ "agent-clan",
+ "agent-yaromir",
+ "agent-monitor",
+ # Node Agents
+ "monitor-node1",
+ "monitor-node2",
+ "node-steward-node1",
+ "node-steward-node2"
+ ]
+
+ def test_required_agents_list(self):
+ """Test required agents are defined"""
+ assert len(self.REQUIRED_AGENTS) == 16
+ assert "agent-daarwizz" in self.REQUIRED_AGENTS
+ assert "monitor-node1" in self.REQUIRED_AGENTS
+ assert "monitor-node2" in self.REQUIRED_AGENTS
+
+
+# Integration tests (require running services)
+class TestIntegration:
+ """Integration tests - skip if services not available"""
+
+ @pytest.mark.skip(reason="Requires running services")
+ async def test_fetch_runtime_prompts(self):
+ """Test fetching runtime prompts from API"""
+ import httpx
+
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ "http://localhost:7001/internal/agents/agent-daarwizz/prompts/runtime"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["agent_id"] == "agent-daarwizz"
+ assert "prompts" in data
+
+ @pytest.mark.skip(reason="Requires running services")
+ async def test_fetch_system_prompt(self):
+ """Test fetching full system prompt from API"""
+ import httpx
+
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ "http://localhost:7001/internal/agents/agent-daarwizz/system-prompt"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["agent_id"] == "agent-daarwizz"
+ assert "system_prompt" in data
+ assert len(data["system_prompt"]) > 100
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
+
diff --git a/tests/test_dagi_router_api.py b/tests/test_dagi_router_api.py
new file mode 100644
index 00000000..2e9a7b77
--- /dev/null
+++ b/tests/test_dagi_router_api.py
@@ -0,0 +1,280 @@
+"""
+DAGI Router API Tests
+
+Тести для endpoints:
+- GET /internal/node/{node_id}/dagi-router/agents
+- GET /internal/node/{node_id}/metrics/current
+- POST /internal/node/{node_id}/dagi-audit/run
+- POST /internal/node/{node_id}/dagi-router/phantom/sync
+- POST /internal/node/{node_id}/dagi-router/stale/mark
+"""
+
+import pytest
+import httpx
+from typing import Any, Dict
+
+# Test configuration
+CITY_SERVICE_URL = "http://localhost:7001"
+NODE1_ID = "node-1-hetzner-gex44"
+NODE2_ID = "node-2-macbook-m4max"
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def client():
+ """HTTP client для тестування"""
+ return httpx.Client(base_url=CITY_SERVICE_URL, timeout=30.0)
+
+
+@pytest.fixture
+def node_ids():
+ """Node IDs для тестування"""
+ return [NODE1_ID, NODE2_ID]
+
+
+# ============================================================================
+# DAGI Router Agents Tests
+# ============================================================================
+
+class TestDAGIRouterAgents:
+ """Тести для GET /internal/node/{node_id}/dagi-router/agents"""
+
+ def test_get_agents_returns_valid_response(self, client):
+ """Endpoint повертає валідну структуру"""
+ response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Перевірка структури
+ assert "node_id" in data
+ assert "summary" in data
+ assert "agents" in data
+
+ # Перевірка summary
+ summary = data["summary"]
+ assert "active" in summary
+ assert "phantom" in summary
+ assert "stale" in summary
+ assert "router_total" in summary
+ assert "system_total" in summary
+
+ # Types
+ assert isinstance(summary["active"], int)
+ assert isinstance(summary["phantom"], int)
+ assert isinstance(data["agents"], list)
+
+ def test_get_agents_for_unknown_node(self, client):
+ """Endpoint повертає пустий response для невідомої ноди"""
+ response = client.get("/city/internal/node/unknown-node-id/dagi-router/agents")
+
+ # Має повернути 200 з пустим списком, не 404
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["agents"] == []
+ assert data["summary"]["active"] == 0
+
+ def test_agents_have_required_fields(self, client):
+ """Агенти мають всі необхідні поля"""
+ response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ if data["agents"]:
+ agent = data["agents"][0]
+
+ # Required fields
+ assert "id" in agent
+ assert "name" in agent
+ assert "status" in agent
+
+ # Status must be valid
+ assert agent["status"] in ["active", "phantom", "stale", "error"]
+
+
+# ============================================================================
+# Node Metrics Tests
+# ============================================================================
+
+class TestNodeMetrics:
+ """Тести для GET /internal/node/{node_id}/metrics/current"""
+
+ def test_get_metrics_returns_valid_response(self, client):
+ """Endpoint повертає валідну структуру"""
+ response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Required fields
+ assert "node_id" in data
+ assert data["node_id"] == NODE1_ID
+
+ # Metric fields
+ assert "cpu_cores" in data
+ assert "cpu_usage" in data
+ assert "gpu_model" in data
+ assert "gpu_memory_total" in data
+ assert "gpu_memory_used" in data
+ assert "ram_total" in data
+ assert "ram_used" in data
+ assert "disk_total" in data
+ assert "disk_used" in data
+ assert "agent_count_router" in data
+ assert "agent_count_system" in data
+
+ def test_get_metrics_for_unknown_node(self, client):
+ """Endpoint повертає minimal response для невідомої ноди"""
+ response = client.get("/city/internal/node/unknown-node-id/metrics/current")
+
+ # Має повернути 200 з мінімальним response
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["node_id"] == "unknown-node-id"
+
+ def test_metrics_have_numeric_values(self, client):
+ """Метрики мають числові значення"""
+ response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # All numeric fields should be numbers
+ numeric_fields = [
+ "cpu_cores", "cpu_usage",
+ "gpu_memory_total", "gpu_memory_used",
+ "ram_total", "ram_used",
+ "disk_total", "disk_used",
+ "agent_count_router", "agent_count_system"
+ ]
+
+ for field in numeric_fields:
+ assert isinstance(data[field], (int, float)), f"{field} should be numeric"
+
+
+# ============================================================================
+# DAGI Audit Tests
+# ============================================================================
+
+class TestDAGIAudit:
+ """Тести для POST /internal/node/{node_id}/dagi-audit/run"""
+
+ def test_run_audit_returns_valid_response(self, client):
+ """POST audit повертає валідну структуру"""
+ response = client.post(f"/city/internal/node/{NODE1_ID}/dagi-audit/run")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert "status" in data
+ assert data["status"] == "completed"
+ assert "summary" in data
+ assert "message" in data
+
+ # Summary fields
+ summary = data["summary"]
+ assert "router_total" in summary
+ assert "db_total" in summary
+ assert "active_count" in summary
+ assert "phantom_count" in summary
+ assert "stale_count" in summary
+
+ def test_get_audit_summary(self, client):
+ """GET audit summary повертає дані"""
+ response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-audit")
+
+ # Може бути 200 з даними або null
+ assert response.status_code == 200
+
+ data = response.json()
+ if data:
+ assert "node_id" in data
+ assert "timestamp" in data
+ assert "active_count" in data
+
+
+# ============================================================================
+# Phantom/Stale Sync Tests
+# ============================================================================
+
+class TestPhantomStaleSync:
+ """Тести для phantom/stale sync endpoints"""
+
+ def test_phantom_sync_empty_list(self, client):
+ """Sync з пустим списком не падає"""
+ response = client.post(
+ f"/city/internal/node/{NODE1_ID}/dagi-router/phantom/sync",
+ json={"agent_ids": []}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["status"] == "completed"
+ assert data["created_count"] == 0
+
+ def test_stale_mark_empty_list(self, client):
+ """Mark stale з пустим списком не падає"""
+ response = client.post(
+ f"/city/internal/node/{NODE1_ID}/dagi-router/stale/mark",
+ json={"agent_ids": []}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["status"] == "completed"
+ assert data["marked_count"] == 0
+
+
+# ============================================================================
+# Integration Tests
+# ============================================================================
+
+class TestIntegration:
+ """Інтеграційні тести"""
+
+ def test_full_audit_flow(self, client):
+ """Повний цикл: audit → get agents → get metrics"""
+ # 1. Run audit
+ audit_response = client.post(f"/city/internal/node/{NODE1_ID}/dagi-audit/run")
+ assert audit_response.status_code == 200
+
+ # 2. Get agents
+ agents_response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
+ assert agents_response.status_code == 200
+ agents_data = agents_response.json()
+
+ # 3. Get metrics
+ metrics_response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
+ assert metrics_response.status_code == 200
+
+ # 4. Verify consistency
+ audit_data = audit_response.json()
+
+ # Agent counts should match
+ assert agents_data["summary"]["active"] + agents_data["summary"]["phantom"] + agents_data["summary"]["stale"] >= 0
+
+ def test_both_nodes_accessible(self, client, node_ids):
+ """Обидві ноди доступні через API"""
+ for node_id in node_ids:
+ response = client.get(f"/city/internal/node/{node_id}/metrics/current")
+ assert response.status_code == 200
+
+ data = response.json()
+ assert data["node_id"] == node_id
+
+
+# ============================================================================
+# Run tests
+# ============================================================================
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v", "--tb=short"])
+
diff --git a/tests/test_infra_smoke.py b/tests/test_infra_smoke.py
new file mode 100644
index 00000000..3dc72262
--- /dev/null
+++ b/tests/test_infra_smoke.py
@@ -0,0 +1,336 @@
+"""
+Infrastructure Smoke Tests
+
+Базові API тести для перевірки після деплою.
+Запускаються як частина deploy pipeline або вручну.
+
+Використання:
+ pytest tests/test_infra_smoke.py -v
+ pytest tests/test_infra_smoke.py -v --base-url http://localhost:7001
+"""
+
+import os
+import pytest
+import requests
+from datetime import datetime, timezone, timedelta
+from typing import Optional
+
+# Configuration
+BASE_URL = os.getenv("CITY_SERVICE_URL", "http://daarion-city-service:7001")
+TIMEOUT = 10
+
+# Node IDs
+NODE1_ID = "node-1-hetzner-gex44"
+NODE2_ID = "node-2-macbook-m4max"
+
+
+def pytest_addoption(parser):
+ """Add command line options"""
+ parser.addoption(
+ "--base-url",
+ action="store",
+ default=BASE_URL,
+ help="Base URL of city-service API"
+ )
+
+
+@pytest.fixture
+def base_url(request):
+ """Get base URL from command line or environment"""
+ return request.config.getoption("--base-url") or BASE_URL
+
+
+@pytest.fixture
+def api_client(base_url):
+ """Create API client session"""
+ session = requests.Session()
+ session.timeout = TIMEOUT
+
+ class Client:
+ def __init__(self, base_url: str, session: requests.Session):
+ self.base_url = base_url.rstrip("/")
+ self.session = session
+
+ def get(self, path: str) -> requests.Response:
+ return self.session.get(f"{self.base_url}{path}", timeout=TIMEOUT)
+
+ def post(self, path: str, json: dict) -> requests.Response:
+ return self.session.post(f"{self.base_url}{path}", json=json, timeout=TIMEOUT)
+
+ return Client(base_url, session)
+
+
+# ==============================================================================
+# Health Checks
+# ==============================================================================
+
+class TestHealthChecks:
+ """Basic health check tests"""
+
+ def test_healthz_endpoint(self, api_client):
+ """Test /healthz returns 200 and status ok"""
+ response = api_client.get("/healthz")
+
+ assert response.status_code == 200, f"Health check failed: {response.text}"
+ data = response.json()
+ assert data.get("status") == "ok", f"Unhealthy status: {data}"
+
+ def test_public_nodes_endpoint(self, api_client):
+ """Test /public/nodes returns node list"""
+ response = api_client.get("/public/nodes")
+
+ assert response.status_code == 200, f"Nodes endpoint failed: {response.text}"
+ data = response.json()
+ assert "items" in data, "Response missing 'items' key"
+ assert "total" in data, "Response missing 'total' key"
+
+
+# ==============================================================================
+# Node Metrics Tests
+# ==============================================================================
+
+class TestNodeMetrics:
+ """Node metrics tests"""
+
+ @pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
+ def test_node_metrics_endpoint(self, api_client, node_id):
+ """Test node metrics endpoint returns data"""
+ response = api_client.get(f"/internal/node/{node_id}/metrics/current")
+
+ assert response.status_code == 200, f"Node metrics failed for {node_id}: {response.text}"
+ data = response.json()
+
+ # Check required fields
+ assert "node_id" in data, "Missing node_id"
+ assert "agent_count_router" in data, "Missing agent_count_router"
+ assert "agent_count_system" in data, "Missing agent_count_system"
+
+ def test_node1_has_agents(self, api_client):
+ """Test NODE1 has at least 1 agent in router"""
+ response = api_client.get(f"/internal/node/{NODE1_ID}/metrics/current")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE1 metrics not available: {response.status_code}")
+
+ data = response.json()
+ agent_count = data.get("agent_count_router", 0)
+
+ assert agent_count >= 1, f"NODE1 has {agent_count} agents in router, expected >= 1"
+
+ def test_node2_has_agents(self, api_client):
+ """Test NODE2 has at least 1 agent in system"""
+ response = api_client.get(f"/internal/node/{NODE2_ID}/metrics/current")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE2 metrics not available: {response.status_code}")
+
+ data = response.json()
+ agent_count = data.get("agent_count_system", 0)
+
+ assert agent_count >= 1, f"NODE2 has {agent_count} agents in system, expected >= 1"
+
+
+# ==============================================================================
+# Node Agents Tests
+# ==============================================================================
+
+class TestNodeAgents:
+ """Node agents (Guardian/Steward) tests"""
+
+ @pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
+ def test_node_agents_endpoint(self, api_client, node_id):
+ """Test node agents endpoint returns data"""
+ response = api_client.get(f"/internal/node/{node_id}/agents")
+
+ assert response.status_code == 200, f"Node agents failed for {node_id}: {response.text}"
+ data = response.json()
+
+ assert "node_id" in data, "Missing node_id"
+ assert "total" in data, "Missing total"
+ assert "agents" in data, "Missing agents list"
+
+ def test_node1_has_guardian(self, api_client):
+ """Test NODE1 has Node Guardian"""
+ response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE1 agents not available: {response.status_code}")
+
+ data = response.json()
+ guardian = data.get("guardian")
+
+ assert guardian is not None, "NODE1 missing Node Guardian"
+ assert guardian.get("id"), "Guardian has no ID"
+
+ def test_node1_has_steward(self, api_client):
+ """Test NODE1 has Node Steward"""
+ response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE1 agents not available: {response.status_code}")
+
+ data = response.json()
+ steward = data.get("steward")
+
+ assert steward is not None, "NODE1 missing Node Steward"
+ assert steward.get("id"), "Steward has no ID"
+
+ def test_node2_has_guardian(self, api_client):
+ """Test NODE2 has Node Guardian"""
+ response = api_client.get(f"/internal/node/{NODE2_ID}/agents")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE2 agents not available: {response.status_code}")
+
+ data = response.json()
+ guardian = data.get("guardian")
+
+ assert guardian is not None, "NODE2 missing Node Guardian"
+
+
+# ==============================================================================
+# DAGI Router Tests
+# ==============================================================================
+
+class TestDAGIRouter:
+ """DAGI Router tests"""
+
+ @pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
+ def test_dagi_router_agents_endpoint(self, api_client, node_id):
+ """Test DAGI Router agents endpoint returns data"""
+ response = api_client.get(f"/internal/node/{node_id}/dagi-router/agents")
+
+ # May return empty if no audit yet
+ if response.status_code == 404:
+ pytest.skip(f"DAGI Router not configured for {node_id}")
+
+ assert response.status_code == 200, f"DAGI Router failed for {node_id}: {response.text}"
+ data = response.json()
+
+ assert "node_id" in data, "Missing node_id"
+ assert "summary" in data, "Missing summary"
+ assert "agents" in data, "Missing agents list"
+
+ def test_node1_router_has_agents(self, api_client):
+ """Test NODE1 DAGI Router has agents"""
+ response = api_client.get(f"/internal/node/{NODE1_ID}/dagi-router/agents")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE1 DAGI Router not available: {response.status_code}")
+
+ data = response.json()
+ summary = data.get("summary", {})
+ router_total = summary.get("router_total", 0)
+
+ # Warn but don't fail - router may not be configured
+ if router_total == 0:
+ pytest.skip("NODE1 DAGI Router has 0 agents (may not be configured)")
+
+ assert router_total >= 1, f"DAGI Router has {router_total} agents, expected >= 1"
+
+
+# ==============================================================================
+# Core Agents Tests
+# ==============================================================================
+
+class TestCoreAgents:
+ """Core agents tests"""
+
+ def test_prompts_status_endpoint(self, api_client):
+ """Test prompts status batch endpoint"""
+ agent_ids = ["agent-daarwizz", "agent-devtools", "agent-soul"]
+
+ response = api_client.post("/internal/agents/prompts/status", {"agent_ids": agent_ids})
+
+ assert response.status_code == 200, f"Prompts status failed: {response.text}"
+ data = response.json()
+
+ assert "status" in data, "Missing status in response"
+ assert isinstance(data["status"], dict), "Status should be a dict"
+
+ def test_daarwizz_runtime_prompt(self, api_client):
+ """Test DAARWIZZ has runtime prompt"""
+ # Try both possible slugs
+ for agent_id in ["agent-daarwizz", "daarwizz"]:
+ response = api_client.get(f"/internal/agents/{agent_id}/prompts/runtime")
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("has_prompts"):
+ assert data.get("prompts", {}).get("core"), "DAARWIZZ missing core prompt"
+ return
+
+ pytest.skip("DAARWIZZ agent not found or no prompts configured")
+
+ def test_runtime_system_prompt_endpoint(self, api_client):
+ """Test runtime system prompt endpoint works"""
+ response = api_client.get("/internal/agents/agent-daarwizz/system-prompt")
+
+ if response.status_code == 404:
+ pytest.skip("DAARWIZZ agent not found")
+
+ assert response.status_code == 200, f"System prompt failed: {response.text}"
+ data = response.json()
+
+ assert "agent_id" in data, "Missing agent_id"
+ assert "system_prompt" in data, "Missing system_prompt"
+ assert len(data.get("system_prompt", "")) > 10, "System prompt too short"
+
+
+# ==============================================================================
+# Integration Tests
+# ==============================================================================
+
+class TestIntegration:
+ """End-to-end integration tests"""
+
+ def test_node_to_agents_flow(self, api_client):
+ """Test full flow: node → agents → prompts"""
+ # Get node
+ response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
+
+ if response.status_code != 200:
+ pytest.skip(f"NODE1 not available: {response.status_code}")
+
+ data = response.json()
+ agents = data.get("agents", [])
+
+ if not agents:
+ pytest.skip("No agents found for NODE1")
+
+ # Get first agent's prompts
+ agent = agents[0]
+ agent_id = agent.get("id")
+
+ response = api_client.get(f"/internal/agents/{agent_id}/prompts/runtime")
+
+ # Should return successfully even if no prompts
+ assert response.status_code == 200, f"Agent prompts failed for {agent_id}: {response.text}"
+
+ def test_public_nodes_have_metrics(self, api_client):
+ """Test public nodes endpoint includes metrics"""
+ response = api_client.get("/public/nodes")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ items = data.get("items", [])
+ if not items:
+ pytest.skip("No nodes in system")
+
+ # Check first node has metrics
+ node = items[0]
+
+ # Should have metrics object after our changes
+ if "metrics" in node:
+ metrics = node["metrics"]
+ assert "cpu_cores" in metrics or "ram_total" in metrics, "Metrics object empty"
+
+
+# ==============================================================================
+# Run as script
+# ==============================================================================
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
+