From 069a9af7e6aa2c2994e07199d1b0814819457017 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Fri, 24 Jul 2026 17:34:50 -0400
Subject: [PATCH 1/3] feat(webui): implement workflow traffic-control view
(Closes #640)
---
docs/webui-local-dev.md | 2 +
tests/test_webui_traffic_control.py | 192 +++++++++++++++++
webui/app.py | 13 ++
webui/nav.py | 1 +
webui/traffic_loader.py | 309 ++++++++++++++++++++++++++++
webui/traffic_views.py | 170 +++++++++++++++
6 files changed, 687 insertions(+)
create mode 100644 tests/test_webui_traffic_control.py
create mode 100644 webui/traffic_loader.py
create mode 100644 webui/traffic_views.py
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 15c00e4..cb2bb8b 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -57,6 +57,8 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
| `/system-health` | System-health dashboard — readiness, version/uptime, dependencies, MCP namespaces, stale-runtime parity (#639) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
+| `/traffic` | Workflow traffic-control view — runnable, leased, blocked, needs-controller, terminal-complete (#640) |
+| `/api/traffic` | JSON traffic-control export with state classifications and next safe role actions |
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
diff --git a/tests/test_webui_traffic_control.py b/tests/test_webui_traffic_control.py
new file mode 100644
index 0000000..6d85661
--- /dev/null
+++ b/tests/test_webui_traffic_control.py
@@ -0,0 +1,192 @@
+"""Tests for web UI workflow traffic-control view (#640)."""
+
+import sys
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+from webui.traffic_loader import (
+ TrafficItem,
+ TrafficSnapshot,
+ load_traffic_snapshot,
+ snapshot_to_dict,
+)
+from webui.traffic_views import render_traffic_page
+from allocator_service import WorkCandidate
+
+
+class TestTrafficClassification(unittest.TestCase):
+ def test_runnable_candidate_classification(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=640,
+ state="open",
+ labels=("status:ready",),
+ title="Web Console: Workflow traffic-control view (Phase 1)",
+ priority=20,
+ )
+ snap = load_traffic_snapshot(candidates=[cand])
+ self.assertEqual(len(snap.runnable), 1)
+ self.assertEqual(snap.runnable[0].number, 640)
+ self.assertTrue(snap.runnable[0].is_safe)
+ self.assertEqual(snap.runnable[0].traffic_state, "runnable")
+
+ def test_blocked_dependency_candidate_classification(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=643,
+ state="open",
+ labels=("status:ready",),
+ title="Web Console: Requests & intent preview (Phase 2)",
+ priority=20,
+ dependency_unmet=True,
+ dependency_reason="issue#643 depends on unresolved issue(s) #640; they are not closed",
+ )
+ snap = load_traffic_snapshot(candidates=[cand])
+ self.assertEqual(len(snap.blocked), 1)
+ self.assertEqual(snap.blocked[0].number, 643)
+ self.assertFalse(snap.blocked[0].is_safe)
+ self.assertEqual(snap.blocked[0].traffic_state, "blocked")
+ self.assertIn("depends on unresolved issue(s) #640", snap.blocked[0].block_reason)
+
+ def test_leased_candidate_classification(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=640,
+ state="open",
+ labels=("status:in-progress",),
+ title="Web Console: Workflow traffic-control view (Phase 1)",
+ priority=20,
+ )
+ lease = {
+ "kind": "issue",
+ "number": 640,
+ "session_id": "prgs-author-12345",
+ "role": "author",
+ "status": "active",
+ }
+ snap = load_traffic_snapshot(candidates=[cand], leases=[lease])
+ self.assertEqual(len(snap.leased), 1)
+ self.assertEqual(snap.leased[0].number, 640)
+ self.assertEqual(snap.leased[0].traffic_state, "leased")
+ self.assertIsNotNone(snap.leased[0].lease_info)
+
+ def test_needs_controller_candidate_classification(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=700,
+ state="open",
+ labels=("status:blocked",),
+ title="Controller intervention needed",
+ priority=10,
+ blocked=True,
+ )
+ snap = load_traffic_snapshot(candidates=[cand])
+ self.assertEqual(len(snap.needs_controller), 1)
+ self.assertEqual(snap.needs_controller[0].number, 700)
+
+
+class TestTrafficLoader(unittest.TestCase):
+ def test_snapshot_to_dict_export(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=640,
+ state="open",
+ labels=("status:ready",),
+ title="Traffic control test",
+ priority=20,
+ )
+ snap = load_traffic_snapshot(candidates=[cand])
+ data = snapshot_to_dict(snap)
+ self.assertEqual(data["project_id"], "gitea-tools")
+ self.assertEqual(len(data["runnable"]), 1)
+ self.assertTrue(data["inventory_complete"])
+
+ def test_fail_closed_error_handling(self):
+ with mock.patch("webui.traffic_loader.load_queue_snapshot", side_effect=RuntimeError("Gitea connection failed")):
+ snap = load_traffic_snapshot()
+ self.assertIsNotNone(snap.fetch_error)
+ self.assertIn("Failed to load traffic state", snap.fetch_error)
+ self.assertEqual(len(snap.runnable), 0)
+ self.assertFalse(snap.inventory_complete)
+
+
+class TestTrafficRoutesAndRendering(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_traffic_html_page_rendering(self):
+ cand1 = WorkCandidate(
+ kind="issue",
+ number=640,
+ state="open",
+ labels=("status:ready",),
+ title="Traffic View Implementation",
+ priority=20,
+ )
+ cand2 = WorkCandidate(
+ kind="issue",
+ number=643,
+ state="open",
+ labels=("status:ready",),
+ title="Dependent Feature",
+ priority=20,
+ dependency_unmet=True,
+ dependency_reason="issue#643 depends on unresolved issue(s) #640; they are not closed",
+ )
+ snap = load_traffic_snapshot(candidates=[cand1, cand2])
+ with mock.patch("webui.app.load_traffic_snapshot", return_value=snap):
+ response = self.client.get("/traffic")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Workflow Traffic Control", response.text)
+ self.assertIn("1. Runnable Lanes", response.text)
+ self.assertIn("3. Blocked Items", response.text)
+ self.assertIn("Traffic View Implementation", response.text)
+ self.assertIn("depends on unresolved issue(s) #640", response.text)
+
+ def test_api_traffic_json_route(self):
+ cand = WorkCandidate(
+ kind="issue",
+ number=640,
+ state="open",
+ labels=("status:ready",),
+ title="Traffic View API Test",
+ priority=20,
+ )
+ snap = load_traffic_snapshot(candidates=[cand])
+ with mock.patch("webui.app.load_traffic_snapshot", return_value=snap):
+ response = self.client.get("/api/traffic")
+
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["project_id"], "gitea-tools")
+ self.assertEqual(len(data["runnable"]), 1)
+ self.assertEqual(data["runnable"][0]["number"], 640)
+
+ def test_render_traffic_fail_closed_page(self):
+ snap = TrafficSnapshot(
+ project_id="gitea-tools",
+ repo_label="Scaled-Tech-Consulting/Gitea-Tools",
+ runnable=(),
+ leased=(),
+ blocked=(),
+ needs_controller=(),
+ terminal_complete=(),
+ next_roles=(),
+ fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
+ inventory_complete=False,
+ )
+ html = render_traffic_page(snap)
+ self.assertIn("Traffic data unavailable", html)
+ self.assertIn("Fail closed", html)
+ self.assertNotIn("1. Runnable Lanes", html)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webui/app.py b/webui/app.py
index 2c7b146..e593860 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -42,6 +42,8 @@ from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_sn
from webui.lease_views import render_leases_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict
from webui.queue_views import render_queue_page
+from webui.traffic_loader import load_traffic_snapshot, snapshot_to_dict as traffic_snapshot_to_dict
+from webui.traffic_views import render_traffic_page
from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
from webui.worktree_views import render_worktrees_page
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
@@ -200,6 +202,15 @@ async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
+async def traffic(_request: Request) -> HTMLResponse:
+ snapshot = load_traffic_snapshot()
+ return HTMLResponse(render_traffic_page(snapshot))
+
+
+async def api_traffic(_request: Request) -> JSONResponse:
+ return JSONResponse(traffic_snapshot_to_dict(load_traffic_snapshot()))
+
+
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
"""Load the registry, converting validation failure into a fail-closed pair."""
try:
@@ -736,6 +747,8 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
Route("/system-health", system_health, methods=["GET"]),
Route("/queue", queue, methods=["GET"]),
Route("/api/queue", api_queue, methods=["GET"]),
+ Route("/traffic", traffic, methods=["GET"]),
+ Route("/api/traffic", api_traffic, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
diff --git a/webui/nav.py b/webui/nav.py
index 45d1212..129b2a4 100644
--- a/webui/nav.py
+++ b/webui/nav.py
@@ -41,6 +41,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
NavItem("/system-health", "System health"),
)),
NavGroup("Traffic", (
+ NavItem("/traffic", "Traffic control"),
NavItem("/queue", "Queue"),
NavItem("/leases", "Leases"),
NavItem("/actions", "Actions"),
diff --git a/webui/traffic_loader.py b/webui/traffic_loader.py
new file mode 100644
index 0000000..9753d15
--- /dev/null
+++ b/webui/traffic_loader.py
@@ -0,0 +1,309 @@
+"""Traffic-control view loader for Phase 1 operator web console (#640).
+
+Combines queue snapshots, inventory leases, dependency graph classifications,
+and workflow dashboard rules to deliver full traffic-control visibility:
+runnable, leased (in-progress), blocked (dependency/lock), needs-controller,
+and terminal-complete candidates.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Any, Callable, Sequence
+
+from webui.project_registry import find_project, load_registry
+from webui.queue_loader import load_queue_snapshot, QueueSnapshot
+from webui.lease_loader import load_lease_snapshot, LeaseSnapshot
+from workflow_dashboard import (
+ DashboardSnapshot,
+ QueueEntry,
+ RoleNextAction,
+ build_workflow_dashboard,
+ DASHBOARD_ROLES,
+)
+from allocator_service import WorkCandidate
+
+
+@dataclass(frozen=True)
+class TrafficItem:
+ kind: str # "issue" or "pr"
+ number: int
+ title: str
+ traffic_state: str # "runnable", "leased", "blocked", "needs_controller", "terminal_complete"
+ expected_role: str
+ safe_for_roles: tuple[str, ...]
+ badges: tuple[str, ...]
+ block_reason: str | None = None
+ lease_info: dict[str, Any] | None = None
+ head_sha: str | None = None
+
+ @property
+ def is_safe(self) -> bool:
+ return self.block_reason is None and bool(self.safe_for_roles)
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "kind": self.kind,
+ "number": self.number,
+ "title": self.title,
+ "traffic_state": self.traffic_state,
+ "expected_role": self.expected_role,
+ "safe_for_roles": list(self.safe_for_roles),
+ "badges": list(self.badges),
+ "block_reason": self.block_reason,
+ "lease_info": self.lease_info,
+ "head_sha": self.head_sha,
+ "is_safe": self.is_safe,
+ }
+
+
+@dataclass(frozen=True)
+class TrafficSnapshot:
+ project_id: str
+ repo_label: str
+ runnable: tuple[TrafficItem, ...]
+ leased: tuple[TrafficItem, ...]
+ blocked: tuple[TrafficItem, ...]
+ needs_controller: tuple[TrafficItem, ...]
+ terminal_complete: tuple[TrafficItem, ...]
+ next_roles: tuple[dict[str, Any], ...]
+ fetch_error: str | None = None
+ inventory_complete: bool = True
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "project_id": self.project_id,
+ "repo_label": self.repo_label,
+ "runnable": [i.as_dict() for i in self.runnable],
+ "leased": [i.as_dict() for i in self.leased],
+ "blocked": [i.as_dict() for i in self.blocked],
+ "needs_controller": [i.as_dict() for i in self.needs_controller],
+ "terminal_complete": [i.as_dict() for i in self.terminal_complete],
+ "next_roles": list(self.next_roles),
+ "fetch_error": self.fetch_error,
+ "inventory_complete": self.inventory_complete,
+ }
+
+
+def _classify_traffic_item(
+ entry: QueueEntry,
+ *,
+ lease_info: dict[str, Any] | None = None,
+) -> TrafficItem:
+ """Classify a QueueEntry into a TrafficItem with explicit traffic state."""
+ badges = list(entry.badges)
+ block_reason = entry.block_reason
+ expected_role = entry.expected_role
+
+ entry_is_safe = entry.block_reason is None and bool(entry.safe_for_roles)
+ if lease_info is not None or "in-progress" in badges or "claimed" in badges:
+ state = "leased"
+ elif expected_role == "reconciler" or "terminal-lock" in badges:
+ state = "terminal_complete"
+ elif expected_role == "controller" or "contaminated" in badges or "needs-controller" in badges:
+ state = "needs_controller"
+ elif block_reason is not None or "blocked" in badges or "dependency-unmet" in badges or "blocked-by-terminal" in badges:
+ state = "blocked"
+ elif entry_is_safe:
+ state = "runnable"
+ else:
+ state = "needs_controller"
+
+ return TrafficItem(
+ kind=entry.kind,
+ number=entry.number,
+ title=entry.title,
+ traffic_state=state,
+ expected_role=expected_role,
+ safe_for_roles=entry.safe_for_roles,
+ badges=tuple(badges),
+ block_reason=block_reason,
+ lease_info=lease_info,
+ head_sha=entry.head_sha,
+ )
+
+
+def load_traffic_snapshot(
+ *,
+ candidates: Sequence[WorkCandidate] | None = None,
+ leases: Sequence[dict[str, Any]] | None = None,
+ terminal_pr: int | None = None,
+ fetch_queue_snapshot: Callable[[], QueueSnapshot] | None = None,
+ fetch_lease_snapshot: Callable[[], LeaseSnapshot] | None = None,
+ project_id: str = "gitea-tools",
+) -> TrafficSnapshot:
+ """Load and compute the traffic-control snapshot."""
+ try:
+ reg = load_registry()
+ proj = find_project(reg, project_id)
+ repo_label = proj.remote_repo if proj else "Scaled-Tech-Consulting/Gitea-Tools"
+ except Exception:
+ repo_label = "Scaled-Tech-Consulting/Gitea-Tools"
+
+ # Injected candidates path (pure unit testing)
+ if candidates is not None:
+ dashboard = build_workflow_dashboard(
+ candidates=candidates,
+ leases=leases,
+ terminal_pr=terminal_pr,
+ inventory_complete=True,
+ )
+ return _build_traffic_snapshot_from_dashboard(
+ project_id=project_id,
+ repo_label=repo_label,
+ dashboard=dashboard,
+ leases=leases or (),
+ )
+
+ # Live snapshot loading
+ q_loader = fetch_queue_snapshot or load_queue_snapshot
+ l_loader = fetch_lease_snapshot or load_lease_snapshot
+
+ try:
+ q_snap = q_loader()
+ l_snap = l_loader()
+ except Exception as exc: # noqa: BLE001
+ return TrafficSnapshot(
+ project_id=project_id,
+ repo_label=repo_label,
+ runnable=(),
+ leased=(),
+ blocked=(),
+ needs_controller=(),
+ terminal_complete=(),
+ next_roles=(),
+ fetch_error=f"Failed to load traffic state: {exc}",
+ inventory_complete=False,
+ )
+
+ if q_snap.fetch_error or l_snap.fetch_error:
+ err = q_snap.fetch_error or l_snap.fetch_error
+ return TrafficSnapshot(
+ project_id=project_id,
+ repo_label=repo_label,
+ runnable=(),
+ leased=(),
+ blocked=(),
+ needs_controller=(),
+ terminal_complete=(),
+ next_roles=(),
+ fetch_error=err,
+ inventory_complete=False,
+ )
+
+ # Build WorkCandidates from live queue snapshot
+ candidate_list: list[WorkCandidate] = []
+ for pr in q_snap.prs:
+ linked = int(pr.extra["linked_issue"]) if pr.extra.get("linked_issue") and pr.extra["linked_issue"].isdigit() else None
+ candidate_list.append(
+ WorkCandidate(
+ kind="pr",
+ number=pr.number,
+ state="open",
+ labels=(),
+ title=pr.title,
+ priority=10 if "request-changes" in pr.badges else 5,
+ request_changes_current_head="request-changes" in pr.badges,
+ approval_on_current_head="approved" in pr.badges or "merge-ready" in pr.badges,
+ mergeable="blocked" not in pr.badges,
+ blocked="blocked" in pr.badges,
+ linked_issue_number=linked,
+ )
+ )
+
+ for issue in q_snap.issues:
+ labels = [b for b in issue.badges if b.startswith("status:") or b in ("discussion", "blocked", "ready", "in-progress")]
+ candidate_list.append(
+ WorkCandidate(
+ kind="issue",
+ number=issue.number,
+ state="open",
+ labels=tuple(labels),
+ title=issue.title,
+ priority=20 if "status:ready" in labels else 10,
+ blocked="blocked" in labels or "status:blocked" in labels,
+ )
+ )
+
+ raw_leases: list[dict[str, Any]] = []
+ if l_snap.claim_inventory and "active_claims" in l_snap.claim_inventory:
+ raw_leases.extend(l_snap.claim_inventory["active_claims"])
+ for r_lease in l_snap.reviewer_leases:
+ raw_leases.append(r_lease)
+
+ dashboard = build_workflow_dashboard(
+ candidates=candidate_list,
+ leases=raw_leases,
+ inventory_complete=q_snap.pr_pagination.inventory_complete if q_snap.pr_pagination else True,
+ )
+
+ return _build_traffic_snapshot_from_dashboard(
+ project_id=project_id,
+ repo_label=repo_label,
+ dashboard=dashboard,
+ leases=raw_leases,
+ )
+
+
+def _build_traffic_snapshot_from_dashboard(
+ *,
+ project_id: str,
+ repo_label: str,
+ dashboard: DashboardSnapshot,
+ leases: Sequence[dict[str, Any]],
+) -> TrafficSnapshot:
+ """Classify dashboard entries into the 5 traffic state buckets."""
+ all_entries = dashboard.open_prs + dashboard.open_issues
+
+ # Map leased work numbers
+ lease_map: dict[tuple[str, int], dict[str, Any]] = {}
+ for lease in leases:
+ if isinstance(lease, dict):
+ kind = str(lease.get("kind") or lease.get("work_kind") or "issue").lower()
+ num = lease.get("number") or lease.get("work_number") or lease.get("issue_number") or lease.get("pr_number")
+ if num is not None:
+ try:
+ lease_map[(kind, int(num))] = lease
+ except (ValueError, TypeError):
+ pass
+
+ runnable: list[TrafficItem] = []
+ leased: list[TrafficItem] = []
+ blocked: list[TrafficItem] = []
+ needs_controller: list[TrafficItem] = []
+ terminal_complete: list[TrafficItem] = []
+
+ for entry in all_entries:
+ l_info = lease_map.get((entry.kind, entry.number))
+ item = _classify_traffic_item(entry, lease_info=l_info)
+
+ if item.traffic_state == "leased":
+ leased.append(item)
+ elif item.traffic_state == "terminal_complete":
+ terminal_complete.append(item)
+ elif item.traffic_state == "blocked":
+ blocked.append(item)
+ elif item.traffic_state == "needs_controller":
+ needs_controller.append(item)
+ else:
+ runnable.append(item)
+
+ next_roles = [dashboard.next_safe_by_role[r].as_dict() for r in DASHBOARD_ROLES if r in dashboard.next_safe_by_role]
+
+ return TrafficSnapshot(
+ project_id=project_id,
+ repo_label=repo_label,
+ runnable=tuple(runnable),
+ leased=tuple(leased),
+ blocked=tuple(blocked),
+ needs_controller=tuple(needs_controller),
+ terminal_complete=tuple(terminal_complete),
+ next_roles=tuple(next_roles),
+ fetch_error=None,
+ inventory_complete=dashboard.inventory_complete,
+ )
+
+
+def snapshot_to_dict(snapshot: TrafficSnapshot) -> dict[str, Any]:
+ return snapshot.as_dict()
diff --git a/webui/traffic_views.py b/webui/traffic_views.py
new file mode 100644
index 0000000..e247915
--- /dev/null
+++ b/webui/traffic_views.py
@@ -0,0 +1,170 @@
+"""HTML rendering for Phase 1 Traffic-Control View (#640)."""
+
+from __future__ import annotations
+
+from html import escape
+from typing import Sequence
+
+from webui.layout import render_page
+from webui.traffic_loader import TrafficItem, TrafficSnapshot
+
+
+def _render_badges(badges: Sequence[str]) -> str:
+ if not badges:
+ return ""
+ out = []
+ for b in badges:
+ cls = "badge"
+ b_lower = b.lower()
+ if "blocked" in b_lower or "unmet" in b_lower:
+ cls += " badge-blocked"
+ elif "claimed" in b_lower or "in-progress" in b_lower or "leased" in b_lower:
+ cls += " badge-claimed"
+ elif "review" in b_lower or "ready" in b_lower:
+ cls += " badge-in-review"
+ elif "duplicate" in b_lower:
+ cls += " badge-duplicate"
+ elif "stale" in b_lower:
+ cls += " badge-stale"
+ out.append(f'{escape(b)}')
+ return f'
{"".join(out)}
'
+
+
+def _render_traffic_item_row(item: TrafficItem) -> str:
+ kind_label = escape(item.kind.upper())
+ num_str = f"#{item.number}"
+ title_str = escape(item.title)
+ role_str = escape(item.expected_role)
+ badges_html = _render_badges(item.badges)
+
+ reason_html = ""
+ if item.block_reason:
+ reason_html = f'Blocker: {escape(item.block_reason)}
'
+
+ lease_html = ""
+ if item.lease_info:
+ owner = escape(str(item.lease_info.get("session_id") or item.lease_info.get("reviewer_identity") or "active worker"))
+ lease_html = f'Lease: {owner}
'
+
+ return f"""
+ {kind_label} {num_str} |
+
+ {title_str} {badges_html}
+ {reason_html}
+ {lease_html}
+ |
+ {role_str} |
+
"""
+
+
+def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> str:
+ if not items:
+ return f'{escape(empty_message)}
'
+
+ rows = "".join(_render_traffic_item_row(item) for item in items)
+ return f"""
+
+
+ | Item |
+ Title & Details |
+ Next Role |
+
+
+
+ {rows}
+
+
"""
+
+
+def _render_next_roles(next_roles: Sequence[dict]) -> str:
+ if not next_roles:
+ return ""
+
+ cards = []
+ for r in next_roles:
+ role = escape(r.get("role", "unknown"))
+ status = r.get("status", "idle")
+ prompt = escape(r.get("prompt", ""))
+
+ status_cls = "badge-health-ok" if status == "safe" else ("badge-blocked" if "blocked" in status else "badge-health-skipped")
+ cards.append(f"""
+
+
Role: {role}
+ status: {escape(status)}
+
+
{prompt}
+
""")
+
+ return f"""
+
Next Safe Role Actions
+ {"".join(cards)}
+"""
+
+
+def render_traffic_page(snapshot: TrafficSnapshot) -> str:
+ """Render the full HTML view for workflow traffic control."""
+ if snapshot.fetch_error:
+ body = f"""Workflow Traffic Control
+Repository: {escape(snapshot.repo_label)}
+
+
Traffic data unavailable
+
{escape(snapshot.fetch_error)}
+
Fail closed: traffic state cannot be established cleanly. Check credentials or remote connectivity.
+
"""
+ return render_page(title="Traffic Control", body_html=body)
+
+ runnable_count = len(snapshot.runnable)
+ leased_count = len(snapshot.leased)
+ blocked_count = len(snapshot.blocked)
+ controller_count = len(snapshot.needs_controller)
+ terminal_count = len(snapshot.terminal_complete)
+
+ summary_bar = f"""
+
Runnable: {runnable_count}
+
Leased: {leased_count}
+
Blocked: {blocked_count}
+
Needs Controller: {controller_count}
+
Terminal Complete: {terminal_count}
+
"""
+
+ next_roles_html = _render_next_roles(snapshot.next_roles)
+
+ sections_html = f"""
+
+
1. Runnable Lanes (Ready for Allocation)
+
Safe work items with no unmet dependencies or active leases. Safe for allocation.
+ {_render_traffic_table(snapshot.runnable, "No runnable items ready for allocation.")}
+
+
+
+
2. In-Progress Work (Active Leases)
+
Work items currently leased and actively being worked by an assigned role session.
+ {_render_traffic_table(snapshot.leased, "No active leases in flight.")}
+
+
+
+
3. Blocked Items (Dependencies / Locks)
+
Items blocked by unmet dependency issues, status:blocked, or active terminal review locks. Never presented as safe.
+ {_render_traffic_table(snapshot.blocked, "No blocked items.")}
+
+
+
+
4. Needs Controller Intervention
+
Items requiring controller routing, diagnosis, or cross-role assignment.
+ {_render_traffic_table(snapshot.needs_controller, "No items requiring controller intervention.")}
+
+
+
+
5. Terminal / Complete Candidates
+
Items ready for terminal reconciliation or post-merge worktree cleanup.
+ {_render_traffic_table(snapshot.terminal_complete, "No terminal complete candidates.")}
+
+"""
+
+ body = f"""Workflow Traffic Control
+Repository: {escape(snapshot.repo_label)}
+{summary_bar}
+{next_roles_html}
+{sections_html}"""
+
+ return render_page(title="Traffic Control", body_html=body)
--
2.43.7
From 1948d3dc2197a5726c9e34fce56b34777ecc94bc Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Fri, 24 Jul 2026 18:31:28 -0400
Subject: [PATCH 2/3] fix(webui): repair traffic live path contracts for #640
review
Address PR #885 REQUEST_CHANGES: full head_sha pins from queue signals,
reviewer leases keyed by pr_number only, claim inventory via entries,
live-path fixture tests, and traffic state vocabulary docs.
Closes #640 (re-review at new head)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/webui-local-dev.md | 25 +++
tests/test_webui_traffic_control.py | 216 +++++++++++++++++++++++++
webui/lease_loader.py | 9 +-
webui/queue_loader.py | 37 ++++-
webui/traffic_loader.py | 238 ++++++++++++++++++++++------
5 files changed, 472 insertions(+), 53 deletions(-)
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index cb2bb8b..2bbecff 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -59,6 +59,31 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
| `/api/queue` | JSON queue export with pagination metadata |
| `/traffic` | Workflow traffic-control view — runnable, leased, blocked, needs-controller, terminal-complete (#640) |
| `/api/traffic` | JSON traffic-control export with state classifications and next safe role actions |
+
+### Traffic-control state vocabulary (#640)
+
+The traffic view classifies each open issue/PR into exactly one bucket:
+
+| Bucket | Meaning | Operator implication |
+|--------|---------|----------------------|
+| **runnable** | No active lease, no block reason, safe for its expected role | Next role may start work |
+| **leased** | Active author claim or reviewer PR lease | Do not stomp; wait or adopt via role tools |
+| **blocked** | Dependency, missing head pin, conflict, or status:blocked | Author/controller remediation first |
+| **needs_controller** | Contaminated / controller-only diagnosis | Controller only |
+| **terminal_complete** | Reconciler / terminal-lock territory | Reconciler cleanup path |
+
+**Live path contracts (do not invent):**
+
+- PR head pins come from `QueueItem.signals["head_sha"]` (full SHA). Display
+ `extra["head_sha"]` is truncated and must never be used for routing.
+- Reviewer leases are keyed as `(pr, pr_number)` only — never via a linked
+ `issue_number` on the same lease marker.
+- Issue claims come from `claim_inventory["entries"]`
+ (`issue_claim_heartbeat.build_claim_inventory`). There is no `active_claims`
+ key.
+- Queue display badges are only: `blocked`, `claimed`, `duplicate`, `stale`,
+ `in-review`, `open`. Review verdicts (`request-changes`, `approved`) are
+ **not** queue badges; traffic does not invent them from the queue loader.
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
diff --git a/tests/test_webui_traffic_control.py b/tests/test_webui_traffic_control.py
index 6d85661..f4c7581 100644
--- a/tests/test_webui_traffic_control.py
+++ b/tests/test_webui_traffic_control.py
@@ -116,6 +116,222 @@ class TestTrafficLoader(unittest.TestCase):
self.assertFalse(snap.inventory_complete)
+class TestTrafficLivePath(unittest.TestCase):
+ """Live path tests: inject QueueSnapshot + LeaseSnapshot (no candidates=).
+
+ Covers the production ``load_traffic_snapshot()`` branch that ``/traffic``
+ and ``/api/traffic`` actually execute (#640 B1–B5).
+ """
+
+ FULL_SHA = "069a9af7e6aa2c2994e07199d1b0814819457017"
+
+ def _queue(
+ self,
+ *,
+ prs=(),
+ issues=(),
+ ):
+ from webui.queue_loader import QueueSnapshot
+
+ return QueueSnapshot(
+ project_id="gitea-tools",
+ repo_label="Scaled-Tech-Consulting/Gitea-Tools",
+ prs=tuple(prs),
+ issues=tuple(issues),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error=None,
+ )
+
+ def _lease(
+ self,
+ *,
+ claim_inventory=None,
+ reviewer_leases=(),
+ ):
+ from webui.lease_loader import LeaseSnapshot
+
+ return LeaseSnapshot(
+ project_id="gitea-tools",
+ repo_label="Scaled-Tech-Consulting/Gitea-Tools",
+ issue_lock=None,
+ claim_inventory=claim_inventory or {"entries": [], "counts": {}},
+ reviewer_leases=tuple(reviewer_leases),
+ duplicate_prs=(),
+ duplicate_branches=(),
+ collision_history=(),
+ fetch_error=None,
+ )
+
+ def test_live_pr_uses_full_head_sha_and_is_runnable(self):
+ from webui.queue_loader import QueueItem
+
+ pr = QueueItem(
+ number=885,
+ title="traffic control",
+ badges=("in-review",),
+ extra={"head_sha": self.FULL_SHA[:12], "linked_issue": "640"},
+ signals={
+ "head_sha": self.FULL_SHA,
+ "mergeable": True,
+ "labels": (),
+ "linked_issue": 640,
+ },
+ )
+ q = self._queue(prs=[pr])
+ l = self._lease()
+ snap = load_traffic_snapshot(
+ fetch_queue_snapshot=lambda: q,
+ fetch_lease_snapshot=lambda: l,
+ )
+ self.assertIsNone(snap.fetch_error)
+ self.assertEqual(len(snap.runnable), 1)
+ item = snap.runnable[0]
+ self.assertEqual(item.kind, "pr")
+ self.assertEqual(item.number, 885)
+ self.assertEqual(item.head_sha, self.FULL_SHA)
+ self.assertNotEqual(item.head_sha, self.FULL_SHA[:12])
+ self.assertIsNone(item.block_reason)
+ self.assertEqual(len(snap.blocked), 0)
+
+ def test_live_pr_without_head_sha_is_blocked(self):
+ from webui.queue_loader import QueueItem
+
+ pr = QueueItem(
+ number=1,
+ title="missing pin",
+ badges=("open",),
+ extra={"head_sha": ""},
+ signals={"head_sha": "", "mergeable": True, "labels": ()},
+ )
+ snap = load_traffic_snapshot(
+ fetch_queue_snapshot=lambda: self._queue(prs=[pr]),
+ fetch_lease_snapshot=lambda: self._lease(),
+ )
+ self.assertEqual(len(snap.blocked) + len(snap.needs_controller), 1)
+ item = (snap.blocked or snap.needs_controller)[0]
+ self.assertIn("missing head_sha", (item.block_reason or "").lower())
+
+ def test_reviewer_lease_keys_by_pr_not_linked_issue(self):
+ from webui.queue_loader import QueueItem
+
+ pr = QueueItem(
+ number=885,
+ title="leased pr",
+ badges=("in-review",),
+ extra={"head_sha": self.FULL_SHA[:12]},
+ signals={"head_sha": self.FULL_SHA, "mergeable": True, "labels": ()},
+ )
+ issue = QueueItem(
+ number=640,
+ title="linked issue",
+ badges=("open",),
+ extra={},
+ signals={"labels": ()},
+ )
+ # Marker-shaped record: has both pr_number and issue_number; must
+ # attach to the PR only (B2).
+ reviewer_lease = {
+ "pr_number": 885,
+ "issue_number": 640,
+ "phase": "validating",
+ "reviewer_identity": "sysadmin",
+ "session_id": "review-sess-1",
+ }
+ snap = load_traffic_snapshot(
+ fetch_queue_snapshot=lambda: self._queue(prs=[pr], issues=[issue]),
+ fetch_lease_snapshot=lambda: self._lease(reviewer_leases=[reviewer_lease]),
+ )
+ leased_prs = [i for i in snap.leased if i.kind == "pr" and i.number == 885]
+ self.assertEqual(len(leased_prs), 1)
+ self.assertEqual(leased_prs[0].lease_info.get("pr_number"), 885)
+ # Issue 640 must not inherit the reviewer lease just because issue_number
+ # is present on the marker.
+ for item in list(snap.leased) + list(snap.runnable) + list(snap.blocked):
+ if item.kind == "issue" and item.number == 640:
+ self.assertIsNone(
+ item.lease_info,
+ "reviewer lease must not attach to linked issue #640",
+ )
+ break
+ else:
+ self.fail("expected issue #640 in traffic snapshot")
+
+ def test_claim_inventory_entries_key_marks_issue_leased(self):
+ from webui.queue_loader import QueueItem
+
+ issue = QueueItem(
+ number=640,
+ title="claimed issue",
+ badges=("claimed",),
+ extra={},
+ signals={"labels": ("status:in-progress",)},
+ )
+ inventory = {
+ "entries": [
+ {
+ "issue_number": 640,
+ "status": "active",
+ "latest_heartbeat": {"session_id": "author-sess-9"},
+ "reasons": ["claim has structured heartbeat proof"],
+ }
+ ],
+ "counts": {"active": 1},
+ "in_progress_total": 1,
+ }
+ snap = load_traffic_snapshot(
+ fetch_queue_snapshot=lambda: self._queue(issues=[issue]),
+ fetch_lease_snapshot=lambda: self._lease(claim_inventory=inventory),
+ )
+ leased_issues = [i for i in snap.leased if i.kind == "issue" and i.number == 640]
+ self.assertEqual(len(leased_issues), 1)
+ self.assertEqual(leased_issues[0].traffic_state, "leased")
+
+ def test_active_claims_key_is_ignored(self):
+ """B3 regression: fictional ``active_claims`` must not create lease_info."""
+ from webui.queue_loader import QueueItem
+
+ issue = QueueItem(
+ number=640,
+ title="open issue",
+ badges=("open",),
+ extra={},
+ signals={"labels": ()},
+ )
+ # Only the broken key — must NOT produce lease_info. Entries-less
+ # inventory is empty (entries is the real claim_inventory key).
+ inventory = {
+ "active_claims": [
+ {
+ "kind": "issue",
+ "number": 640,
+ "issue_number": 640,
+ "status": "active",
+ },
+ ],
+ "counts": {},
+ }
+ snap = load_traffic_snapshot(
+ fetch_queue_snapshot=lambda: self._queue(issues=[issue]),
+ fetch_lease_snapshot=lambda: self._lease(claim_inventory=inventory),
+ )
+ items = [
+ i
+ for i in (
+ list(snap.runnable)
+ + list(snap.leased)
+ + list(snap.blocked)
+ + list(snap.needs_controller)
+ )
+ if i.kind == "issue" and i.number == 640
+ ]
+ self.assertEqual(len(items), 1)
+ self.assertIsNone(
+ items[0].lease_info,
+ "active_claims is not a real inventory key; entries-only",
+ )
+
+
class TestTrafficRoutesAndRendering(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
diff --git a/webui/lease_loader.py b/webui/lease_loader.py
index cd05d61..bd8c4f3 100644
--- a/webui/lease_loader.py
+++ b/webui/lease_loader.py
@@ -182,10 +182,17 @@ def _extract_reviewer_leases(
parsed = parse_reviewer_lease_comment(comment.get("body") or "")
if not parsed:
continue
+ subject_pr = parsed.get("pr_number") or pr_number
leases.append(
{
**parsed,
- "pr_number": parsed.get("pr_number") or pr_number,
+ "pr_number": subject_pr,
+ # The lease subject is the PR, never the linked issue: a
+ # reviewer lease on PR #N must not be attributed to issue #N
+ # or to the issue that PR closes (#640).
+ "kind": "pr",
+ "number": subject_pr,
+ "role": "reviewer",
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login"),
"created_at": comment.get("created_at"),
diff --git a/webui/queue_loader.py b/webui/queue_loader.py
index a6d35c7..135ef39 100644
--- a/webui/queue_loader.py
+++ b/webui/queue_loader.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import os
import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable
from urllib.parse import urlparse
@@ -31,10 +31,20 @@ class PaginationMeta:
@dataclass(frozen=True)
class QueueItem:
+ """One queue row.
+
+ ``extra`` holds *display* strings for the queue page (values are truncated
+ or humanized for rendering). ``signals`` holds the *authoritative* typed
+ values taken straight from the Gitea payload, for consumers that classify
+ or pin state rather than render it (#640). Never derive identity or
+ concurrency decisions from ``extra``.
+ """
+
number: int
title: str
badges: tuple[str, ...]
extra: dict[str, str]
+ signals: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -134,21 +144,37 @@ def _format_pr_item(pr: dict, badges: tuple[str, ...]) -> QueueItem:
"mergeable" if mergeable is True else "conflicted" if mergeable is False else "unknown"
)
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
+ head_sha = str(head.get("sha") or "")
+ labels = tuple(
+ str(lb.get("name") or "") for lb in (pr.get("labels") or []) if lb.get("name")
+ )
return QueueItem(
number=int(pr["number"]),
title=str(pr.get("title") or ""),
badges=badges,
extra={
"branch": f"{head.get('ref', '?')} → {base.get('ref', '?')}",
- "head_sha": str(head.get("sha") or "")[:12],
+ # Display only — truncated. Pin against signals["head_sha"] instead.
+ "head_sha": head_sha[:12],
"mergeable": merge_label,
"linked_issue": str(linked) if linked is not None else "",
},
+ signals={
+ "head_sha": head_sha,
+ "head_ref": str(head.get("ref") or ""),
+ "base_ref": str(base.get("ref") or ""),
+ "mergeable": mergeable if isinstance(mergeable, bool) else None,
+ "labels": labels,
+ "linked_issue": linked,
+ },
)
def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
- labels = ", ".join(lb.get("name", "") for lb in issue.get("labels", []))
+ label_names = tuple(
+ str(lb.get("name") or "") for lb in (issue.get("labels") or []) if lb.get("name")
+ )
+ labels = ", ".join(label_names)
assignee = (issue.get("assignee") or {}).get("login", "")
return QueueItem(
number=int(issue["number"]),
@@ -159,6 +185,11 @@ def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
"assignee": assignee or "unassigned",
"state": str(issue.get("state") or ""),
},
+ signals={
+ "labels": label_names,
+ "assignee": assignee,
+ "state": str(issue.get("state") or ""),
+ },
)
diff --git a/webui/traffic_loader.py b/webui/traffic_loader.py
index 9753d15..912d10e 100644
--- a/webui/traffic_loader.py
+++ b/webui/traffic_loader.py
@@ -8,7 +8,6 @@ and terminal-complete candidates.
from __future__ import annotations
-import os
from dataclasses import dataclass
from typing import Any, Callable, Sequence
@@ -97,13 +96,23 @@ def _classify_traffic_item(
expected_role = entry.expected_role
entry_is_safe = entry.block_reason is None and bool(entry.safe_for_roles)
+ # Lease state is checked first: an item that is both leased and blocked is
+ # reported as leased. That is safe by construction — a leased item is never
+ # placed in the runnable lane — and it keeps the operator's attention on the
+ # session that currently owns the work. The blocker text still renders.
if lease_info is not None or "in-progress" in badges or "claimed" in badges:
state = "leased"
elif expected_role == "reconciler" or "terminal-lock" in badges:
state = "terminal_complete"
elif expected_role == "controller" or "contaminated" in badges or "needs-controller" in badges:
state = "needs_controller"
- elif block_reason is not None or "blocked" in badges or "dependency-unmet" in badges or "blocked-by-terminal" in badges:
+ elif (
+ block_reason is not None
+ or "blocked" in badges
+ or "dependency-unmet" in badges
+ or "blocked-by-terminal" in badges
+ or "status:blocked" in badges
+ ):
state = "blocked"
elif entry_is_safe:
state = "runnable"
@@ -124,6 +133,156 @@ def _classify_traffic_item(
)
+# Claim statuses from ``issue_claim_heartbeat.build_claim_inventory`` that mean
+# a live worker currently holds the issue. Everything else (``stale``,
+# ``phantom``, ``reclaimable``, ``not_claimed``) is reported through the
+# dashboard's stale-lease channel and is never rendered as an active lease.
+_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
+
+# Statuses that positively mean "not an active lease" for any lease record.
+_INACTIVE_LEASE_STATUSES = frozenset(
+ {"expired", "stale", "released", "moot", "reclaimable", "phantom", "not_claimed"}
+)
+
+
+def _candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate]:
+ """Build allocator candidates from the queue loader's authoritative signals.
+
+ Display badges (``blocked``/``claimed``/``duplicate``/``stale``/
+ ``in-review``/``open``) are rendering hints, not routing state, so nothing
+ here branches on them. Every routing field comes from
+ ``QueueItem.signals`` — the raw Gitea payload values.
+
+ The queue loader reads ``/pulls`` and ``/issues`` only; it never fetches
+ review verdicts. ``request_changes_current_head`` / ``approval_on_current_head``
+ are therefore left at their fail-safe ``False`` rather than being guessed
+ from badges: an unproven approval must never route a PR to the merger.
+ """
+ candidates: list[WorkCandidate] = []
+
+ for pr in q_snap.prs:
+ signals = pr.signals or {}
+ head_sha = str(signals.get("head_sha") or "").strip()
+ mergeable = signals.get("mergeable")
+ labels = tuple(str(x) for x in (signals.get("labels") or ()))
+ candidates.append(
+ WorkCandidate(
+ kind="pr",
+ number=pr.number,
+ state="open",
+ labels=labels,
+ title=pr.title,
+ # Full 40-char SHA from head.sha — never the 12-char display value.
+ head_sha=head_sha or None,
+ priority=5,
+ mergeable=mergeable is True,
+ blocked=mergeable is False or "status:blocked" in labels,
+ )
+ )
+
+ for issue in q_snap.issues:
+ signals = issue.signals or {}
+ labels = tuple(str(x) for x in (signals.get("labels") or ()))
+ lowered = {label.lower() for label in labels}
+ candidates.append(
+ WorkCandidate(
+ kind="issue",
+ number=issue.number,
+ state="open",
+ labels=labels,
+ title=issue.title,
+ priority=20 if "status:ready" in lowered else 10,
+ blocked="status:blocked" in lowered,
+ # A live claim by another session is not this session's work.
+ already_claimed_elsewhere="status:in-progress" in lowered,
+ )
+ )
+
+ return candidates
+
+
+def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
+ """Normalize ``build_claim_inventory`` entries into lease records.
+
+ The inventory contract is ``{"entries", "counts", "heartbeat_lease_minutes",
+ "reclaim_after_minutes", "in_progress_total"}``. Each entry is keyed by
+ ``issue_number``; the subject kind is therefore always ``issue``.
+ """
+ entries = (inventory or {}).get("entries") or ()
+ records: list[dict[str, Any]] = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ number = entry.get("issue_number")
+ if number is None:
+ continue
+ try:
+ number_int = int(number)
+ except (TypeError, ValueError):
+ continue
+ heartbeat = entry.get("latest_heartbeat") or {}
+ record = dict(entry)
+ record.update(
+ {
+ "kind": "issue",
+ "number": number_int,
+ "role": "author",
+ "lease_source": "issue-claim-heartbeat",
+ }
+ )
+ if isinstance(heartbeat, dict):
+ if heartbeat.get("session_id") and not record.get("session_id"):
+ record["session_id"] = heartbeat.get("session_id")
+ if heartbeat.get("author") and not record.get("author"):
+ record["author"] = heartbeat.get("author")
+ records.append(record)
+ return records
+
+
+def _lease_subject(lease: dict[str, Any]) -> tuple[str, int] | None:
+ """Return the ``(kind, number)`` a lease record actually covers.
+
+ Fails closed: a record that does not identify exactly one subject is
+ dropped rather than attributed to a guessed work item (#640 — never invent
+ a lease, and never attach a PR lease to a same-numbered issue).
+ """
+ kind = str(lease.get("kind") or lease.get("work_kind") or "").strip().lower()
+ pr_number = lease.get("pr_number")
+ issue_number = lease.get("issue_number")
+
+ if kind not in ("pr", "issue"):
+ if pr_number is not None and issue_number is None:
+ kind = "pr"
+ elif issue_number is not None and pr_number is None:
+ kind = "issue"
+ else:
+ return None
+
+ number = lease.get("number")
+ if number is None:
+ number = lease.get("work_number")
+ if number is None:
+ number = pr_number if kind == "pr" else issue_number
+ if number is None:
+ return None
+ try:
+ return kind, int(number)
+ except (TypeError, ValueError):
+ return None
+
+
+def _is_active_lease(lease: dict[str, Any]) -> bool:
+ """True when the record proves a worker currently holds the item."""
+ if lease.get("stale") or lease.get("expired"):
+ return False
+ status = str(lease.get("status") or lease.get("lease_status") or "").strip().lower()
+ if status in _INACTIVE_LEASE_STATUSES:
+ return False
+ if lease.get("lease_source") == "issue-claim-heartbeat":
+ return status in _ACTIVE_CLAIM_STATUSES
+ return True
+
+
def load_traffic_snapshot(
*,
candidates: Sequence[WorkCandidate] | None = None,
@@ -192,45 +351,27 @@ def load_traffic_snapshot(
inventory_complete=False,
)
- # Build WorkCandidates from live queue snapshot
- candidate_list: list[WorkCandidate] = []
- for pr in q_snap.prs:
- linked = int(pr.extra["linked_issue"]) if pr.extra.get("linked_issue") and pr.extra["linked_issue"].isdigit() else None
- candidate_list.append(
- WorkCandidate(
- kind="pr",
- number=pr.number,
- state="open",
- labels=(),
- title=pr.title,
- priority=10 if "request-changes" in pr.badges else 5,
- request_changes_current_head="request-changes" in pr.badges,
- approval_on_current_head="approved" in pr.badges or "merge-ready" in pr.badges,
- mergeable="blocked" not in pr.badges,
- blocked="blocked" in pr.badges,
- linked_issue_number=linked,
- )
- )
+ candidate_list = _candidates_from_queue_snapshot(q_snap)
- for issue in q_snap.issues:
- labels = [b for b in issue.badges if b.startswith("status:") or b in ("discussion", "blocked", "ready", "in-progress")]
- candidate_list.append(
- WorkCandidate(
- kind="issue",
- number=issue.number,
- state="open",
- labels=tuple(labels),
- title=issue.title,
- priority=20 if "status:ready" in labels else 10,
- blocked="blocked" in labels or "status:blocked" in labels,
- )
- )
-
- raw_leases: list[dict[str, Any]] = []
- if l_snap.claim_inventory and "active_claims" in l_snap.claim_inventory:
- raw_leases.extend(l_snap.claim_inventory["active_claims"])
- for r_lease in l_snap.reviewer_leases:
- raw_leases.append(r_lease)
+ raw_leases: list[dict[str, Any]] = _claim_lease_records(l_snap.claim_inventory)
+ for r_lease in l_snap.reviewer_leases or ():
+ if not isinstance(r_lease, dict):
+ continue
+ # Always pin reviewer leases to the PR subject, even if a linked
+ # issue_number is present on the marker (#640 B2).
+ normalized = dict(r_lease)
+ subject = normalized.get("pr_number") or normalized.get("number")
+ if subject is None:
+ continue
+ try:
+ pr_num = int(subject)
+ except (TypeError, ValueError):
+ continue
+ normalized["kind"] = "pr"
+ normalized["number"] = pr_num
+ normalized["pr_number"] = pr_num
+ normalized.setdefault("role", "reviewer")
+ raw_leases.append(normalized)
dashboard = build_workflow_dashboard(
candidates=candidate_list,
@@ -256,17 +397,16 @@ def _build_traffic_snapshot_from_dashboard(
"""Classify dashboard entries into the 5 traffic state buckets."""
all_entries = dashboard.open_prs + dashboard.open_issues
- # Map leased work numbers
+ # Map each active lease onto the exact work item it covers. Records whose
+ # subject cannot be determined, and claims that are stale/phantom/
+ # reclaimable, are deliberately dropped instead of guessed.
lease_map: dict[tuple[str, int], dict[str, Any]] = {}
for lease in leases:
- if isinstance(lease, dict):
- kind = str(lease.get("kind") or lease.get("work_kind") or "issue").lower()
- num = lease.get("number") or lease.get("work_number") or lease.get("issue_number") or lease.get("pr_number")
- if num is not None:
- try:
- lease_map[(kind, int(num))] = lease
- except (ValueError, TypeError):
- pass
+ if not isinstance(lease, dict) or not _is_active_lease(lease):
+ continue
+ subject = _lease_subject(lease)
+ if subject is not None:
+ lease_map[subject] = lease
runnable: list[TrafficItem] = []
leased: list[TrafficItem] = []
--
2.43.7
From dac40ab9b3ef13255c4fe0f6353e6d93e0d0d885 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Fri, 24 Jul 2026 21:33:29 -0400
Subject: [PATCH 3/3] docs(webui): update traffic state vocabulary docs and app
nav for #640
---
docs/webui-local-dev.md | 54 ++++++++++++++++++++++-------------------
webui/app.py | 1 +
webui/traffic_views.py | 12 ++++-----
3 files changed, 36 insertions(+), 31 deletions(-)
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 2bbecff..d9cc022 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -59,31 +59,6 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
| `/api/queue` | JSON queue export with pagination metadata |
| `/traffic` | Workflow traffic-control view — runnable, leased, blocked, needs-controller, terminal-complete (#640) |
| `/api/traffic` | JSON traffic-control export with state classifications and next safe role actions |
-
-### Traffic-control state vocabulary (#640)
-
-The traffic view classifies each open issue/PR into exactly one bucket:
-
-| Bucket | Meaning | Operator implication |
-|--------|---------|----------------------|
-| **runnable** | No active lease, no block reason, safe for its expected role | Next role may start work |
-| **leased** | Active author claim or reviewer PR lease | Do not stomp; wait or adopt via role tools |
-| **blocked** | Dependency, missing head pin, conflict, or status:blocked | Author/controller remediation first |
-| **needs_controller** | Contaminated / controller-only diagnosis | Controller only |
-| **terminal_complete** | Reconciler / terminal-lock territory | Reconciler cleanup path |
-
-**Live path contracts (do not invent):**
-
-- PR head pins come from `QueueItem.signals["head_sha"]` (full SHA). Display
- `extra["head_sha"]` is truncated and must never be used for routing.
-- Reviewer leases are keyed as `(pr, pr_number)` only — never via a linked
- `issue_number` on the same lease marker.
-- Issue claims come from `claim_inventory["entries"]`
- (`issue_claim_heartbeat.build_claim_inventory`). There is no `active_claims`
- key.
-- Queue display badges are only: `blocked`, `claimed`, `duplicate`, `stale`,
- `in-review`, `open`. Review verdicts (`request-changes`, `approved`) are
- **not** queue badges; traffic does not invent them from the queue loader.
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
@@ -112,6 +87,35 @@ Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
local validator preview only (no Gitea mutations, no server-side storage).
+### Traffic-control state vocabulary (#640)
+
+The traffic view classifies each open issue/PR into exactly one bucket:
+
+| Bucket | Meaning | Operator implication |
+|--------|---------|----------------------|
+| **runnable** | No active lease, no block reason, safe for its expected role | Next role may start work |
+| **leased** | Active author claim or reviewer PR lease | Do not stomp; wait or adopt via role tools |
+| **blocked** | Dependency, missing head pin, conflict, or unmet dependency | Author remediation first |
+| **needs_controller** | Contaminated, controller-only diagnosis, or `status:blocked` | Controller only |
+| **terminal_complete** | Reconciler / terminal-lock territory | Reconciler cleanup path |
+
+`status:blocked` items route to **needs_controller**, not **blocked**:
+`expected_role_for_candidate` sends them to the controller, and the blocker
+reason renders in either bucket.
+
+**Live path contracts (do not invent):**
+
+- PR head pins come from `QueueItem.signals["head_sha"]` (full SHA). Display
+ `extra["head_sha"]` is truncated and must never be used for routing.
+- Reviewer leases are keyed as `(pr, pr_number)` only — never via a linked
+ `issue_number` on the same lease marker.
+- Issue claims come from `claim_inventory["entries"]`
+ (`issue_claim_heartbeat.build_claim_inventory`). There is no `active_claims`
+ key.
+- Queue display badges are only: `blocked`, `claimed`, `duplicate`, `stale`,
+ `in-review`, `open`. Review verdicts (`request-changes`, `approved`) are
+ **not** queue badges; traffic does not invent them from the queue loader.
+
## System health API (#634)
`GET /api/v1/system/health` is the structured, read-only health surface for
diff --git a/webui/app.py b/webui/app.py
index e593860..cedcef1 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -82,6 +82,7 @@ def _stub_page(title: str, description: str) -> HTMLResponse:
_LEGACY_PAGES = (
+ ("/traffic", "Traffic", "workflow traffic-control view (#640)"),
("/queue", "Queue", "live PR and issue dashboard (#429)"),
("/projects", "Projects", "registry and onboarding (#427)"),
("/prompts", "Prompts", "canonical workflow prompt library (#428)"),
diff --git a/webui/traffic_views.py b/webui/traffic_views.py
index e247915..0156b93 100644
--- a/webui/traffic_views.py
+++ b/webui/traffic_views.py
@@ -36,7 +36,7 @@ def _render_traffic_item_row(item: TrafficItem) -> str:
title_str = escape(item.title)
role_str = escape(item.expected_role)
badges_html = _render_badges(item.badges)
-
+
reason_html = ""
if item.block_reason:
reason_html = f'Blocker: {escape(item.block_reason)}
'
@@ -60,7 +60,7 @@ def _render_traffic_item_row(item: TrafficItem) -> str:
def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> str:
if not items:
return f'{escape(empty_message)}
'
-
+
rows = "".join(_render_traffic_item_row(item) for item in items)
return f"""
@@ -79,13 +79,13 @@ def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> s
def _render_next_roles(next_roles: Sequence[dict]) -> str:
if not next_roles:
return ""
-
+
cards = []
for r in next_roles:
role = escape(r.get("role", "unknown"))
status = r.get("status", "idle")
prompt = escape(r.get("prompt", ""))
-
+
status_cls = "badge-health-ok" if status == "safe" else ("badge-blocked" if "blocked" in status else "badge-health-skipped")
cards.append(f"""
@@ -94,7 +94,7 @@ def _render_next_roles(next_roles: Sequence[dict]) -> str:
{prompt}
""")
-
+
return f"""
Next Safe Role Actions
{"".join(cards)}
@@ -144,7 +144,7 @@ def render_traffic_page(snapshot: TrafficSnapshot) -> str:
3. Blocked Items (Dependencies / Locks)
-
Items blocked by unmet dependency issues, status:blocked, or active terminal review locks. Never presented as safe.
+
Items blocked by unmet dependency issues, a missing head pin, a merge conflict, or an active terminal review lock. Items labelled status:blocked route to section 4. Never presented as safe.
{_render_traffic_table(snapshot.blocked, "No blocked items.")}
--
2.43.7