API Clients + structured JSON results: app-level tokens for Synap/WSIT integration
- New api_clients + api_client_scopes tables; tokens scoped per-instance
- Admin UI tab at /admin for token create/rotate/revoke/delete with one-time reveal
- Dual-auth dependency (user session OR Bearer app token) on trigger + runs endpoints
- /api/instances/{id}/trigger pre-creates a run and returns run_id + cached last_result instantly
- New GET /api/runs/{id} for polling
- Generic trigger path for sub-agent instances (weather, calendar, etc.)
- runs.result column for structured JSON alongside markdown output
- agent_catalog.result_schema describes each agent's result shape
- Weather, daily-briefing, project-monitor retrofitted to emit structured results
- log_run: env INSTANCE_ID/RUN_ID only used when target matches, so nested sub-agents don't clobber parent runs
- Wiki docs: API Clients & Token Scoping + Calling Agents From Your Apps
This commit is contained in:
+462
-29
@@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, Response, Cookie
|
||||
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
|
||||
@@ -13,8 +13,8 @@ import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from database import get_db, init_db
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog
|
||||
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")
|
||||
|
||||
@@ -70,6 +70,94 @@ def require_admin(session: Optional[str] = Cookie(None)) -> dict:
|
||||
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):
|
||||
@@ -96,6 +184,8 @@ class RunCreate(BaseModel):
|
||||
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
|
||||
@@ -126,6 +216,16 @@ class LLMProviderUpdate(BaseModel):
|
||||
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 ---
|
||||
|
||||
@@ -329,6 +429,7 @@ def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_d
|
||||
"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]
|
||||
|
||||
@@ -343,6 +444,8 @@ def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: S
|
||||
"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 {},
|
||||
}
|
||||
|
||||
|
||||
@@ -462,54 +565,108 @@ def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Se
|
||||
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, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""Trigger a manual run of an agent instance. Runs async via subprocess."""
|
||||
inst = db.query(AgentInstance).filter(
|
||||
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
|
||||
).first()
|
||||
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")
|
||||
|
||||
# Determine which script to run based on catalog type and user
|
||||
catalog_id = inst.catalog_id
|
||||
u = db.query(User).filter(User.id == user["user_id"]).first()
|
||||
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}
|
||||
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, None)
|
||||
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:
|
||||
# Generic: run the engine directly with instance config
|
||||
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': {{}}}})"]
|
||||
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
||||
return {"status": "triggered", "message": f"Running {catalog_id} for {u.display_name}"}
|
||||
|
||||
if catalog_id == "project-monitor":
|
||||
# Write config to a temp file to avoid shell escaping issues
|
||||
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)
|
||||
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'])"]
|
||||
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
||||
return {"status": "triggered", "message": f"Running project monitor: {inst.name}"}
|
||||
elif catalog_id in SUB_AGENT_SCRIPTS:
|
||||
cmd = ["python3", f"{agent_dir}/{SUB_AGENT_SCRIPTS[catalog_id]}", "--from-api"]
|
||||
|
||||
return {"status": "error", "message": f"Manual trigger not yet supported for {catalog_id}"}
|
||||
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) ---
|
||||
@@ -533,9 +690,31 @@ def get_instance_config(instance_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@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,
|
||||
@@ -543,6 +722,7 @@ def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
|
||||
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)
|
||||
@@ -551,17 +731,46 @@ def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
|
||||
return {"id": new_run.id, "status": new_run.status}
|
||||
|
||||
|
||||
# --- Runs (user-scoped) ---
|
||||
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 [{
|
||||
"id": r.id, "instance_id": r.instance_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 [_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 ---
|
||||
@@ -671,6 +880,146 @@ def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin)
|
||||
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):
|
||||
@@ -682,6 +1031,8 @@ class CatalogCreate(BaseModel):
|
||||
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
|
||||
@@ -691,6 +1042,8 @@ class CatalogUpdate(BaseModel):
|
||||
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")
|
||||
@@ -1142,8 +1495,88 @@ def root(session: Optional[str] = Cookie(None)):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user