diff --git a/src/psyc/cockpit/pulse_routes.py b/src/psyc/cockpit/pulse_routes.py new file mode 100644 index 0000000..7291ff6 --- /dev/null +++ b/src/psyc/cockpit/pulse_routes.py @@ -0,0 +1,120 @@ +"""Cockpit routes for the Pulse scheduler — admin-gated. + +The integration is intentionally single-call: `register(app, TEMPLATES)` adds +the routes AND wires the FastAPI startup hook that launches the background +scheduler loop. Caller in app.py just imports + invokes register(). +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Optional + +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from psyc import log +from psyc.lines import pulse + + +_log = log.get(__name__) + +TICK_INTERVAL_SECONDS = 30 + + +def _admin_ok(request: Request) -> bool: + """Mirror of the local helper in app.py — admin session is just session['admin_ok'].""" + return bool(request.session.get("admin_ok")) + + +def _relative(dt: Optional[datetime]) -> str: + """Human-friendly "3m ago" / "in 12m" / "now". None → '—'.""" + if dt is None: + return "—" + now = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = (dt - now).total_seconds() + past = delta < 0 + secs = abs(int(delta)) + if secs < 5: + return "now" + if secs < 60: + unit = f"{secs}s" + elif secs < 3600: + unit = f"{secs // 60}m" + elif secs < 86400: + unit = f"{secs // 3600}h" + else: + unit = f"{secs // 86400}d" + return f"{unit} ago" if past else f"in {unit}" + + +def register(app: FastAPI, templates: Jinja2Templates) -> None: + """Attach the /admin/pulse routes and the background scheduler loop to `app`.""" + + @app.get("/admin/pulse", response_class=HTMLResponse) + def pulse_view(request: Request) -> HTMLResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + flash = request.query_params.get("flash", "") + pipelines = pulse.state() + return templates.TemplateResponse( + request, + "admin_pulse.html", + { + "pipelines": pipelines, + "kill_switch": pulse.kill_switch_state(), + "tick_interval": TICK_INTERVAL_SECONDS, + "relative": _relative, + "flash": flash, + }, + ) + + @app.post("/admin/pulse/kill") + def pulse_toggle_kill(request: Request) -> RedirectResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + new = not pulse.kill_switch_state() + pulse.set_kill_switch(new) + flash = "kill switch ARMED — all pipelines halted" if new else "kill switch disarmed — pulse resumes" + return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303) + + @app.post("/admin/pulse/{name}/update") + def pulse_update( + request: Request, + name: str, + mode: str = Form(...), + cadence_seconds: int = Form(...), + enabled: Optional[str] = Form(None), + ) -> RedirectResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + try: + pulse.set_mode(name, pulse.PulseMode(mode)) + pulse.set_cadence(name, int(cadence_seconds)) + pulse.set_enabled(name, enabled is not None) + flash = f"updated {name}: mode={mode}, cadence={cadence_seconds}s, enabled={enabled is not None}" + except (ValueError, KeyError) as exc: + _log.warning("pulse.update.error", name=name, error=str(exc)) + flash = f"update failed: {exc}" + return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303) + + @app.post("/admin/pulse/{name}/run") + def pulse_run_now(request: Request, name: str) -> RedirectResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + try: + outcome, result = pulse.run_now(name) + flash = f"{name} → {outcome}: {result[:120]}" + except ValueError as exc: + flash = f"run failed: {exc}" + return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303) + + @app.on_event("startup") + async def _start_pulse_loop() -> None: + # Fire-and-forget; the loop catches its own exceptions and self-restarts. + asyncio.create_task(pulse.start_background_loop(interval_seconds=TICK_INTERVAL_SECONDS)) + _log.info("pulse.routes.registered", tick=TICK_INTERVAL_SECONDS) diff --git a/src/psyc/cockpit/templates/admin_pulse.html b/src/psyc/cockpit/templates/admin_pulse.html new file mode 100644 index 0000000..f542881 --- /dev/null +++ b/src/psyc/cockpit/templates/admin_pulse.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% block title %}Pulse — psyc admin{% endblock %} +{% block content %} +
+
+

Pulse — autonomous heartbeat

+ {{ pipelines|length }} pipeline{{ '' if pipelines|length == 1 else 's' }} +
+

Cron-style scheduler that drives every psyc line on a cadence without human input. Each pipeline has an autonomy mode and a cadence in seconds. The kill switch halts everything instantly — it overrides cadence, mode, and the enabled flag.

+

← back to admin

+ + {% if flash %} +
{{ flash }}
+ {% endif %} +
+ +
+
+

Global kill switch

+ {{ 'ARMED' if kill_switch else 'OFF' }} +
+ {% if kill_switch %} +
+ ✗ KILL SWITCH ARMED — every pipeline is paused. tick() returns "skipped" for everything. Run-now is also blocked. Toggle off to resume. +
+
+ +
+ {% else %} +
✓ Pulse is live — the background loop ticks every {{ tick_interval }}s.
+
+ +
+ {% endif %} +
+ +
+
+

Pipelines

+ {{ pipelines|selectattr('enabled')|list|length }} enabled +
+

Mode: auto-execute fires the line, auto-propose stages proposals for human approval, manual runs only when you press “Run now”. Cadence is the gap between ticks; the loop wakes up every {{ tick_interval }}s.

+ + + + + + + + + + + + + + {% for p in pipelines %} + + + + + + + + + {% endfor %} + +
PipelineMode · cadence · enabledLast firedNext fireLast result
+ {{ p.title }} +
{{ p.name }} · {{ p.description }}
+
+
+ + + + +
+
+ {% if p.last_fired %}{{ relative(p.last_fired) }}{% else %}—{% endif %} + + {% if p.next_fire %}{{ relative(p.next_fire) }}{% else %}—{% endif %} + + {% if p.last_outcome == 'ok' %} + {% elif p.last_outcome == 'err' %} + {% elif p.last_outcome == 'skipped' %} + {% endif %} + {{ (p.last_result or '—')[:60] }}{% if (p.last_result or '')|length > 60 %}…{% endif %} + +
+ +
+
+
+{% endblock %}