"""Read-only workflow dashboard for live queue / lease / next-safe-action (#605). Builds a machine-readable + human-readable operational view so humans and LLMs can see what is safe to work on without reconstructing state from comments. Design rules: * Read-only: never assigns work. Assignment still goes through ``gitea_allocate_next_work`` (#600). * Never present blocked / terminal-locked / dependency-unmet items as safe. * Prefer pure classification so unit tests can inject inventory (including terminal-blocked queues from #593/#592/#587-style scenarios). """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable, Mapping, Sequence from allocator_service import ( ROLE_AUTHOR, ROLE_CONTROLLER, ROLE_MERGER, ROLE_RECONCILER, ROLE_REVIEWER, OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN, SKIP_CLAIMED_BY_OTHER_SESSION, WorkCandidate, classify_claim_ownership, classify_skip, expected_role_for_candidate, sort_candidates, ) DASHBOARD_VERSION = "1.0.0-issue-605" # Roles the dashboard surfaces next-safe prompts for. DASHBOARD_ROLES: tuple[str, ...] = ( ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER, ) # Exact operator prompts (fill-in tokens only — no self-selection). PROMPT_AUTHOR = ( "AUTHOR session: call gitea_allocate_next_work(apply=true, role='author') " "for {remote}/{org}/{repo}, then implement only the assigned issue under " "branches/ and open/update its PR. Do not self-select outside the allocator." ) PROMPT_REVIEWER = ( "REVIEWER session: call gitea_allocate_next_work(apply=true, role='reviewer') " "for {remote}/{org}/{repo}, pin the assigned PR head SHA, submit exactly one " "formal review verdict for that head. Do not merge." ) PROMPT_MERGER = ( "MERGER session: call gitea_allocate_next_work(apply=true, role='merger') " "for {remote}/{org}/{repo}, reassess the assigned approved head, and merge " "only that exact head via gitea_merge_pr. Do not review." ) PROMPT_RECONCILER = ( "RECONCILER session: call gitea_allocate_next_work(apply=true, role='reconciler') " "for {remote}/{org}/{repo}, then perform only the assigned terminal " "reconciliation (already-landed / post-merge cleanup). Do not approve or merge." ) PROMPT_CONTROLLER = ( "CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, " "diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule " "exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band." ) PROMPT_IDLE = ( "IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. " "Do not self-select. Re-run gitea_workflow_dashboard on the next cycle." ) PROMPT_TERMINAL_BLOCK = ( "BLOCKED by terminal-review lock on PR #{terminal_pr} for {remote}/{org}/{repo}. " "Resolve the terminal path for that exact PR before any other review/merge work. " "Do not treat other open PRs as safe." ) PROMPT_BLOCKED_ITEM = ( "NOT SAFE: {kind}#{number} is blocked ({reason}). Never present as next safe work." ) @dataclass(frozen=True) class QueueEntry: kind: str number: int title: str expected_role: str safe_for_roles: tuple[str, ...] badges: tuple[str, ...] block_reason: str | None = None head_sha: str | None = None def as_dict(self) -> dict[str, Any]: return { "kind": self.kind, "number": self.number, "title": self.title, "expected_role": self.expected_role, "safe_for_roles": list(self.safe_for_roles), "badges": list(self.badges), "block_reason": self.block_reason, "head_sha": self.head_sha, "is_safe": self.block_reason is None and bool(self.safe_for_roles), } @dataclass(frozen=True) class RoleNextAction: role: str status: str # safe | idle | blocked_terminal | none target_kind: str | None target_number: int | None head_sha: str | None prompt: str reasons: tuple[str, ...] = () def as_dict(self) -> dict[str, Any]: return { "role": self.role, "status": self.status, "target_kind": self.target_kind, "target_number": self.target_number, "head_sha": self.head_sha, "prompt": self.prompt, "reasons": list(self.reasons), "is_safe": self.status == "safe", } @dataclass class DashboardSnapshot: remote: str org: str repo: str inventory_complete: bool candidate_count: int open_prs: list[QueueEntry] = field(default_factory=list) open_issues: list[QueueEntry] = field(default_factory=list) review_ready_prs: list[QueueEntry] = field(default_factory=list) merge_ready_prs: list[QueueEntry] = field(default_factory=list) author_remediation: list[QueueEntry] = field(default_factory=list) discussion_issues: list[QueueEntry] = field(default_factory=list) blocked_items: list[QueueEntry] = field(default_factory=list) controller_needed: list[QueueEntry] = field(default_factory=list) active_leases_by_role: dict[str, list[dict[str, Any]]] = field(default_factory=dict) stale_or_expired_leases: list[dict[str, Any]] = field(default_factory=list) terminal_review_lock: dict[str, Any] | None = None next_safe_by_role: dict[str, RoleNextAction] = field(default_factory=dict) primary_next_safe_action: RoleNextAction | None = None reasons: list[str] = field(default_factory=list) dashboard_version: str = DASHBOARD_VERSION def as_dict(self) -> dict[str, Any]: return { "success": self.inventory_complete and not any( r.startswith("inventory incomplete") for r in self.reasons ), "read_only": True, "dashboard_version": self.dashboard_version, "remote": self.remote, "org": self.org, "repo": self.repo, "inventory_complete": self.inventory_complete, "candidate_count": self.candidate_count, "open_pr_queue": [e.as_dict() for e in self.open_prs], "open_issue_queue": [e.as_dict() for e in self.open_issues], "review_ready_prs": [e.as_dict() for e in self.review_ready_prs], "merge_ready_prs": [e.as_dict() for e in self.merge_ready_prs], "author_remediation": [e.as_dict() for e in self.author_remediation], "discussion_issues": [e.as_dict() for e in self.discussion_issues], "blocked_items": [e.as_dict() for e in self.blocked_items], "controller_needed": [e.as_dict() for e in self.controller_needed], "active_leases_by_role": { role: list(items) for role, items in self.active_leases_by_role.items() }, "stale_or_expired_leases": list(self.stale_or_expired_leases), "terminal_review_lock": self.terminal_review_lock, "next_safe_by_role": { role: action.as_dict() for role, action in self.next_safe_by_role.items() }, "primary_next_safe_action": ( self.primary_next_safe_action.as_dict() if self.primary_next_safe_action else None ), "reasons": list(self.reasons), "human_summary": format_human_summary(self), } def _scope_tokens(remote: str, org: str, repo: str) -> dict[str, str]: return {"remote": remote, "org": org, "repo": repo} def _prompt_for_role( role: str, *, remote: str, org: str, repo: str, terminal_pr: int | None = None, idle: bool = False, ) -> str: scope = _scope_tokens(remote, org, repo) if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER): return PROMPT_TERMINAL_BLOCK.format(terminal_pr=terminal_pr, **scope) if idle: return PROMPT_IDLE.format(role=role, **scope) templates = { ROLE_AUTHOR: PROMPT_AUTHOR, ROLE_REVIEWER: PROMPT_REVIEWER, ROLE_MERGER: PROMPT_MERGER, ROLE_RECONCILER: PROMPT_RECONCILER, ROLE_CONTROLLER: PROMPT_CONTROLLER, } return templates.get(role, PROMPT_CONTROLLER).format(**scope) def _badges_for_candidate(c: WorkCandidate, *, terminal_pr: int | None) -> tuple[str, ...]: badges: list[str] = [] if c.kind == "pr": if c.request_changes_current_head: badges.append("request-changes") if c.approval_on_current_head and c.mergeable: badges.append("merge-ready") elif c.approval_on_current_head and not c.mergeable: badges.append("approved-not-mergeable") if c.approval_stale: badges.append("approval-stale") if c.approval_contaminated: badges.append("contaminated") if not c.approval_on_current_head and not c.request_changes_current_head: badges.append("review-ready") if terminal_pr is not None and c.number == terminal_pr: badges.append("terminal-lock") if terminal_pr is not None and c.number != terminal_pr: badges.append("blocked-by-terminal") else: labels = set(c.labels) if "status:ready" in labels: badges.append("ready") if "status:in-progress" in labels: badges.append("in-progress") if "status:blocked" in labels or c.blocked: badges.append("blocked") if "discussion" in labels or "type:discussion" in labels: badges.append("discussion") if c.dependency_unmet: badges.append("dependency-unmet") if c.already_claimed_elsewhere: badges.append("claimed") if c.blocked: badges.append("blocked") # de-dupe preserve order seen: set[str] = set() out: list[str] = [] for b in badges: if b not in seen: seen.add(b) out.append(b) return tuple(out) def _entry_for_candidate( c: WorkCandidate, *, terminal_pr: int | None, claim_ownership: str | None = None, ) -> QueueEntry: expected = expected_role_for_candidate(c) badges = _badges_for_candidate(c, terminal_pr=terminal_pr) claimed_by_other = claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN) if claimed_by_other and "claimed" not in badges: badges = tuple(list(badges) + ["claimed"]) safe_roles: list[str] = [] block_reason: str | None = None # Global hard blocks (never safe for any worker role). if c.blocked or "status:blocked" in c.labels: block_reason = "status blocked" elif c.dependency_unmet: block_reason = c.dependency_reason or "unmet dependency" elif claimed_by_other: # #765: never advertise another controller's active task as safe work. block_reason = ( f"{SKIP_CLAIMED_BY_OTHER_SESSION}: active lease held by another " "controller" ) elif c.already_claimed_elsewhere: block_reason = "already claimed elsewhere" elif c.kind == "pr" and not (c.head_sha or "").strip(): block_reason = "missing head_sha pin" elif ( terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr ): # Other PRs remain visible but are not safe for review/merge while a # terminal lock is active (#593/#592/#587-style queue). block_reason = f"active terminal-review lock on PR #{terminal_pr}" if block_reason is None: # Safe only for the expected role, and only when classify_skip agrees. skip = classify_skip( c, role=expected, terminal_pr=terminal_pr, claim_ownership=claim_ownership, ) if skip is None: safe_roles.append(expected) else: block_reason = skip return QueueEntry( kind=c.kind, number=c.number, title=c.title or "", expected_role=expected, safe_for_roles=tuple(safe_roles), badges=badges, block_reason=block_reason, head_sha=c.head_sha, ) def _partition_leases( leases: Sequence[dict[str, Any]] | None, ) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]: by_role: dict[str, list[dict[str, Any]]] = {r: [] for r in DASHBOARD_ROLES} stale: list[dict[str, Any]] = [] for raw in leases or (): if not isinstance(raw, dict): continue role = str(raw.get("role") or raw.get("owner_role") or "unknown").strip().lower() status = str(raw.get("status") or raw.get("lease_status") or "active").strip().lower() entry = dict(raw) if status in ("expired", "stale", "released", "moot") or raw.get("stale") or raw.get( "expired" ): stale.append(entry) continue if role in by_role: by_role[role].append(entry) else: by_role.setdefault(role, []).append(entry) return by_role, stale def _first_safe_for_role( entries: Iterable[QueueEntry], role: str, ) -> QueueEntry | None: for entry in entries: if role in entry.safe_for_roles and entry.block_reason is None: return entry return None def _role_next_action( role: str, *, entries: Sequence[QueueEntry], remote: str, org: str, repo: str, terminal_pr: int | None, ) -> RoleNextAction: # Terminal lock blocks reviewer/merger from non-terminal work. if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER): terminal_entry = next( ( e for e in entries if e.kind == "pr" and e.number == terminal_pr and role in e.safe_for_roles ), None, ) if terminal_entry is None: return RoleNextAction( role=role, status="blocked_terminal", target_kind="pr", target_number=terminal_pr, head_sha=None, prompt=_prompt_for_role( role, remote=remote, org=org, repo=repo, terminal_pr=terminal_pr, ), reasons=( f"active terminal-review lock on PR #{terminal_pr}; " "no other review/merge target is safe", ), ) return RoleNextAction( role=role, status="safe", target_kind="pr", target_number=terminal_pr, head_sha=terminal_entry.head_sha, prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), reasons=(f"terminal-path PR #{terminal_pr} is the only safe target",), ) if role == ROLE_CONTROLLER: needed = [e for e in entries if e.expected_role == ROLE_CONTROLLER or e.block_reason] if not needed: return RoleNextAction( role=role, status="idle", target_kind=None, target_number=None, head_sha=None, prompt=_prompt_for_role( role, remote=remote, org=org, repo=repo, idle=True ), reasons=("no controller-needed items",), ) target = needed[0] return RoleNextAction( role=role, status="safe", target_kind=target.kind, target_number=target.number, head_sha=target.head_sha, prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), reasons=(target.block_reason or "controller diagnosis required",), ) hit = _first_safe_for_role(entries, role) if hit is None: return RoleNextAction( role=role, status="idle", target_kind=None, target_number=None, head_sha=None, prompt=_prompt_for_role( role, remote=remote, org=org, repo=repo, idle=True ), reasons=(f"no safe assignable work for role '{role}'",), ) return RoleNextAction( role=role, status="safe", target_kind=hit.kind, target_number=hit.number, head_sha=hit.head_sha, prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), reasons=(f"highest-ranked safe candidate for role '{role}'",), ) def build_workflow_dashboard( *, candidates: Sequence[WorkCandidate], remote: str = "prgs", org: str = "Scaled-Tech-Consulting", repo: str = "Gitea-Tools", leases: Sequence[dict[str, Any]] | None = None, terminal_pr: int | None = None, terminal_lock: dict[str, Any] | None = None, inventory_complete: bool = True, inventory_reasons: Sequence[str] | None = None, claims: Mapping[tuple[str, int], dict[str, Any]] | None = None, session_id: str | None = None, controller_instance_id: str | None = None, ) -> DashboardSnapshot: """Build a full dashboard snapshot from injected inventory (pure). *claims* (#765) maps ``(kind, number)`` to the live lease holding that work item. Items claimed by a different controller are never presented as safe next work for this one. """ reasons = [str(r) for r in (inventory_reasons or ()) if str(r).strip()] if not inventory_complete: reasons.append( "inventory incomplete: refuse to present partial queues as complete " "(fail closed, #605/#758)" ) ranked = sort_candidates(list(candidates)) entries = [ _entry_for_candidate( c, terminal_pr=terminal_pr, claim_ownership=classify_claim_ownership( (claims or {}).get((c.kind, int(c.number))), session_id=session_id, controller_instance_id=controller_instance_id, ), ) for c in ranked ] open_prs = [e for e in entries if e.kind == "pr"] open_issues = [e for e in entries if e.kind == "issue"] review_ready = [ e for e in open_prs if "review-ready" in e.badges and e.block_reason is None and ROLE_REVIEWER in e.safe_for_roles ] merge_ready = [ e for e in open_prs if "merge-ready" in e.badges and e.block_reason is None and ROLE_MERGER in e.safe_for_roles ] author_remediation = [ e for e in open_prs if "request-changes" in e.badges and e.block_reason is None and ROLE_AUTHOR in e.safe_for_roles ] discussion = [ e for e in open_issues if "discussion" in e.badges ] blocked = [e for e in entries if e.block_reason is not None] controller_needed = [ e for e in entries if e.expected_role == ROLE_CONTROLLER or (e.block_reason and "contaminated" in (e.badges or ())) or "contaminated" in e.badges ] leases_by_role, stale_leases = _partition_leases(leases) term_payload = None if terminal_lock is not None: term_payload = dict(terminal_lock) elif terminal_pr is not None: term_payload = { "active": True, "terminal_pr": terminal_pr, "state": "locked", } next_by_role: dict[str, RoleNextAction] = {} for role in DASHBOARD_ROLES: next_by_role[role] = _role_next_action( role, entries=entries, remote=remote, org=org, repo=repo, terminal_pr=terminal_pr, ) # Primary next action prefers in-flight PR work, then author issues. primary: RoleNextAction | None = None for role in (ROLE_REVIEWER, ROLE_MERGER, ROLE_AUTHOR, ROLE_RECONCILER, ROLE_CONTROLLER): action = next_by_role[role] if action.status == "safe": primary = action break if primary is None: # Prefer an explicit terminal block signal over generic idle. for role in (ROLE_REVIEWER, ROLE_MERGER): if next_by_role[role].status == "blocked_terminal": primary = next_by_role[role] break if primary is None: primary = next_by_role[ROLE_AUTHOR] # Incomplete inventory: strip all safe flags / never suggest work. if not inventory_complete: for role, action in list(next_by_role.items()): next_by_role[role] = RoleNextAction( role=role, status="none", target_kind=None, target_number=None, head_sha=None, prompt=( f"BLOCKED: inventory incomplete for {remote}/{org}/{repo}; " "do not select work. Re-run after a complete listing." ), reasons=tuple(reasons) or ("inventory incomplete",), ) primary = next_by_role[ROLE_CONTROLLER] review_ready = [] merge_ready = [] author_remediation = [] return DashboardSnapshot( remote=remote, org=org, repo=repo, inventory_complete=inventory_complete, candidate_count=len(ranked), open_prs=open_prs, open_issues=open_issues, review_ready_prs=review_ready, merge_ready_prs=merge_ready, author_remediation=author_remediation, discussion_issues=discussion, blocked_items=blocked, controller_needed=controller_needed, active_leases_by_role=leases_by_role, stale_or_expired_leases=stale_leases, terminal_review_lock=term_payload, next_safe_by_role=next_by_role, primary_next_safe_action=primary, reasons=reasons, ) def format_human_summary(snapshot: DashboardSnapshot) -> str: """Compact human-readable multi-line summary for menus and operators.""" lines: list[str] = [] lines.append( f"Workflow dashboard v{snapshot.dashboard_version} — " f"{snapshot.remote}/{snapshot.org}/{snapshot.repo}" ) lines.append( f"Inventory: complete={snapshot.inventory_complete} " f"candidates={snapshot.candidate_count}" ) if snapshot.terminal_review_lock: tpr = snapshot.terminal_review_lock.get("terminal_pr") lines.append(f"Terminal review lock: ACTIVE on PR #{tpr}") else: lines.append("Terminal review lock: none") lines.append( f"Open PRs: {len(snapshot.open_prs)} | Open issues: {len(snapshot.open_issues)}" ) lines.append( f"Review-ready: {len(snapshot.review_ready_prs)} | " f"Merge-ready: {len(snapshot.merge_ready_prs)} | " f"Author remediation: {len(snapshot.author_remediation)}" ) lines.append( f"Blocked: {len(snapshot.blocked_items)} | " f"Controller-needed: {len(snapshot.controller_needed)} | " f"Discussion: {len(snapshot.discussion_issues)}" ) active_counts = { role: len(items) for role, items in snapshot.active_leases_by_role.items() if items } if active_counts: parts = [f"{role}={n}" for role, n in sorted(active_counts.items())] lines.append("Active leases by role: " + ", ".join(parts)) else: lines.append("Active leases by role: none") lines.append( f"Stale/expired leases: {len(snapshot.stale_or_expired_leases)}" ) # Never list blocked items as safe. if snapshot.blocked_items: lines.append("Blocked (NOT safe):") for entry in snapshot.blocked_items[:12]: lines.append( f" - {entry.kind}#{entry.number}: {entry.block_reason}" ) lines.append( " " + PROMPT_BLOCKED_ITEM.format( kind=entry.kind, number=entry.number, reason=entry.block_reason or "blocked", ) ) lines.append("Next safe action by role:") for role in DASHBOARD_ROLES: action = snapshot.next_safe_by_role.get(role) if action is None: continue target = ( f"{action.target_kind}#{action.target_number}" if action.target_number is not None else "none" ) lines.append( f" - {role}: status={action.status} target={target} " f"safe={action.status == 'safe'}" ) lines.append(f" prompt: {action.prompt}") if snapshot.primary_next_safe_action: p = snapshot.primary_next_safe_action lines.append( f"Primary next: role={p.role} status={p.status} " f"target={p.target_kind}#{p.target_number if p.target_number else 'none'}" ) lines.append(f" prompt: {p.prompt}") if snapshot.reasons: lines.append("Notes:") for r in snapshot.reasons: lines.append(f" - {r}") lines.append( "Assignment still requires gitea_allocate_next_work; " "this dashboard never self-selects exclusive work." ) return "\n".join(lines)