feat(webui): Runtime and session view (Phase 1) (Closes #641) #898

Merged
sysadmin merged 5 commits from feat/issue-641-runtime-session-view into master 2026-07-25 05:50:49 -05:00
6 changed files with 556 additions and 41 deletions
Showing only changes of commit 1ca2b50406 - Show all commits
+20
View File
@@ -307,6 +307,26 @@ reconnect/operator restart docs (`docs/mcp-namespace-eof-recovery.md`,
document). The page does **not** restart, kill, or take over sessions; manual document). The page does **not** restart, kill, or take over sessions; manual
`pkill` of MCP daemons is contamination, not recovery. `pkill` of MCP daemons is contamination, not recovery.
Honesty rules specific to this view:
* **Ownership columns never assert absence they cannot prove.** When the
`leases` or `locks` section is degraded or unavailable, the Leases and
Worktree-binding cells render `unknown (inventory <status>)` with an
*authority unproven* badge instead of `none` / `unbound`, and a caveat names
the unreadable sections. A worktree binding is correlated through lease work
numbers, so it is unproven when *either* section fails to read.
`/api/sessions` carries the same facts as `ownership_authority_complete`,
`ownership_section_status`, and per-row `lease_authority` /
`worktree_authority`, so a JSON consumer can tell "holds none" from "could
not be read".
* **Contamination text is redacted at the display boundary.** Marker payloads
(`command_summary`, `reason_class`, `session_id`, `role`) are
operator-supplied free text that does not arrive through inventory scrubbing,
so they pass through `webui.inventory.scrub_text`, which collapses `$HOME` and
redacts credential-shaped tokens and URL userinfo *anywhere* in the string.
The write-time redactor is a narrow denylist and is not relied on. The field
itself is kept — it is the `#630` evidence naming which daemon was killed.
## System-health dashboard (#639) ## System-health dashboard (#639)
`/system-health` renders the same snapshot the `/api/v1/system/health` API `/system-health` renders the same snapshot the `/api/v1/system/health` API
+287 -5
View File
@@ -3,10 +3,16 @@
Covers clean and stale session rendering, contamination marker surfacing, Covers clean and stale session rendering, contamination marker surfacing,
worktree binding display, sanctioned recovery links (no pkill), nav/live worktree binding display, sanctioned recovery links (no pkill), nav/live
status, and the JSON API export. status, and the JSON API export.
Also pins the two invariants a reviewer found violated at head a81db754:
degraded ownership sections must render as *unknown* rather than as an
affirmative "none"/"unbound", and contamination payload text must be redacted
at the display boundary rather than trusted from the write-time denylist.
""" """
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -22,6 +28,7 @@ from webui.inventory import (
AUTHORITY_FILESYSTEM, AUTHORITY_FILESYSTEM,
InventorySection, InventorySection,
InventorySnapshot, InventorySnapshot,
STATUS_DEGRADED,
STATUS_OK, STATUS_OK,
STATUS_UNAVAILABLE, STATUS_UNAVAILABLE,
) )
@@ -32,6 +39,7 @@ from webui.session_loader import (
SessionRow, SessionRow,
SessionViewSnapshot, SessionViewSnapshot,
_build_session_rows, _build_session_rows,
_inspect_contamination,
load_session_view_snapshot, load_session_view_snapshot,
snapshot_to_dict, snapshot_to_dict,
) )
@@ -77,36 +85,43 @@ def _inventory(
locks: tuple[dict, ...] = (), locks: tuple[dict, ...] = (),
worktrees: tuple[dict, ...] = (), worktrees: tuple[dict, ...] = (),
namespaces: tuple[dict, ...] = (), namespaces: tuple[dict, ...] = (),
statuses: dict[str, str] | None = None,
) -> InventorySnapshot: ) -> InventorySnapshot:
"""Build a snapshot; ``statuses`` degrades named sections (default all ok)."""
status_of = statuses or {}
def _status(name: str) -> str:
return status_of.get(name, STATUS_OK)
sections = ( sections = (
InventorySection( InventorySection(
name="sessions", name="sessions",
authority=AUTHORITY_CONTROL_PLANE_DB, authority=AUTHORITY_CONTROL_PLANE_DB,
status=STATUS_OK, status=_status("sessions"),
items=sessions, items=sessions,
), ),
InventorySection( InventorySection(
name="leases", name="leases",
authority=AUTHORITY_CONTROL_PLANE_DB, authority=AUTHORITY_CONTROL_PLANE_DB,
status=STATUS_OK, status=_status("leases"),
items=leases, items=leases,
), ),
InventorySection( InventorySection(
name="locks", name="locks",
authority=AUTHORITY_FILESYSTEM, authority=AUTHORITY_FILESYSTEM,
status=STATUS_OK, status=_status("locks"),
items=locks, items=locks,
), ),
InventorySection( InventorySection(
name="worktrees", name="worktrees",
authority=AUTHORITY_FILESYSTEM, authority=AUTHORITY_FILESYSTEM,
status=STATUS_OK, status=_status("worktrees"),
items=worktrees, items=worktrees,
), ),
InventorySection( InventorySection(
name="namespaces", name="namespaces",
authority=AUTHORITY_FILESYSTEM, authority=AUTHORITY_FILESYSTEM,
status=STATUS_OK, status=_status("namespaces"),
items=namespaces items=namespaces
or ( or (
{ {
@@ -453,5 +468,272 @@ class TestSessionsRoutes(unittest.TestCase):
) )
class TestDegradedOwnershipAuthority(unittest.TestCase):
"""B1: a section that could not be read must never render as absence."""
def _snapshot(self, inventory: InventorySnapshot) -> SessionViewSnapshot:
return SessionViewSnapshot(
runtime=_runtime(),
inventory=inventory,
sessions=_build_session_rows(inventory, contamination=()),
contamination_markers=(),
)
def test_unavailable_leases_mark_row_authority_unproven(self):
inventory = _inventory(
sessions=(_clean_session(),),
statuses={"leases": STATUS_UNAVAILABLE},
)
row = _build_session_rows(inventory, contamination=())[0]
self.assertEqual(row.lease_ids, ())
self.assertEqual(row.lease_authority, STATUS_UNAVAILABLE)
# Worktree binding is correlated through lease work numbers, so it
# inherits the unreadable lease section.
self.assertEqual(row.worktree_authority, STATUS_UNAVAILABLE)
self.assertFalse(row.ownership_authority_complete)
def test_readable_locks_are_not_reported_unbound_when_leases_degrade(self):
# The narrow variant: locks hold a real worktree_path and read cleanly,
# but the lease section that supplies the correlating work number does
# not. The row must say unknown, not "unbound".
inventory = _inventory(
sessions=(_clean_session(),),
locks=(
{
"issue_number": 641,
"worktree_path": "branches/feat-issue-641",
},
),
statuses={"leases": STATUS_DEGRADED},
)
row = _build_session_rows(inventory, contamination=())[0]
self.assertEqual(row.worktree_paths, ())
self.assertEqual(row.worktree_authority, STATUS_DEGRADED)
self.assertFalse(row.ownership_authority_complete)
def test_degraded_render_says_unknown_not_none_or_unbound(self):
inventory = _inventory(
sessions=(_clean_session(),),
statuses={"leases": STATUS_UNAVAILABLE, "locks": STATUS_UNAVAILABLE},
)
html = render_sessions_page(self._snapshot(inventory))
self.assertIn("unknown (inventory unavailable)", html)
self.assertIn("authority unproven", html)
self.assertIn("Ownership authority incomplete", html)
# The affirmative-absence strings must be gone from the row entirely.
self.assertNotIn(">none<", html)
self.assertNotIn(">unbound<", html)
def test_clean_inventory_still_renders_affirmative_absence(self):
# Guards against over-correcting B1 into "everything is unknown".
inventory = _inventory(sessions=(_clean_session(),))
html = render_sessions_page(self._snapshot(inventory))
self.assertIn(">none<", html)
self.assertIn(">unbound<", html)
# The column legend mentions "unknown (inventory …)" as static copy, so
# assert on the per-row marker and the concrete statuses instead.
self.assertNotIn("authority unproven", html)
self.assertNotIn("unknown (inventory unavailable)", html)
self.assertNotIn("unknown (inventory degraded)", html)
self.assertNotIn("Ownership authority incomplete", html)
def test_json_export_carries_snapshot_and_per_row_authority(self):
inventory = _inventory(
sessions=(_clean_session(),),
statuses={"locks": STATUS_UNAVAILABLE},
)
data = snapshot_to_dict(self._snapshot(inventory))
self.assertFalse(data["ownership_authority_complete"])
self.assertEqual(
data["ownership_section_status"]["locks"], STATUS_UNAVAILABLE
)
self.assertEqual(data["ownership_section_status"]["leases"], STATUS_OK)
self.assertIn("unknown, not unowned", data["ownership_note"])
row = data["sessions"][0]
self.assertTrue(row["lease_authority_complete"])
self.assertFalse(row["worktree_authority_complete"])
self.assertEqual(row["worktree_authority"], STATUS_UNAVAILABLE)
self.assertFalse(row["ownership_authority_complete"])
self.assertIn("unknown, not unowned", row["ownership_note"])
def test_json_export_is_affirmative_when_every_source_reads(self):
inventory = _inventory(sessions=(_clean_session(),))
data = snapshot_to_dict(self._snapshot(inventory))
self.assertTrue(data["ownership_authority_complete"])
self.assertTrue(data["sessions"][0]["ownership_authority_complete"])
def test_missing_session_list_is_not_reported_as_no_sessions(self):
inventory = _inventory(statuses={"sessions": STATUS_UNAVAILABLE})
html = render_sessions_page(self._snapshot(inventory))
self.assertIn("could not be read", html)
self.assertIn("not evidence that no sessions exist", html)
def test_expired_lease_flags_row_as_stale(self):
inventory = _inventory(
sessions=(_clean_session(),),
leases=(
{
"lease_id": "lease-expired-1",
"session_id": "prgs-author-111-clean",
"status": "active",
"expired": True,
"work_kind": "issue",
"work_number": 641,
},
),
)
row = _build_session_rows(inventory, contamination=())[0]
self.assertIn("lease-expired", row.stale_flags)
self.assertIn("active-lease-past-expiry", row.stale_flags)
class TestContaminationRedaction(unittest.TestCase):
"""B2: marker payload text is redacted at the display boundary."""
def _marker(self, payload: dict) -> ContaminationMarker:
return _inspect_contamination(
"runtime_recovery_contamination",
remote="prgs",
inspect=lambda **_k: {
"on_disk": True,
"has_payload": True,
"summary": "",
},
load=lambda **_k: payload,
)
def test_home_paths_are_collapsed(self):
home = os.path.expanduser("~")
marker = self._marker(
{"command_summary": f"pkill -f {home}/Development/Gitea-Tools/x.py"}
)
self.assertNotIn(home, marker.command_summary)
self.assertIn("~/Development/Gitea-Tools/x.py", marker.command_summary)
def test_secrets_missed_by_the_write_time_denylist_are_redacted(self):
# Each of these was verified in review to survive
# stable_branch_push_guard.redact_command untouched.
cases = (
("curl -H 'X-Api-Key: SUPERSECRET123' https://example.invalid", "SUPERSECRET123"),
("cmd --password hunter2 origin master", "hunter2"),
("PRIVATE_KEY=abc123 python deploy.py", "abc123"),
("fetch https://user:[email protected]/x.git", "user:pw"),
)
for raw, secret in cases:
with self.subTest(raw=raw):
marker = self._marker({"command_summary": raw})
self.assertNotIn(secret, marker.command_summary)
self.assertIn("[redacted]", marker.command_summary)
def test_command_summary_is_redacted_not_removed(self):
# It is legitimate #630 evidence: the operator must still see which
# daemon was killed.
marker = self._marker(
{
"command_summary": "pkill -f gitea_mcp_server.py",
"reason_class": "manual_daemon_kill",
"session_id": "prgs-author-111-clean",
"role": "author",
}
)
self.assertIn("pkill -f gitea_mcp_server.py", marker.command_summary)
self.assertEqual(marker.reason_class, "manual_daemon_kill")
self.assertEqual(marker.session_id, "prgs-author-111-clean")
self.assertEqual(marker.role, "author")
def test_rendered_page_exposes_no_home_path_from_a_marker(self):
home = os.path.expanduser("~")
marker = self._marker(
{
"command_summary": f"pkill -f {home}/Development/Gitea-Tools/x.py",
"reason_class": "manual_daemon_kill",
}
)
inventory = _inventory(sessions=(_clean_session(),))
html = render_sessions_page(
SessionViewSnapshot(
runtime=_runtime(),
inventory=inventory,
sessions=_build_session_rows(inventory, (marker,)),
contamination_markers=(marker,),
)
)
self.assertIn("Contamination markers", html)
self.assertNotIn(home, html)
class TestSessionsPageEscaping(unittest.TestCase):
"""Hostile values from every rendered source stay inert (N2)."""
HOSTILE = '<script>alert("xss")</script>'
def test_hostile_session_and_marker_values_are_escaped(self):
session = dict(_clean_session())
session["session_id"] = f"sid-{self.HOSTILE}"
session["role"] = self.HOSTILE
session["profile"] = self.HOSTILE
session["namespace"] = self.HOSTILE
session["status"] = self.HOSTILE
inventory = _inventory(
sessions=(session,),
leases=(
{
"lease_id": self.HOSTILE,
"session_id": session["session_id"],
"status": "active",
"expired": False,
"work_kind": self.HOSTILE,
"work_number": 641,
},
),
locks=(
{
"issue_number": 641,
"worktree_path": self.HOSTILE,
},
),
)
marker = ContaminationMarker(
kind="runtime_recovery_contamination",
on_disk=True,
has_payload=True,
summary=self.HOSTILE,
reason_class=self.HOSTILE,
session_id=session["session_id"],
role=self.HOSTILE,
command_summary=self.HOSTILE,
cleared=False,
)
html = render_sessions_page(
SessionViewSnapshot(
runtime=_runtime(),
inventory=inventory,
sessions=_build_session_rows(inventory, (marker,)),
contamination_markers=(marker,),
)
)
self.assertNotIn("<script>", html)
self.assertNotIn('alert("xss")', html)
self.assertIn("&lt;script&gt;", html)
def test_hostile_values_in_a_degraded_render_are_escaped(self):
inventory = _inventory(
sessions=(dict(_clean_session(), session_id=f"sid-{self.HOSTILE}"),),
statuses={"leases": STATUS_UNAVAILABLE, "locks": STATUS_DEGRADED},
)
html = render_sessions_page(
SessionViewSnapshot(
runtime=_runtime(),
inventory=inventory,
sessions=_build_session_rows(inventory, contamination=()),
contamination_markers=(),
)
)
self.assertNotIn("<script>", html)
self.assertIn("&lt;script&gt;", html)
self.assertIn("unknown (inventory", html)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+65
View File
@@ -76,6 +76,35 @@ _CREDENTIAL_KEY_RE = re.compile(
) )
_REDACTED = "[redacted]" _REDACTED = "[redacted]"
#: Credential-shaped *name* as it appears inside a free-form command line. This
#: is deliberately broader than :data:`_CREDENTIAL_KEY_RE` — it also matches a
#: bare ``key`` component, so ``PRIVATE_KEY=`` is caught. Over-redacting a
#: displayed string is safe; under-redacting one is not.
_TEXT_CREDENTIAL_NAME = (
r"[A-Za-z0-9_.\-]*"
r"(?:token|secret|password|passwd|key|authorization|bearer|credential)"
r"[A-Za-z0-9_.\-]*"
)
#: A value following such a name: single-quoted, double-quoted, or bare. The
#: bare form stops at a quote so an enclosing quote survives the redaction.
_TEXT_CREDENTIAL_VALUE = r"'[^']*'|\"[^\"]*\"|[^\s'\"]+"
_TEXT_CREDENTIAL_FLAG_RE = re.compile(
rf"(?P<key>(?<![\w\-])--?{_TEXT_CREDENTIAL_NAME})"
rf"(?P<sep>[=\s]+)"
rf"(?P<value>{_TEXT_CREDENTIAL_VALUE})",
re.IGNORECASE,
)
_TEXT_CREDENTIAL_ASSIGN_RE = re.compile(
rf"(?P<key>(?<![\w\-]){_TEXT_CREDENTIAL_NAME})"
rf"(?P<sep>\s*[:=]\s*)"
rf"(?P<value>{_TEXT_CREDENTIAL_VALUE})",
re.IGNORECASE,
)
_TEXT_URL_USERINFO_RE = re.compile(
r"(?P<scheme>\b[A-Za-z][A-Za-z0-9+.\-]*://)[^\s/@]+@"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class InventorySection: class InventorySection:
@@ -225,6 +254,42 @@ def scrub(value: Any, *, key: str | None = None) -> Any:
return repr(value) return repr(value)
def collapse_home(text: str) -> str:
"""Collapse every ``$HOME`` occurrence *inside* a string, not just a prefix."""
home = os.path.expanduser("~")
if not home or home == "/":
return text
return text.replace(home, "~")
def scrub_text(value: Any) -> Any:
"""Redact a free-form text blob such as a recorded command line.
:func:`scrub` keys off structured field *names* and whole-value prefixes,
which is right for inventory records but blind to a secret embedded in the
middle of a sentence. This collapses ``$HOME`` and redacts credential-shaped
tokens and URL userinfo *anywhere* in the string, so operator-supplied text
rendered verbatim — contamination ``command_summary`` (#630) above all — is
held to the same standard as every other field on the page.
Returns ``None`` unchanged so callers can keep "absent" distinct from "".
"""
if value is None:
return None
text = value if isinstance(value, str) else str(value)
text = collapse_home(text)
text = _TEXT_URL_USERINFO_RE.sub(
lambda m: f"{m.group('scheme')}{_REDACTED}@", text
)
text = _TEXT_CREDENTIAL_FLAG_RE.sub(
lambda m: f"{m.group('key')}{m.group('sep')}{_REDACTED}", text
)
text = _TEXT_CREDENTIAL_ASSIGN_RE.sub(
lambda m: f"{m.group('key')}{m.group('sep')}{_REDACTED}", text
)
return text
# ── control-plane database (read-only) ─────────────────────────────────────── # ── control-plane database (read-only) ───────────────────────────────────────
+1 -1
View File
@@ -91,4 +91,4 @@ def render_runtime_page(snapshot: RuntimeSnapshot) -> str:
"<p class='muted'>This page does not expose tokens or perform MCP restarts. " "<p class='muted'>This page does not expose tokens or perform MCP restarts. "
"Correlated sessions, worktree bindings, and contamination markers: " "Correlated sessions, worktree bindings, and contamination markers: "
"<a href='/sessions'>/sessions</a> (#641).</p>" "<a href='/sessions'>/sessions</a> (#641).</p>"
) )
+105 -16
View File
@@ -12,18 +12,30 @@ Sources:
and collision signals from the control-plane DB + filesystem. and collision signals from the control-plane DB + filesystem.
* :mod:`mcp_session_state` — durable contamination markers (inspect only). * :mod:`mcp_session_state` — durable contamination markers (inspect only).
Secrets are never read. Absolute paths are collapsed by inventory redaction. Secrets are never read. Inventory-sourced values arrive already redacted by
:func:`webui.inventory.scrub`; free-form marker text this module loads itself is
put through :func:`webui.inventory.scrub_text`, which collapses ``$HOME`` and
redacts credential-shaped tokens *inside* a string rather than only at its start.
Ownership columns are authority-aware. A lease or lock section that could not be
read renders as ``unknown``, never as ``none`` or ``unbound``: a lease the reader
could not load is not an absent lease (see
:attr:`webui.inventory.InventorySnapshot.ownership_authority_complete`).
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any, Callable from typing import Any, Callable
import mcp_session_state import mcp_session_state
from webui.inventory import ( from webui.inventory import (
OWNERSHIP_SECTIONS,
STATUS_OK,
InventorySection,
InventorySnapshot, InventorySnapshot,
load_inventory_snapshot, load_inventory_snapshot,
scrub_text,
snapshot_to_dict as inventory_snapshot_to_dict, snapshot_to_dict as inventory_snapshot_to_dict,
) )
from webui.runtime_health import ( from webui.runtime_health import (
@@ -61,6 +73,29 @@ _CONTAMINATION_KINDS: tuple[str, ...] = (
mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION, mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
) )
#: Status recorded on a row when the backing inventory section is absent
#: entirely — distinct from a section that reported itself degraded.
AUTHORITY_MISSING = "missing"
def _section_status(section: InventorySection | None) -> str:
"""Status of an ownership section, treating an absent section as missing."""
if section is None:
return AUTHORITY_MISSING
return section.status
def _combined_authority(*statuses: str) -> str:
"""Worst status of the sections a derived column depends on.
A column proved from two sections is only trustworthy when *both* read
cleanly, so the first non-``ok`` status wins.
"""
for status in statuses:
if status != STATUS_OK:
return status
return STATUS_OK
@dataclass(frozen=True) @dataclass(frozen=True)
class ContaminationMarker: class ContaminationMarker:
@@ -109,6 +144,17 @@ class SessionRow:
worktree_paths: tuple[str, ...] = () worktree_paths: tuple[str, ...] = ()
stale_flags: tuple[str, ...] = () stale_flags: tuple[str, ...] = ()
contamination_flags: tuple[str, ...] = () contamination_flags: tuple[str, ...] = ()
#: Status of the section backing ``lease_ids``/``work_refs``. While this is
#: not ``ok`` those tuples mean "could not be read", never "none held".
lease_authority: str = STATUS_OK
#: Worst status across the sections backing ``worktree_paths`` (locks are
#: correlated through lease work numbers, so both must read cleanly).
worktree_authority: str = STATUS_OK
@property
def ownership_authority_complete(self) -> bool:
"""True only when this row's ownership columns are provable."""
return self.lease_authority == STATUS_OK and self.worktree_authority == STATUS_OK
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -128,6 +174,18 @@ class SessionRow:
"contamination_flags": list(self.contamination_flags), "contamination_flags": list(self.contamination_flags),
"is_stale": bool(self.stale_flags), "is_stale": bool(self.stale_flags),
"is_contaminated": bool(self.contamination_flags), "is_contaminated": bool(self.contamination_flags),
"lease_authority": self.lease_authority,
"worktree_authority": self.worktree_authority,
"lease_authority_complete": self.lease_authority == STATUS_OK,
"worktree_authority_complete": self.worktree_authority == STATUS_OK,
"ownership_authority_complete": self.ownership_authority_complete,
"ownership_note": (
"Lease and worktree columns are proved from sections that read "
"cleanly."
if self.ownership_authority_complete
else "An ownership source could not be read; empty lease_ids or "
"worktree_paths on this row mean unknown, not unowned."
),
} }
@@ -154,6 +212,19 @@ class SessionViewSnapshot:
def active_contamination(self) -> tuple[ContaminationMarker, ...]: def active_contamination(self) -> tuple[ContaminationMarker, ...]:
return tuple(m for m in self.contamination_markers if m.to_dict()["active"]) return tuple(m for m in self.contamination_markers if m.to_dict()["active"])
@property
def ownership_authority_complete(self) -> bool:
"""False while any ownership section is degraded, absent, or unavailable."""
return self.inventory.ownership_authority_complete
@property
def ownership_section_status(self) -> dict[str, str]:
"""Per-section status for the three ownership-bearing sections."""
return {
name: _section_status(self.inventory.section(name))
for name in OWNERSHIP_SECTIONS
}
def _inspect_contamination( def _inspect_contamination(
kind: str, kind: str,
@@ -180,7 +251,7 @@ def _inspect_contamination(
role = None role = None
command_summary = None command_summary = None
cleared = False cleared = False
summary = str(envelope.get("summary") or "") summary = scrub_text(str(envelope.get("summary") or ""))
if envelope.get("on_disk") and envelope.get("has_payload"): if envelope.get("on_disk") and envelope.get("has_payload"):
try: try:
@@ -194,20 +265,25 @@ def _inspect_contamination(
command_summary = payload.get("command_summary") or payload.get("detail") command_summary = payload.get("command_summary") or payload.get("detail")
cleared = bool(payload.get("cleared_by_reconciler")) cleared = bool(payload.get("cleared_by_reconciler"))
if not summary: if not summary:
summary = ( summary = scrub_text(
f"{kind}: {reason_class or 'present'}" f"{kind}: {reason_class or 'present'}"
+ (" (cleared)" if cleared else "") + (" (cleared)" if cleared else "")
) )
# Marker payloads are operator-supplied free text and are the one thing on
# this page that does not arrive through inventory scrubbing. The write-time
# redactor is a narrow denylist, so redact again at the display boundary:
# it leaves $HOME paths, `-H 'X-Api-Key: …'`, `--password …`, and
# `PRIVATE_KEY=…` intact. The field itself stays — it is #630 evidence.
return ContaminationMarker( return ContaminationMarker(
kind=kind, kind=kind,
on_disk=bool(envelope.get("on_disk")), on_disk=bool(envelope.get("on_disk")),
has_payload=bool(envelope.get("has_payload")), has_payload=bool(envelope.get("has_payload")),
summary=summary or f"{kind}: not present", summary=summary or f"{kind}: not present",
reason_class=str(reason_class) if reason_class else None, reason_class=scrub_text(str(reason_class)) if reason_class else None,
session_id=str(session_id) if session_id else None, session_id=scrub_text(str(session_id)) if session_id else None,
role=str(role) if role else None, role=scrub_text(str(role)) if role else None,
command_summary=str(command_summary) if command_summary else None, command_summary=scrub_text(str(command_summary)) if command_summary else None,
cleared=cleared, cleared=cleared,
) )
@@ -232,18 +308,21 @@ def _build_session_rows(
leases_section = inventory.section("leases") leases_section = inventory.section("leases")
locks_section = inventory.section("locks") locks_section = inventory.section("locks")
# Ownership columns may only assert absence when their source read cleanly.
lease_authority = _section_status(leases_section)
# Locks carry claimant profile/username, not control-plane session ids, so a
# worktree binding is correlated through lease work numbers: it depends on
# the locks *and* the leases section.
worktree_authority = _combined_authority(
lease_authority, _section_status(locks_section)
)
leases_by_session: dict[str, list[dict[str, Any]]] = {} leases_by_session: dict[str, list[dict[str, Any]]] = {}
for lease in (leases_section.items if leases_section else ()): for lease in (leases_section.items if leases_section else ()):
sid = str(lease.get("session_id") or "") sid = str(lease.get("session_id") or "")
if sid: if sid:
leases_by_session.setdefault(sid, []).append(lease) leases_by_session.setdefault(sid, []).append(lease)
locks_by_session_hint: dict[str, list[dict[str, Any]]] = {}
for lock in (locks_section.items if locks_section else ()):
# Locks carry claimant profile/username, not control-plane session ids.
# Correlate later by matching lease work_number ↔ lock issue_number.
pass
active_markers = [m for m in contamination if m.to_dict()["active"]] active_markers = [m for m in contamination if m.to_dict()["active"]]
marker_session_ids = { marker_session_ids = {
m.session_id for m in active_markers if m.session_id m.session_id for m in active_markers if m.session_id
@@ -324,11 +403,11 @@ def _build_session_rows(
worktree_paths=tuple(worktree_paths), worktree_paths=tuple(worktree_paths),
stale_flags=tuple(dict.fromkeys(stale_flags)), stale_flags=tuple(dict.fromkeys(stale_flags)),
contamination_flags=tuple(dict.fromkeys(contamination_flags)), contamination_flags=tuple(dict.fromkeys(contamination_flags)),
lease_authority=lease_authority,
worktree_authority=worktree_authority,
) )
) )
# Silence unused variable for the locks_by_session_hint placeholder path.
_ = locks_by_session_hint
return tuple(rows) return tuple(rows)
@@ -415,6 +494,16 @@ def snapshot_to_dict(snapshot: SessionViewSnapshot) -> dict[str, Any]:
"stale": snapshot.stale_session_count, "stale": snapshot.stale_session_count,
"contaminated": snapshot.contaminated_session_count, "contaminated": snapshot.contaminated_session_count,
}, },
"ownership_authority_complete": snapshot.ownership_authority_complete,
"ownership_section_status": snapshot.ownership_section_status,
"ownership_note": (
"Every ownership source read cleanly; a session with no lease and no "
"worktree path genuinely holds neither."
if snapshot.ownership_authority_complete
else "An ownership source is degraded or unavailable. Empty lease_ids "
"and worktree_paths mean unknown, not unowned; consult each row's "
"lease_authority and worktree_authority."
),
"contamination_markers": [ "contamination_markers": [
marker.to_dict() for marker in snapshot.contamination_markers marker.to_dict() for marker in snapshot.contamination_markers
], ],
+78 -19
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
from html import escape from html import escape
from typing import Sequence from typing import Sequence
from webui.inventory import STATUS_OK
from webui.layout import render_page from webui.layout import render_page
from webui.session_loader import ( from webui.session_loader import (
ContaminationMarker, ContaminationMarker,
@@ -29,6 +30,19 @@ def _flags(flags: Sequence[str], *, css: str) -> str:
return " ".join(_badge(flag, css) for flag in flags) return " ".join(_badge(flag, css) for flag in flags)
def _unproven_cell(status: str) -> str:
"""Render an ownership column whose backing inventory section failed to read.
Never "none" and never "unbound": an unreadable source proves nothing about
ownership, and claiming otherwise is the exact failure the
``ownership_authority_complete`` invariant exists to prevent.
"""
return (
f'<span class="muted">unknown (inventory {escape(status)})</span><br>'
f'{_badge("authority unproven", "badge-health-degraded")}'
)
def _runtime_banner(snapshot: SessionViewSnapshot) -> str: def _runtime_banner(snapshot: SessionViewSnapshot) -> str:
runtime = snapshot.runtime runtime = snapshot.runtime
stale = runtime.stale_runtime_warning stale = runtime.stale_runtime_warning
@@ -73,15 +87,37 @@ def _summary_bar(snapshot: SessionViewSnapshot) -> str:
contaminated = snapshot.contaminated_session_count contaminated = snapshot.contaminated_session_count
active_markers = len(snapshot.active_contamination) active_markers = len(snapshot.active_contamination)
inv_status = snapshot.inventory.status inv_status = snapshot.inventory.status
authority_complete = snapshot.ownership_authority_complete
authority_text = "complete" if authority_complete else "incomplete"
authority_css = "badge-health-ok" if authority_complete else "badge-health-degraded"
return f"""<div class="health-card" style="display:flex; flex-wrap:wrap; gap:1rem; align-items:center;"> return f"""<div class="health-card" style="display:flex; flex-wrap:wrap; gap:1rem; align-items:center;">
<div><strong>Sessions:</strong> <span class="badge badge-health-ok">{total}</span></div> <div><strong>Sessions:</strong> <span class="badge badge-health-ok">{total}</span></div>
<div><strong>Stale:</strong> <span class="badge badge-stale">{stale}</span></div> <div><strong>Stale:</strong> <span class="badge badge-stale">{stale}</span></div>
<div><strong>Contaminated:</strong> <span class="badge badge-blocked">{contaminated}</span></div> <div><strong>Contaminated:</strong> <span class="badge badge-blocked">{contaminated}</span></div>
<div><strong>Active markers:</strong> <span class="badge badge-health-unproven">{active_markers}</span></div> <div><strong>Active markers:</strong> <span class="badge badge-health-unproven">{active_markers}</span></div>
<div><strong>Inventory:</strong> <span class="badge badge-health-skipped">{escape(inv_status)}</span></div> <div><strong>Inventory:</strong> <span class="badge badge-health-skipped">{escape(inv_status)}</span></div>
<div><strong>Ownership authority:</strong> <span class="badge {authority_css}">{authority_text}</span></div>
</div>""" </div>"""
def _ownership_caveat(snapshot: SessionViewSnapshot) -> str:
"""Name the unreadable ownership sections, or render nothing when all read."""
if snapshot.ownership_authority_complete:
return ""
degraded = ", ".join(
f"{name}: {status}"
for name, status in snapshot.ownership_section_status.items()
if status != STATUS_OK
)
return (
'<div class="health-card health-stale">'
"<strong>Ownership authority incomplete:</strong> "
f"{escape(degraded)}. Columns marked <em>unknown</em> could not be read. "
"No session below may be treated as holding no lease or no worktree "
"binding — absence of evidence is not evidence of absence.</div>"
)
def _render_session_row(row: SessionRow) -> str: def _render_session_row(row: SessionRow) -> str:
pid = "" if row.pid is None else str(row.pid) pid = "" if row.pid is None else str(row.pid)
pid_alive = "" if row.pid_alive is None else ("alive" if row.pid_alive else "dead") pid_alive = "" if row.pid_alive is None else ("alive" if row.pid_alive else "dead")
@@ -90,21 +126,33 @@ def _render_session_row(row: SessionRow) -> str:
if row.pid_alive is True if row.pid_alive is True
else ("badge-blocked" if row.pid_alive is False else "badge-health-skipped") else ("badge-blocked" if row.pid_alive is False else "badge-health-skipped")
) )
leases = ( # An empty tuple only means "holds none" when its source read cleanly.
", ".join(f"<code>{escape(lid)}</code>" for lid in row.lease_ids) if row.lease_authority != STATUS_OK:
if row.lease_ids lease_cell = _unproven_cell(row.lease_authority)
else '<span class="muted">none</span>' else:
) leases = (
work = ( ", ".join(f"<code>{escape(lid)}</code>" for lid in row.lease_ids)
", ".join(escape(ref) for ref in row.work_refs) if row.lease_ids
if row.work_refs else '<span class="muted">none</span>'
else '<span class="muted">—</span>' )
) work = (
worktrees = ( ", ".join(escape(ref) for ref in row.work_refs)
"<br>".join(f"<code>{escape(path)}</code>" for path in row.worktree_paths) if row.work_refs
if row.worktree_paths else '<span class="muted">—</span>'
else '<span class="muted">unbound</span>' )
) lease_cell = (
f'{leases}<div class="muted" style="font-size:0.82rem; '
f'margin-top:0.2rem;">{work}</div>'
)
if row.worktree_authority != STATUS_OK:
worktrees = _unproven_cell(row.worktree_authority)
else:
worktrees = (
"<br>".join(f"<code>{escape(path)}</code>" for path in row.worktree_paths)
if row.worktree_paths
else '<span class="muted">unbound</span>'
)
return f"""<tr> return f"""<tr>
<td><code>{escape(row.session_id)}</code></td> <td><code>{escape(row.session_id)}</code></td>
<td> <td>
@@ -116,15 +164,24 @@ def _render_session_row(row: SessionRow) -> str:
{_badge(pid_alive, pid_css)} {_badge(pid_alive, pid_css)}
</td> </td>
<td>{escape(str(row.status or ""))}<div class="muted" style="font-size:0.82rem;">{escape(str(row.last_heartbeat_at or ""))}</div></td> <td>{escape(str(row.status or ""))}<div class="muted" style="font-size:0.82rem;">{escape(str(row.last_heartbeat_at or ""))}</div></td>
<td>{leases}<div class="muted" style="font-size:0.82rem; margin-top:0.2rem;">{work}</div></td> <td>{lease_cell}</td>
<td>{worktrees}</td> <td>{worktrees}</td>
<td>{_flags(row.stale_flags, css="badge-stale")}</td> <td>{_flags(row.stale_flags, css="badge-stale")}</td>
<td>{_flags(row.contamination_flags, css="badge-blocked")}</td> <td>{_flags(row.contamination_flags, css="badge-blocked")}</td>
</tr>""" </tr>"""
def _sessions_table(rows: Sequence[SessionRow]) -> str: def _sessions_table(snapshot: SessionViewSnapshot) -> str:
rows: Sequence[SessionRow] = snapshot.sessions
if not rows: if not rows:
sessions_status = snapshot.ownership_section_status.get("sessions", STATUS_OK)
if sessions_status != STATUS_OK:
return (
'<p class="muted">Session inventory is '
f"<strong>{escape(sessions_status)}</strong> — the session list "
"could not be read. This is not evidence that no sessions "
"exist.</p>"
)
return ( return (
'<p class="muted">No control-plane sessions recorded. Inventory may ' '<p class="muted">No control-plane sessions recorded. Inventory may '
"be unavailable, or no MCP workers have registered yet.</p>" "be unavailable, or no MCP workers have registered yet.</p>"
@@ -329,8 +386,10 @@ def render_sessions_page(snapshot: SessionViewSnapshot) -> str:
<div class="prompt-card"> <div class="prompt-card">
<h3>Sessions</h3> <h3>Sessions</h3>
<p class="muted">Control-plane sessions correlated with leases and worktree bindings. <p class="muted">Control-plane sessions correlated with leases and worktree bindings.
Stale and contamination flags are fail-soft: absence of a marker is not proof of cleanliness when inventory is degraded.</p> Stale and contamination flags are fail-soft: absence of a marker is not proof of cleanliness when inventory is degraded.
{_sessions_table(snapshot.sessions)} Lease and worktree columns read <em>unknown (inventory …)</em> when their source could not be loaded.</p>
{_ownership_caveat(snapshot)}
{_sessions_table(snapshot)}
</div> </div>
{_namespaces_section(snapshot)} {_namespaces_section(snapshot)}