feat(gui+runtime): Phase D — kill-switch arm/disarm from the dashboard

Wires the GUI's first write path through the manual_actions queue:

* runtime/manual_actions_consumer.py — drains the queue and
  dispatches arm_kill / disarm_kill via KillSwitch (preserving the
  audit chain). Unsupported kinds (force_close, approve/reject_proposal)
  are marked result="not_supported" so they don't sit forever.
* runtime/orchestrator.py — adds a `manual_actions` job at */1 cron
  to the canonical scheduler manifest.
* gui/data_layer.py — write helpers enqueue_arm_kill /
  enqueue_disarm_kill (the only write path the GUI uses) plus
  load_pending_manual_actions for the pending strip.
* gui/pages/1_📊_Status.py — kill-switch arm/disarm panel with typed
  confirmation ("yes I am sure") + reason field; pending-actions table
  rendered when the queue is non-empty.

End-to-end smoke against the testnet state.sqlite:
  GUI enqueue → consumer dispatch → KillSwitch transition → audit
  chain hash linkage holds, "source":"manual_gui" recorded.

7 new unit tests for the consumer (arm, disarm, drain, unsupported,
default-reason, KillSwitchError handling, empty queue); 360/360 pass.
ruff clean; mypy strict src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 12:33:58 +02:00
parent 6f6dd4c8dd
commit e8345a29c8
6 changed files with 470 additions and 4 deletions
+84 -1
View File
@@ -14,6 +14,7 @@ poking at the repository directly.
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import Decimal
@@ -27,12 +28,14 @@ from cerbero_bite.safety.audit_log import (
iter_entries,
verify_chain,
)
from cerbero_bite.state import Repository, connect
from cerbero_bite.state import Repository, connect, transaction
from cerbero_bite.state.models import (
DecisionRecord,
ManualAction,
PositionRecord,
SystemStateRecord,
)
from cerbero_bite.state.repository import _row_to_manual
__all__ = [
"DEFAULT_AUDIT_PATH",
@@ -50,12 +53,15 @@ __all__ = [
"compute_kpis",
"compute_monthly_stats",
"compute_payoff_curve",
"enqueue_arm_kill",
"enqueue_disarm_kill",
"load_audit_chain_status",
"load_audit_tail",
"load_closed_positions",
"load_decisions_for_position",
"load_engine_snapshot",
"load_open_positions",
"load_pending_manual_actions",
"load_position_by_id",
]
@@ -559,6 +565,83 @@ def compute_distance_metrics(
)
# ---------------------------------------------------------------------------
# Manual actions queue (the GUI's only write path)
# ---------------------------------------------------------------------------
def _enqueue_action(
*,
db_path: Path | str,
kind: str,
payload: dict[str, object],
proposal_id: UUID | None = None,
) -> int:
"""Insert a row in ``manual_actions``. The engine consumer applies it."""
db_path = Path(db_path)
repo = Repository()
now = datetime.now(UTC)
conn = connect(db_path)
try:
with transaction(conn):
return repo.enqueue_manual_action(
conn,
ManualAction(
kind=kind, # type: ignore[arg-type]
proposal_id=proposal_id,
payload_json=json.dumps(payload),
created_at=now,
),
)
finally:
conn.close()
def enqueue_arm_kill(
*, reason: str, db_path: Path | str = DEFAULT_DB_PATH
) -> int:
"""Queue an ``arm_kill`` action for the engine consumer."""
if not reason or not reason.strip():
raise ValueError("reason is required")
return _enqueue_action(
db_path=db_path,
kind="arm_kill",
payload={"reason": reason.strip()},
)
def enqueue_disarm_kill(
*, reason: str, db_path: Path | str = DEFAULT_DB_PATH
) -> int:
"""Queue a ``disarm_kill`` action for the engine consumer."""
if not reason or not reason.strip():
raise ValueError("reason is required")
return _enqueue_action(
db_path=db_path,
kind="disarm_kill",
payload={"reason": reason.strip()},
)
def load_pending_manual_actions(
*, db_path: Path | str = DEFAULT_DB_PATH
) -> list[ManualAction]:
"""All unconsumed actions, oldest first (used for the pending strip)."""
db_path = Path(db_path)
if not db_path.exists():
return []
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT * FROM manual_actions WHERE consumed_at IS NULL "
"ORDER BY created_at ASC"
).fetchall()
finally:
conn.close()
return [_row_to_manual(row) for row in rows]
def load_audit_tail(
*,
audit_path: Path | str = DEFAULT_AUDIT_PATH,
+98 -2
View File
@@ -10,10 +10,14 @@ import streamlit as st
from cerbero_bite.gui.data_layer import (
DEFAULT_AUDIT_PATH,
DEFAULT_DB_PATH,
EngineSnapshot,
enqueue_arm_kill,
enqueue_disarm_kill,
humanize_age,
humanize_dt,
load_engine_snapshot,
load_open_positions,
load_pending_manual_actions,
)
@@ -31,6 +35,74 @@ _HEALTH_COLORS = {
"unknown": ("", "info"),
}
_TYPED_PHRASE = "yes I am sure"
def _render_kill_switch_panel(db_path: Path, snap: EngineSnapshot) -> None:
st.subheader("Kill switch controls")
if snap.kill_switch_armed:
st.warning(
"Kill switch is **armed**. Disarming queues a `disarm_kill` "
"action; the engine consumer applies it on the next minute "
"tick and the transition is recorded in the audit chain."
)
with st.form("kill_disarm_form", clear_on_submit=True):
reason = st.text_input(
"Reason (required)",
placeholder="e.g. macro window passed",
)
confirm = st.text_input(
f"Type `{_TYPED_PHRASE}` to confirm",
placeholder=_TYPED_PHRASE,
)
submitted = st.form_submit_button(
"🟢 Queue disarm",
type="primary",
use_container_width=True,
)
if submitted:
if confirm.strip() != _TYPED_PHRASE:
st.error(f"Type exactly `{_TYPED_PHRASE}` to confirm.")
elif not reason.strip():
st.error("Reason is required.")
else:
aid = enqueue_disarm_kill(reason=reason, db_path=db_path)
st.success(
f"✅ disarm queued (id #{aid}). "
"The engine will pick it up within ~1 minute."
)
else:
st.info(
"Kill switch is **disarmed**. Arming queues an `arm_kill` "
"action; the engine consumer applies it on the next minute tick."
)
with st.form("kill_arm_form", clear_on_submit=True):
reason = st.text_input(
"Reason (required)",
placeholder="e.g. macro shock — pause trading",
)
confirm = st.text_input(
f"Type `{_TYPED_PHRASE}` to confirm",
placeholder=_TYPED_PHRASE,
)
submitted = st.form_submit_button(
"🔴 Queue arm",
type="secondary",
use_container_width=True,
)
if submitted:
if confirm.strip() != _TYPED_PHRASE:
st.error(f"Type exactly `{_TYPED_PHRASE}` to confirm.")
elif not reason.strip():
st.error("Reason is required.")
else:
aid = enqueue_arm_kill(reason=reason, db_path=db_path)
st.success(
f"✅ arm queued (id #{aid}). "
"The engine will pick it up within ~1 minute."
)
def render() -> None:
st.title("📊 Status")
@@ -54,8 +126,7 @@ def render() -> None:
st.error(
f"**Kill switch armed** — engine will refuse new entries.\n\n"
f"- reason: `{snap.kill_reason or ''}`\n"
f"- since: `{humanize_dt(snap.kill_at)}`\n\n"
"Disarm via CLI: `cerbero-bite kill-switch disarm --reason '<your reason>'`"
f"- since: `{humanize_dt(snap.kill_at)}`"
)
# Top metrics
@@ -69,6 +140,31 @@ def render() -> None:
st.divider()
# Kill switch controls
_render_kill_switch_panel(db_path, snap)
st.divider()
# Pending manual actions
pending = load_pending_manual_actions(db_path=db_path)
if pending:
st.subheader("Pending manual actions")
st.caption(
"Queued from this dashboard, not yet consumed. The engine "
"drains the queue every minute via the `manual_actions` job."
)
rows_pending = [
{
"id": a.id,
"kind": a.kind,
"payload": a.payload_json or "",
"created_at": humanize_dt(a.created_at),
}
for a in pending
]
st.dataframe(rows_pending, use_container_width=True, hide_index=True)
st.divider()
# Audit anchor
st.subheader("Audit anchor")
if snap.last_audit_hash is None: