feat: Node Self-Healing, DAGI Audit, Agent Prompts, Infra Invariants

### Backend (city-service)
- Node Registry + Self-Healing API (migration 039)
- Improved get_all_nodes() with robust fallback for node_registry/node_cache
- Agent Prompts Runtime API for DAGI Router integration
- DAGI Router Audit endpoints (phantom/stale detection)
- Node Agents API (Guardian/Steward)
- Node metrics extended (CPU/GPU/RAM/Disk)

### Frontend (apps/web)
- Node Directory with improved error handling
- Node Cabinet with metrics cards
- DAGI Router Card component
- Node Metrics Card component
- useDAGIAudit hook

### Scripts
- check-invariants.py - deploy verification
- node-bootstrap.sh - node self-registration
- node-guardian-loop.py - continuous self-healing
- dagi_agent_audit.py - DAGI audit utility

### Migrations
- 034: Agent prompts seed
- 035: Agent DAGI audit
- 036: Node metrics extended
- 037: Node agents complete
- 038: Agent prompts full coverage
- 039: Node registry self-healing

### Tests
- test_infra_smoke.py
- test_agent_prompts_runtime.py
- test_dagi_router_api.py

### Documentation
- DEPLOY_CHECKLIST_2024_11_30.md
- Multiple TASK_PHASE docs
This commit is contained in:
Apple
2025-11-30 13:52:01 -08:00
parent 0c7836af5a
commit bca81dc719
36 changed files with 10630 additions and 55 deletions

View File

@@ -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 }
);
}

View File

@@ -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() {
<AgentsCard agents={dashboard.agents} />
</div>
{/* DAGI Router Audit */}
<DAGIRouterCard nodeId={nodeId} />
<AIServicesCard ai={dashboard.ai} />
</div>
<div className="space-y-6">
{/* Node Metrics */}
<NodeMetricsCard nodeId={nodeId} />
{/* Node Guardian & Steward Agents */}
<NodeGuardianCard
guardian={nodeProfile?.guardian_agent}
steward={nodeProfile?.steward_agent}
guardian={guardian ? { id: guardian.id, name: guardian.name, kind: guardian.kind, slug: guardian.slug } : nodeProfile?.guardian_agent}
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
{/* MicroDAO Presence */}
@@ -293,11 +305,21 @@ export default function NodeCabinetPage() {
{/* Node Guardian & Steward Agents */}
<div className="mb-6">
<NodeGuardianCard
guardian={nodeProfile?.guardian_agent}
steward={nodeProfile?.steward_agent}
guardian={guardian ? { id: guardian.id, name: guardian.name, kind: guardian.kind, slug: guardian.slug } : nodeProfile?.guardian_agent}
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
</div>
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
<div className="mb-6">
<NodeMetricsCard nodeId={nodeId} />
</div>
{/* DAGI Router Audit */}
<div className="mb-6">
<DAGIRouterCard nodeId={nodeId} />
</div>
{/* MicroDAO Presence */}
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 mb-6">
@@ -323,14 +345,6 @@ export default function NodeCabinetPage() {
</div>
)}
{/* Notice for non-NODE1 */}
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 mb-6">
<p className="text-amber-400 text-sm">
Детальний моніторинг доступний тільки для НОДА1 (Production).
Для цієї ноди показано базову інформацію з node_cache.
</p>
</div>
{/* Link to agents */}
<Link
href={`/agents?node_id=${nodeId}`}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { Server, Cpu, Users, Activity, ExternalLink } from 'lucide-react';
import { Server, Cpu, Users, Activity, ExternalLink, Zap, HardDrive, MemoryStick } from 'lucide-react';
import { useNodeList } from '@/hooks/useNodes';
import { NodeProfile } from '@/lib/types/nodes';
@@ -11,10 +11,32 @@ function getNodeLabel(nodeId: string): string {
return 'НОДА';
}
function formatMB(mb: number): string {
if (mb >= 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<string, string> = {
purple: 'bg-purple-500',
emerald: 'bg-emerald-500',
cyan: 'bg-cyan-500',
amber: 'bg-amber-500'
};
return (
<div className="w-12 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className={`h-full ${colors[color]} transition-all`} style={{ width: `${percent}%` }} />
</div>
);
}
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 (
<Link
@@ -76,12 +98,62 @@ function NodeCard({ node }: { node: NodeProfile }) {
</div>
)}
{/* Metrics */}
{m && (m.gpu_model || m.ram_total > 0) && (
<div className="grid grid-cols-4 gap-2 mb-4 p-3 bg-slate-800/30 rounded-xl">
{/* GPU */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Zap className="w-3 h-3 text-purple-400" />
<span className="text-[10px] text-white/40">GPU</span>
</div>
<MiniBar value={m.gpu_vram_used} max={m.gpu_vram_total} color="purple" />
<div className="text-[10px] text-white/50 mt-1">
{m.gpu_vram_total > 0 ? formatMB(m.gpu_vram_total) : '—'}
</div>
</div>
{/* CPU */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Cpu className="w-3 h-3 text-cyan-400" />
<span className="text-[10px] text-white/40">CPU</span>
</div>
<MiniBar value={m.cpu_usage} max={100} color="cyan" />
<div className="text-[10px] text-white/50 mt-1">
{m.cpu_cores > 0 ? `${m.cpu_cores}c` : '—'}
</div>
</div>
{/* RAM */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<MemoryStick className="w-3 h-3 text-emerald-400" />
<span className="text-[10px] text-white/40">RAM</span>
</div>
<MiniBar value={m.ram_used} max={m.ram_total} color="emerald" />
<div className="text-[10px] text-white/50 mt-1">
{m.ram_total > 0 ? formatMB(m.ram_total) : '—'}
</div>
</div>
{/* Disk */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<HardDrive className="w-3 h-3 text-amber-400" />
<span className="text-[10px] text-white/40">Disk</span>
</div>
<MiniBar value={m.disk_used} max={m.disk_total} color="amber" />
<div className="text-[10px] text-white/50 mt-1">
{m.disk_total > 0 ? formatMB(m.disk_total) : '—'}
</div>
</div>
</div>
)}
{/* Stats */}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-sm">
<Users className="w-4 h-4 text-cyan-400" />
<span className="text-white">{node.agents_total}</span>
<span className="text-white">{m?.agent_count_system || node.agents_total}</span>
<span className="text-white/40">агентів</span>
</div>
<div className="text-sm">

View File

@@ -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<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.bgClass} ${config.textClass}`}>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
};
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<Router className="w-5 h-5 text-purple-400" />
DAGI Router
</h2>
<div className="flex items-center gap-2">
{summary.phantom > 0 && (
<button
onClick={handleSyncPhantom}
disabled={isSyncing || isRunning}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-500/20 hover:bg-amber-500/30
text-amber-400 rounded-lg transition-colors text-xs disabled:opacity-50"
title="Синхронізувати phantom агентів"
>
<Download className="w-3.5 h-3.5" />
Sync {summary.phantom}
</button>
)}
<button
onClick={handleRunAudit}
disabled={isRunning || isLoading || isSyncing}
className="flex items-center gap-2 px-3 py-1.5 bg-purple-500/20 hover:bg-purple-500/30
text-purple-400 rounded-lg transition-colors text-sm disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRunning ? 'animate-spin' : ''}`} />
{isRunning ? 'Аудит...' : 'Запустити'}
</button>
</div>
</div>
{/* Error */}
{(error || runError) && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
<AlertTriangle className="w-4 h-4 inline mr-2" />
{runError || error?.message || 'Помилка завантаження'}
</div>
)}
{/* Loading */}
{isLoading && agents.length === 0 && (
<div className="text-center py-8 text-white/40">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
Завантаження...
</div>
)}
{/* No data */}
{!isLoading && agents.length === 0 && !error && (
<div className="text-center py-8 text-white/40">
<Router className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>Ще немає даних аудиту</p>
<p className="text-sm mt-1">Натисніть &quot;Запустити&quot; для аудиту</p>
</div>
)}
{/* Content */}
{agents.length > 0 && (
<>
{/* Timestamp */}
{lastAuditAt && (
<div className="flex items-center gap-2 text-xs text-white/40 mb-4">
<Clock className="w-3 h-3" />
Останній аудит: {formatTime(lastAuditAt)}
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<button
onClick={() => setStatusFilter(statusFilter === 'active' ? 'all' : 'active')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'active'
? 'bg-emerald-500/30 ring-2 ring-emerald-500/50'
: 'bg-emerald-500/10 hover:bg-emerald-500/20'
}`}
>
<div className="text-2xl font-bold text-emerald-400">
{summary.active}
</div>
<div className="text-xs text-white/50">Active</div>
</button>
<button
onClick={() => setStatusFilter(statusFilter === 'phantom' ? 'all' : 'phantom')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'phantom'
? 'bg-amber-500/30 ring-2 ring-amber-500/50'
: summary.phantom > 0 ? 'bg-amber-500/10 hover:bg-amber-500/20' : 'bg-slate-800/50'
}`}
>
<div className={`text-2xl font-bold ${
summary.phantom > 0 ? 'text-amber-400' : 'text-white/40'
}`}>
{summary.phantom}
</div>
<div className="text-xs text-white/50">Phantom</div>
</button>
<button
onClick={() => setStatusFilter(statusFilter === 'stale' ? 'all' : 'stale')}
className={`rounded-lg p-3 text-center transition-all ${
statusFilter === 'stale'
? 'bg-orange-500/30 ring-2 ring-orange-500/50'
: summary.stale > 0 ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-slate-800/50'
}`}
>
<div className={`text-2xl font-bold ${
summary.stale > 0 ? 'text-orange-400' : 'text-white/40'
}`}>
{summary.stale}
</div>
<div className="text-xs text-white/50">Stale</div>
</button>
</div>
{/* Source counts */}
<div className="flex justify-between text-xs text-white/40 mb-4 px-1">
<span>Router: {summary.router_total} агентів</span>
<span>System: {summary.system_total} агентів</span>
</div>
{/* Search & Filter */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
placeholder="Пошук агентів..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<div className="relative">
<button
onClick={() => setShowFilterMenu(!showFilterMenu)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
statusFilter !== 'all'
? 'bg-purple-500/20 border-purple-500/50 text-purple-400'
: 'bg-slate-800/50 border-white/10 text-white/60 hover:bg-slate-800'
}`}
>
<Filter className="w-4 h-4" />
<ChevronDown className="w-3 h-3" />
</button>
{showFilterMenu && (
<div className="absolute right-0 top-full mt-1 bg-slate-800 border border-white/10
rounded-lg shadow-xl z-10 min-w-[120px] py-1">
{(['all', 'active', 'phantom', 'stale'] as const).map((status) => (
<button
key={status}
onClick={() => {
setStatusFilter(status);
setShowFilterMenu(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-white/5 ${
statusFilter === status ? 'text-purple-400' : 'text-white/70'
}`}
>
{status === 'all' ? 'All' : STATUS_CONFIG[status].label}
</button>
))}
</div>
)}
</div>
</div>
{/* Agents Table */}
<div className="overflow-hidden rounded-lg border border-white/10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-800/50 text-left">
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider">
Agent
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider hidden md:table-cell">
Runtime
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider hidden lg:table-cell">
Last Seen
</th>
<th className="px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider text-right">
Cabinet
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredAgents.map((agent) => (
<tr
key={agent.id}
className="hover:bg-white/5 transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
STATUS_CONFIG[agent.status as keyof typeof STATUS_CONFIG]?.bgClass || 'bg-slate-700'
}`}>
<Bot className={`w-4 h-4 ${
STATUS_CONFIG[agent.status as keyof typeof STATUS_CONFIG]?.textClass || 'text-white/50'
}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{agent.name}</span>
{agent.has_prompts && (
<span
className="text-purple-400"
title="System Prompts налаштовані"
>
<Brain className="w-3.5 h-3.5" />
</span>
)}
{!agent.has_prompts && agent.status === 'active' && (
<span
className="text-amber-400/60"
title="System Prompts відсутні"
>
<Brain className="w-3.5 h-3.5 opacity-50" />
</span>
)}
</div>
{agent.role && (
<div className="text-xs text-white/40">{agent.role}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
{getStatusBadge(agent.status)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="text-sm text-white/60">
{agent.gpu && <div>{agent.gpu}</div>}
{agent.cpu && <div className="text-xs text-white/40">{agent.cpu}</div>}
</div>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-white/50">
{agent.last_seen_at ? formatTime(agent.last_seen_at) : '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
{agent.has_cabinet && agent.cabinet_slug ? (
<Link
href={`/agents/${agent.cabinet_slug}`}
className="inline-flex items-center gap-1 px-2.5 py-1.5 bg-purple-500/10
hover:bg-purple-500/20 text-purple-400 rounded-lg text-xs
transition-colors"
>
Open
<ExternalLink className="w-3 h-3" />
</Link>
) : (
<span className="text-xs text-white/30"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Empty state for filtered results */}
{filteredAgents.length === 0 && agents.length > 0 && (
<div className="text-center py-8 text-white/40">
<Search className="w-6 h-6 mx-auto mb-2 opacity-50" />
<p>Немає агентів за цим фільтром</p>
</div>
)}
</div>
{/* Footer stats */}
<div className="mt-4 flex items-center justify-between text-xs text-white/40">
<span>
Показано {filteredAgents.length} з {agents.length} агентів
</span>
{summary.phantom === 0 && summary.stale === 0 && (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle className="w-3 h-3" />
Всі агенти синхронізовані
</span>
)}
</div>
</>
)}
</div>
);
}
export default DAGIRouterCard;

View File

@@ -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<string, string> = {
purple: 'bg-purple-500',
emerald: 'bg-emerald-500',
cyan: 'bg-cyan-500',
amber: 'bg-amber-500'
};
return (
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[color] || colorClasses.purple} transition-all duration-300`}
style={{ width: `${percentage}%` }}
/>
</div>
);
}
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 (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-white/40" />
</div>
</div>
);
}
if (error && !metrics) {
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<div className="text-center text-white/40">
<p>Не вдалося завантажити метрики</p>
</div>
</div>
);
}
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 (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
Node Metrics
</h2>
<button
onClick={() => refresh()}
className="p-2 hover:bg-white/5 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4 text-white/40" />
</button>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-4">
{/* GPU */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center">
<Zap className="w-4 h-4 text-purple-400" />
</div>
<div>
<div className="text-xs text-white/40">GPU</div>
<div className="text-sm font-medium text-white truncate max-w-[120px]" title={metrics.gpu_model || 'Unknown'}>
{metrics.gpu_model || 'Unknown'}
</div>
</div>
</div>
<ProgressBar
value={metrics.gpu_memory_used}
max={metrics.gpu_memory_total}
color="purple"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.gpu_memory_used)}</span>
<span>{formatBytes(metrics.gpu_memory_total)}</span>
</div>
</div>
{/* CPU */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-cyan-500/20 rounded-lg flex items-center justify-center">
<Cpu className="w-4 h-4 text-cyan-400" />
</div>
<div>
<div className="text-xs text-white/40">CPU</div>
<div className="text-sm font-medium text-white truncate max-w-[120px]" title={metrics.cpu_model || 'Unknown'}>
{metrics.cpu_cores} cores
</div>
</div>
</div>
<ProgressBar
value={metrics.cpu_usage}
max={100}
color="cyan"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{metrics.cpu_usage.toFixed(1)}%</span>
<span>100%</span>
</div>
</div>
{/* RAM */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-emerald-500/20 rounded-lg flex items-center justify-center">
<MemoryStick className="w-4 h-4 text-emerald-400" />
</div>
<div>
<div className="text-xs text-white/40">RAM</div>
<div className="text-sm font-medium text-white">
{ramUsagePercent.toFixed(0)}% used
</div>
</div>
</div>
<ProgressBar
value={metrics.ram_used}
max={metrics.ram_total}
color="emerald"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.ram_used)}</span>
<span>{formatBytes(metrics.ram_total)}</span>
</div>
</div>
{/* Disk */}
<div className="bg-slate-800/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-amber-500/20 rounded-lg flex items-center justify-center">
<HardDrive className="w-4 h-4 text-amber-400" />
</div>
<div>
<div className="text-xs text-white/40">Disk</div>
<div className="text-sm font-medium text-white">
{diskUsagePercent.toFixed(0)}% used
</div>
</div>
</div>
<ProgressBar
value={metrics.disk_used}
max={metrics.disk_total}
color="amber"
/>
<div className="flex justify-between mt-2 text-xs text-white/50">
<span>{formatBytes(metrics.disk_used)}</span>
<span>{formatBytes(metrics.disk_total)}</span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-white/40" />
<span className="text-sm text-white/60">
<span className="text-white font-medium">{metrics.agent_count_router}</span> Router
<span className="mx-1">/</span>
<span className="text-white font-medium">{metrics.agent_count_system}</span> System
</span>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-white/40">
<Clock className="w-3 h-3" />
{formatTime(metrics.last_heartbeat)}
</div>
</div>
</div>
);
}
export default NodeMetricsCard;

View File

@@ -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';

View File

@@ -102,7 +102,7 @@ function AgentMiniCard({ title, description, agent, accentClass, icon }: AgentMi
</Link>
)}
<Link
href={`/agents/${agent.id}`}
href={`/agents/${agent.slug || agent.id}`}
className="px-2 py-1 rounded-lg border border-white/10 bg-white/5 text-white/70 hover:bg-white/10 hover:text-white transition-colors flex items-center gap-1"
>
Кабінет

View File

@@ -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<DAGIAuditSummary | null>(
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<DAGIAuditFull | null>(
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<DAGIRouterAgentsResponse>(
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<DAGIAuditHistory>(
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<NodeMetrics>(
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<NodeAgentsResponse>(
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
};
}

View File

@@ -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 {

View File

@@ -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@<NODE1_IP>
# або через ваш алиас
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=<password> 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
```

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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** — з'єднання нод між собою

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

758
scripts/check-invariants.py Executable file
View File

@@ -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()

482
scripts/dagi_agent_audit.py Normal file
View File

@@ -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())

View File

@@ -188,18 +188,64 @@ echo " - Docker logs: docker logs <container_name>"
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

155
scripts/node-bootstrap.sh Executable file
View File

@@ -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 <<EOF
{
"id": "$NODE_ID",
"name": "$NODE_NAME",
"hostname": "$NODE_HOSTNAME",
"environment": "$NODE_ENVIRONMENT",
"roles": $roles_json,
"description": "$NODE_DESCRIPTION"
}
EOF
)
log_info "Starting node self-registration..."
log_info " Node ID: $NODE_ID"
log_info " Node Name: $NODE_NAME"
log_info " Environment: $NODE_ENVIRONMENT"
log_info " Hostname: $NODE_HOSTNAME"
log_info " Roles: $NODE_ROLES"
log_info " City Service: $CITY_SERVICE_URL"
# Self-registration with retries
attempt=1
while [ $attempt -le $MAX_RETRIES ]; do
log_info "Registration attempt $attempt/$MAX_RETRIES..."
response=$(curl -s -w "\n%{http_code}" \
-X POST "${CITY_SERVICE_URL}/internal/nodes/register-or-update" \
-H "Content-Type: application/json" \
-d "$payload" \
--max-time 10 \
2>/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

432
scripts/node-guardian-loop.py Executable file
View File

@@ -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()

View File

@@ -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):

File diff suppressed because it is too large Load Diff

View File

@@ -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}")

View File

@@ -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,

View File

@@ -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

View File

@@ -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"])

View File

@@ -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"])

336
tests/test_infra_smoke.py Normal file
View File

@@ -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"])