commit c86eb9ccc56e7c2872207f98ccfc485ae48a46e5 Author: Eric Jungbauer Date: Mon Apr 13 01:06:42 2026 +0000 Initial commit: Agent Command Center dashboard + weather briefing agent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dbe0a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +data/ +__pycache__/ +*.pyc +.DS_Store +._* +*.db diff --git a/agents/weather_briefing.py b/agents/weather_briefing.py new file mode 100644 index 0000000..d35a54d --- /dev/null +++ b/agents/weather_briefing.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Weather Briefing Agent +Fetches weather for Providence, UT and posts to Outline wiki. +Logs run to the Agent Dashboard API. +""" + +import json +import os +import sys +from datetime import datetime, timezone +from urllib import request, error, parse + +# --- Config --- +DASHBOARD_API = os.environ.get("DASHBOARD_API", "http://localhost:8550") +AGENT_ID = "weather-briefing" + +WIKI_API = "https://wiki.jfamily.io/api" +WIKI_TOKEN = os.environ.get("WIKI_TOKEN", "ol_api_yHXypRyqf4CscWDzPluGfPev9GhdFg6mwrXwkT") +WIKI_COLLECTION_ID = os.environ.get("WIKI_COLLECTION_ID", "9d9e471c-84cd-4ba7-bae5-c70f61805228") # Set after collection creation + +# Providence, UT +LAT = 41.7064 +LON = -111.8133 + +WEATHER_URL = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={LAT}&longitude={LON}" + f"¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m" + f"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunrise,sunset" + f"&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch" + f"&timezone=America/Denver&forecast_days=7" +) + +WMO_CODES = { + 0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast", + 45: "Foggy", 48: "Icy fog", 51: "Light drizzle", 53: "Drizzle", + 55: "Heavy drizzle", 61: "Light rain", 63: "Rain", 65: "Heavy rain", + 66: "Freezing rain", 67: "Heavy freezing rain", + 71: "Light snow", 73: "Snow", 75: "Heavy snow", 77: "Snow grains", + 80: "Light showers", 81: "Showers", 82: "Heavy showers", + 85: "Light snow showers", 86: "Heavy snow showers", + 95: "Thunderstorm", 96: "Thunderstorm w/ hail", 99: "Severe thunderstorm", +} + +DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + +def api_request(url, data=None, headers=None, method="GET"): + """Simple HTTP helper using urllib.""" + if data is not None: + data = json.dumps(data).encode("utf-8") + req = request.Request(url, data=data, headers=headers or {}, method=method) + if data: + req.add_header("Content-Type", "application/json") + with request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + +def fetch_weather(): + """Fetch weather data from Open-Meteo.""" + return api_request(WEATHER_URL) + + +def format_briefing(weather): + """Format weather data into markdown.""" + now = datetime.now() + date_str = now.strftime("%A, %B %d, %Y") + + current = weather["current"] + daily = weather["daily"] + + condition = WMO_CODES.get(current["weather_code"], "Unknown") + temp = round(current["temperature_2m"]) + feels = round(current["apparent_temperature"]) + wind = round(current["wind_speed_10m"]) + humidity = current["relative_humidity_2m"] + + md = f"# Daily Weather Briefing\n" + md += f"**{date_str}** | Providence, Utah\n\n" + md += f"---\n\n" + md += f"## Current Conditions\n\n" + md += f"| | |\n|---|---|\n" + md += f"| **Condition** | {condition} |\n" + md += f"| **Temperature** | {temp}°F (feels like {feels}°F) |\n" + md += f"| **Wind** | {wind} mph |\n" + md += f"| **Humidity** | {humidity}% |\n\n" + + md += f"## 7-Day Forecast\n\n" + md += f"| Day | Condition | High | Low | Precip | Wind |\n" + md += f"|-----|-----------|------|-----|--------|------|\n" + + for i in range(len(daily["time"])): + d = datetime.strptime(daily["time"][i], "%Y-%m-%d") + day_name = DAY_NAMES[d.weekday()] + if i == 0: + day_name = "**Today**" + elif i == 1: + day_name = "Tomorrow" + + cond = WMO_CODES.get(daily["weather_code"][i], "?") + hi = round(daily["temperature_2m_max"][i]) + lo = round(daily["temperature_2m_min"][i]) + precip = daily["precipitation_sum"][i] + wind_max = round(daily["wind_speed_10m_max"][i]) + + precip_str = f'{precip}"' if precip > 0 else "-" + md += f"| {day_name} | {cond} | {hi}°F | {lo}°F | {precip_str} | {wind_max} mph |\n" + + # Sunrise/sunset for today + sunrise = daily["sunrise"][0].split("T")[1] if daily["sunrise"][0] else "?" + sunset = daily["sunset"][0].split("T")[1] if daily["sunset"][0] else "?" + md += f"\n**Sunrise:** {sunrise} | **Sunset:** {sunset}\n" + + md += f"\n---\n*Generated at {now.strftime('%I:%M %p MT')} by Weather Briefing Agent*\n" + + return md, f"{condition}, {temp}°F (feels like {feels}°F), wind {wind} mph" + + +def post_to_wiki(markdown, date_str): + """Create or update a daily briefing doc in Outline.""" + headers = { + "Authorization": f"Bearer {WIKI_TOKEN}", + } + + # Search for existing doc with today's date + title = f"Daily Briefing — {date_str}" + search_result = api_request( + f"{WIKI_API}/documents.search", + data={"query": title, "collectionId": WIKI_COLLECTION_ID}, + headers=headers, + method="POST", + ) + + doc_id = None + for doc in search_result.get("data", []): + if doc.get("document", {}).get("title") == title: + doc_id = doc["document"]["id"] + break + + if doc_id: + api_request( + f"{WIKI_API}/documents.update", + data={"id": doc_id, "text": markdown, "publish": True}, + headers=headers, + method="POST", + ) + return doc_id, "updated" + else: + result = api_request( + f"{WIKI_API}/documents.create", + data={ + "title": title, + "text": markdown, + "collectionId": WIKI_COLLECTION_ID, + "publish": True, + }, + headers=headers, + method="POST", + ) + return result["data"]["id"], "created" + + +def log_run(status, output="", err="", metadata=None): + """Log this run to the dashboard API.""" + try: + api_request( + f"{DASHBOARD_API}/api/agents/{AGENT_ID}/runs", + data={ + "status": status, + "output": output, + "error": err, + "metadata": metadata or {}, + }, + method="POST", + ) + except Exception as e: + print(f"Warning: failed to log run to dashboard: {e}", file=sys.stderr) + + +def main(): + try: + print("Fetching weather...") + weather = fetch_weather() + + print("Formatting briefing...") + date_str = datetime.now().strftime("%Y-%m-%d") + markdown, summary = format_briefing(weather) + + print("Posting to wiki...") + doc_id, action = post_to_wiki(markdown, date_str) + print(f"Wiki doc {action}: {doc_id}") + + output = f"Weather: {summary}. Wiki doc {action}." + log_run("success", output=output, metadata={"wiki_doc_id": doc_id, "action": action}) + print(f"Done: {output}") + + except Exception as e: + err_msg = f"{type(e).__name__}: {e}" + print(f"Error: {err_msg}", file=sys.stderr) + log_run("failed", err=err_msg) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..868fe8a --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir fastapi uvicorn sqlalchemy + +COPY . . + +EXPOSE 8550 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8550"] diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..02e7d29 --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,202 @@ +from fastapi import FastAPI, Depends, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from pydantic import BaseModel +from datetime import datetime, timezone +from typing import Optional +import json + +from database import get_db, init_db +from models import Agent, Run + +app = FastAPI(title="Agent Command Center", version="1.0.0") + + +# --- Pydantic schemas --- + +class AgentCreate(BaseModel): + id: str + name: str + description: str = "" + schedule: str = "manual" + +class AgentUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + schedule: Optional[str] = None + status: Optional[str] = None + +class RunCreate(BaseModel): + status: str = "running" + output: str = "" + error: str = "" + metadata: dict = {} + +class RunUpdate(BaseModel): + status: Optional[str] = None + output: Optional[str] = None + error: Optional[str] = None + finished_at: Optional[str] = None + metadata: Optional[dict] = None + + +# --- API routes --- + +@app.get("/api/health") +def health(): + return {"status": "ok", "service": "agent-command-center"} + + +@app.get("/api/agents") +def list_agents(db: Session = Depends(get_db)): + agents = db.query(Agent).all() + result = [] + for a in agents: + last_run = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).first() + recent_runs = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).limit(10).all() + success_streak = 0 + for r in recent_runs: + if r.status == "success": + success_streak += 1 + else: + break + result.append({ + "id": a.id, + "name": a.name, + "description": a.description, + "schedule": a.schedule, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + "last_run": { + "status": last_run.status, + "started_at": last_run.started_at.isoformat() if last_run.started_at else None, + "finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None, + } if last_run else None, + "success_streak": success_streak, + "total_runs": db.query(Run).filter(Run.agent_id == a.id).count(), + }) + return result + + +@app.post("/api/agents") +def create_agent(agent: AgentCreate, db: Session = Depends(get_db)): + existing = db.query(Agent).filter(Agent.id == agent.id).first() + if existing: + raise HTTPException(status_code=409, detail="Agent already exists") + new_agent = Agent( + id=agent.id, + name=agent.name, + description=agent.description, + schedule=agent.schedule, + ) + db.add(new_agent) + db.commit() + return {"id": new_agent.id, "status": "created"} + + +@app.get("/api/agents/{agent_id}") +def get_agent(agent_id: str, db: Session = Depends(get_db)): + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + runs = db.query(Run).filter(Run.agent_id == agent_id).order_by(Run.started_at.desc()).limit(50).all() + return { + "id": agent.id, + "name": agent.name, + "description": agent.description, + "schedule": agent.schedule, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "runs": [{ + "id": r.id, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "status": r.status, + "output": r.output, + "error": r.error, + "metadata": r.metadata_, + } for r in runs], + } + + +@app.put("/api/agents/{agent_id}") +def update_agent(agent_id: str, update: AgentUpdate, db: Session = Depends(get_db)): + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + for field, value in update.model_dump(exclude_none=True).items(): + setattr(agent, field, value) + db.commit() + return {"id": agent.id, "status": "updated"} + + +@app.post("/api/agents/{agent_id}/runs") +def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)): + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + new_run = Run( + agent_id=agent_id, + status=run.status, + output=run.output, + error=run.error, + metadata_=run.metadata, + ) + if run.status in ("success", "failed"): + new_run.finished_at = datetime.now(timezone.utc) + db.add(new_run) + db.commit() + return {"id": new_run.id, "status": new_run.status} + + +@app.put("/api/runs/{run_id}") +def update_run(run_id: int, update: RunUpdate, db: Session = Depends(get_db)): + run = db.query(Run).filter(Run.id == run_id).first() + if not run: + raise HTTPException(status_code=404, detail="Run not found") + if update.status is not None: + run.status = update.status + if update.output is not None: + run.output = update.output + if update.error is not None: + run.error = update.error + if update.metadata is not None: + run.metadata_ = update.metadata + if update.finished_at is not None: + run.finished_at = datetime.fromisoformat(update.finished_at) + elif update.status in ("success", "failed"): + run.finished_at = datetime.now(timezone.utc) + db.commit() + return {"id": run.id, "status": run.status} + + +@app.get("/api/runs") +def list_runs(limit: int = 50, db: Session = Depends(get_db)): + runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all() + return [{ + "id": r.id, + "agent_id": r.agent_id, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "status": r.status, + "output": r.output, + "error": r.error, + "metadata": r.metadata_, + } for r in runs] + + +# --- Static files (frontend) --- + +app.mount("/static", StaticFiles(directory="static"), name="static") + +@app.get("/") +def root(): + return FileResponse("static/index.html") + + +# --- Startup --- + +@app.on_event("startup") +def startup(): + init_db() diff --git a/dashboard/database.py b/dashboard/database.py new file mode 100644 index 0000000..b82b630 --- /dev/null +++ b/dashboard/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DB_PATH = os.environ.get("DB_PATH", "/app/data/agents.db") +engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + Base.metadata.create_all(bind=engine) diff --git a/dashboard/models.py b/dashboard/models.py new file mode 100644 index 0000000..e433c86 --- /dev/null +++ b/dashboard/models.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, JSON +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +class Agent(Base): + __tablename__ = "agents" + + id = Column(String, primary_key=True) + name = Column(String, nullable=False) + description = Column(Text, default="") + schedule = Column(String, default="manual") + status = Column(String, default="active") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + runs = relationship("Run", back_populates="agent", order_by="Run.started_at.desc()") + + +class Run(Base): + __tablename__ = "runs" + + id = Column(Integer, primary_key=True, autoincrement=True) + agent_id = Column(String, ForeignKey("agents.id"), nullable=False) + started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + finished_at = Column(DateTime, nullable=True) + status = Column(String, default="running") + output = Column(Text, default="") + error = Column(Text, default="") + metadata_ = Column("metadata", JSON, default=dict) + + agent = relationship("Agent", back_populates="runs") diff --git a/dashboard/static/index.html b/dashboard/static/index.html new file mode 100644 index 0000000..0682b5d --- /dev/null +++ b/dashboard/static/index.html @@ -0,0 +1,366 @@ + + + + + +Agent Command Center + + + + +
+

Agent Command Center

+
+
+ 0 agents +
+
+ +
+
+

Agents

+
+
+ +
+

Recent Runs

+
+
+
+ + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a830f2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + dashboard: + build: ./dashboard + container_name: agent-dashboard + ports: + - "8550:8550" + volumes: + - ./data:/app/data + environment: + - DB_PATH=/app/data/agents.db + restart: unless-stopped