Implement ACTIVITY-WP-0024 operator automation console
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 36s

Add /ops REST inventory, status, runs, and fail-closed operator-token
mutations (trigger, enable/disable, pause/unpause) with audit trail.
Ship thin HTML UI at /ops/ui, runbook/k8s access docs, and contract tests.
This commit is contained in:
tegwick 2026-07-21 23:51:39 +02:00
parent 81d350de71
commit 71027f0a67
11 changed files with 1494 additions and 20 deletions

View file

@ -1,6 +1,7 @@
"""FastAPI REST API for activity-core.
T30: CRUD for ActivityDefinition + manual one-shot trigger.
ACTIVITY-WP-0024: operator automation console under /ops.
Endpoints:
GET /activity-definitions/ list all
@ -9,6 +10,7 @@ Endpoints:
PUT /activity-definitions/{id} update
DELETE /activity-definitions/{id} delete
POST /activity-definitions/{id}/trigger manual one-shot run
GET/POST /ops/... operator console (inventory, status, control)
Schedule lifecycle:
- POST/PUT with trigger_type='cron' upserts a Temporal Schedule.
@ -38,6 +40,7 @@ from temporalio.api.workflowservice.v1 import GetSystemInfoRequest
from temporalio.client import Client
from activity_core.models import ActivityDefinition, CronTriggerConfig
from activity_core.ops_api import bind_ops_deps, router as ops_router
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow, EventType as EventTypeRow
from activity_core.schedule_manager import delete_schedule, upsert_schedule
from activity_core.sync_service import run_sync
@ -69,6 +72,7 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg]
engine = create_async_engine(db_url)
_session_factory = async_sessionmaker(engine, expire_on_commit=False)
_temporal_client = await Client.connect(TEMPORAL_HOST, namespace=TEMPORAL_NAMESPACE)
bind_ops_deps(_get_db, _get_temporal)
yield
@ -77,6 +81,7 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg]
app = FastAPI(title="activity-core API", lifespan=lifespan)
app.include_router(webhook_router)
app.include_router(ops_router)
def _get_db() -> async_sessionmaker[AsyncSession]:

View file

@ -0,0 +1,477 @@
"""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, operator_token_configured, require_operator
from activity_core.ops_console import (
is_side_effect_definition,
ops_definition_detail,
ops_inventory,
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"),
) -> dict[str, Any]:
return await ops_inventory(
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"),
enabled=enabled,
)
@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]:
return 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,
)
@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.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",
}
return {
"operator_token_configured": operator_token_configured(),
"mutation_header": HEADER_NAME,
"mutations_require_token": operator_token_configured() or not allow,
}
# --- 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; }
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 _page(title: str, body: str) -> HTMLResponse:
doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{html.escape(title)} · activity-core ops</title>
<style>{_CSS}</style>
</head>
<body>
<nav>
<strong>activity-core ops</strong>
<a href="/ops/ui">Inventory</a>
<a href="/ops/ui/status?since=sunday">Status</a>
<a href="/ops/auth/status">Auth JSON</a>
</nav>
<hr/>
{body}
<script>
function operatorHeaders() {{
const t = localStorage.getItem('actcore_operator_token') || '';
const h = {{'Content-Type': 'application/json'}};
if (t) h['{HEADER_NAME}'] = t;
return h;
}}
function saveToken() {{
const v = document.getElementById('op-token').value;
localStorage.setItem('actcore_operator_token', v);
alert('Token saved in browser localStorage (not sent to server until you act).');
}}
async function opsAction(id, action, body) {{
const res = await fetch('/ops/automations/' + id + '/' + action, {{
method: 'POST',
headers: operatorHeaders(),
body: body ? JSON.stringify(body) : '{{}}'
}});
const text = await res.text();
let data; try {{ data = JSON.parse(text); }} catch {{ data = text; }}
if (!res.ok) {{
alert(action + ' failed: ' + res.status + ' ' + JSON.stringify(data));
return;
}}
alert(action + ' ok: ' + JSON.stringify(data).slice(0, 400));
location.reload();
}}
</script>
</body>
</html>"""
return HTMLResponse(doc)
def _token_form() -> str:
configured = "yes" if operator_token_configured() else "no"
return f"""
<div class="card">
<label>Operator token (browser only):
<input id="op-token" type="password" placeholder="X-Operator-Token value" autocomplete="off"/>
</label>
<button type="button" onclick="saveToken()">Save token</button>
<p class="muted">Server token configured: <strong>{configured}</strong>.
Mutations require header <code>{html.escape(HEADER_NAME)}</code>.</p>
</div>
"""
@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"<tr><td><a href='/ops/ui/automations/{aid}'>{name}</a></td>"
f"<td>{enabled}</td><td>{html.escape(str(a.get('trigger_type')))}</td>"
f"<td><code>{cron}</code></td><td>{tz}</td><td>{pstate}</td></tr>"
)
summary = html.escape(json.dumps(inv.get("summary") or {}, indent=2))
warn = html.escape("; ".join(inv.get("warnings") or []) or "none")
err_html = f"<p class='status-bad'>Error: {html.escape(error)}</p>" if error else ""
body = f"""
<h1>Scheduled automations</h1>
{err_html}
{_token_form()}
<p class="muted">Warnings: {warn}</p>
<pre>{summary}</pre>
<table>
<thead><tr><th>Name</th><th>Enabled</th><th>Trigger</th><th>Schedule</th><th>TZ</th><th>Temporal</th></tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="6">No automations found</td></tr>'}</tbody>
</table>
<p class="muted">Schedule cron is read-only in MVP edit definition files + sync.</p>
"""
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"<h1>Status</h1><p class='status-bad'>{html.escape(error or 'no report')}</p>{_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 ""))
rows.append(
f"<tr><td><a href='/ops/ui/automations/{aid}'>{html.escape(str(a.get('name')))}</a></td>"
f"<td class='{cls}'>{html.escape(st)}</td>"
f"<td>{html.escape(str(a.get('run_count', a.get('runs_count', ''))))}</td>"
f"<td>{html.escape(str(len(a.get('expected_fires') or a.get('expected') or [])))}</td>"
f"<td>{html.escape(str(a.get('enabled')))}</td></tr>"
)
window = html.escape(json.dumps(report.get("window") or {}, indent=2))
summary = html.escape(json.dumps(report.get("summary") or {}, indent=2))
body = f"""
<h1>Automation status</h1>
{_token_form()}
<form method="get" action="/ops/ui/status">
<label>since <input type="text" name="since" value="{html.escape(since)}"/></label>
<button type="submit">Refresh</button>
</form>
<pre>window: {window}</pre>
<pre>summary: {summary}</pre>
<table>
<thead><tr><th>Name</th><th>Status</th><th>Runs</th><th>Expected</th><th>Enabled</th></tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="5">No activities</td></tr>'}</tbody>
</table>
"""
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 []:
run_rows.append(
f"<tr><td><code>{html.escape(str(r.get('run_id')))}</code></td>"
f"<td>{html.escape(str(r.get('fired_at')))}</td>"
f"<td>{html.escape(str(r.get('scheduled_for')))}</td>"
f"<td>{html.escape(str(r.get('tasks_spawned')))}</td>"
f"<td>{html.escape(str(len((r.get('evidence') or {}).get('task_spawns') or [])))}</td></tr>"
)
detail_json = html.escape(json.dumps(detail, indent=2, default=str))
body = f"""
<h1>{name}</h1>
{_token_form()}
<p class="muted">id: <code>{html.escape(aid)}</code>
side_effect_risk: <strong>{side}</strong></p>
<div class="card">
<button type="button" onclick="opsAction('{html.escape(aid)}','trigger',{{confirm_side_effect:{confirm}}})">Run now</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','enable')">Enable</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','disable')">Disable</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','pause')">Pause schedule</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','unpause')">Unpause schedule</button>
</div>
<h2>Definition (read-only schedule)</h2>
<pre>{detail_json}</pre>
<h2>Recent runs</h2>
<table>
<thead><tr><th>run_id</th><th>fired_at</th><th>scheduled_for</th><th>tasks</th><th>spawn evidence</th></tr></thead>
<tbody>{''.join(run_rows) or '<tr><td colspan="5">No runs</td></tr>'}</tbody>
</table>
"""
return _page(name, body)

View file

@ -0,0 +1,89 @@
"""Operator token auth for activity-core ops console (ACTIVITY-WP-0024).
Mutations under ``/ops`` are fail-closed:
- If ``ACTIVITY_CORE_OPERATOR_TOKEN`` is set, requests must send matching
``X-Operator-Token`` (or ``Authorization: Bearer <token>``).
- If the token is **unset**, mutations are refused unless
``ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS`` is truthy (local dev only).
Read endpoints do not require the token (ClusterIP / port-forward posture).
"""
from __future__ import annotations
import hmac
import os
from typing import Annotated
from fastapi import Header, HTTPException, Request
OPERATOR_TOKEN_ENV = "ACTIVITY_CORE_OPERATOR_TOKEN"
ALLOW_UNAUTH_ENV = "ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS"
HEADER_NAME = "X-Operator-Token"
def operator_token_configured() -> bool:
return bool((os.environ.get(OPERATOR_TOKEN_ENV) or "").strip())
def allow_unauth_mutations() -> bool:
return (os.environ.get(ALLOW_UNAUTH_ENV) or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def extract_operator_token(
*,
x_operator_token: str | None = None,
authorization: str | None = None,
) -> str | None:
if x_operator_token and x_operator_token.strip():
return x_operator_token.strip()
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip() or None
return None
def verify_operator_token(provided: str | None) -> str:
"""Return operator principal label or raise HTTPException."""
expected = (os.environ.get(OPERATOR_TOKEN_ENV) or "").strip()
if not expected:
if allow_unauth_mutations():
return "anonymous-dev"
raise HTTPException(
status_code=403,
detail=(
"operator auth not configured; set "
f"{OPERATOR_TOKEN_ENV} or enable {ALLOW_UNAUTH_ENV} for local dev"
),
)
if not provided:
raise HTTPException(
status_code=401,
detail=f"missing operator token ({HEADER_NAME} or Authorization Bearer)",
)
if not hmac.compare_digest(provided, expected):
raise HTTPException(status_code=401, detail="invalid operator token")
return "operator"
async def require_operator(
request: Request,
x_operator_token: Annotated[str | None, Header(alias=HEADER_NAME)] = None,
authorization: Annotated[str | None, Header()] = None,
) -> str:
"""FastAPI dependency: require valid operator token for mutations."""
# Prefer dependency headers; fall back to raw request (HTML form headers rare).
provided = extract_operator_token(
x_operator_token=x_operator_token,
authorization=authorization,
)
if provided is None:
provided = extract_operator_token(
x_operator_token=request.headers.get(HEADER_NAME),
authorization=request.headers.get("Authorization"),
)
return verify_operator_token(provided)

View file

@ -0,0 +1,441 @@
"""Operator console services (ACTIVITY-WP-0024) — inventory, status, runs, audit."""
from __future__ import annotations
import argparse
import logging
import os
import uuid
from collections import deque
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from temporalio.client import Client
from activity_core.automation_status import (
DEFAULT_STATE_HUB_URL,
DEFAULT_TEMPORAL_NAMESPACE,
DEFAULT_TIMEZONE,
automation_schedule_id,
build_inventory_report,
build_report,
inventory_row,
load_temporal_visibility,
public_trigger_config,
)
from activity_core.models import ActivityDefinition, CronTriggerConfig, ScheduledTriggerConfig
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
from activity_core.orm import ActivityRun, TaskSpawnLog
from activity_core.runtime_paths import custodian_working_memory_dir
from activity_core.schedule_manager import pause_schedule, unpause_schedule, upsert_schedule
from activity_core.state_hub_write import idempotency_headers, parse_state_hub_write_response
logger = logging.getLogger(__name__)
_AUDIT_BUFFER: deque[dict[str, Any]] = deque(maxlen=100)
SIDE_EFFECT_MARKERS = (
"forgejo_package_prune",
"apply: true",
'"apply": true',
"'apply': true",
)
def _status_namespace(
*,
since: str,
until: str | None,
timezone_name: str,
db_url: str | None,
temporal_host: str | None,
temporal_namespace: str,
state_hub_url: str | None,
timeout_seconds: float = 5.0,
activity_id: list[str] | None = None,
activity_name: list[str] | None = None,
) -> argparse.Namespace:
return argparse.Namespace(
since=since,
until=until,
timezone=timezone_name,
activity_id=activity_id or [],
activity_name=activity_name or [],
db_url=db_url,
state_hub_url=state_hub_url or os.environ.get("STATE_HUB_URL", DEFAULT_STATE_HUB_URL),
working_memory_dir=os.environ.get(
"AUTOMATION_STATUS_WORKING_MEMORY_DIR",
str(custodian_working_memory_dir()),
),
temporal_host=temporal_host or os.environ.get("TEMPORAL_HOST"),
temporal_namespace=temporal_namespace
or os.environ.get("TEMPORAL_NAMESPACE", DEFAULT_TEMPORAL_NAMESPACE),
timeout_seconds=timeout_seconds,
progress_limit=int(os.environ.get("AUTOMATION_STATUS_PROGRESS_LIMIT", "100")),
progress_event_type=None,
format="json",
)
def _inventory_namespace(
*,
db_url: str | None,
temporal_host: str | None,
temporal_namespace: str,
enabled: str = "all",
trigger: list[str] | None = None,
activity_id: list[str] | None = None,
activity_name: list[str] | None = None,
timeout_seconds: float = 5.0,
) -> argparse.Namespace:
return argparse.Namespace(
db_url=db_url,
temporal_host=temporal_host or os.environ.get("TEMPORAL_HOST"),
temporal_namespace=temporal_namespace
or os.environ.get("TEMPORAL_NAMESPACE", DEFAULT_TEMPORAL_NAMESPACE),
timeout_seconds=timeout_seconds,
enabled=enabled,
trigger_type=trigger or [],
activity_id=activity_id or [],
activity_name=activity_name or [],
format="json",
)
async def ops_inventory(
*,
db_url: str | None,
temporal_host: str | None = None,
temporal_namespace: str = DEFAULT_TEMPORAL_NAMESPACE,
enabled: str = "all",
) -> dict[str, Any]:
args = _inventory_namespace(
db_url=db_url,
temporal_host=temporal_host,
temporal_namespace=temporal_namespace,
enabled=enabled,
)
report, _exit = await build_inventory_report(args)
return report
async def ops_status(
*,
since: str = "today",
until: str | None = None,
timezone_name: str = DEFAULT_TIMEZONE,
db_url: str | None = None,
temporal_host: str | None = None,
temporal_namespace: str = DEFAULT_TEMPORAL_NAMESPACE,
state_hub_url: str | None = None,
activity_id: str | None = None,
) -> dict[str, Any]:
ids = [activity_id] if activity_id else []
args = _status_namespace(
since=since,
until=until,
timezone_name=timezone_name,
db_url=db_url,
temporal_host=temporal_host,
temporal_namespace=temporal_namespace,
state_hub_url=state_hub_url,
activity_id=ids,
)
report, exit_code = await build_report(args)
report["exit_code"] = exit_code
return report
async def ops_definition_detail(
session_factory: async_sessionmaker[AsyncSession],
temporal: Client | None,
definition_id: uuid.UUID,
*,
db_url: str | None,
temporal_host: str | None = None,
temporal_namespace: str = DEFAULT_TEMPORAL_NAMESPACE,
) -> dict[str, Any]:
async with session_factory() as session:
row = await session.get(ActivityDefinitionRow, definition_id)
if row is None:
return {}
definition = {
"id": str(row.id),
"name": row.name,
"enabled": row.enabled,
"trigger_type": row.trigger_type,
"trigger_config": row.trigger_config or {},
"source": "database",
"version": row.version,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"side_effect": is_side_effect_definition(row),
}
temporal_map: dict[str, dict[str, Any]] = {}
if temporal is not None or temporal_host:
host = temporal_host or os.environ.get("TEMPORAL_HOST")
temporal_map, _src = await load_temporal_visibility(
host,
temporal_namespace,
[definition],
timeout_seconds=5.0,
)
row_out = inventory_row(definition, temporal_map.get(definition["id"]))
row_out["version"] = definition.get("version")
row_out["created_at"] = definition.get("created_at")
row_out["updated_at"] = definition.get("updated_at")
row_out["side_effect"] = definition.get("side_effect")
row_out["trigger_config_public"] = public_trigger_config(definition.get("trigger_config") or {})
return row_out
def is_side_effect_definition(row: ActivityDefinitionRow) -> bool:
blob = str(row.context_sources or []) + str(row.task_templates or []) + str(row.trigger_config or {})
lower = blob.lower()
return any(marker in lower for marker in SIDE_EFFECT_MARKERS)
async def ops_runs(
session_factory: async_sessionmaker[AsyncSession],
definition_id: uuid.UUID,
*,
since: datetime | None = None,
limit: int = 50,
) -> dict[str, Any]:
limit = max(1, min(limit, 200))
async with session_factory() as session:
stmt = (
select(ActivityRun)
.where(ActivityRun.activity_id == definition_id)
.order_by(ActivityRun.fired_at.desc())
.limit(limit)
)
if since is not None:
stmt = stmt.where(ActivityRun.fired_at >= since)
runs = list((await session.scalars(stmt)).all())
run_ids = [r.run_id for r in runs]
spawn_by_run: dict[str, list[dict[str, Any]]] = {str(rid): [] for rid in run_ids}
if run_ids:
# Spawns keyed by triggering_event_id often equal run_id or workflow key;
# also collect by activity_def_id for recent window.
spawn_stmt = (
select(TaskSpawnLog)
.where(TaskSpawnLog.activity_def_id == definition_id)
.order_by(TaskSpawnLog.id.desc())
.limit(limit * 5)
)
for log in (await session.scalars(spawn_stmt)).all():
entry = {
"task_ref": log.task_ref,
"source_type": log.source_type,
"source_id": log.source_id,
"triggering_event_id": log.triggering_event_id,
"condition_matched": log.condition_matched,
}
# Attach if triggering_event_id matches a run_id string, else keep under activity.
tid = log.triggering_event_id or ""
matched = False
for rid in run_ids:
if tid == str(rid) or str(rid) in tid:
spawn_by_run[str(rid)].append(entry)
matched = True
break
if not matched and runs:
# bucket orphan spawns onto most recent run for operator visibility
spawn_by_run[str(runs[0].run_id)].append(entry)
items = []
for r in runs:
items.append(
{
"run_id": str(r.run_id),
"activity_id": str(r.activity_id),
"scheduled_for": r.scheduled_for.isoformat() if r.scheduled_for else None,
"fired_at": r.fired_at.isoformat() if r.fired_at else None,
"tasks_spawned": r.tasks_spawned,
"version_used": r.version_used,
"evidence": {
"task_spawns": spawn_by_run.get(str(r.run_id), [])[:20],
# context_snapshot may be large; only surface shallow keys
"context_keys": sorted((r.context_snapshot or {}).keys())[:40],
},
}
)
return {
"activity_id": str(definition_id),
"count": len(items),
"runs": items,
}
async def record_ops_audit(
*,
action: str,
activity_id: str,
activity_name: str | None,
principal: str,
detail: dict[str, Any] | None = None,
) -> dict[str, Any]:
event = {
"event_type": "ops_console_audit",
"action": action,
"activity_id": activity_id,
"activity_name": activity_name,
"principal": principal,
"detail": detail or {},
"at": datetime.now(tz=timezone.utc).isoformat(),
"audit_id": str(uuid.uuid4()),
}
_AUDIT_BUFFER.appendleft(event)
hub = (os.environ.get("STATE_HUB_URL") or "").rstrip("/")
if hub:
try:
body = {
"event_type": "ops_console_audit",
"summary": f"ops {action} {activity_name or activity_id} by {principal}",
"author": f"activity-core-ops:{principal}",
"detail": {
"action": action,
"activity_id": activity_id,
"activity_name": activity_name,
"principal": principal,
**(detail or {}),
},
}
headers = {
"Content-Type": "application/json",
**idempotency_headers("ops_console_audit", action, activity_id, event["audit_id"]),
}
with httpx.Client(timeout=3.0) as client:
resp = client.post(f"{hub}/progress/", json=body, headers=headers)
parse_state_hub_write_response(resp)
event["hub"] = "ok"
except Exception as exc: # noqa: BLE001 — audit must not fail mutation
logger.warning("ops audit hub write failed: %s", exc)
event["hub"] = f"failed:{type(exc).__name__}"
else:
event["hub"] = "skipped"
return event
def recent_audits(limit: int = 20) -> list[dict[str, Any]]:
return list(_AUDIT_BUFFER)[: max(1, min(limit, 100))]
def clear_audit_buffer_for_tests() -> None:
_AUDIT_BUFFER.clear()
async def set_definition_enabled(
session_factory: async_sessionmaker[AsyncSession],
temporal: Client,
definition_id: uuid.UUID,
*,
enabled: bool,
principal: str,
) -> dict[str, Any]:
async with session_factory() as session:
row = await session.get(ActivityDefinitionRow, definition_id)
if row is None:
raise KeyError("not found")
row.enabled = enabled
async with session.begin():
session.add(row)
name = row.name
defn = _row_to_model(row)
schedule_result: dict[str, Any] = {}
try:
if isinstance(defn.trigger_config, (CronTriggerConfig, ScheduledTriggerConfig)):
await upsert_schedule(temporal, defn)
schedule_result = {"upserted": True, "paused": not enabled}
else:
schedule_result = {"upserted": False, "reason": "non-scheduled trigger"}
except Exception as exc: # noqa: BLE001
schedule_result = {"upserted": False, "error": str(exc)}
audit = await record_ops_audit(
action="enable" if enabled else "disable",
activity_id=str(definition_id),
activity_name=name,
principal=principal,
detail={"enabled": enabled, "schedule": schedule_result},
)
return {
"activity_id": str(definition_id),
"name": name,
"enabled": enabled,
"schedule": schedule_result,
"audit": audit,
}
async def set_schedule_paused(
session_factory: async_sessionmaker[AsyncSession],
temporal: Client,
definition_id: uuid.UUID,
*,
paused: bool,
principal: str,
) -> dict[str, Any]:
async with session_factory() as session:
row = await session.get(ActivityDefinitionRow, definition_id)
if row is None:
raise KeyError("not found")
name = row.name
trigger_type = row.trigger_type
onetime = trigger_type == "scheduled"
note = f"{'paused' if paused else 'unpaused'} by ops console ({principal})"
if paused:
schedule_result = await pause_schedule(temporal, definition_id, note=note, onetime=onetime)
else:
schedule_result = await unpause_schedule(temporal, definition_id, note=note, onetime=onetime)
audit = await record_ops_audit(
action="pause" if paused else "unpause",
activity_id=str(definition_id),
activity_name=name,
principal=principal,
detail={"schedule": schedule_result},
)
return {
"activity_id": str(definition_id),
"name": name,
"schedule": schedule_result,
"audit": audit,
}
def _row_to_model(row: ActivityDefinitionRow) -> ActivityDefinition:
return ActivityDefinition.model_validate(
{
"id": row.id,
"name": row.name,
"enabled": row.enabled,
"trigger_config": row.trigger_config,
"context_sources": row.context_sources or [],
"task_templates": row.task_templates or [],
"rules": row.rules_json or [],
"instructions": row.instructions_json or [],
"dedupe_key_strategy": row.dedupe_key_strategy,
"version": row.version,
}
)
def definition_has_side_effect(
session_row: ActivityDefinitionRow | None,
*,
context_sources: Any = None,
) -> bool:
if session_row is not None:
return is_side_effect_definition(session_row)
blob = str(context_sources or "").lower()
return any(m in blob for m in SIDE_EFFECT_MARKERS)

View file

@ -353,6 +353,51 @@ async def delete_schedule(client: Client, activity_id: str | UUID) -> None:
pass # Not found — treat as success.
async def pause_schedule(
client: Client,
activity_id: str | UUID,
*,
note: str = "paused via ops console",
onetime: bool = False,
) -> dict:
"""Pause a Temporal Schedule. Returns status dict; missing schedule is an error."""
sid = _onetime_schedule_id(activity_id) if onetime else schedule_id(activity_id)
handle = client.get_schedule_handle(sid)
try:
await handle.pause(note=note)
except ScheduleAlreadyRunningError:
# Race with in-flight action — treat as best-effort success after retry note.
try:
await handle.pause(note=note)
except (RPCError, ScheduleAlreadyRunningError) as exc:
return {"schedule_id": sid, "paused": None, "warning": str(exc)}
except RPCError as exc:
return {"schedule_id": sid, "paused": None, "error": str(exc)}
return {"schedule_id": sid, "paused": True, "note": note}
async def unpause_schedule(
client: Client,
activity_id: str | UUID,
*,
note: str = "unpaused via ops console",
onetime: bool = False,
) -> dict:
"""Unpause a Temporal Schedule. Returns status dict."""
sid = _onetime_schedule_id(activity_id) if onetime else schedule_id(activity_id)
handle = client.get_schedule_handle(sid)
try:
await handle.unpause(note=note)
except ScheduleAlreadyRunningError:
try:
await handle.unpause(note=note)
except (RPCError, ScheduleAlreadyRunningError) as exc:
return {"schedule_id": sid, "paused": None, "warning": str(exc)}
except RPCError as exc:
return {"schedule_id": sid, "paused": None, "error": str(exc)}
return {"schedule_id": sid, "paused": False, "note": note}
async def list_schedules(client: Client) -> list[dict]:
"""Enumerate all activity-core Temporal Schedules.