feat(city-map): Add 2D City Map with coordinates and agent presence

- Add migration 013_city_map_coordinates.sql with map coordinates, zones, and agents table
- Add /city/map API endpoint in city-service
- Add /city/agents and /city/agents/online endpoints
- Extend presence aggregator to include agents[] in snapshot
- Add AgentsSource for fetching agent data from DB
- Create CityMap component with interactive room tiles
- Add useCityMap hook for fetching map data
- Update useGlobalPresence to include agents
- Add map/list view toggle on /city page
- Add agent badges to room cards and map tiles
This commit is contained in:
Apple
2025-11-27 07:00:47 -08:00
parent 3de3c8cb36
commit 6bd769ef40
258 changed files with 1747 additions and 79 deletions

View File

@@ -15,3 +15,4 @@ COPY app ./app
# Run the service
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8085"]

View File

@@ -0,0 +1,92 @@
"""Agents data source from PostgreSQL"""
from sqlalchemy import create_engine, text
from typing import List, Dict
import logging
logger = logging.getLogger(__name__)
class AgentsSource:
"""Fetches agent data from PostgreSQL"""
def __init__(self, db_dsn: str):
self.engine = create_engine(db_dsn)
def get_online_agents(self) -> List[Dict]:
"""
Get all online/busy agents.
Returns list of dicts with:
- agent_id
- display_name
- kind
- status
- room_id (current_room_id)
- color
"""
query = text("""
SELECT
id as agent_id,
display_name,
kind,
status,
current_room_id as room_id,
color
FROM agents
WHERE status IN ('online', 'busy')
ORDER BY display_name
""")
try:
with self.engine.connect() as conn:
rows = conn.execute(query).mappings().all()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"Failed to get online agents: {e}")
return []
def get_agents_by_room(self, room_id: str) -> List[Dict]:
"""Get agents in a specific room"""
query = text("""
SELECT
id as agent_id,
display_name,
kind,
status,
current_room_id as room_id,
color
FROM agents
WHERE current_room_id = :room_id AND status != 'offline'
ORDER BY display_name
""")
try:
with self.engine.connect() as conn:
rows = conn.execute(query, {"room_id": room_id}).mappings().all()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"Failed to get agents for room {room_id}: {e}")
return []
def get_all_agents(self) -> List[Dict]:
"""Get all agents (including offline)"""
query = text("""
SELECT
id as agent_id,
display_name,
kind,
status,
current_room_id as room_id,
color
FROM agents
ORDER BY display_name
""")
try:
with self.engine.connect() as conn:
rows = conn.execute(query).mappings().all()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"Failed to get all agents: {e}")
return []

View File

@@ -4,9 +4,10 @@ from datetime import datetime, timezone
from typing import List, Optional
import logging
from .models import PresenceSnapshot, RoomPresence, CityPresence
from .models import PresenceSnapshot, RoomPresence, CityPresence, AgentPresence
from .matrix_client import MatrixClient
from .rooms_source import RoomsSource
from .agents_source import AgentsSource
logger = logging.getLogger(__name__)
@@ -16,6 +17,7 @@ class PresenceAggregator:
Aggregates presence data from Matrix and broadcasts to subscribers.
- Periodically polls Matrix for room members and presence
- Fetches agent status from database
- Caches the latest snapshot
- Broadcasts updates to SSE subscribers
"""
@@ -24,10 +26,12 @@ class PresenceAggregator:
self,
matrix_client: MatrixClient,
rooms_source: RoomsSource,
agents_source: Optional[AgentsSource] = None,
poll_interval_seconds: int = 5,
):
self.matrix_client = matrix_client
self.rooms_source = rooms_source
self.agents_source = agents_source
self.poll_interval_seconds = poll_interval_seconds
self._snapshot: Optional[PresenceSnapshot] = None
@@ -62,18 +66,47 @@ class PresenceAggregator:
pass
async def _compute_snapshot(self) -> PresenceSnapshot:
"""Compute a new presence snapshot from Matrix"""
"""Compute a new presence snapshot from Matrix and agents DB"""
rooms = self.rooms_source.get_rooms()
if not rooms:
logger.warning("No rooms with matrix_room_id found")
# Fetch agents from database
all_agents: List[AgentPresence] = []
agents_by_room: dict = {}
if self.agents_source:
try:
online_agents = self.agents_source.get_online_agents()
for agent in online_agents:
ap = AgentPresence(
agent_id=agent["agent_id"],
display_name=agent["display_name"],
kind=agent.get("kind", "assistant"),
status=agent.get("status", "online"),
room_id=agent.get("room_id"),
color=agent.get("color", "cyan")
)
all_agents.append(ap)
# Group by room
room_id = agent.get("room_id")
if room_id:
if room_id not in agents_by_room:
agents_by_room[room_id] = []
agents_by_room[room_id].append(ap)
except Exception as e:
logger.error(f"Error fetching agents: {e}")
room_presences: List[RoomPresence] = []
city_online_total = 0
rooms_online = 0
for r in rooms:
matrix_room_id = r["matrix_room_id"]
room_id = r["room_id"]
try:
# Get room members
@@ -99,24 +132,30 @@ class PresenceAggregator:
city_online_total += online_count
# Get agents for this room
room_agents = agents_by_room.get(room_id, [])
room_presences.append(
RoomPresence(
room_id=r["room_id"],
room_id=room_id,
matrix_room_id=matrix_room_id,
online=online_count,
typing=typing_count,
agents=room_agents,
)
)
except Exception as e:
logger.error(f"Error processing room {r['room_id']}: {e}")
# Add room with 0 online
logger.error(f"Error processing room {room_id}: {e}")
# Add room with 0 online but include agents
room_agents = agents_by_room.get(room_id, [])
room_presences.append(
RoomPresence(
room_id=r["room_id"],
room_id=room_id,
matrix_room_id=matrix_room_id,
online=0,
typing=0,
agents=room_agents,
)
)
@@ -125,11 +164,13 @@ class PresenceAggregator:
city=CityPresence(
online_total=city_online_total,
rooms_online=rooms_online,
agents_online=len(all_agents),
),
rooms=room_presences,
agents=all_agents,
)
logger.info(f"Computed snapshot: {city_online_total} online in {rooms_online} rooms")
logger.info(f"Computed snapshot: {city_online_total} online, {len(all_agents)} agents in {rooms_online} rooms")
return snapshot
async def run_forever(self):
@@ -152,3 +193,4 @@ class PresenceAggregator:
self._running = False
logger.info("Stopping presence aggregator")

View File

@@ -34,3 +34,4 @@ def load_settings() -> Settings:
presence_daemon_user=os.getenv("PRESENCE_DAEMON_USER", "@presence_daemon:daarion.space"),
)

View File

@@ -13,6 +13,7 @@ import logging
from .config import load_settings
from .matrix_client import MatrixClient
from .rooms_source import RoomsSource, StaticRoomsSource
from .agents_source import AgentsSource
from .aggregator import PresenceAggregator
# Configure logging
@@ -58,9 +59,19 @@ else:
rooms_source = RoomsSource(db_dsn=settings.db_dsn or "postgresql://postgres:postgres@localhost:5432/postgres")
logger.warning("No rooms source configured, using default database")
# Initialize agents source (uses same DB as rooms)
agents_source = None
if settings.db_dsn:
try:
agents_source = AgentsSource(db_dsn=settings.db_dsn)
logger.info("Agents source initialized")
except Exception as e:
logger.warning(f"Failed to initialize agents source: {e}")
aggregator = PresenceAggregator(
matrix_client=matrix_client,
rooms_source=rooms_source,
agents_source=agents_source,
poll_interval_seconds=settings.poll_interval_seconds,
)
@@ -157,3 +168,4 @@ if __name__ == "__main__":
reload=True,
)

View File

@@ -92,3 +92,4 @@ class MatrixClient:
async def close(self):
await self._client.aclose()

View File

@@ -1,19 +1,31 @@
"""Data models for Presence Aggregator"""
from pydantic import BaseModel
from typing import List
from typing import List, Optional
from datetime import datetime
class AgentPresence(BaseModel):
"""Agent presence in a room"""
agent_id: str
display_name: str
kind: str = "assistant" # assistant, civic, oracle, builder
status: str = "offline" # online, offline, busy
room_id: Optional[str] = None
color: Optional[str] = None
class RoomPresence(BaseModel):
room_id: str # internal room id from DB
matrix_room_id: str # Matrix room ID (!xxx:domain)
online: int
typing: int
agents: List[AgentPresence] = [] # Agents present in this room
class CityPresence(BaseModel):
online_total: int
rooms_online: int
agents_online: int = 0
class PresenceSnapshot(BaseModel):
@@ -21,4 +33,6 @@ class PresenceSnapshot(BaseModel):
timestamp: datetime
city: CityPresence
rooms: List[RoomPresence]
agents: List[AgentPresence] = [] # All online agents

View File

@@ -67,3 +67,4 @@ class StaticRoomsSource:
def get_rooms(self) -> List[Dict]:
return self._rooms