feat: implement Task 029 (Agent Orchestrator & Visibility Flow)

This commit is contained in:
Apple
2025-11-28 10:17:57 -08:00
parent 1327295ff8
commit 69cc76fe00
3183 changed files with 1513720 additions and 129 deletions

View File

@@ -481,7 +481,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
id: 'agent-iris',
name: 'Iris',
role: 'Image Analyzer',
model: 'qwen2-vl:32b',
model: 'llava:13b',
backend: 'ollama',
status: 'active',
node: 'node-2',
@@ -492,7 +492,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
id: 'agent-lumen',
name: 'Lumen',
role: 'Visual Content Creator',
model: 'qwen2-vl:32b',
model: 'llava:13b',
backend: 'ollama',
status: 'active',
node: 'node-2',
@@ -503,7 +503,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
id: 'agent-spectra',
name: 'Spectra',
role: 'Multimodal Processor',
model: 'qwen3-vl:latest',
model: 'llava:13b',
backend: 'ollama',
status: 'active',
node: 'node-2',
@@ -514,7 +514,7 @@ const ALL_NODE2_AGENTS: Node2Agent[] = [
id: 'agent-video-analyzer',
name: 'Video Analyzer',
role: 'Video Analysis & Processing',
model: 'qwen2-vl:32b',
model: 'llava:13b',
backend: 'ollama',
status: 'active',
node: 'node-2',

View File

@@ -0,0 +1,480 @@
import type {
SpaceScene,
SpaceSourceData,
SpaceCluster,
StarObject,
PlanetObject,
MoonObject,
GatewayObject,
AnomalyObject,
} from '../types/space';
import type {
NodeInfo as CityNodeInfo,
MicroDAOInfo,
AgentInfo,
CityEvent,
} from '../../city-dashboard/types/city';
const GALAXY_CENTER = { x: 480, y: 320 };
const STAR_RING_RADIUS = 240;
const STAR_BASE_RADIUS = 58;
const CLUSTER_PRESETS = [
{
key: 'core',
id: 'cluster-core',
clusterId: 'constellation-core',
name: 'Core Infrastructure',
description: 'Hetzner, Supabase та базові дата-центри',
position: { x: 360, y: 320, radius: 240 },
},
{
key: 'frontier',
id: 'cluster-frontier',
clusterId: 'constellation-frontier',
name: 'Frontier & Edge',
description: 'MacBook / Edge вузли, польові лабораторії',
position: { x: 660, y: 260, radius: 200 },
},
] as const;
type ClusterKey = (typeof CLUSTER_PRESETS)[number]['key'];
const MIN_HEALTH = 18;
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function normaliseNodeIdentifier(
value: string | undefined,
nodes: CityNodeInfo[],
): string | null {
if (!value) return null;
const lowered = value.toLowerCase();
const direct = nodes.find(
(node) =>
node.id.toLowerCase() === lowered || node.name.toLowerCase() === lowered,
);
if (direct) {
return direct.id;
}
const partial = nodes.find(
(node) =>
lowered.includes(node.id.toLowerCase()) ||
node.id.toLowerCase().includes(lowered) ||
lowered.includes(node.name.toLowerCase()),
);
return partial ? partial.id : null;
}
function detectClusterKey(node: CityNodeInfo): ClusterKey {
const haystack = `${node.name} ${node.location}`.toLowerCase();
if (haystack.includes('hetzner') || haystack.includes('core')) {
return 'core';
}
return 'frontier';
}
function statusFromNodeStatus(status: CityNodeInfo['status']) {
switch (status) {
case 'online':
return 'stable';
case 'degraded':
case 'maintenance':
return 'warning';
default:
return 'critical';
}
}
function statusFromMicroDaoStatus(
status: MicroDAOInfo['status'],
): SpaceCluster['status'] {
switch (status) {
case 'active':
return 'stable';
case 'forming':
return 'warning';
default:
return 'critical';
}
}
function statusFromAgentStatus(status: AgentInfo['status']) {
switch (status) {
case 'active':
return 'stable';
case 'idle':
return 'warning';
default:
return 'critical';
}
}
function severityFromPriority(event: CityEvent['priority']) {
switch (event) {
case 'critical':
return 'high';
case 'high':
return 'medium';
default:
return 'low';
}
}
function buildMicroDaoHostMap(
microDaos: MicroDAOInfo[],
agents: AgentInfo[],
nodes: CityNodeInfo[],
) {
const assignment = new Map<string, string>();
microDaos.forEach((microDao, index) => {
const matchingAgents = agents.filter((agent) => {
if (!agent.microDao) return false;
const target = agent.microDao.toLowerCase();
return (
target === microDao.id.toLowerCase() ||
target === microDao.name.toLowerCase() ||
target.includes(microDao.id.toLowerCase()) ||
microDao.name.toLowerCase().includes(target)
);
});
const nodeCounts = new Map<string, number>();
matchingAgents.forEach((agent) => {
const resolved = normaliseNodeIdentifier(agent.node, nodes);
if (!resolved) return;
nodeCounts.set(resolved, (nodeCounts.get(resolved) ?? 0) + 1);
});
if (nodeCounts.size) {
const [bestNode] = [...nodeCounts.entries()].sort(
(a, b) => b[1] - a[1],
)[0];
assignment.set(microDao.id, bestNode);
} else if (nodes.length) {
assignment.set(microDao.id, nodes[index % nodes.length].id);
}
});
return assignment;
}
function computeStarPosition(index: number, total: number) {
if (total <= 1) {
return { x: GALAXY_CENTER.x, y: GALAXY_CENTER.y, radius: STAR_BASE_RADIUS };
}
const angle = (index / total) * Math.PI * 2;
const eccentricity = 0.65;
return {
x: GALAXY_CENTER.x + Math.cos(angle) * STAR_RING_RADIUS,
y: GALAXY_CENTER.y + Math.sin(angle) * STAR_RING_RADIUS * eccentricity,
radius: STAR_BASE_RADIUS,
};
}
function buildClusters(
nodes: CityNodeInfo[],
microDaos: MicroDAOInfo[],
agents: AgentInfo[],
microDaoAssignments: Map<string, string>,
): SpaceCluster[] {
const nodeGroups = new Map<ClusterKey, CityNodeInfo[]>();
const microDaoGroups = new Map<ClusterKey, number>();
const agentGroups = new Map<ClusterKey, number>();
const nodeClusterMap = new Map<string, ClusterKey>();
nodes.forEach((node) => {
const clusterKey = detectClusterKey(node);
nodeClusterMap.set(node.id, clusterKey);
const list = nodeGroups.get(clusterKey) ?? [];
list.push(node);
nodeGroups.set(clusterKey, list);
});
microDaos.forEach((microDao) => {
const hostNodeId = microDaoAssignments.get(microDao.id);
const clusterKey = hostNodeId
? nodeClusterMap.get(hostNodeId) ?? 'frontier'
: 'frontier';
microDaoGroups.set(
clusterKey,
(microDaoGroups.get(clusterKey) ?? 0) + 1,
);
});
agents.forEach((agent) => {
const nodeId = normaliseNodeIdentifier(agent.node, nodes);
const clusterKey = nodeId
? nodeClusterMap.get(nodeId) ?? 'frontier'
: 'frontier';
agentGroups.set(clusterKey, (agentGroups.get(clusterKey) ?? 0) + 1);
});
return CLUSTER_PRESETS.map((preset) => {
const nodeCount = nodeGroups.get(preset.key)?.length ?? 0;
const microCount = microDaoGroups.get(preset.key) ?? 0;
const agentCount = agentGroups.get(preset.key) ?? 0;
const density = clamp(
(nodeCount + microCount * 0.35 + agentCount * 0.02) /
Math.max(nodes.length || 1, 1),
0,
1,
);
const status =
density >= 0.75
? 'stable'
: density >= 0.4
? ('warning' as const)
: ('critical' as const);
return {
id: preset.id,
type: 'cluster',
clusterId: preset.clusterId,
name: preset.name,
description: preset.description,
nodes: nodeCount,
microDaos: microCount,
agents: agentCount,
density,
status,
position: preset.position,
};
});
}
function buildStars(
nodes: CityNodeInfo[],
microDaos: MicroDAOInfo[],
agents: AgentInfo[],
microDaoAssignments: Map<string, string>,
): StarObject[] {
return nodes.map((node, index) => {
const position = computeStarPosition(index, nodes.length);
const assignedMicroDaos = microDaos.filter(
(microDao) => microDaoAssignments.get(microDao.id) === node.id,
);
const assignedAgents = agents.filter((agent) =>
(normaliseNodeIdentifier(agent.node, nodes) ?? '') === node.id,
);
const health =
node.metrics && node.metrics.cpuUsage !== undefined
? clamp(
100 -
(node.metrics.cpuUsage * 0.4 +
node.metrics.ramUsage * 0.35 +
(node.metrics.diskUsage ?? 30) * 0.15),
MIN_HEALTH,
100,
)
: 80;
return {
id: `star-${node.id}`,
type: 'star',
name: node.name,
nodeId: node.id,
health,
microDaos: assignedMicroDaos.length,
agents: assignedAgents.length || node.agents || 0,
status: statusFromNodeStatus(node.status),
position,
};
});
}
function buildPlanets(
microDaos: MicroDAOInfo[],
microDaoAssignments: Map<string, string>,
stars: StarObject[],
) {
const planets: PlanetObject[] = [];
const planetsByStar = new Map<string, MicroDAOInfo[]>();
microDaos.forEach((microDao) => {
const hostNodeId =
microDaoAssignments.get(microDao.id) ??
stars[0]?.nodeId ??
microDao.id;
const arr = planetsByStar.get(hostNodeId) ?? [];
arr.push(microDao);
planetsByStar.set(hostNodeId, arr);
});
planetsByStar.forEach((daoList, nodeId) => {
const star = stars.find((s) => s.nodeId === nodeId);
if (!star) return;
daoList.forEach((microDao, index) => {
const orbitRadius = 110 + index * 45;
const angle = (index / Math.max(daoList.length, 1)) * Math.PI * 2;
const position = {
x: star.position.x + Math.cos(angle) * orbitRadius,
y: star.position.y + Math.sin(angle) * orbitRadius,
};
planets.push({
id: `planet-${microDao.id}`,
type: 'planet',
name: microDao.name,
microDaoId: microDao.id,
population: microDao.members,
agents: microDao.agents,
orbitRadius,
starId: star.id,
status: statusFromMicroDaoStatus(microDao.status),
position,
});
});
});
return planets;
}
function buildMoons(
agents: AgentInfo[],
planets: PlanetObject[],
microDaos: MicroDAOInfo[],
) {
const moons: MoonObject[] = [];
const agentsByMicroDao = new Map<string, AgentInfo[]>();
agents.forEach((agent) => {
if (!agent.microDao) return;
const target = agent.microDao.toLowerCase();
const microDao =
microDaos.find(
(dao) =>
dao.id.toLowerCase() === target ||
dao.name.toLowerCase() === target ||
target.includes(dao.id.toLowerCase()),
) ?? null;
if (!microDao) return;
const list = agentsByMicroDao.get(microDao.id) ?? [];
list.push(agent);
agentsByMicroDao.set(microDao.id, list);
});
planets.forEach((planet) => {
const list = agentsByMicroDao.get(planet.microDaoId)?.slice(0, 4) ?? [];
list.forEach((agent, index) => {
const orbitRadius = 20 + index * 6;
const angle = (index / Math.max(list.length, 1)) * Math.PI * 2;
const position = {
x: planet.position.x + Math.cos(angle) * (orbitRadius + 16),
y: planet.position.y + Math.sin(angle) * (orbitRadius + 16),
};
moons.push({
id: `moon-${agent.id}`,
type: 'moon',
name: agent.name,
agentId: agent.id,
focus: agent.role,
planetId: planet.id,
orbitRadius,
status: statusFromAgentStatus(agent.status),
position,
});
});
});
return moons;
}
function buildGateways(agents: AgentInfo[]): GatewayObject[] {
const integrations = agents
.filter(
(agent) =>
agent.type === 'service-agent' ||
agent.type === 'platform-agent' ||
/bridge|gateway|connector|monitor/i.test(agent.role),
)
.slice(0, 3);
if (!integrations.length) {
return [
{
id: 'gateway-matrix',
type: 'gateway',
name: 'Matrix Bridge',
integration: 'Matrix',
position: { x: 520, y: 160 },
status: 'stable',
},
];
}
const basePositions = [
{ x: 520, y: 160 },
{ x: 220, y: 180 },
{ x: 780, y: 200 },
];
return integrations.map((agent, index) => ({
id: `gateway-${agent.id}`,
type: 'gateway',
name: agent.name,
integration: agent.role,
position: basePositions[index] ?? { x: 520 + index * 80, y: 160 },
status: statusFromAgentStatus(agent.status),
}));
}
function buildAnomalies(events: CityEvent[]): AnomalyObject[] {
const alertEvents = events
.filter(
(event) =>
event.type.startsWith('alerts') ||
event.type.startsWith('metrics.reconciled') ||
event.priority === 'high' ||
event.priority === 'critical',
)
.slice(0, 3);
return alertEvents.map((event, index) => ({
id: `anomaly-${event.id}`,
type: 'anomaly',
name: event.title,
severity: severityFromPriority(event.priority),
description: event.description,
status: 'warning',
position: { x: 220 + index * 180, y: 480 - index * 60 },
}));
}
export function buildSpaceScene(source: SpaceSourceData): SpaceScene {
const nodes = source.nodes ?? [];
const microDaos = source.microDaos ?? [];
const agents = source.agents ?? [];
const events = source.events ?? [];
const microDaoAssignments = buildMicroDaoHostMap(microDaos, agents, nodes);
const stars = buildStars(nodes, microDaos, agents, microDaoAssignments);
const planets = buildPlanets(microDaos, microDaoAssignments, stars);
const moons = buildMoons(agents, planets, microDaos);
const clusters = buildClusters(
nodes,
microDaos,
agents,
microDaoAssignments,
);
const gateways = buildGateways(agents);
const anomalies = buildAnomalies(events);
return {
clusters,
stars,
planets,
moons,
gateways,
anomalies,
};
}

185
src/lib/presence.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* Presence System — Система відстеження онлайн-статусу користувачів
*/
import { WebSocketClient } from './ws';
export type PresenceStatus = 'online' | 'offline' | 'away';
export interface UserPresence {
userId: string;
status: PresenceStatus;
lastSeen: string;
}
export type PresenceUpdateCallback = (presence: Map<string, UserPresence>) => void;
export class PresenceManager {
private ws: WebSocketClient | null = null;
private presenceMap: Map<string, UserPresence> = new Map();
private heartbeatInterval: number | null = null;
private callbacks: Set<PresenceUpdateCallback> = new Set();
private userId: string | null = null;
constructor(private wsUrl: string, private heartbeatIntervalMs: number = 20000) {}
/**
* Підключитися до Presence System
*/
connect(userId: string): void {
this.userId = userId;
this.ws = new WebSocketClient({ url: this.wsUrl });
this.ws.onOpen(() => {
console.log('[Presence] Connected');
this.startHeartbeat();
});
this.ws.onMessage((data: any) => {
if (data.event === 'presence.update') {
this.handlePresenceUpdate(data.user_id, data.status, data.last_seen);
} else if (data.event === 'presence.bulk_update') {
this.handleBulkUpdate(data.users);
}
});
this.ws.onClose(() => {
console.log('[Presence] Disconnected');
this.stopHeartbeat();
});
this.ws.connect();
}
/**
* Від'єднатися від Presence System
*/
disconnect(): void {
this.stopHeartbeat();
if (this.ws) {
this.ws.disconnect();
this.ws = null;
}
this.presenceMap.clear();
this.userId = null;
}
/**
* Підписатися на оновлення presence
*/
subscribe(callback: PresenceUpdateCallback): () => void {
this.callbacks.add(callback);
// Відразу відправити поточний стан
callback(new Map(this.presenceMap));
return () => this.callbacks.delete(callback);
}
/**
* Отримати presence конкретного користувача
*/
getPresence(userId: string): UserPresence | undefined {
return this.presenceMap.get(userId);
}
/**
* Отримати всі онлайн користувачів
*/
getOnlineUsers(): UserPresence[] {
return Array.from(this.presenceMap.values()).filter(p => p.status === 'online');
}
/**
* Отримати кількість онлайн користувачів
*/
getOnlineCount(): number {
return this.getOnlineUsers().length;
}
/**
* Запустити heartbeat
*/
private startHeartbeat(): void {
this.stopHeartbeat();
// Відправити перший heartbeat одразу
this.sendHeartbeat();
// Потім кожні N секунд
this.heartbeatInterval = window.setInterval(() => {
this.sendHeartbeat();
}, this.heartbeatIntervalMs);
}
/**
* Зупинити heartbeat
*/
private stopHeartbeat(): void {
if (this.heartbeatInterval !== null) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* Відправити heartbeat
*/
private sendHeartbeat(): void {
if (!this.ws || !this.userId) return;
this.ws.send({
event: 'presence.heartbeat',
user_id: this.userId,
});
}
/**
* Обробити оновлення presence
*/
private handlePresenceUpdate(userId: string, status: PresenceStatus, lastSeen?: string): void {
const presence: UserPresence = {
userId,
status,
lastSeen: lastSeen || new Date().toISOString(),
};
this.presenceMap.set(userId, presence);
this.notifyCallbacks();
}
/**
* Обробити bulk update
*/
private handleBulkUpdate(users: Array<{ user_id: string; status: PresenceStatus; last_seen?: string }>): void {
users.forEach(user => {
this.handlePresenceUpdate(user.user_id, user.status, user.last_seen);
});
}
/**
* Сповістити всіх підписників
*/
private notifyCallbacks(): void {
const presenceCopy = new Map(this.presenceMap);
this.callbacks.forEach(callback => callback(presenceCopy));
}
}
// Global singleton instance
let globalPresenceManager: PresenceManager | null = null;
/**
* Отримати глобальний екземпляр PresenceManager
*/
export function getPresenceManager(): PresenceManager {
if (!globalPresenceManager) {
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:8000'}/ws/city/presence`;
globalPresenceManager = new PresenceManager(wsUrl);
}
return globalPresenceManager;
}

231
src/lib/ws.ts Normal file
View File

@@ -0,0 +1,231 @@
/**
* Централізований WebSocket клієнт для DAARION
* Підтримує auto-reconnect, heartbeat, та множинні підписки
*/
type MessageHandler = (data: unknown) => void;
type ErrorHandler = (error: Event) => void;
type ConnectionHandler = () => void;
export interface WebSocketClientOptions {
url: string;
reconnectInterval?: number;
heartbeatInterval?: number;
maxReconnectAttempts?: number;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectInterval: number;
private heartbeatInterval: number;
private maxReconnectAttempts: number;
private reconnectAttempts = 0;
private heartbeatTimer: number | null = null;
private reconnectTimer: number | null = null;
private messageHandlers: Set<MessageHandler> = new Set();
private errorHandlers: Set<ErrorHandler> = new Set();
private openHandlers: Set<ConnectionHandler> = new Set();
private closeHandlers: Set<ConnectionHandler> = new Set();
private isIntentionallyClosed = false;
constructor(options: WebSocketClientOptions) {
this.url = options.url;
this.reconnectInterval = options.reconnectInterval || 3000;
this.heartbeatInterval = options.heartbeatInterval || 30000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
}
/**
* Підключитися до WebSocket сервера
*/
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
console.log('[WebSocket] Already connected');
return;
}
this.isIntentionallyClosed = false;
console.log(`[WebSocket] Connecting to ${this.url}...`);
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('[WebSocket] Connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
this.openHandlers.forEach(handler => handler());
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Ігноруємо heartbeat відповіді
if (data.type === 'pong') {
return;
}
this.messageHandlers.forEach(handler => handler(data));
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error);
}
};
this.ws.onerror = (event) => {
console.error('[WebSocket] Error:', event);
this.errorHandlers.forEach(handler => handler(event));
};
this.ws.onclose = () => {
console.log('[WebSocket] Disconnected');
this.stopHeartbeat();
this.closeHandlers.forEach(handler => handler());
if (!this.isIntentionallyClosed) {
this.scheduleReconnect();
}
};
} catch (error) {
console.error('[WebSocket] Connection error:', error);
this.scheduleReconnect();
}
}
/**
* Від'єднатися від WebSocket сервера
*/
disconnect(): void {
this.isIntentionallyClosed = true;
this.stopHeartbeat();
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Відправити повідомлення
*/
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('[WebSocket] Cannot send: not connected');
}
}
/**
* Підписатися на повідомлення
*/
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
/**
* Підписатися на помилки
*/
onError(handler: ErrorHandler): () => void {
this.errorHandlers.add(handler);
return () => this.errorHandlers.delete(handler);
}
/**
* Підписатися на відкриття з'єднання
*/
onOpen(handler: ConnectionHandler): () => void {
this.openHandlers.add(handler);
return () => this.openHandlers.delete(handler);
}
/**
* Підписатися на закриття з'єднання
*/
onClose(handler: ConnectionHandler): () => void {
this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler);
}
/**
* Перевірити, чи активне з'єднання
*/
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Запустити heartbeat
*/
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'ping' });
}
}, this.heartbeatInterval);
}
/**
* Зупинити heartbeat
*/
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Запланувати перепідключення
*/
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[WebSocket] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectInterval * Math.min(this.reconnectAttempts, 5);
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = window.setTimeout(() => {
this.connect();
}, delay);
}
}
/**
* Фабрика для створення WebSocket клієнтів для різних сервісів
*/
export const createWebSocketClient = (options: WebSocketClientOptions): WebSocketClient => {
return new WebSocketClient(options);
};
/**
* Hook-friendly wrapper для React компонентів
*/
export const useWebSocket = (url: string) => {
const client = new WebSocketClient({ url });
return {
connect: () => client.connect(),
disconnect: () => client.disconnect(),
send: (data: unknown) => client.send(data),
onMessage: (handler: MessageHandler) => client.onMessage(handler),
onError: (handler: ErrorHandler) => client.onError(handler),
onOpen: (handler: ConnectionHandler) => client.onOpen(handler),
onClose: (handler: ConnectionHandler) => client.onClose(handler),
isConnected: () => client.isConnected(),
};
};