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:
@@ -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]}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user