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

@ -84,9 +84,12 @@ The two evaluation modes:
(load → resolve → evaluate → report → **log_run** → emit). Emit failures no
longer skip `activity_runs` rows.
- **REST admin API** (FastAPI): CRUD, manual trigger, admin sync, metrics.
- **Operator automation console** (ACTIVITY-WP-0024): `/ops` inventory, status
window, run history, Run now / enable / disable / pause with operator-token
auth (fail-closed), plus thin HTML UI at `/ops/ui`.
- **Automation status surface**: `make automation-status` and
`make prod-automation-status` / `scripts/prod_automation_status.sh` for
railiance01 evidence without LLM authority.
railiance01 evidence without LLM authority (same contract as `/ops/automations/status`).
- **Operational runbook**: `docs/runbook.md`, emission boundary docs.
---
@ -103,7 +106,9 @@ The two evaluation modes:
- **General ops control plane** — Kubernetes, SSH, tunnels, secret custody,
OpenBao policy administration.
- **Event broker / Temporal server hosting** — consume, do not own lifecycle.
- **End-user task UI**.
- **End-user task UI** — tracking human work items. The **operator automation
console** (`/ops`) is in scope; it manages ActivityDefinitions and schedules,
not task lifecycle.
- **Coding assistant schedulers** as production authority.
**Boundary note (side-effect resolvers):** A small set of shell/context
@ -208,7 +213,7 @@ Open product/policy workplan: **ACTIVITY-WP-0022** (IssueSink no-default-Forgejo
| **G7. Credential delivery** | Low (residual) | **FORGEJO_TOKEN** via ESO `actcore-forgejo-admin` (WP-0023-T05, Ready). issue-core `GITEA_BACKEND_TOKEN` still 503 forgejo-inbox for path A rest — **issue-core owner** (WP-0023-T06). |
| **G8. Live-images hygiene** | Medium (ops) | Multi-cluster `live-images-all.txt` must be refreshed after deploys or prune can delete live tags (incident 2026-07-21, restored). `scripts/refresh_live_images.sh` (T04). |
| **G9. Evidence federation** | Low | Progress often lands on railiance01 edge/hub; workstation primary hub may not show the same feed without tunnel/outbox health. |
| **G10. API external access** | Low | ClusterIP-only; intentional until auth policy. |
| **G10. API external access** | Low | ClusterIP-only; ops mutations use operator token (WP-0024). Public Ingress / OIDC still deferred. |
### Drift risks

View file

@ -43,11 +43,92 @@ ACTCORE_DB_URL=postgresql+asyncpg://actcore:actcore@localhost:5433/actcore \
|---------|-----|
| Temporal Web UI | http://localhost:8080 |
| REST API docs (Swagger) | http://localhost:8010/docs |
| Operator console UI | http://localhost:8010/ops/ui |
| Operator status JSON | http://localhost:8010/ops/automations/status?since=sunday |
| NATS monitoring | http://localhost:8222 |
| Prometheus metrics (worker) | http://localhost:9090/metrics |
---
## Operator automation console (ACTIVITY-WP-0024)
Prefer the **ops console** over ad-hoc SSH/SQL for “did automations run?” and
“run this now”.
### Auth
| Env | Purpose |
| --- | --- |
| `ACTIVITY_CORE_OPERATOR_TOKEN` | Shared operator token; required for **mutations** |
| `ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS` | `1` only for local dev without a token |
Mutations: `POST /ops/automations/{id}/trigger|enable|disable|pause|unpause`
Header: `X-Operator-Token: <token>` (or `Authorization: Bearer <token>`).
Fail-closed: if the token is unset and unauth is not allowed, mutations return
**403**. Reads (`GET /ops/...`) do not require the token (ClusterIP / port-forward
posture). **Do not** put the token in git, chat, or workplans. Store in
`actcore-runtime-secret` (or local `.env`) via operator custody.
### Daily checklist
```bash
# How did automations go since Sunday?
curl -sS "http://localhost:8010/ops/automations/status?since=sunday" | python3 -m json.tool
# or CLI equivalent:
make automation-status SINCE=sunday
# Inventory
curl -sS "http://localhost:8010/ops/automations" | python3 -m json.tool
# Run now (requires token)
curl -sS -X POST "http://localhost:8010/ops/automations/<id>/trigger" \
-H "X-Operator-Token: $ACTIVITY_CORE_OPERATOR_TOKEN" \
-H "Content-Type: application/json" -d '{}'
# Side-effect activities (e.g. forgejo prune) need explicit confirm:
curl -sS -X POST "http://localhost:8010/ops/automations/<id>/trigger" \
-H "X-Operator-Token: $ACTIVITY_CORE_OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirm_side_effect": true}'
# Pause / disable schedule (token required)
curl -sS -X POST "http://localhost:8010/ops/automations/<id>/pause" \
-H "X-Operator-Token: $ACTIVITY_CORE_OPERATOR_TOKEN"
curl -sS -X POST "http://localhost:8010/ops/automations/<id>/disable" \
-H "X-Operator-Token: $ACTIVITY_CORE_OPERATOR_TOKEN"
```
Thin UI: open `/ops/ui`, paste the operator token into the browser field
(localStorage only), then use Run now / pause actions. **Cron edits are not in
the UI** — change definition files and sync.
### Production access (railiance01)
API remains **ClusterIP** (no public Ingress in WP-0024).
```bash
# From a machine with kubectl to railiance01:
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
# Browser: http://127.0.0.1:8010/ops/ui
# Ensure ACTIVITY_CORE_OPERATOR_TOKEN is set on actcore-api (runtime secret key).
```
Bootstrap token (operator workstation; never commit the value):
```bash
# Generate and inject (example — adjust secret key name to match cluster)
TOKEN=$(openssl rand -hex 24)
kubectl -n activity-core create secret generic actcore-runtime-secret \
--from-literal=ACTIVITY_CORE_OPERATOR_TOKEN="$TOKEN" \
--dry-run=client -o yaml | kubectl apply -f - # only if creating fresh;
# Prefer: kubectl patch / edit to merge the key into existing secret, then
kubectl -n activity-core rollout restart deploy/actcore-api
unset TOKEN
```
---
## REST API — common operations
```bash

View file

@ -110,3 +110,21 @@ kubectl -n activity-core exec deploy/actcore-api -- \
kubectl -n activity-core get pods
kubectl -n activity-core get svc
```
## Operator automation console (ACTIVITY-WP-0024)
API Service is ClusterIP-only. From a shell with cluster access:
```bash
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
# UI: http://127.0.0.1:8010/ops/ui
# JSON: http://127.0.0.1:8010/ops/automations/status?since=sunday
```
Mutations require `ACTIVITY_CORE_OPERATOR_TOKEN` in `actcore-runtime-secret`
(injected via `envFrom` on `actcore-api`). Merge the key into the existing
secret (do not replace DB URL keys). Header: `X-Operator-Token`. See
`docs/runbook.md` § Operator automation console.
Cron/schedule expression changes remain git-owned (definition files + sync);
the console supports Run now, enable/disable, and Temporal pause/unpause only.

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.

58
tests/test_ops_auth.py Normal file
View file

@ -0,0 +1,58 @@
"""Unit tests for operator token auth (ACTIVITY-WP-0024-T02/T06)."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from activity_core.ops_auth import (
extract_operator_token,
operator_token_configured,
verify_operator_token,
)
def test_extract_operator_token_prefers_header() -> None:
assert (
extract_operator_token(
x_operator_token="abc",
authorization="Bearer other",
)
== "abc"
)
def test_extract_operator_token_bearer() -> None:
assert extract_operator_token(authorization="Bearer secret-token") == "secret-token"
def test_verify_requires_config_when_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
with pytest.raises(HTTPException) as exc:
verify_operator_token(None)
assert exc.value.status_code == 403
def test_verify_allows_anonymous_dev(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
monkeypatch.setenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", "1")
assert verify_operator_token(None) == "anonymous-dev"
def test_verify_token_match(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "correct-horse")
assert verify_operator_token("correct-horse") == "operator"
with pytest.raises(HTTPException) as exc:
verify_operator_token("wrong")
assert exc.value.status_code == 401
with pytest.raises(HTTPException) as exc2:
verify_operator_token(None)
assert exc2.value.status_code == 401
def test_operator_token_configured(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
assert operator_token_configured() is False
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "x")
assert operator_token_configured() is True

View file

@ -0,0 +1,248 @@
"""Contract tests for /ops console API (ACTIVITY-WP-0024)."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from activity_core import ops_api
from activity_core.ops_console import clear_audit_buffer_for_tests, recent_audits, record_ops_audit
@pytest.fixture(autouse=True)
def _clean_audit() -> None:
clear_audit_buffer_for_tests()
yield
clear_audit_buffer_for_tests()
@pytest.fixture
def ops_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "test-token")
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
monkeypatch.setenv("ACTCORE_DB_URL", "postgresql+asyncpg://unused/unused")
app = FastAPI()
app.include_router(ops_api.router)
session_factory = MagicMock(name="session_factory")
temporal = MagicMock(name="temporal")
ops_api.bind_ops_deps(lambda: session_factory, lambda: temporal)
app.state.session_factory = session_factory
app.state.temporal = temporal
return app
@pytest.mark.asyncio
async def test_auth_status(ops_app: FastAPI) -> None:
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/auth/status")
assert res.status_code == 200
body = res.json()
assert body["operator_token_configured"] is True
assert body["mutation_header"] == "X-Operator-Token"
@pytest.mark.asyncio
async def test_trigger_requires_token(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
def_id = uuid.uuid4()
async def fake_status(*_a: Any, **_k: Any) -> dict[str, Any]:
return {"mode": "automation-status", "activities": []}
monkeypatch.setattr(ops_api, "ops_status", fake_status)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(f"/ops/automations/{def_id}/trigger")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_trigger_with_token(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
def_id = uuid.uuid4()
row = MagicMock()
row.name = "Weekly SBOM"
row.context_sources = []
row.task_templates = []
row.trigger_config = {"trigger_type": "cron", "cron_expression": "0 9 * * 1"}
session = AsyncMock()
session.get = AsyncMock(return_value=row)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
handle = MagicMock()
handle.id = f"activity-{def_id}:manual-test"
ops_app.state.temporal.start_workflow = AsyncMock(return_value=handle)
monkeypatch.setattr(
"activity_core.ops_api.record_ops_audit",
AsyncMock(
return_value={
"action": "trigger",
"audit_id": "a1",
"principal": "operator",
}
),
)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(
f"/ops/automations/{def_id}/trigger",
headers={"X-Operator-Token": "test-token"},
json={},
)
assert res.status_code == 200
body = res.json()
assert body["workflow_id"] == handle.id
assert body["name"] == "Weekly SBOM"
assert "token" not in str(body).lower() or "test-token" not in str(body)
@pytest.mark.asyncio
async def test_side_effect_requires_confirm(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
def_id = uuid.uuid4()
row = MagicMock()
row.name = "Weekly Forgejo Package Prune"
row.context_sources = [{"query": "forgejo_package_prune", "apply": True}]
row.task_templates = []
row.trigger_config = {}
session = AsyncMock()
session.get = AsyncMock(return_value=row)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(
f"/ops/automations/{def_id}/trigger",
headers={"X-Operator-Token": "test-token"},
json={},
)
assert res.status_code == 400
assert "side effect" in res.json()["detail"].lower()
@pytest.mark.asyncio
async def test_status_endpoint_wraps_report(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_status(**kwargs: Any) -> dict[str, Any]:
return {
"mode": "automation-status",
"window": {"since": kwargs.get("since")},
"summary": {"total": 0},
"activities": [],
"warnings": [],
"exit_code": 0,
}
monkeypatch.setattr(ops_api, "ops_status", fake_status)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/automations/status", params={"since": "sunday"})
assert res.status_code == 200
assert res.json()["mode"] == "automation-status"
assert res.json()["window"]["since"] == "sunday"
@pytest.mark.asyncio
async def test_inventory_endpoint(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_inv(**_k: Any) -> dict[str, Any]:
return {
"mode": "automation-inventory",
"automations": [
{
"id": str(uuid.uuid4()),
"name": "Daily Triage",
"enabled": True,
"trigger_type": "cron",
"cron_expression": "20 7 * * *",
}
],
"summary": {"total": 1},
"warnings": [],
}
monkeypatch.setattr(ops_api, "ops_inventory", fake_inv)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/automations")
assert res.status_code == 200
assert res.json()["summary"]["total"] == 1
assert res.json()["automations"][0]["name"] == "Daily Triage"
@pytest.mark.asyncio
async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_inv(**_k: Any) -> dict[str, Any]:
return {
"automations": [
{
"id": str(uuid.uuid4()),
"name": "Daily Triage",
"enabled": True,
"trigger_type": "cron",
"cron_expression": "0 0 * * *",
"timezone": "Europe/Berlin",
"temporal": {"paused": False},
}
],
"summary": {"total": 1},
"warnings": [],
}
monkeypatch.setattr(ops_api, "ops_inventory", fake_inv)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/ui/")
assert res.status_code == 200
assert "Daily Triage" in res.text
assert "Operator token" in res.text
@pytest.mark.asyncio
async def test_record_ops_audit_no_secret_leak(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("STATE_HUB_URL", raising=False)
event = await record_ops_audit(
action="trigger",
activity_id=str(uuid.uuid4()),
activity_name="X",
principal="operator",
detail={"workflow_id": "wf-1"},
)
assert event["action"] == "trigger"
assert "token" not in event
audits = recent_audits()
assert audits[0]["audit_id"] == event["audit_id"]
@pytest.mark.asyncio
async def test_runs_404(ops_app: FastAPI) -> None:
def_id = uuid.uuid4()
session = AsyncMock()
session.get = AsyncMock(return_value=None)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get(f"/ops/automations/{def_id}/runs")
assert res.status_code == 404

View file

@ -4,7 +4,7 @@ type: workplan
title: "Operator automation console (status API + control plane + thin UI)"
domain: infotech
repo: activity-core
status: proposed
status: finished
owner: codex
topic_slug: activity-core
created: "2026-07-21"
@ -107,7 +107,7 @@ Deep-links into State Hub / edge when evidence lives there (G9-aware).
```task
id: ACTIVITY-WP-0024-T01
status: todo
status: done
priority: high
state_hub_task_id: "d8830c31-bbcd-41d4-b74b-e1632d8d0257"
```
@ -124,7 +124,7 @@ state_hub_task_id: "d8830c31-bbcd-41d4-b74b-e1632d8d0257"
```task
id: ACTIVITY-WP-0024-T02
status: todo
status: done
priority: high
state_hub_task_id: "a94decc6-8ac9-4738-823a-6c7b19dade80"
```
@ -143,7 +143,7 @@ runbook describes custody without leaking secrets.
```task
id: ACTIVITY-WP-0024-T03
status: todo
status: done
priority: high
state_hub_task_id: "ed665a54-0a5c-4d35-b47d-50de77ef91d6"
```
@ -163,7 +163,7 @@ window within documented field mapping.
```task
id: ACTIVITY-WP-0024-T04
status: todo
status: done
priority: high
state_hub_task_id: "720e0783-93b8-480a-8bfb-00a6aae592a1"
```
@ -179,7 +179,7 @@ state_hub_task_id: "720e0783-93b8-480a-8bfb-00a6aae592a1"
```task
id: ACTIVITY-WP-0024-T05
status: todo
status: done
priority: high
state_hub_task_id: "2673bd8e-f746-428a-9a20-79336dcad9d4"
```
@ -200,7 +200,7 @@ evidence; pause/disable visible in inventory/status; unauthenticated mutate fail
```task
id: ACTIVITY-WP-0024-T06
status: todo
status: done
priority: high
state_hub_task_id: "8b10eb8f-90d1-498b-a0a3-5444c590a691"
```
@ -215,7 +215,7 @@ state_hub_task_id: "8b10eb8f-90d1-498b-a0a3-5444c590a691"
```task
id: ACTIVITY-WP-0024-T07
status: todo
status: done
priority: medium
state_hub_task_id: "32c695ac-f208-4540-b2fa-199a3f83c4c9"
```
@ -232,7 +232,7 @@ state_hub_task_id: "32c695ac-f208-4540-b2fa-199a3f83c4c9"
```task
id: ACTIVITY-WP-0024-T08
status: todo
status: done
priority: medium
state_hub_task_id: "032f7485-3cf6-4ec2-a076-cbf82312a63c"
```
@ -250,7 +250,7 @@ still deferred with a clear follow-up note.
```task
id: ACTIVITY-WP-0024-T09
status: todo
status: done
priority: low
state_hub_task_id: "1a937c1a-6534-467e-94fb-8ba54e18236e"
```
@ -263,13 +263,13 @@ state_hub_task_id: "1a937c1a-6534-467e-94fb-8ba54e18236e"
## Success criteria
- [ ] `GET /ops/automations/status?since=…` matches CLI automation-status semantics
- [ ] Operator can list last N runs for a definition without SSH/SQL
- [ ] Authenticated Run now returns workflow_id and produces `activity_runs` evidence
- [ ] Pause/disable visible in inventory/status and Temporal schedule state
- [ ] Unauthenticated mutation returns 401/403 when token is configured
- [ ] Thin UI usable via port-forward for the above flows
- [ ] Runbook documents token custody without secrets in git
- [x] `GET /ops/automations/status?since=…` matches CLI automation-status semantics
- [x] Operator can list last N runs for a definition without SSH/SQL
- [x] Authenticated Run now returns workflow_id and produces `activity_runs` evidence
- [x] Pause/disable visible in inventory/status and Temporal schedule state
- [x] Unauthenticated mutation returns 401/403 when token is configured
- [x] Thin UI usable via port-forward for the above flows
- [x] Runbook documents token custody without secrets in git
## Implementation order
@ -286,3 +286,10 @@ state_hub_task_id: "1a937c1a-6534-467e-94fb-8ba54e18236e"
- `src/activity_core/automation_status.py`, `src/activity_core/api.py`
- `docs/runbook.md`, `SCOPE.md` G10
- ACTIVITY-WP-0018, ACTIVITY-WP-0019, ACTIVITY-WP-0021
## Closeout 2026-07-21
Implemented `/ops` console: auth (fail-closed operator token), inventory/status/runs
REST, Run now + enable/disable/pause with audit buffer + optional State Hub progress,
thin HTML UI at `/ops/ui`, runbook + k8s access docs. Tests: `tests/test_ops_auth.py`,
`tests/test_ops_console_api.py`.