stage-auto-d pulse: cockpit auto-response state panel + CLI

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 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-06-06 21:11:52 +02:00
parent e66c3d3359
commit f5ca928f92
3 changed files with 178 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ import typer
from psyc import db from psyc import db
from psyc.lines import pulse from psyc.lines import pulse
from psyc.models import Severity
def _relative(dt: Optional[datetime]) -> str: def _relative(dt: Optional[datetime]) -> str:
@@ -120,3 +121,62 @@ def register(typer_app: typer.Typer) -> None:
db.init_db() db.init_db()
pulse.set_kill_switch(False) pulse.set_kill_switch(False)
typer.echo("kill switch disarmed — pulse resumes") 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]}"
)

View File

@@ -8,15 +8,16 @@ scheduler loop. Caller in app.py just imports + invokes register().
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import List, Optional
from fastapi import FastAPI, Form, Request from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from psyc import log from psyc import db, log
from psyc.lines import pulse from psyc.lines import pulse
from psyc.models import Severity
_log = log.get(__name__) _log = log.get(__name__)
@@ -61,6 +62,10 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None:
return RedirectResponse("/admin", status_code=303) return RedirectResponse("/admin", status_code=303)
flash = request.query_params.get("flash", "") flash = request.query_params.get("flash", "")
pipelines = pulse.state() 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( return templates.TemplateResponse(
request, request,
"admin_pulse.html", "admin_pulse.html",
@@ -70,6 +75,13 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None:
"tick_interval": TICK_INTERVAL_SECONDS, "tick_interval": TICK_INTERVAL_SECONDS,
"relative": _relative, "relative": _relative,
"flash": flash, "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}" flash = f"run failed: {exc}"
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303) 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") @app.on_event("startup")
async def _start_pulse_loop() -> None: async def _start_pulse_loop() -> None:
# Fire-and-forget; the loop catches its own exceptions and self-restarts. # Fire-and-forget; the loop catches its own exceptions and self-restarts.

View File

@@ -35,6 +35,86 @@
{% endif %} {% endif %}
</section> </section>
<section class="panel">
<div class="panel-head">
<h2>AUTO-RESPONSE STATE</h2>
<span class="count">{{ respond_auto_fired_24h }} auto-fired in last 24h</span>
</div>
<p class="page-intro">When the <code>respond</code> pipeline runs in <code>auto-execute</code>, every PROPOSED action that passes all three gates fires automatically. Below shows the live config + audit trail.</p>
<div style="display:flex; gap:14px; flex-wrap:wrap; margin:14px 0;">
{# Mode badge — traffic-light coloring. auto-execute is "armed" (red), auto-propose is amber, manual is green/safe. #}
{% if respond_mode == 'auto-execute' %}
<div style="padding:10px 14px; border-radius:6px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5; font-weight:700; letter-spacing:0.04em;">
MODE: auto-execute (ARMED)
</div>
{% elif respond_mode == 'auto-propose' %}
<div style="padding:10px 14px; border-radius:6px; background:rgba(250,204,21,0.12); border:1px solid rgba(250,204,21,0.45); color:#fde047; font-weight:700; letter-spacing:0.04em;">
MODE: auto-propose (staging only)
</div>
{% else %}
<div style="padding:10px 14px; border-radius:6px; background:rgba(74,222,128,0.10); border:1px solid var(--green); color:var(--green); font-weight:700; letter-spacing:0.04em;">
MODE: manual (no proposals)
</div>
{% endif %}
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
Threshold: <strong>{{ respond_threshold|upper }}+</strong>
</div>
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
Quorum: <strong style="color: {{ 'var(--green)' if respond_require_quorum else 'var(--muted)' }};">{{ 'ON' if respond_require_quorum else 'OFF' }}</strong>
</div>
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
Local-only: <strong style="color: {{ 'var(--green)' if respond_local_only else 'var(--muted)' }};">{{ 'ON' if respond_local_only else 'OFF' }}</strong>
</div>
</div>
<form method="post" action="/admin/pulse/respond-config" style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:14px;">
<label style="font-size:12px;">Min severity:
<select name="threshold" style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px;">
{% for s in severity_choices %}
<option value="{{ s }}" {% if s == respond_threshold %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</label>
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input type="checkbox" name="require_quorum" value="1" {% if respond_require_quorum %}checked{% endif %}> require quorum
</label>
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input type="checkbox" name="local_only" value="1" {% if respond_local_only %}checked{% endif %}> local-only
</label>
<button type="submit" class="btn">save gates</button>
</form>
{% if respond_audit_recent %}
<table class="ledger" style="margin-top:18px;">
<thead>
<tr>
<th style="width:18%;">timestamp</th>
<th style="width:12%;">decision</th>
<th style="width:18%;">case</th>
<th>detail</th>
</tr>
</thead>
<tbody>
{% for row in respond_audit_recent %}
<tr class="ledger-row">
<td class="lg-ts">{{ row.timestamp }}</td>
<td>
{% if row.action == 'auto-fire' %}<span style="color: var(--green);">✓ {{ row.action }}</span>
{% elif row.action == 'error' %}<span style="color: var(--red);">✗ {{ row.action }}</span>
{% else %}<span style="color: var(--muted);">⊘ {{ row.action }}</span>{% endif %}
</td>
<td class="lg-sub"><code>{{ row.case_id or '—' }}</code>{% if row.action_id %} · #{{ row.action_id }}{% endif %}</td>
<td class="lg-sub">{{ row.detail or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="lg-sub" style="margin-top:14px;">No auto-response decisions logged yet.</p>
{% endif %}
</section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Pipelines</h2> <h2>Pipelines</h2>