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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user