"""FastAPI router for operator automation console (ACTIVITY-WP-0024).""" from __future__ import annotations import html import json import os import uuid from datetime import datetime, timezone from typing import Any, Callable from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from temporalio.client import Client from activity_core.ops_auth import ( HEADER_NAME, extract_sso_principal, operator_token_configured, require_operator, ) from activity_core.ops_console import ( is_side_effect_definition, ops_definition_detail, ops_inventory, ops_run_detail, ops_runs, ops_status, recent_audits, record_ops_audit, set_definition_enabled, set_schedule_paused, ) from activity_core.orm import ActivityDefinition as ActivityDefinitionRow router = APIRouter(prefix="/ops", tags=["ops-console"]) _get_db: Callable[[], async_sessionmaker[AsyncSession]] | None = None _get_temporal: Callable[[], Client] | None = None def bind_ops_deps( get_db: Callable[[], async_sessionmaker[AsyncSession]], get_temporal: Callable[[], Client], ) -> None: global _get_db, _get_temporal _get_db = get_db _get_temporal = get_temporal def _db() -> async_sessionmaker[AsyncSession]: assert _get_db is not None return _get_db() def _temporal() -> Client: assert _get_temporal is not None return _get_temporal() def _db_url() -> str | None: return os.environ.get("ACTCORE_DB_URL") class TriggerBody(BaseModel): confirm_side_effect: bool = False class MutateBody(BaseModel): note: str | None = Field(default=None, max_length=500) @router.get("/automations") async def list_automations( enabled: str = Query(default="all"), target_repo: str | None = Query( default=None, description="Filter automations whose name/id/labels mention this repo slug (ACTIVITY-WP-0028)", ), ) -> dict[str, Any]: report = await ops_inventory( db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"), enabled=enabled, ) if target_repo and isinstance(report, dict): needle = target_repo.strip().lower() autos = report.get("automations") or [] if isinstance(autos, list) and needle: filtered = [] for a in autos: if not isinstance(a, dict): continue blob = " ".join( str(a.get(k) or "") for k in ("name", "id", "activity_id", "slug", "labels") ).lower() if needle in blob: filtered.append(a) report = dict(report) report["automations"] = filtered report["filters"] = { **(report.get("filters") or {}), "target_repo": target_repo, } return report @router.get("/automations/status") async def automations_status( since: str = Query(default="today"), until: str | None = Query(default=None), timezone_name: str = Query(default="Europe/Berlin", alias="timezone"), activity_id: str | None = Query(default=None), ) -> dict[str, Any]: report = await ops_status( since=since, until=until, timezone_name=timezone_name, db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"), state_hub_url=os.environ.get("STATE_HUB_URL"), activity_id=activity_id, ) # ACTIVITY-WP-0026 T05: ops_run claim-queue visibility + SLA signals try: from datetime import timedelta from sqlalchemy import and_, func, select from activity_core.orm import OpsRun from activity_core.ops_run_queue import ops_run_counts sla_hours = float(os.environ.get("OPS_RUN_SLA_HOURS", "1") or "1") now = datetime.now(timezone.utc) sla_cutoff = now - timedelta(hours=max(0.1, sla_hours)) Session = _db() async with Session() as session: counts = await ops_run_counts(session) stuck_stmt = select(func.count()).where( and_( OpsRun.state.in_(("open", "claimed")), OpsRun.created_at < sla_cutoff, ) ) stuck = int((await session.execute(stuck_stmt)).scalar_one() or 0) report["ops_runs"] = { "counts": counts, "stuck_open_or_claimed": stuck, "sla_hours": sla_hours, "list_url": "/ops-runs?state=open", } except Exception as exc: # table may not exist pre-migration report["ops_runs"] = {"error": str(exc), "counts": {}} return report @router.get("/automations/{definition_id}") async def get_automation(definition_id: uuid.UUID) -> dict[str, Any]: detail = await ops_definition_detail( _db(), _temporal(), definition_id, db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"), ) if not detail: raise HTTPException(status_code=404, detail="ActivityDefinition not found") return detail @router.get("/automations/{definition_id}/runs") async def list_runs( definition_id: uuid.UUID, since: str | None = Query(default=None, description="ISO datetime lower bound on fired_at"), limit: int = Query(default=50, ge=1, le=200), ) -> dict[str, Any]: since_dt: datetime | None = None if since: try: since_dt = datetime.fromisoformat(since) if since_dt.tzinfo is None: since_dt = since_dt.replace(tzinfo=timezone.utc) except ValueError as exc: raise HTTPException(status_code=400, detail=f"invalid since: {exc}") from exc async with _db()() as session: if await session.get(ActivityDefinitionRow, definition_id) is None: raise HTTPException(status_code=404, detail="ActivityDefinition not found") return await ops_runs(_db(), definition_id, since=since_dt, limit=limit) @router.get("/automations/{definition_id}/runs/{run_id}") async def get_run( definition_id: uuid.UUID, run_id: uuid.UUID, ) -> dict[str, Any]: detail = await ops_run_detail(_db(), definition_id, run_id) if not detail: raise HTTPException(status_code=404, detail="run not found") return detail @router.post("/automations/{definition_id}/trigger") async def trigger_automation( definition_id: uuid.UUID, body: TriggerBody | None = None, principal: str = Depends(require_operator), ) -> dict[str, Any]: body = body or TriggerBody() Session = _db() async with Session() as session: row = await session.get(ActivityDefinitionRow, definition_id) if row is None: raise HTTPException(status_code=404, detail="ActivityDefinition not found") if is_side_effect_definition(row) and not body.confirm_side_effect: raise HTTPException( status_code=400, detail=( "this activity may perform side effects; " "pass confirm_side_effect=true to proceed" ), ) name = row.name trigger_key = f"manual-{uuid.uuid4()}" workflow_id = f"activity-{definition_id}:{trigger_key}" handle = await _temporal().start_workflow( "RunActivityWorkflow", args=[str(definition_id), trigger_key, datetime.now(tz=timezone.utc).isoformat()], id=workflow_id, task_queue="orchestrator-tq", ) audit = await record_ops_audit( action="trigger", activity_id=str(definition_id), activity_name=name, principal=principal, detail={"workflow_id": handle.id, "trigger_key": trigger_key}, ) return { "workflow_id": handle.id, "trigger_key": trigger_key, "activity_id": str(definition_id), "name": name, "audit": audit, } @router.post("/automations/{definition_id}/enable") async def enable_automation( definition_id: uuid.UUID, principal: str = Depends(require_operator), ) -> dict[str, Any]: try: return await set_definition_enabled( _db(), _temporal(), definition_id, enabled=True, principal=principal ) except KeyError: raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None @router.post("/automations/{definition_id}/disable") async def disable_automation( definition_id: uuid.UUID, principal: str = Depends(require_operator), ) -> dict[str, Any]: try: return await set_definition_enabled( _db(), _temporal(), definition_id, enabled=False, principal=principal ) except KeyError: raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None @router.post("/automations/{definition_id}/pause") async def pause_automation( definition_id: uuid.UUID, principal: str = Depends(require_operator), ) -> dict[str, Any]: try: return await set_schedule_paused( _db(), _temporal(), definition_id, paused=True, principal=principal ) except KeyError: raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None @router.post("/automations/{definition_id}/unpause") async def unpause_automation( definition_id: uuid.UUID, principal: str = Depends(require_operator), ) -> dict[str, Any]: try: return await set_schedule_paused( _db(), _temporal(), definition_id, paused=False, principal=principal ) except KeyError: raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None @router.get("/audits") async def list_audits(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]: return {"audits": recent_audits(limit)} @router.get("/auth/status") async def auth_status() -> dict[str, Any]: allow = (os.environ.get("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS") or "").lower() in { "1", "true", "yes", "on", } temporal = temporal_ui_url() return { "operator_token_configured": operator_token_configured(), "mutation_header": HEADER_NAME, # True when a shared token is required for *break-glass* / non-SSO clients. # Browser SSO (Authelia Remote-User / Remote-Email) does not need the token. "mutations_require_token": operator_token_configured() or not allow, "sso_preferred": True, "sso_headers": ["Remote-User", "Remote-Email", "Remote-Groups"], "temporal_ui_url": temporal, "sso_docs": "docs/ops-sso-access.md", "public_hosts": { "ops": "https://activity.coulomb.social", "temporal_ui": temporal, }, } # --- Thin UI (no Jinja dependency) ------------------------------------------- _CSS = """ :root { font-family: system-ui, sans-serif; color: #1a1a1a; } body { margin: 1.5rem; max-width: 1100px; } nav a { margin-right: 1rem; } nav a.external::after { content: " ↗"; font-size: 0.75em; opacity: 0.7; } table { border-collapse: collapse; width: 100%; margin: 1rem 0; } th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; font-size: 0.9rem; } th { background: #f4f4f4; } .status-ok { color: #0a0; } .status-bad { color: #a00; } .status-warn { color: #a60; } .card { border: 1px solid #ddd; border-radius: 6px; padding: 1rem; margin: 1rem 0; } button, .btn { padding: 0.35rem 0.7rem; margin-right: 0.35rem; cursor: pointer; } input[type=password], input[type=text] { padding: 0.3rem; min-width: 16rem; } .muted { color: #666; font-size: 0.85rem; } pre { background: #f8f8f8; padding: 0.75rem; overflow: auto; font-size: 0.8rem; } """ def temporal_ui_url() -> str: """Browser URL for Temporal Web UI (SSO ingress or local port-forward). Override with ACTIVITY_CORE_TEMPORAL_UI_URL. Default prefers the public SSO hostname when set via env; otherwise local port-forward on :8080. """ raw = ( os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL") or os.environ.get("TEMPORAL_UI_URL") or "https://temporal.coulomb.social" ).strip() return raw.rstrip("/") or "https://temporal.coulomb.social" def _page(title: str, body: str) -> HTMLResponse: temporal_href = html.escape(temporal_ui_url(), quote=True) doc = f""" {html.escape(title)} · activity-core ops
{body} """ return HTMLResponse(doc) def _token_form() -> str: configured = "yes" if operator_token_configured() else "no" return f"""

Auth: On activity.coulomb.social, Authelia SSO identity is preferred for mutations (no token paste needed).

Shared token configured on server: {configured}. Header {html.escape(HEADER_NAME)} only when not using SSO.

""" @router.get("/ui", response_class=HTMLResponse) @router.get("/ui/", response_class=HTMLResponse) async def ui_index() -> HTMLResponse: try: inv = await ops_inventory( db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), ) error = None except Exception as exc: # noqa: BLE001 inv = {"automations": [], "summary": {}, "warnings": [str(exc)]} error = str(exc) rows = [] for a in inv.get("automations") or []: aid = html.escape(str(a.get("id") or "")) name = html.escape(str(a.get("name") or "")) enabled = "yes" if a.get("enabled") else "no" cron = html.escape(str(a.get("cron_expression") or a.get("at") or "")) tz = html.escape(str(a.get("timezone") or "")) paused = a.get("temporal") or {} pstate = html.escape(str(paused.get("paused") if "paused" in paused else paused.get("status"))) rows.append( f"{name}" f"{enabled}{html.escape(str(a.get('trigger_type')))}" f"{cron}{tz}{pstate}" ) summary = html.escape(json.dumps(inv.get("summary") or {}, indent=2)) warn = html.escape("; ".join(inv.get("warnings") or []) or "none") err_html = f"

Error: {html.escape(error)}

" if error else "" body = f"""

Scheduled automations

{err_html} {_token_form()}

Warnings: {warn}

{summary}
{''.join(rows) or ''}
NameEnabledTriggerScheduleTZTemporal
No automations found

Schedule cron is read-only in MVP — edit definition files + sync.

Temporal Web UI (workflow debugger): {html.escape(temporal_ui_url())} — primary SSO URL; port-forward of svc/actcore-temporal-ui is break-glass only.

""" return _page("Inventory", body) @router.get("/ui/status", response_class=HTMLResponse) async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse: try: report = await ops_status( since=since, db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), state_hub_url=os.environ.get("STATE_HUB_URL"), ) error = None except Exception as exc: # noqa: BLE001 report = None error = str(exc) if error or not report: body = f"

Status

{html.escape(error or 'no report')}

{_token_form()}" return _page("Status", body) rows = [] for a in report.get("activities") or []: st = str(a.get("status") or "") cls = "status-ok" if st in {"completed", "ok", "disabled"} else ( "status-bad" if st in {"missed", "validation_failed", "sink_failed"} else "status-warn" ) aid = html.escape(str(a.get("id") or "")) last_run = a.get("last_run") or {} last_at = ( a.get("last_run_at") or last_run.get("fired_at") or a.get("temporal_last_fired_at") or "—" ) last_tasks = last_run.get("tasks_spawned") last_tasks_s = "" if last_tasks is None else str(last_tasks) run_count = a.get("observed_run_count", a.get("run_count", a.get("runs_count", ""))) expected_n = a.get("expected_fire_count") if expected_n is None: expected_n = len(a.get("expected_fires") or a.get("expected") or []) rows.append( f"{html.escape(str(a.get('name')))}" f"{html.escape(st)}" f"" f"{html.escape(str(last_at))}" f"{html.escape(last_tasks_s)}" f"{html.escape(str(run_count))}" f"{html.escape(str(expected_n))}" f"{html.escape(str(a.get('enabled')))}" ) window = html.escape(json.dumps(report.get("window") or {}, indent=2)) summary = html.escape(json.dumps(report.get("summary") or {}, indent=2)) body = f"""

Automation status

{_token_form()}

Last run = newest fired_at in the selected window (falls back to Temporal schedule last action when no DB run is in-window).

window: {window}
summary: {summary}
{''.join(rows) or ''}
NameStatusLast runTasks RunsExpectedEnabled
No activities
""" return _page("Status", body) @router.get("/ui/automations/{definition_id}", response_class=HTMLResponse) async def ui_detail(definition_id: uuid.UUID) -> HTMLResponse: detail = await ops_definition_detail( _db(), _temporal(), definition_id, db_url=_db_url(), temporal_host=os.environ.get("TEMPORAL_HOST"), ) if not detail: raise HTTPException(status_code=404, detail="not found") runs = await ops_runs(_db(), definition_id, limit=25) aid = str(definition_id) name = html.escape(str(detail.get("name") or "")) side = bool(detail.get("side_effect")) confirm = "true" if side else "false" run_rows = [] for r in runs.get("runs") or []: rid = str(r.get("run_id") or "") arts = r.get("artifacts") or [] if arts: art_html = " ".join( f'' f'{html.escape(str(a.get("label") or a.get("kind") or "artifact"))}' for a in arts if a.get("url") and str(a.get("url")).startswith("https://") ) or 'none' else: ops_states = [o.get("state") for o in (r.get("ops_runs") or [])] if any(s == "open" for s in ops_states): art_html = 'pending' elif any(s in ("failed", "expired") for s in ops_states): art_html = 'failed' else: art_html = 'none' run_rows.append( f"" f"{html.escape(rid[:13])}…" f"{html.escape(str(r.get('fired_at') or ''))}" f"{html.escape(str(r.get('scheduled_for') or ''))}" f"{html.escape(str(r.get('tasks_spawned')))}" f"{html.escape(str(len((r.get('evidence') or {}).get('task_spawns') or [])))}" f"{art_html}" ) detail_json = html.escape(json.dumps(detail, indent=2, default=str)) body = f"""

{name}

{_token_form()}

id: {html.escape(aid)} side_effect_risk: {side}

Definition (read-only schedule)

{detail_json}

Recent runs

{''.join(run_rows) or ''}
run_idfired_atscheduled_fortasksspawnsArtifacts
No runs
""" return _page(name, body) @router.get("/ui/automations/{definition_id}/runs/{run_id}", response_class=HTMLResponse) async def ui_run_detail(definition_id: uuid.UUID, run_id: uuid.UUID) -> HTMLResponse: run = await ops_run_detail(_db(), definition_id, run_id) if not run: raise HTTPException(status_code=404, detail="run not found") aid = str(definition_id) rid = str(run_id) arts = run.get("artifacts") or [] art_lis = [] for a in arts: url = a.get("url") or "" label = html.escape(str(a.get("label") or a.get("kind") or "artifact")) if isinstance(url, str) and url.startswith("https://"): art_lis.append( f'
  • {label}' f' ({html.escape(str(a.get("kind") or ""))})
  • ' ) else: art_lis.append(f"
  • {label} {html.escape(str(url))}
  • ") ops_blocks = [] for op in run.get("ops_runs") or []: ops_blocks.append( "
    " f"

    ops_run {html.escape(str(op.get('id')))} " f"state={html.escape(str(op.get('state')))}

    " f"

    {html.escape(str(op.get('title') or ''))}

    " f"
    {html.escape(json.dumps(op.get('result') or {{}}, indent=2, default=str))}
    " "
    " ) body = f"""

    ← back to automation

    Run {html.escape(rid)}

    fired_at: {html.escape(str(run.get('fired_at') or ''))}

    scheduled_for: {html.escape(str(run.get('scheduled_for') or ''))}

    tasks_spawned: {html.escape(str(run.get('tasks_spawned')))} version: {html.escape(str(run.get('version_used') or ''))}

    Artifacts

    Ops runs

    {''.join(ops_blocks) or '

    No linked ops_runs

    '}

    Evidence (compact)

    {html.escape(json.dumps(run.get('evidence') or {{}}, indent=2, default=str))}
    """ return _page(f"Run {rid[:8]}", body)