feat(webui): Workflow traffic-control view (Phase 1) (Closes #640) #885
+29
-25
@@ -59,31 +59,6 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
|
|||||||
| `/api/queue` | JSON queue export with pagination metadata |
|
| `/api/queue` | JSON queue export with pagination metadata |
|
||||||
| `/traffic` | Workflow traffic-control view — runnable, leased, blocked, needs-controller, terminal-complete (#640) |
|
| `/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 |
|
| `/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` | Project registry list with status and onboarding progress (#427, #635) |
|
||||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||||
| `/api/v1/projects` | Versioned JSON registry export (#635) |
|
| `/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
|
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||||
local validator preview only (no Gitea mutations, no server-side storage).
|
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)
|
## System health API (#634)
|
||||||
|
|
||||||
`GET /api/v1/system/health` is the structured, read-only health surface for
|
`GET /api/v1/system/health` is the structured, read-only health surface for
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def _stub_page(title: str, description: str) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
_LEGACY_PAGES = (
|
_LEGACY_PAGES = (
|
||||||
|
("/traffic", "Traffic", "workflow traffic-control view (#640)"),
|
||||||
("/queue", "Queue", "live PR and issue dashboard (#429)"),
|
("/queue", "Queue", "live PR and issue dashboard (#429)"),
|
||||||
("/projects", "Projects", "registry and onboarding (#427)"),
|
("/projects", "Projects", "registry and onboarding (#427)"),
|
||||||
("/prompts", "Prompts", "canonical workflow prompt library (#428)"),
|
("/prompts", "Prompts", "canonical workflow prompt library (#428)"),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def _render_traffic_item_row(item: TrafficItem) -> str:
|
|||||||
title_str = escape(item.title)
|
title_str = escape(item.title)
|
||||||
role_str = escape(item.expected_role)
|
role_str = escape(item.expected_role)
|
||||||
badges_html = _render_badges(item.badges)
|
badges_html = _render_badges(item.badges)
|
||||||
|
|
||||||
reason_html = ""
|
reason_html = ""
|
||||||
if item.block_reason:
|
if item.block_reason:
|
||||||
reason_html = f'<div class="muted" style="font-size:0.82rem; margin-top:0.2rem;"><strong>Blocker:</strong> {escape(item.block_reason)}</div>'
|
reason_html = f'<div class="muted" style="font-size:0.82rem; margin-top:0.2rem;"><strong>Blocker:</strong> {escape(item.block_reason)}</div>'
|
||||||
@@ -60,7 +60,7 @@ def _render_traffic_item_row(item: TrafficItem) -> str:
|
|||||||
def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> str:
|
def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> str:
|
||||||
if not items:
|
if not items:
|
||||||
return f'<p class="muted">{escape(empty_message)}</p>'
|
return f'<p class="muted">{escape(empty_message)}</p>'
|
||||||
|
|
||||||
rows = "".join(_render_traffic_item_row(item) for item in items)
|
rows = "".join(_render_traffic_item_row(item) for item in items)
|
||||||
return f"""<table class="registry">
|
return f"""<table class="registry">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -79,13 +79,13 @@ def _render_traffic_table(items: Sequence[TrafficItem], empty_message: str) -> s
|
|||||||
def _render_next_roles(next_roles: Sequence[dict]) -> str:
|
def _render_next_roles(next_roles: Sequence[dict]) -> str:
|
||||||
if not next_roles:
|
if not next_roles:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
cards = []
|
cards = []
|
||||||
for r in next_roles:
|
for r in next_roles:
|
||||||
role = escape(r.get("role", "unknown"))
|
role = escape(r.get("role", "unknown"))
|
||||||
status = r.get("status", "idle")
|
status = r.get("status", "idle")
|
||||||
prompt = escape(r.get("prompt", ""))
|
prompt = escape(r.get("prompt", ""))
|
||||||
|
|
||||||
status_cls = "badge-health-ok" if status == "safe" else ("badge-blocked" if "blocked" in status else "badge-health-skipped")
|
status_cls = "badge-health-ok" if status == "safe" else ("badge-blocked" if "blocked" in status else "badge-health-skipped")
|
||||||
cards.append(f"""<div class="health-card" style="margin-bottom:0.75rem;">
|
cards.append(f"""<div class="health-card" style="margin-bottom:0.75rem;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
@@ -94,7 +94,7 @@ def _render_next_roles(next_roles: Sequence[dict]) -> str:
|
|||||||
</div>
|
</div>
|
||||||
<p class="meta" style="margin:0.35rem 0 0;">{prompt}</p>
|
<p class="meta" style="margin:0.35rem 0 0;">{prompt}</p>
|
||||||
</div>""")
|
</div>""")
|
||||||
|
|
||||||
return f"""<div style="margin: 1.5rem 0;">
|
return f"""<div style="margin: 1.5rem 0;">
|
||||||
<h3>Next Safe Role Actions</h3>
|
<h3>Next Safe Role Actions</h3>
|
||||||
{"".join(cards)}
|
{"".join(cards)}
|
||||||
@@ -144,7 +144,7 @@ def render_traffic_page(snapshot: TrafficSnapshot) -> str:
|
|||||||
|
|
||||||
<div class="prompt-card">
|
<div class="prompt-card">
|
||||||
<h3>3. Blocked Items (Dependencies / Locks)</h3>
|
<h3>3. Blocked Items (Dependencies / Locks)</h3>
|
||||||
<p class="muted">Items blocked by unmet dependency issues, status:blocked, or active terminal review locks. Never presented as safe.</p>
|
<p class="muted">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.</p>
|
||||||
{_render_traffic_table(snapshot.blocked, "No blocked items.")}
|
{_render_traffic_table(snapshot.blocked, "No blocked items.")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user