from fastapi import FastAPI, Depends, HTTPException, Response, Cookie, Header
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone, timedelta
from typing import Optional
import hashlib
import json
import os
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from database import get_db, init_db, SessionLocal
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog, APIClient, APIClientScope, APIClientCall
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
# --- Auth ---
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
_magic_links: dict[str, dict] = {} # token -> {user_id, email, expires}
# SMTP Config
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "eric.jungbauer@gmail.com")
SMTP_PASS = os.environ.get("SMTP_PASS", "jozj oags ifqy auey")
SMTP_FROM = os.environ.get("SMTP_FROM", "eric.jungbauer@gmail.com")
APP_URL = os.environ.get("APP_URL", "https://agents.jfamily.io")
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode()).hexdigest()
return f"{salt}:{h}"
def verify_password(password: str, password_hash: str) -> bool:
salt, h = password_hash.split(":", 1)
return hashlib.sha256((salt + password).encode()).hexdigest() == h
def create_session(user: User) -> str:
token = secrets.token_urlsafe(32)
_sessions[token] = {"user_id": user.id, "username": user.username, "role": user.role}
return token
def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[dict]:
if session and session in _sessions:
return _sessions[session]
return None
def require_auth(session: Optional[str] = Cookie(None)) -> dict:
user = get_current_user(session)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_admin(session: Optional[str] = Cookie(None)) -> dict:
user = require_auth(session)
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
# --- API Client auth (for external apps like Synap) ---
def _hash_api_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _load_api_client(authorization: Optional[str], db: Session) -> Optional[APIClient]:
"""Resolve an API client from a Bearer token. Returns None if absent/invalid/revoked."""
if not authorization or not authorization.lower().startswith("bearer "):
return None
token = authorization[7:].strip()
if not token:
return None
th = _hash_api_token(token)
client = db.query(APIClient).filter(
APIClient.token_hash == th,
APIClient.revoked_at.is_(None),
).first()
if client:
client.last_used_at = datetime.now(timezone.utc)
db.commit()
return client
def require_user_or_api(
session: Optional[str] = Cookie(None),
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db),
) -> dict:
"""Accept either a logged-in user session OR a Bearer API token.
Returns a dict describing the caller:
{"kind": "user", "user_id": int, "username": str, "role": str, "allowed_instance_ids": None}
{"kind": "api", "api_client_id": int, "api_client_name": str, "allowed_instance_ids": set[int]}
"""
u = get_current_user(session)
if u:
return {
"kind": "user",
"user_id": u["user_id"],
"username": u["username"],
"role": u["role"],
"allowed_instance_ids": None, # user auth is scoped by instance ownership
}
client = _load_api_client(authorization, db)
if client:
allowed = {s.instance_id for s in client.scopes}
return {
"kind": "api",
"api_client_id": client.id,
"api_client_name": client.name,
"allowed_instance_ids": allowed,
}
raise HTTPException(status_code=401, detail="Not authenticated")
def caller_can_access_instance(caller: dict, inst: AgentInstance) -> bool:
"""Authorization check: does this caller have rights to read/trigger this instance?"""
if caller["kind"] == "user":
# Admins can access any instance; users only their own
return caller["role"] == "admin" or inst.user_id == caller["user_id"]
# API client: must have instance in scope
return inst.id in caller["allowed_instance_ids"]
def caller_label(caller: dict) -> str:
"""Short identifier for logging: 'user:eric' or 'api_client:synap'."""
if caller["kind"] == "user":
return f"user:{caller['username']}"
return f"api_client:{caller['api_client_name']}"
def log_api_client_call(db: Session, caller: dict, endpoint: str, instance_id: Optional[int], status_code: int):
"""Record an audit entry for API client calls (no-op for user sessions)."""
if caller["kind"] != "api":
return
try:
entry = APIClientCall(
api_client_id=caller["api_client_id"],
instance_id=instance_id,
endpoint=endpoint,
status_code=status_code,
)
db.add(entry)
db.commit()
except Exception:
db.rollback()
# --- Schemas ---
class LoginRequest(BaseModel):
username: str # accepts username or email
password: str
class MagicLinkRequest(BaseModel):
email: str
class InstanceCreate(BaseModel):
catalog_id: str
name: Optional[str] = None
config: dict = {}
schedule: Optional[str] = None
class InstanceUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
schedule: Optional[str] = None
status: Optional[str] = None
class RunCreate(BaseModel):
status: str = "running"
output: str = ""
error: str = ""
metadata: dict = {}
result: Optional[dict] = None # structured data for API consumers
run_id: Optional[int] = None # if set, update this run instead of creating a new one
class UserCreate(BaseModel):
username: str
email: str = ""
password: str
display_name: str = ""
role: str = "user"
class UserUpdate(BaseModel):
display_name: Optional[str] = None
email: Optional[str] = None
role: Optional[str] = None
password: Optional[str] = None
class LLMProviderCreate(BaseModel):
name: str
provider_type: str = "anthropic"
api_url: str = ""
api_key: str = ""
default_model: str = ""
is_default: bool = False
class LLMProviderUpdate(BaseModel):
name: Optional[str] = None
provider_type: Optional[str] = None
api_url: Optional[str] = None
api_key: Optional[str] = None
default_model: Optional[str] = None
is_default: Optional[bool] = None
class APIClientCreate(BaseModel):
name: str
description: str = ""
instance_ids: list[int] = [] # which instances this client can trigger/read
class APIClientUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
instance_ids: Optional[list[int]] = None # replaces existing scopes if provided
# --- Auth Routes ---
@app.post("/api/login")
def login(creds: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(
(User.username == creds.username) | (User.email == creds.username)
).first()
if not user or not verify_password(creds.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_session(user)
response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return {"status": "ok", "user": user.username, "email": user.email or "", "role": user.role, "display_name": user.display_name}
@app.post("/api/logout")
def logout(response: Response, session: Optional[str] = Cookie(None)):
if session and session in _sessions:
del _sessions[session]
response.delete_cookie("session")
return {"status": "ok"}
def send_magic_email(email: str, token: str):
"""Send a magic link email via SMTP."""
link = f"{APP_URL}/auth/verify?token={token}"
html = f"""
Agent Command Center
Click the button below to sign in:
Sign In
This link expires in 15 minutes. If you didn't request this, ignore this email.
{APP_URL}
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Sign in to Agent Command Center"
msg["From"] = SMTP_FROM
msg["To"] = email
msg.attach(MIMEText(f"Sign in: {link}\n\nExpires in 15 minutes.", "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)
@app.post("/api/auth/magic-link")
def request_magic_link(data: MagicLinkRequest, db: Session = Depends(get_db)):
"""Send a magic link to the user's email."""
user = db.query(User).filter(User.email == data.email).first()
if not user:
# Don't reveal whether email exists — return success either way
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
# Generate token
token = secrets.token_urlsafe(32)
_magic_links[token] = {
"user_id": user.id,
"email": user.email,
"expires": datetime.now(timezone.utc) + timedelta(minutes=15),
}
# Send email
try:
send_magic_email(user.email, token)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
@app.get("/auth/verify")
def verify_magic_link(token: str, response: Response, db: Session = Depends(get_db)):
"""Verify a magic link token and log the user in."""
link_data = _magic_links.pop(token, None)
if not link_data:
return RedirectResponse("/login?error=invalid", status_code=302)
if datetime.now(timezone.utc) > link_data["expires"]:
return RedirectResponse("/login?error=expired", status_code=302)
user = db.query(User).filter(User.id == link_data["user_id"]).first()
if not user:
return RedirectResponse("/login?error=invalid", status_code=302)
# Create session
session_token = create_session(user)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("session", session_token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
@app.get("/api/me")
def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
u = db.query(User).filter(User.id == user["user_id"]).first()
llm = u.llm_config or {}
return {
"id": u.id, "username": u.username, "email": u.email or "",
"display_name": u.display_name, "role": u.role,
"has_llm": bool(llm.get("api_key")),
"llm_provider": llm.get("provider_type", ""),
"llm_model": llm.get("default_model", ""),
"created_at": u.created_at.isoformat() if u.created_at else None,
}
class UserLLMConfig(BaseModel):
provider_type: str = "" # anthropic, openai, litellm, ollama
api_url: str = ""
api_key: str = ""
default_model: str = ""
@app.get("/api/me/llm")
def get_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Get current user's LLM config."""
u = db.query(User).filter(User.id == user["user_id"]).first()
llm = u.llm_config or {}
return {
"provider_type": llm.get("provider_type", ""),
"api_url": llm.get("api_url", ""),
"api_key": "***" if llm.get("api_key") else "",
"default_model": llm.get("default_model", ""),
"configured": bool(llm.get("api_key")),
}
@app.put("/api/me/llm")
def update_my_llm(data: UserLLMConfig, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Update current user's LLM config (bring your own LLM)."""
u = db.query(User).filter(User.id == user["user_id"]).first()
current = u.llm_config or {}
update = data.model_dump()
# Only update api_key if a real value was provided (not "***")
if update.get("api_key") == "***" or not update.get("api_key"):
update["api_key"] = current.get("api_key", "")
u.llm_config = update
db.commit()
return {"status": "updated"}
@app.delete("/api/me/llm")
def delete_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Remove user's LLM config (fall back to system default)."""
u = db.query(User).filter(User.id == user["user_id"]).first()
u.llm_config = {}
db.commit()
return {"status": "removed"}
@app.get("/api/users/{user_id}/llm")
def get_user_llm(user_id: int, db: Session = Depends(get_db)):
"""Internal: resolve LLM config for a user. Returns user's own config if set, otherwise system default."""
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404)
user_llm = u.llm_config or {}
if user_llm.get("api_key"):
return {
"source": "user",
"provider_type": user_llm.get("provider_type", ""),
"api_url": user_llm.get("api_url", ""),
"api_key": user_llm["api_key"],
"default_model": user_llm.get("default_model", ""),
}
# Fall back to system default
default = db.query(LLMProvider).filter(LLMProvider.is_default == True).first()
if default:
return {
"source": "system",
"provider_type": default.provider_type,
"api_url": default.api_url,
"api_key": default.api_key,
"default_model": default.default_model,
}
return {"source": "none", "provider_type": "", "api_url": "", "api_key": "", "default_model": ""}
# --- Health ---
@app.get("/api/health")
def health():
return {"status": "ok", "service": "agent-command-center", "version": "2026.04.12.01"}
# --- Agent Catalog ---
@app.get("/api/catalog")
def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entries = db.query(AgentCatalog).all()
# Check which ones this user already has instances of
user_instance_ids = {
i.catalog_id for i in db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
}
return [{
"id": e.id, "name": e.name, "description": e.description,
"category": e.category, "config_schema": e.config_schema or {},
"default_config": e.default_config or {},
"supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent,
"requires_llm": e.requires_llm,
"result_schema": e.result_schema or {},
"enabled": e.id in user_instance_ids,
} for e in entries]
@app.get("/api/catalog/{catalog_id}")
def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
return {
"id": entry.id, "name": entry.name, "description": entry.description,
"category": entry.category, "config_schema": entry.config_schema or {},
"default_config": entry.default_config or {},
"supports_schedule": entry.supports_schedule, "is_sub_agent": entry.is_sub_agent,
"requires_llm": entry.requires_llm,
"result_schema": entry.result_schema or {},
}
# --- Agent Instances (user-scoped) ---
def serialize_instance(inst, db):
last_run = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).first()
recent = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).limit(10).all()
streak = 0
for r in recent:
if r.status == "success":
streak += 1
else:
break
return {
"id": inst.id, "catalog_id": inst.catalog_id, "name": inst.name,
"config": inst.config or {}, "schedule": inst.schedule, "status": inst.status,
"created_at": inst.created_at.isoformat() if inst.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": streak,
"total_runs": db.query(Run).filter(Run.instance_id == inst.id).count(),
}
@app.get("/api/instances")
def list_instances(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
instances = db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
return [serialize_instance(i, db) for i in instances]
@app.post("/api/instances")
def create_instance(data: InstanceCreate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == data.catalog_id).first()
if not catalog:
raise HTTPException(status_code=404, detail="Agent type not found in catalog")
# Enforce LLM requirement
if catalog.requires_llm:
u = db.query(User).filter(User.id == user["user_id"]).first()
user_llm = u.llm_config or {} if u else {}
has_user_llm = bool(user_llm.get("api_key"))
has_system_llm = db.query(LLMProvider).filter(LLMProvider.is_default == True).first() is not None
if not has_user_llm and not has_system_llm:
raise HTTPException(status_code=400, detail="This agent requires an LLM provider. Configure one via the LLM button in the header.")
config = {**(catalog.default_config or {}), **data.config}
inst = AgentInstance(
user_id=user["user_id"],
catalog_id=data.catalog_id,
name=data.name or catalog.name,
config=config,
schedule=data.schedule or ("sub-agent" if catalog.is_sub_agent else "manual"),
)
db.add(inst)
db.commit()
return {"id": inst.id, "status": "created"}
@app.get("/api/instances/{instance_id}")
def get_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
runs = db.query(Run).filter(Run.instance_id == instance_id).order_by(Run.started_at.desc()).limit(50).all()
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == inst.catalog_id).first()
result = serialize_instance(inst, db)
result["config_schema"] = catalog.config_schema if catalog else {}
result["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]
return result
@app.put("/api/instances/{instance_id}")
def update_instance(instance_id: int, update: InstanceUpdate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
if update.name is not None:
inst.name = update.name
if update.schedule is not None:
inst.schedule = update.schedule
if update.status is not None:
inst.status = update.status
if update.config is not None:
# Must assign a NEW dict — SQLAlchemy won't detect in-place mutations on JSON columns
new_config = {**(inst.config or {}), **update.config}
inst.config = new_config
# Auto-rename instance if project_name is set (e.g. "Project Monitor - WSIT")
if new_config.get("project_name") and inst.catalog_id == "project-monitor":
inst.name = f"Project Monitor - {new_config['project_name']}"
from sqlalchemy.orm.attributes import flag_modified
flag_modified(inst, "config")
db.commit()
return {"id": inst.id, "status": "updated"}
@app.delete("/api/instances/{instance_id}")
def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
db.query(Run).filter(Run.instance_id == instance_id).delete()
db.delete(inst)
db.commit()
return {"status": "deleted"}
SUB_AGENT_SCRIPTS = {
"weather": "weather_agent.py",
"calendar": "calendar_agent.py",
"reminders": "reminders_agent.py",
"notes": "notes_agent.py",
"reading-list": "reading_list_agent.py",
}
@app.post("/api/instances/{instance_id}/trigger")
def trigger_instance(
instance_id: int,
caller: dict = Depends(require_user_or_api),
db: Session = Depends(get_db),
):
"""Trigger a manual run of an agent instance.
Accepts either a user session cookie or a Bearer app token (scoped to this instance).
Returns immediately with a run_id to poll + the last successful run's cached result."""
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
if not caller_can_access_instance(caller, inst):
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 403)
raise HTTPException(status_code=403, detail="Not authorized for this instance")
catalog_id = inst.catalog_id
u = db.query(User).filter(User.id == inst.user_id).first()
# Look up last successful run — we return its cached result immediately
last = db.query(Run).filter(
Run.instance_id == instance_id,
Run.status == "success",
).order_by(Run.started_at.desc()).first()
last_result = last.result if last else None
last_run_at = last.started_at.isoformat() if last and last.started_at else None
# Create a placeholder run row so the caller gets an id to poll
new_run = Run(
instance_id=instance_id,
user_id=inst.user_id,
status="queued",
triggered_by=caller_label(caller),
)
db.add(new_run)
db.commit()
db.refresh(new_run)
import subprocess
agent_dir = "/app/agents"
env = {
**dict(os.environ),
"PYTHONPATH": agent_dir,
"RUN_ID": str(new_run.id),
"INSTANCE_ID": str(instance_id),
}
cmd = None
if catalog_id == "daily-briefing":
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
script = script_map.get(u.username)
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
env[env_key] = str(instance_id)
if script:
cmd = ["python3", f"{agent_dir}/{script}"]
else:
cmd = ["python3", "-c",
f"import sys; sys.path.insert(0, '{agent_dir}'); "
f"from daily_briefing import run; "
f"run({{'person': '{u.display_name}', 'agent_id': '{catalog_id}', "
f"'instance_id': {instance_id}, 'wiki_parent_doc_id': '', 'location': {{}}}})"]
elif catalog_id == "project-monitor":
config_path = f"/tmp/pm_config_{instance_id}.json"
with open(config_path, "w") as f:
json.dump({"config": inst.config or {}, "user_id": inst.user_id, "instance_id": instance_id}, f)
cmd = ["python3", "-c",
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
f"d = json.load(open('{config_path}')); "
f"from project_monitor import run; "
f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"]
elif catalog_id in SUB_AGENT_SCRIPTS:
cmd = ["python3", f"{agent_dir}/{SUB_AGENT_SCRIPTS[catalog_id]}", "--from-api"]
if not cmd:
new_run.status = "failed"
new_run.error = f"No runner configured for catalog_id={catalog_id}"
new_run.finished_at = datetime.now(timezone.utc)
db.commit()
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 400)
raise HTTPException(status_code=400, detail=f"Manual trigger not supported for {catalog_id}")
subprocess.Popen(cmd, env=env, cwd=agent_dir)
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 200)
return {
"run_id": new_run.id,
"status": "queued",
"instance_id": instance_id,
"catalog_id": catalog_id,
"triggered_by": new_run.triggered_by,
"last_result": last_result,
"last_run_at": last_run_at,
}
# --- Internal endpoints (no auth, for agent scripts) ---
@app.get("/api/instances/by-user/{user_id}")
def get_user_instances(user_id: int, catalog_id: str = None, db: Session = Depends(get_db)):
"""Internal: get a user's instances, optionally filtered by catalog type."""
query = db.query(AgentInstance).filter(AgentInstance.user_id == user_id, AgentInstance.status == "active")
if catalog_id:
query = query.filter(AgentInstance.catalog_id == catalog_id)
instances = query.all()
return [{"id": i.id, "catalog_id": i.catalog_id, "name": i.name, "config": i.config or {}} for i in instances]
@app.get("/api/instances/{instance_id}/config")
def get_instance_config(instance_id: int, db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
return inst.config or {}
@app.post("/api/instances/{instance_id}/runs")
def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
"""Internal endpoint: agents POST here when they start and finish.
If run.run_id is set, update that existing row (used when /trigger pre-created a run).
Otherwise create a new row (used by cron-launched agents that weren't pre-triggered)."""
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
if run.run_id:
existing = db.query(Run).filter(Run.id == run.run_id, Run.instance_id == instance_id).first()
if not existing:
raise HTTPException(status_code=404, detail=f"Run {run.run_id} not found for this instance")
existing.status = run.status
if run.output:
existing.output = run.output
if run.error:
existing.error = run.error
if run.metadata:
existing.metadata_ = run.metadata
if run.result is not None:
existing.result = run.result
if run.status in ("success", "failed"):
existing.finished_at = datetime.now(timezone.utc)
db.commit()
return {"id": existing.id, "status": existing.status}
new_run = Run(
instance_id=instance_id,
user_id=inst.user_id,
status=run.status,
output=run.output,
error=run.error,
metadata_=run.metadata,
result=run.result,
)
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}
def _serialize_run(r: Run) -> dict:
return {
"id": r.id,
"instance_id": r.instance_id,
"status": r.status,
"result": r.result,
"output": r.output,
"error": r.error,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"triggered_by": getattr(r, "triggered_by", "") or "",
"metadata": r.metadata_,
}
# --- Runs (user or api-client scoped) ---
@app.get("/api/runs")
def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all()
return [_serialize_run(r) for r in runs]
@app.get("/api/runs/{run_id}")
def get_run(
run_id: int,
caller: dict = Depends(require_user_or_api),
db: Session = Depends(get_db),
):
"""Fetch a single run. Used by callers polling a triggered run until it finishes.
Accepts user session or Bearer app token (scoped to the run's instance)."""
run = db.query(Run).filter(Run.id == run_id).first()
if not run:
raise HTTPException(status_code=404)
inst = db.query(AgentInstance).filter(AgentInstance.id == run.instance_id).first()
if not inst or not caller_can_access_instance(caller, inst):
log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 403)
raise HTTPException(status_code=403, detail="Not authorized for this run")
log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 200)
return _serialize_run(run)
# --- Admin: Users ---
@app.get("/api/admin/users")
def admin_list_users(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).all()
return [{
"id": u.id, "username": u.username, "email": u.email or "",
"display_name": u.display_name, "role": u.role,
"created_at": u.created_at.isoformat() if u.created_at else None,
"instance_count": db.query(AgentInstance).filter(AgentInstance.user_id == u.id).count(),
} for u in users]
@app.post("/api/admin/users")
def admin_create_user(data: UserCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=409, detail="Username exists")
user = User(
username=data.username,
email=data.email or None,
password_hash=hash_password(data.password),
display_name=data.display_name or data.username,
role=data.role,
)
db.add(user)
db.commit()
return {"id": user.id, "status": "created"}
@app.put("/api/admin/users/{user_id}")
def admin_update_user(user_id: int, update: UserUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
if update.display_name is not None:
user.display_name = update.display_name
if update.email is not None:
user.email = update.email or None
if update.role is not None:
user.role = update.role
if update.password is not None:
user.password_hash = hash_password(update.password)
db.commit()
return {"id": user.id, "status": "updated"}
@app.delete("/api/admin/users/{user_id}")
def admin_delete_user(user_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
for inst in db.query(AgentInstance).filter(AgentInstance.user_id == user_id).all():
db.query(Run).filter(Run.instance_id == inst.id).delete()
db.delete(inst)
db.delete(user)
db.commit()
return {"status": "deleted"}
# --- Admin: LLM Providers ---
@app.get("/api/admin/llm-providers")
def admin_list_providers(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
providers = db.query(LLMProvider).all()
return [{
"id": p.id, "name": p.name, "provider_type": p.provider_type,
"api_url": p.api_url, "api_key": "***" if p.api_key else "",
"default_model": p.default_model, "is_default": p.is_default,
} for p in providers]
@app.post("/api/admin/llm-providers")
def admin_create_provider(data: LLMProviderCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if data.is_default:
db.query(LLMProvider).update({"is_default": False})
provider = LLMProvider(
name=data.name, provider_type=data.provider_type, api_url=data.api_url,
api_key=data.api_key, default_model=data.default_model, is_default=data.is_default,
)
db.add(provider)
db.commit()
return {"id": provider.id, "status": "created"}
@app.put("/api/admin/llm-providers/{provider_id}")
def admin_update_provider(provider_id: int, update: LLMProviderUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
if update.is_default:
db.query(LLMProvider).update({"is_default": False})
for field, value in update.model_dump(exclude_none=True).items():
setattr(provider, field, value)
db.commit()
return {"id": provider.id, "status": "updated"}
@app.delete("/api/admin/llm-providers/{provider_id}")
def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
db.delete(provider)
db.commit()
return {"status": "deleted"}
# --- Admin: API Clients (external app tokens) ---
def _serialize_api_client(client: APIClient, include_token: Optional[str] = None) -> dict:
return {
"id": client.id,
"name": client.name,
"description": client.description or "",
"token_prefix": client.token_prefix or "",
"token": include_token, # only set once, on creation
"instance_ids": [s.instance_id for s in client.scopes],
"created_at": client.created_at.isoformat() if client.created_at else None,
"last_used_at": client.last_used_at.isoformat() if client.last_used_at else None,
"revoked_at": client.revoked_at.isoformat() if client.revoked_at else None,
"revoked": client.revoked_at is not None,
}
@app.get("/api/admin/instances")
def admin_list_all_instances(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
"""Admin-only: list every instance across all users. Used by the API Clients UI to
pick which instances a token can access."""
rows = db.query(AgentInstance, User).join(User, AgentInstance.user_id == User.id)\
.order_by(User.username, AgentInstance.catalog_id).all()
return [{
"id": inst.id,
"name": inst.name,
"catalog_id": inst.catalog_id,
"status": inst.status,
"user_id": u.id,
"username": u.username,
"display_name": u.display_name,
} for inst, u in rows]
@app.get("/api/admin/api-clients")
def admin_list_api_clients(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
clients = db.query(APIClient).order_by(APIClient.created_at.desc()).all()
return [_serialize_api_client(c) for c in clients]
@app.post("/api/admin/api-clients")
def admin_create_api_client(data: APIClientCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(APIClient).filter(APIClient.name == data.name).first():
raise HTTPException(status_code=409, detail="A client with that name already exists")
# Verify every instance_id exists
for iid in data.instance_ids:
if not db.query(AgentInstance).filter(AgentInstance.id == iid).first():
raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist")
# Generate a token: 'acc_' + 40 hex chars (no ambiguity with OAuth/other schemes)
plaintext = "acc_" + secrets.token_hex(20)
client = APIClient(
name=data.name,
description=data.description,
token_hash=_hash_api_token(plaintext),
token_prefix=plaintext[:12], # "acc_" + 8 hex
)
db.add(client)
db.flush()
for iid in data.instance_ids:
db.add(APIClientScope(api_client_id=client.id, instance_id=iid))
db.commit()
db.refresh(client)
return _serialize_api_client(client, include_token=plaintext)
@app.put("/api/admin/api-clients/{client_id}")
def admin_update_api_client(client_id: int, update: APIClientUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
client = db.query(APIClient).filter(APIClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404)
if update.name is not None:
dup = db.query(APIClient).filter(APIClient.name == update.name, APIClient.id != client_id).first()
if dup:
raise HTTPException(status_code=409, detail="A client with that name already exists")
client.name = update.name
if update.description is not None:
client.description = update.description
if update.instance_ids is not None:
for iid in update.instance_ids:
if not db.query(AgentInstance).filter(AgentInstance.id == iid).first():
raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist")
# Replace scope set
db.query(APIClientScope).filter(APIClientScope.api_client_id == client_id).delete()
for iid in update.instance_ids:
db.add(APIClientScope(api_client_id=client_id, instance_id=iid))
db.commit()
db.refresh(client)
return _serialize_api_client(client)
@app.post("/api/admin/api-clients/{client_id}/revoke")
def admin_revoke_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
client = db.query(APIClient).filter(APIClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404)
client.revoked_at = datetime.now(timezone.utc)
db.commit()
return {"id": client.id, "status": "revoked"}
@app.post("/api/admin/api-clients/{client_id}/rotate")
def admin_rotate_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
"""Issue a new token for an existing client (invalidates the old one)."""
client = db.query(APIClient).filter(APIClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404)
plaintext = "acc_" + secrets.token_hex(20)
client.token_hash = _hash_api_token(plaintext)
client.token_prefix = plaintext[:12]
client.revoked_at = None # un-revoke if it was
db.commit()
db.refresh(client)
return _serialize_api_client(client, include_token=plaintext)
@app.delete("/api/admin/api-clients/{client_id}")
def admin_delete_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
client = db.query(APIClient).filter(APIClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404)
db.delete(client)
db.commit()
return {"status": "deleted"}
@app.get("/api/admin/api-clients/{client_id}/calls")
def admin_api_client_calls(client_id: int, limit: int = 50, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if not db.query(APIClient).filter(APIClient.id == client_id).first():
raise HTTPException(status_code=404)
calls = db.query(APIClientCall).filter(APIClientCall.api_client_id == client_id)\
.order_by(APIClientCall.called_at.desc()).limit(limit).all()
return [{
"id": c.id,
"endpoint": c.endpoint,
"instance_id": c.instance_id,
"status_code": c.status_code,
"called_at": c.called_at.isoformat() if c.called_at else None,
} for c in calls]
# --- Admin: Catalog Management ---
class CatalogCreate(BaseModel):
id: str
name: str
description: str = ""
category: str = "utility"
config_schema: dict = {}
default_config: dict = {}
supports_schedule: bool = True
is_sub_agent: bool = False
requires_llm: bool = False
result_schema: dict = {}
class CatalogUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
config_schema: Optional[dict] = None
default_config: Optional[dict] = None
supports_schedule: Optional[bool] = None
is_sub_agent: Optional[bool] = None
requires_llm: Optional[bool] = None
result_schema: Optional[dict] = None
@app.post("/api/admin/catalog")
def admin_create_catalog(data: CatalogCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(AgentCatalog).filter(AgentCatalog.id == data.id).first():
raise HTTPException(status_code=409, detail="Catalog entry exists")
entry = AgentCatalog(**data.model_dump())
db.add(entry)
db.commit()
return {"id": entry.id, "status": "created"}
@app.put("/api/admin/catalog/{catalog_id}")
def admin_update_catalog(catalog_id: str, update: CatalogUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
for field, value in update.model_dump(exclude_none=True).items():
setattr(entry, field, value)
db.commit()
return {"id": entry.id, "status": "updated"}
@app.delete("/api/admin/catalog/{catalog_id}")
def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
db.delete(entry)
db.commit()
return {"status": "deleted"}
# --- Agent Router ---
class RouterRequest(BaseModel):
request: str
@app.get("/api/catalog/all")
def list_catalog_all(db: Session = Depends(get_db)):
"""Internal: full catalog for the router (no auth)."""
entries = db.query(AgentCatalog).all()
return [{
"id": e.id, "name": e.name, "description": e.description,
"category": e.category, "supports_schedule": e.supports_schedule,
"is_sub_agent": e.is_sub_agent, "requires_llm": e.requires_llm,
} for e in entries]
@app.post("/api/router")
def ask_router(data: RouterRequest, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Route a natural language request to the best agent."""
import subprocess
import sys
# Call the router agent
agent_dir = "/app/agents"
config_path = f"/tmp/router_{user['user_id']}_{secrets.token_hex(4)}.json"
with open(config_path, "w") as f:
json.dump({"user_id": user["user_id"], "request": data.request}, f)
try:
result = subprocess.run(
["python3", "-c",
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
f"d = json.load(open('{config_path}')); "
f"from agent_router import route; "
f"r = route(d['user_id'], d['request']); "
f"print(json.dumps(r))"],
capture_output=True, text=True, timeout=120, cwd=agent_dir,
env={**dict(os.environ), "PYTHONPATH": agent_dir},
)
os.remove(config_path)
if result.returncode != 0:
raise RuntimeError(result.stderr[:500])
recommendation = json.loads(result.stdout.strip())
except Exception as e:
return {"error": str(e), "action": "not_possible", "reasoning": f"Router error: {e}"}
# Log the route
log = RouteLog(
user_id=user["user_id"],
request_text=data.request,
recommended_agent=recommendation.get("agent_name", ""),
action=recommendation.get("action", ""),
reasoning=recommendation.get("reasoning", ""),
outcome="pending",
metadata_={
"instance_id": recommendation.get("instance_id"),
"catalog_id": recommendation.get("catalog_id"),
"config": recommendation.get("config"),
"model": recommendation.get("model", ""),
"tokens_in": recommendation.get("tokens_in", 0),
"tokens_out": recommendation.get("tokens_out", 0),
},
)
db.add(log)
db.commit()
recommendation["route_id"] = log.id
return recommendation
@app.post("/api/router/{route_id}/accept")
def accept_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Accept a router suggestion and execute it."""
log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first()
if not log:
raise HTTPException(status_code=404)
log.outcome = "accepted"
meta = log.metadata_ or {}
action = log.action
instance_id = meta.get("instance_id")
# Execute the recommended action
if action == "run_existing" and instance_id:
# Trigger the instance
import subprocess
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if inst:
agent_dir = "/app/agents"
catalog_id = inst.catalog_id
u = db.query(User).filter(User.id == user["user_id"]).first()
env = {**dict(os.environ), "PYTHONPATH": agent_dir}
if catalog_id == "daily-briefing":
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
script = script_map.get(u.username)
if script:
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
env[env_key] = str(instance_id)
subprocess.Popen(["python3", f"{agent_dir}/{script}"], env=env, cwd=agent_dir)
elif catalog_id == "project-monitor":
config_path = f"/tmp/pm_config_{instance_id}.json"
with open(config_path, "w") as f:
json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f)
subprocess.Popen(
["python3", "-c",
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
f"d = json.load(open('{config_path}')); "
f"from project_monitor import run; "
f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"],
env=env, cwd=agent_dir,
)
db.commit()
return {"status": "executing", "message": f"Running {inst.name}"}
elif action == "configure" and instance_id:
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if inst and meta.get("config"):
new_config = {**(inst.config or {}), **meta["config"]}
inst.config = new_config
from sqlalchemy.orm.attributes import flag_modified
flag_modified(inst, "config")
if new_config.get("project_name") and inst.catalog_id == "project-monitor":
inst.name = f"Project Monitor - {new_config['project_name']}"
db.commit()
return {"status": "configured", "message": f"Updated {inst.name}"}
elif action == "create_and_run":
catalog_id = meta.get("catalog_id")
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if catalog:
config = {**(catalog.default_config or {}), **(meta.get("config") or {})}
inst = AgentInstance(
user_id=user["user_id"],
catalog_id=catalog_id,
name=meta.get("instance_name") or catalog.name,
config=config,
schedule="manual",
)
db.add(inst)
db.commit()
return {"status": "created", "message": f"Created and ready: {inst.name}", "instance_id": inst.id}
elif action == "info":
db.commit()
return {"status": "info", "message": log.reasoning}
db.commit()
return {"status": "accepted"}
@app.post("/api/router/{route_id}/reject")
def reject_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Reject a router suggestion."""
log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first()
if not log:
raise HTTPException(status_code=404)
log.outcome = "rejected"
db.commit()
return {"status": "rejected"}
# --- Bridge Management ---
class BridgeRegister(BaseModel):
user_id: int
api_key: str
bridge_url: str
hostname: str = ""
platform: str = "macos"
capabilities: list = []
class BridgeHeartbeat(BaseModel):
bridge_url: str = ""
capabilities: list = []
@app.post("/api/bridge/register")
def register_bridge(data: BridgeRegister, db: Session = Depends(get_db)):
"""Called by bridge installer to register with the dashboard."""
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Remove existing bridge for this user (one bridge per user)
db.query(Bridge).filter(Bridge.user_id == data.user_id).delete()
bridge = Bridge(
user_id=data.user_id,
api_key=data.api_key,
bridge_url=data.bridge_url,
hostname=data.hostname,
platform=data.platform,
capabilities=data.capabilities,
status="online",
last_heartbeat=datetime.now(timezone.utc),
)
db.add(bridge)
db.commit()
return {"status": "registered", "bridge_id": bridge.id}
@app.post("/api/bridge/heartbeat")
def bridge_heartbeat(data: BridgeHeartbeat, request_api_key: str = None,
db: Session = Depends(get_db)):
"""Called periodically by the bridge to update its status and IP."""
# Auth via X-Bridge-Token header
from fastapi import Request
return {"status": "ok"}
@app.post("/api/bridge/{api_key}/heartbeat")
def bridge_heartbeat_keyed(api_key: str, data: BridgeHeartbeat, db: Session = Depends(get_db)):
"""Heartbeat endpoint keyed by bridge API key."""
bridge = db.query(Bridge).filter(Bridge.api_key == api_key).first()
if not bridge:
raise HTTPException(status_code=404, detail="Bridge not found")
if data.bridge_url:
bridge.bridge_url = data.bridge_url
if data.capabilities:
bridge.capabilities = data.capabilities
bridge.status = "online"
bridge.last_heartbeat = datetime.now(timezone.utc)
db.commit()
return {"status": "ok"}
@app.get("/api/bridge/me")
def get_my_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Get the current user's bridge info."""
bridge = db.query(Bridge).filter(Bridge.user_id == user["user_id"]).first()
if not bridge:
return {"connected": False}
# Mark offline if no heartbeat in 10 minutes
if bridge.last_heartbeat:
hb = bridge.last_heartbeat.replace(tzinfo=timezone.utc) if bridge.last_heartbeat.tzinfo is None else bridge.last_heartbeat
age = (datetime.now(timezone.utc) - hb).total_seconds()
if age > 600:
bridge.status = "offline"
db.commit()
return {
"connected": True,
"bridge_url": bridge.bridge_url,
"hostname": bridge.hostname,
"platform": bridge.platform,
"capabilities": bridge.capabilities or [],
"status": bridge.status,
"last_heartbeat": bridge.last_heartbeat.isoformat() if bridge.last_heartbeat else None,
}
@app.delete("/api/bridge/me")
def disconnect_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Disconnect the current user's bridge."""
db.query(Bridge).filter(Bridge.user_id == user["user_id"]).delete()
db.commit()
return {"status": "disconnected"}
@app.get("/api/users/{user_id}/bridge")
def get_user_bridge(user_id: int, db: Session = Depends(get_db)):
"""Internal endpoint for agents to get a user's bridge URL + auth."""
bridge = db.query(Bridge).filter(Bridge.user_id == user_id).first()
if not bridge or bridge.status == "offline":
return {"available": False}
return {
"available": True,
"bridge_url": bridge.bridge_url,
"api_key": bridge.api_key,
}
@app.get("/api/bridge/install-script")
def get_install_script(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Generate a personalized bridge installer script for the current user."""
user_id = user["user_id"]
username = user["username"]
api_key = secrets.token_urlsafe(32)
dashboard_url = "https://agents.jfamily.io"
script = f'''#!/bin/bash
# Agent Command Center — Mac Bridge Installer
# Generated for: {username} (user {user_id})
# Run this on your Mac to install the Apple Ecosystem Bridge.
set -e
echo "Installing Agent Command Center Mac Bridge for {username}..."
BRIDGE_DIR="$HOME/.local/apple-bridge"
mkdir -p "$BRIDGE_DIR"
# Download bridge.py
cat > "$BRIDGE_DIR/bridge.py" << 'BRIDGE_EOF'
BRIDGE_PLACEHOLDER
BRIDGE_EOF
# Create config
cat > "$BRIDGE_DIR/config.json" << 'CONFIG_EOF'
{{
"user_id": {user_id},
"username": "{username}",
"api_key": "{api_key}",
"dashboard_url": "{dashboard_url}",
"port": {8551 + user_id}
}}
CONFIG_EOF
# Create launcher
cat > "$BRIDGE_DIR/start-bridge.sh" << 'LAUNCHER_EOF'
#!/bin/bash
exec /opt/homebrew/bin/python3 "$HOME/.local/apple-bridge/bridge.py"
LAUNCHER_EOF
chmod +x "$BRIDGE_DIR/start-bridge.sh"
# Create launchd plist
PLIST="$HOME/Library/LaunchAgents/com.jfamily.apple-bridge.plist"
cat > "$PLIST" << PLIST_EOF
Label
com.jfamily.apple-bridge
ProgramArguments
$BRIDGE_DIR/start-bridge.sh
RunAtLoad
KeepAlive
StandardOutPath
/tmp/apple-bridge.log
StandardErrorPath
/tmp/apple-bridge.err
PLIST_EOF
# Install Python dependencies
echo "Installing Python dependencies..."
/opt/homebrew/bin/pip3 install --break-system-packages -q fastapi uvicorn 2>/dev/null || pip3 install --break-system-packages -q fastapi uvicorn
# Start the bridge
echo "Starting bridge..."
launchctl unload "$PLIST" 2>/dev/null
launchctl load "$PLIST"
sleep 3
# Register with dashboard
BRIDGE_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "unknown")
BRIDGE_PORT={8551 + user_id}
HOSTNAME=$(hostname)
echo "Registering with dashboard..."
curl -s -X POST "{dashboard_url}/api/bridge/register" \\
-H "Content-Type: application/json" \\
-d '{{"user_id": {user_id}, "api_key": "{api_key}", "bridge_url": "http://'$BRIDGE_IP':'$BRIDGE_PORT'", "hostname": "'$HOSTNAME'", "platform": "macos", "capabilities": ["notes", "reading-list"]}}'
echo ""
echo "===================================="
echo " Mac Bridge installed for {username}!"
echo " Bridge URL: http://$BRIDGE_IP:$BRIDGE_PORT"
echo " Dashboard: {dashboard_url}"
echo "===================================="
'''
from fastapi.responses import PlainTextResponse
return PlainTextResponse(script, media_type="text/x-shellscript",
headers={"Content-Disposition": f"attachment; filename=install-bridge-{username}.sh"})
# Admin: Bridge overview
@app.get("/api/admin/bridges")
def admin_list_bridges(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
bridges = db.query(Bridge).all()
return [{
"id": b.id,
"user_id": b.user_id,
"username": db.query(User).filter(User.id == b.user_id).first().username if db.query(User).filter(User.id == b.user_id).first() else "?",
"bridge_url": b.bridge_url,
"hostname": b.hostname,
"platform": b.platform,
"capabilities": b.capabilities or [],
"status": b.status,
"last_heartbeat": b.last_heartbeat.isoformat() if b.last_heartbeat else None,
} for b in bridges]
# --- Static files ---
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/login")
def login_page():
return FileResponse("static/login.html")
@app.get("/admin")
def admin_page(session: Optional[str] = Cookie(None)):
user = get_current_user(session)
if not user:
return RedirectResponse("/login", status_code=302)
if user["role"] != "admin":
return RedirectResponse("/", status_code=302)
return FileResponse("static/admin.html")
@app.get("/")
def root(session: Optional[str] = Cookie(None)):
user = get_current_user(session)
if not user:
return RedirectResponse("/login", status_code=302)
return FileResponse("static/index.html")
# --- Result schemas (what each agent's structured result looks like) ---
RESULT_SCHEMAS = {
"weather": {
"description": "Current conditions + 7-day forecast for a configured location.",
"shape": {
"location": {
"name": "string", "state": "string", "country": "string",
"lat": "number", "lon": "number", "label": "string",
},
"current": {
"condition": "string", "weather_code": "int",
"temperature_f": "int", "feels_like_f": "int",
"wind_mph": "int", "humidity_pct": "int",
},
"forecast": "array of 7 day objects: {date, weekday, condition, weather_code, temp_high_f, temp_low_f, precip_in, wind_max_mph, sunrise, sunset}",
"fetched_at": "ISO datetime string (Mountain Time)",
},
},
"calendar": {
"description": "Upcoming calendar events across configured sources. Structured result not yet populated — markdown only.",
"shape": {"note": "result_schema not yet implemented for this agent"},
},
"reminders": {
"description": "Pending/overdue reminders from configured CalDAV sources. Structured result not yet populated — markdown only.",
"shape": {"note": "result_schema not yet implemented for this agent"},
},
"notes": {
"description": "Recent Apple Notes via Mac bridge. Structured result not yet populated — markdown only.",
"shape": {"note": "result_schema not yet implemented for this agent"},
},
"reading-list": {
"description": "Safari Reading List items via Mac bridge. Structured result not yet populated — markdown only.",
"shape": {"note": "result_schema not yet implemented for this agent"},
},
"daily-briefing": {
"description": "Aggregated daily briefing: wiki doc posted + each sub-agent's result nested under sections.",
"shape": {
"date": "YYYY-MM-DD",
"generated_at": "ISO datetime string",
"person": "string",
"location": "object (same shape as weather.location)",
"sections": "object keyed by sub-agent (weather|calendar|reminders|notes|reading_list|projects), each value is {name, summary, result, error?}",
"wiki_doc_id": "string (Outline doc id) or null",
"wiki_action": "'created' | 'updated' | null",
},
},
"project-monitor": {
"description": "LLM-generated status report for a tracked project.",
"shape": {
"project_name": "string",
"app_url": "string or null",
"wiki_collection_id": "string or null",
"wiki_report_id": "string or null (Outline doc id of the full report)",
"gitea_repo": "string or null",
"summary": "string (first paragraph of the report)",
"report_markdown": "string (full LLM-generated report)",
"model": "string (LLM model used)",
"tokens": {"input": "int", "output": "int"},
"generated_at": "ISO datetime string",
},
},
}
def _seed_result_schemas(db: Session):
"""Populate agent_catalog.result_schema for known agents. Idempotent — only fills empty."""
for catalog_id, schema in RESULT_SCHEMAS.items():
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if entry and not entry.result_schema:
entry.result_schema = schema
db.commit()
# --- Startup ---
@app.on_event("startup")
def startup():
init_db()
# Seed result schemas for catalog entries that don't have them yet
db = SessionLocal()
try:
_seed_result_schemas(db)
finally:
db.close()