"""Read-only restart status, impact preview, and approval state (#667). Phase 1 of the console restart surface. It *consumes* the #655 coordinator substrate and renders it; it never restarts, reloads, drains, approves, or kills anything. There is no apply path in this module, so there is no execution gate here to arm incorrectly — the only writes the console could perform are the ones it does not implement. Sources, each independently fail-soft and each reported with its own :class:`SourceStatus`: * :mod:`restart_coordinator` — restart-class policy matrix (#663) and the blast-radius impact report (#658). * :mod:`drain_proof` — drain checklist and gate verdict (#661), verified read-only against a caller-supplied proof. * :mod:`post_restart_reconcile` — post-restart completion proof (#662). * :mod:`webui.console_authz` — role authorization for the approval controls (#633). Three rules this module holds itself to, because a status surface that lies is worse than one that is absent: **A source that could not be read is reported unavailable, never green.** No default, placeholder, or self-comparison is substituted for a reading that failed. An unreadable control-plane DB yields ``inventory_complete=False``, which the coordinator itself turns into a fail-closed verdict. **Authorization is asked the way execution would ask it.** Every authorization probe passes ``for_execution=True``, so the console reports whether the action could actually run rather than the weaker "this principal is the right role". While the console is in Phase 1 that answer is ``phase_not_active`` for every phase-2 action, and the surface says so plainly instead of showing an allow. **The database is opened read-only.** ``ControlPlaneDB()`` creates directories and runs migrations on construction, which is a write; this module opens the sqlite file with ``mode=ro`` exactly as :mod:`webui.inventory` does, and treats a missing file as missing authority rather than an empty inventory. """ from __future__ import annotations import os import sqlite3 from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Callable, Mapping import control_plane_db import drain_proof import restart_coordinator from webui import console_authz from webui.inventory import redact_path, scrub # --- Source status ---------------------------------------------------------- STATUS_OK = "ok" STATUS_UNAVAILABLE = "unavailable" #: Console actions whose authorization state this surface reports. Both are #: pre-existing #642 actions; this module adds no new console action because it #: performs no console action. REPORTED_ACTIONS: tuple[str, ...] = ( "system.restart_namespace", "system.reload_namespace", ) #: The break-glass workflow (#664) is not consumed here. It is declared so the #: surface is honest about the gap rather than silently omitting a governance #: path the operator has been told exists. BREAK_GLASS_ISSUE = 664 BREAK_GLASS_PENDING_REASON = ( "The break-glass workflow (#664) is not yet available on this branch's " "base; no break-glass control is offered and none is implied." ) @dataclass(frozen=True) class SourceStatus: """Whether one backing source could be read, and why not when it could not.""" name: str status: str detail: str = "" @property def available(self) -> bool: return self.status == STATUS_OK def as_dict(self) -> dict[str, Any]: return { "name": self.name, "status": self.status, "available": self.available, "detail": self.detail, } @dataclass(frozen=True) class RestartClassView: """One row of the #663 restart-class matrix, scoped to the viewer's role.""" restart_class: str required_permission: str expected_blast_radius: str drain_requirement: str full_drain_required: bool approval_requirement: str request_roles: tuple[str, ...] execution_roles: tuple[str, ...] viewer_may_request: bool viewer_may_execute: bool def as_dict(self) -> dict[str, Any]: return { "restart_class": self.restart_class, "required_permission": self.required_permission, "expected_blast_radius": self.expected_blast_radius, "drain_requirement": self.drain_requirement, "full_drain_required": self.full_drain_required, "approval_requirement": self.approval_requirement, "request_roles": list(self.request_roles), "execution_roles": list(self.execution_roles), "viewer_may_request": self.viewer_may_request, "viewer_may_execute": self.viewer_may_execute, } @dataclass(frozen=True) class ActionAuthorization: """Authorization state for one console action, asked as execution would.""" action_id: str summary: str required_role: str allowed: bool execution_enabled: bool reason_code: str detail: str def as_dict(self) -> dict[str, Any]: return { "action_id": self.action_id, "summary": self.summary, "required_role": self.required_role, "allowed": self.allowed, "execution_enabled": self.execution_enabled, "reason_code": self.reason_code, "detail": self.detail, } @dataclass(frozen=True) class BreakGlassSurface: """Declared-but-unavailable break-glass panel (#664 is not on this base).""" available: bool issue: int reason: str viewer_is_privileged: bool def as_dict(self) -> dict[str, Any]: return { "available": self.available, "issue": self.issue, "reason": self.reason, "viewer_is_privileged": self.viewer_is_privileged, } @dataclass(frozen=True) class RestartConsoleSnapshot: """Everything the read-only restart console renders.""" generated_at: str viewer_role: str viewer_authenticated: bool read_only: bool impact: dict[str, Any] | None impact_source: SourceStatus drain: dict[str, Any] | None drain_source: SourceStatus reconcile: dict[str, Any] | None reconcile_source: SourceStatus restart_classes: tuple[RestartClassView, ...] authorizations: tuple[ActionAuthorization, ...] break_glass: BreakGlassSurface notes: tuple[str, ...] = field(default_factory=tuple) def as_dict(self) -> dict[str, Any]: return { "generated_at": self.generated_at, "viewer_role": self.viewer_role, "viewer_authenticated": self.viewer_authenticated, "read_only": self.read_only, "impact": self.impact, "impact_source": self.impact_source.as_dict(), "drain": self.drain, "drain_source": self.drain_source.as_dict(), "reconcile": self.reconcile, "reconcile_source": self.reconcile_source.as_dict(), "restart_classes": [c.as_dict() for c in self.restart_classes], "authorizations": [a.as_dict() for a in self.authorizations], "break_glass": self.break_glass.as_dict(), "notes": list(self.notes), "links": { "issue": 667, "extends": 642, "umbrella": 655, "coordinator": 658, "drain_proof": 661, "reconcile": 662, "restart_classes": 663, "break_glass": BREAK_GLASS_ISSUE, "vision": 652, "roadmap": 653, }, } def _utc_now() -> datetime: return datetime.now(timezone.utc) # --- Control-plane inventory (read-only) ------------------------------------ def read_control_plane_inventory( *, db_path: str | None = None, limit: int = 200, ) -> dict[str, Any]: """Read sessions and leases for an impact evaluation, read-only. Returns the inventory mapping :func:`restart_coordinator.evaluate_restart_impact` expects. ``inventory_complete`` is True only when every read succeeded, so a partial read denies rather than under-reporting the blast radius. The database is never created, migrated, or written: a missing file means the console has no session authority, which is not the same as there being no sessions. """ path = (db_path or control_plane_db.default_db_path() or "").strip() incomplete: list[str] = [] def _incomplete(reason: str) -> dict[str, Any]: return { "sessions": [], "leases": [], "terminal_lock": None, "prior_recovery_attempts": [], "inventory_complete": False, "incomplete_reasons": [reason], } if not path: return _incomplete("control-plane database path is not configured") if not os.path.exists(path): return _incomplete( f"control-plane database not present at {redact_path(path)}; " "no session or lease authority available" ) try: conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5) conn.row_factory = sqlite3.Row except sqlite3.Error as exc: return _incomplete(f"control-plane database could not be opened: {exc}") sessions: list[dict[str, Any]] = [] leases: list[dict[str, Any]] = [] capped = max(1, int(limit)) try: tables = { str(row[0]) for row in conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table'" ).fetchall() } if "sessions" not in tables: incomplete.append("control-plane database has no sessions table") else: sessions = [ dict(row) for row in conn.execute( "SELECT session_id, role, profile, pid, status," " last_heartbeat_at FROM sessions" " WHERE status = 'active'" " ORDER BY last_heartbeat_at DESC LIMIT ?", (capped,), ).fetchall() ] if "leases" not in tables: incomplete.append("control-plane database has no leases table") elif "work_items" not in tables: incomplete.append( "control-plane database has no work_items table; lease work " "identity cannot be resolved" ) else: leases = [ dict(row) for row in conn.execute( "SELECT l.lease_id, l.session_id, l.role, l.phase," " l.status AS freshness, l.worktree_path," " w.kind AS work_kind, w.number AS work_number" " FROM leases l" " JOIN work_items w ON w.work_item_id = l.work_item_id" " WHERE l.status = 'active'" " ORDER BY l.expires_at DESC LIMIT ?", (capped,), ).fetchall() ] except sqlite3.Error as exc: return _incomplete(f"control-plane database read failed: {exc}") finally: conn.close() return { "sessions": sessions, "leases": leases, "terminal_lock": None, "prior_recovery_attempts": [], "inventory_complete": not incomplete, "incomplete_reasons": incomplete, } # --- Composition ------------------------------------------------------------ def build_restart_class_views(viewer_role: str | None) -> tuple[RestartClassView, ...]: """Render the #663 class matrix, marking what this viewer may request.""" normalized = str(viewer_role or "").strip().lower() views: list[RestartClassView] = [] for policy in restart_coordinator.RESTART_CLASS_POLICIES.values(): views.append( RestartClassView( restart_class=policy.restart_class.value, required_permission=policy.required_permission, expected_blast_radius=policy.expected_blast_radius, drain_requirement=policy.drain_requirement, full_drain_required=policy.full_drain_required, approval_requirement=policy.approval_requirement, request_roles=tuple(policy.request_roles), execution_roles=tuple(policy.execution_roles), viewer_may_request=normalized in policy.request_roles, viewer_may_execute=normalized in policy.execution_roles, ) ) return tuple(views) def build_action_authorizations( principal: console_authz.Principal | None, ) -> tuple[ActionAuthorization, ...]: """Authorization state for the approval controls, asked as execution. ``for_execution=True`` is deliberate. Asking without it answers "is this principal senior enough", which is not the question an operator looking at a control needs answered; asking with it answers "would this run", and while the console is in Phase 1 the honest answer is no. """ results: list[ActionAuthorization] = [] for action_id in REPORTED_ACTIONS: action = console_authz.get_action(action_id) decision = console_authz.authorize(action_id, principal, for_execution=True) results.append( ActionAuthorization( action_id=action_id, summary=action.summary if action else "", required_role=( action.minimum_role if action else console_authz.OPERATOR ), allowed=bool(decision.allowed), execution_enabled=bool(decision.execution_enabled), reason_code=str(decision.reason_code or ""), detail=str(decision.detail or ""), ) ) return tuple(results) def viewer_is_privileged(principal: console_authz.Principal | None) -> bool: """True when the viewer holds at least the operator role.""" who = principal if principal is not None else console_authz.ANONYMOUS if not who.authenticated: return False return who.rank >= console_authz.ROLE_ORDER.index(console_authz.OPERATOR) def load_impact_report( *, principal: console_authz.Principal | None = None, restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value, db_path: str | None = None, limit: int = 200, read_inventory: Callable[..., Mapping[str, Any]] | None = None, now: datetime | None = None, ) -> tuple[dict[str, Any] | None, SourceStatus]: """Evaluate the blast radius for *restart_class*, always dry-run.""" reader = read_inventory or read_control_plane_inventory try: inventory = dict(reader(db_path=db_path, limit=limit)) except Exception as exc: # noqa: BLE001 return None, SourceStatus( "impact", STATUS_UNAVAILABLE, f"control-plane inventory failed: {type(exc).__name__}: {exc}", ) who = principal if principal is not None else console_authz.ANONYMOUS viewer_role = str(who.role or "").strip().lower() try: report = restart_coordinator.evaluate_restart_impact( inventory, now=now, dry_run=True, restart_class=restart_class, requester_role=viewer_role, requester_permissions=restart_coordinator.permissions_for_role( viewer_role ), ) except Exception as exc: # noqa: BLE001 return None, SourceStatus( "impact", STATUS_UNAVAILABLE, f"impact evaluation failed: {type(exc).__name__}: {exc}", ) payload = scrub(report.as_dict()) detail = "" if not report.inventory_complete: detail = "; ".join(report.incomplete_reasons) or "inventory incomplete" return payload, SourceStatus("impact", STATUS_OK, detail) def load_drain_status( *, proof: Mapping[str, Any] | None = None, now: datetime | None = None, expected_impact_fingerprint: str | None = None, ) -> tuple[dict[str, Any] | None, SourceStatus]: """Verify a supplied drain proof read-only and report the verdict. No proof supplied is not a failure and not a pass: it is reported as the absence of a proof, which is exactly what the #661 gate would deny on. """ if proof is None: return None, SourceStatus( "drain", STATUS_UNAVAILABLE, "no drain proof supplied; the #661 gate denies a restart without a " "valid unexpired clean proof", ) try: verified = drain_proof.verify_drain_proof( proof, now=now, expected_impact_fingerprint=expected_impact_fingerprint, ) except Exception as exc: # noqa: BLE001 return None, SourceStatus( "drain", STATUS_UNAVAILABLE, f"drain proof verification failed: {type(exc).__name__}: {exc}", ) return scrub(verified.as_dict()), SourceStatus("drain", STATUS_OK) def load_reconcile_status( *, load_proof: Callable[[], Any] | None = None, ) -> tuple[dict[str, Any] | None, SourceStatus]: """Report the most recent post-restart completion proof (#662).""" if load_proof is None: return None, SourceStatus( "reconcile", STATUS_UNAVAILABLE, "no post-restart completion proof source is wired into this view", ) try: proof = load_proof() except Exception as exc: # noqa: BLE001 return None, SourceStatus( "reconcile", STATUS_UNAVAILABLE, f"reconcile proof unavailable: {type(exc).__name__}: {exc}", ) if proof is None: return None, SourceStatus( "reconcile", STATUS_UNAVAILABLE, "no post-restart reconcile has been recorded", ) payload = proof.as_dict() if hasattr(proof, "as_dict") else dict(proof) return scrub(payload), SourceStatus("reconcile", STATUS_OK) def load_restart_console_snapshot( *, principal: console_authz.Principal | None = None, restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value, db_path: str | None = None, limit: int = 200, drain_proof_payload: Mapping[str, Any] | None = None, read_inventory: Callable[..., Mapping[str, Any]] | None = None, load_reconcile_proof: Callable[[], Any] | None = None, now: datetime | None = None, ) -> RestartConsoleSnapshot: """Compose the read-only restart console snapshot.""" who = principal if principal is not None else console_authz.ANONYMOUS moment = now or _utc_now() impact, impact_source = load_impact_report( principal=who, restart_class=restart_class, db_path=db_path, limit=limit, read_inventory=read_inventory, now=moment, ) fingerprint = None if impact is not None: try: fingerprint = drain_proof.impact_fingerprint(impact) except Exception: # noqa: BLE001 fingerprint = None drain, drain_source = load_drain_status( proof=drain_proof_payload, now=moment, expected_impact_fingerprint=fingerprint, ) reconcile, reconcile_source = load_reconcile_status( load_proof=load_reconcile_proof ) notes: list[str] = [ "This surface is read-only: it evaluates and displays, and performs no " "restart, reload, drain, approval, or process action.", ] if not impact_source.available: notes.append( "Impact preview unavailable — a restart decision must not be made " "from this page while the blast radius is unknown." ) return RestartConsoleSnapshot( generated_at=moment.isoformat(), viewer_role=str(who.role or "anonymous"), viewer_authenticated=bool(who.authenticated), read_only=True, impact=impact, impact_source=impact_source, drain=drain, drain_source=drain_source, reconcile=reconcile, reconcile_source=reconcile_source, restart_classes=build_restart_class_views(who.role), authorizations=build_action_authorizations(who), break_glass=BreakGlassSurface( available=False, issue=BREAK_GLASS_ISSUE, reason=BREAK_GLASS_PENDING_REASON, viewer_is_privileged=viewer_is_privileged(who), ), notes=tuple(notes), )