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:
Eric Jungbauer
2026-04-20 17:54:32 +00:00
parent f01553c511
commit 043aa18f3f
8 changed files with 983 additions and 111 deletions
+462 -29
View File
@@ -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()