From f5ca928f92e29059a924a2aa883f40759c057bb0 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sat, 6 Jun 2026 21:11:52 +0200 Subject: [PATCH] stage-auto-d pulse: cockpit auto-response state panel + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit: - /admin/pulse now renders an "AUTO-RESPONSE STATE" panel above the pipeline table — mode badge (traffic-light colored), threshold, quorum on/off, local-only on/off, auto-fired-in-24h count, last 5 audit entries, and a one-form save for threshold + gates. - POST /admin/pulse/respond-config writes the new gates. CLI: - pulse-respond-config [--threshold …] [--quorum/--no-quorum] [--local-only/--no-local-only] Args left unset are unchanged; echoes the post-state. - pulse-respond-status prints mode, gates, and the last 10 audit entries. Co-Authored-By: Claude Opus 4.7 --- src/psyc/_pulse_cli.py | 60 ++++++++++++++++ src/psyc/cockpit/pulse_routes.py | 41 ++++++++++- src/psyc/cockpit/templates/admin_pulse.html | 80 +++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/psyc/_pulse_cli.py b/src/psyc/_pulse_cli.py index 97fee7b..84bd8a5 100644 --- a/src/psyc/_pulse_cli.py +++ b/src/psyc/_pulse_cli.py @@ -14,6 +14,7 @@ import typer from psyc import db from psyc.lines import pulse +from psyc.models import Severity def _relative(dt: Optional[datetime]) -> str: @@ -120,3 +121,62 @@ def register(typer_app: typer.Typer) -> None: db.init_db() pulse.set_kill_switch(False) typer.echo("kill switch disarmed — pulse resumes") + + @typer_app.command("pulse-respond-config") + def pulse_respond_config( + threshold: Optional[str] = typer.Option( + None, "--threshold", help="min severity: low | medium | high | critical" + ), + quorum: Optional[bool] = typer.Option( + None, "--quorum/--no-quorum", help="require quorum on federation-sourced cases" + ), + local_only: Optional[bool] = typer.Option( + None, "--local-only/--no-local-only", + help="when armed, auto-execute defers federation cases until quorum" + ), + ) -> None: + """Update the respond-pipeline auto-fire gates. Args left unset are unchanged.""" + db.init_db() + if threshold is not None: + try: + pulse.set_respond_auto_threshold(Severity(threshold)) + except ValueError: + typer.echo(f"error: unknown severity {threshold!r}", err=True) + raise typer.Exit(1) + if quorum is not None: + pulse.set_respond_require_quorum(quorum) + if local_only is not None: + pulse.set_respond_local_only(local_only) + typer.echo( + f"threshold={pulse.respond_auto_threshold().value} " + f"quorum={'on' if pulse.respond_require_quorum() else 'off'} " + f"local-only={'on' if pulse.respond_local_only() else 'off'}" + ) + + @typer_app.command("pulse-respond-status") + def pulse_respond_status() -> None: + """Print the respond-pipeline gates + the last 10 audit entries.""" + db.init_db() + mode = "manual" + for p in pulse.state(): + if p.name == "respond": + mode = p.mode.value + break + typer.echo(f"respond mode : {mode}") + typer.echo(f"threshold : {pulse.respond_auto_threshold().value}") + typer.echo(f"require quorum : {'yes' if pulse.respond_require_quorum() else 'no'}") + typer.echo(f"local-only : {'yes' if pulse.respond_local_only() else 'no'}") + + audit = db.pulse_audit_recent("respond", limit=10) + if not audit: + typer.echo("(no audit entries yet)") + return + typer.echo("") + typer.echo(f"{'timestamp':<28} {'action':<11} {'case_id':<22} detail") + for row in audit: + typer.echo( + f"{(row['timestamp'] or '')[:27]:<28} " + f"{(row['action'] or ''):<11} " + f"{(row['case_id'] or '—'):<22} " + f"{(row['detail'] or '')[:80]}" + ) diff --git a/src/psyc/cockpit/pulse_routes.py b/src/psyc/cockpit/pulse_routes.py index 7291ff6..c3b5e21 100644 --- a/src/psyc/cockpit/pulse_routes.py +++ b/src/psyc/cockpit/pulse_routes.py @@ -8,15 +8,16 @@ 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 datetime import datetime, timedelta, timezone +from typing import List, Optional from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from psyc import log +from psyc import db, log from psyc.lines import pulse +from psyc.models import Severity _log = log.get(__name__) @@ -61,6 +62,10 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None: return RedirectResponse("/admin", status_code=303) flash = request.query_params.get("flash", "") pipelines = pulse.state() + respond_mode = next((p.mode.value for p in pipelines if p.name == "respond"), "manual") + since = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() + auto_fired_24h = db.pulse_audit_count_since("respond", "auto-fire", since) + audit_recent = db.pulse_audit_recent("respond", limit=5) return templates.TemplateResponse( request, "admin_pulse.html", @@ -70,6 +75,13 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None: "tick_interval": TICK_INTERVAL_SECONDS, "relative": _relative, "flash": flash, + "respond_mode": respond_mode, + "respond_threshold": pulse.respond_auto_threshold().value, + "respond_require_quorum": pulse.respond_require_quorum(), + "respond_local_only": pulse.respond_local_only(), + "respond_auto_fired_24h": auto_fired_24h, + "respond_audit_recent": audit_recent, + "severity_choices": [s.value for s in Severity], }, ) @@ -113,6 +125,29 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None: flash = f"run failed: {exc}" return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303) + @app.post("/admin/pulse/respond-config") + def pulse_respond_config( + request: Request, + threshold: str = Form(...), + require_quorum: Optional[str] = Form(None), + local_only: Optional[str] = Form(None), + ) -> RedirectResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + try: + sev = Severity(threshold) + pulse.set_respond_auto_threshold(sev) + pulse.set_respond_require_quorum(require_quorum is not None) + pulse.set_respond_local_only(local_only is not None) + flash = ( + f"respond gates updated: threshold={sev.value}, " + f"quorum={'on' if require_quorum is not None else 'off'}, " + f"local-only={'on' if local_only is not None else 'off'}" + ) + except ValueError as exc: + flash = f"respond-config 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. diff --git a/src/psyc/cockpit/templates/admin_pulse.html b/src/psyc/cockpit/templates/admin_pulse.html index f542881..2cc4b8e 100644 --- a/src/psyc/cockpit/templates/admin_pulse.html +++ b/src/psyc/cockpit/templates/admin_pulse.html @@ -35,6 +35,86 @@ {% endif %} +
+
+

AUTO-RESPONSE STATE

+ {{ respond_auto_fired_24h }} auto-fired in last 24h +
+

When the respond pipeline runs in auto-execute, every PROPOSED action that passes all three gates fires automatically. Below shows the live config + audit trail.

+ +
+ {# Mode badge — traffic-light coloring. auto-execute is "armed" (red), auto-propose is amber, manual is green/safe. #} + {% if respond_mode == 'auto-execute' %} +
+ MODE: auto-execute (ARMED) +
+ {% elif respond_mode == 'auto-propose' %} +
+ MODE: auto-propose (staging only) +
+ {% else %} +
+ MODE: manual (no proposals) +
+ {% endif %} +
+ Threshold: {{ respond_threshold|upper }}+ +
+
+ Quorum: {{ 'ON' if respond_require_quorum else 'OFF' }} +
+
+ Local-only: {{ 'ON' if respond_local_only else 'OFF' }} +
+
+ +
+ + + + +
+ + {% if respond_audit_recent %} + + + + + + + + + + + {% for row in respond_audit_recent %} + + + + + + + {% endfor %} + +
timestampdecisioncasedetail
{{ row.timestamp }} + {% if row.action == 'auto-fire' %}✓ {{ row.action }} + {% elif row.action == 'error' %}✗ {{ row.action }} + {% else %}⊘ {{ row.action }}{% endif %} + {{ row.case_id or '—' }}{% if row.action_id %} · #{{ row.action_id }}{% endif %}{{ row.detail or '' }}
+ {% else %} +

No auto-response decisions logged yet.

+ {% endif %} +
+

Pipelines