|
| 1 | +"""View models for the dashboard: plain dicts out of GraphStore and the checkpointer. |
| 2 | +
|
| 3 | +Every function returns primitives -- dicts, lists, strings, numbers, None -- |
| 4 | +never live models. A page that received a model could lazily re-query or |
| 5 | +mutate it, and the dashboard is read-only by design. |
| 6 | +
|
| 7 | +The graph side is composed entirely from GraphStore's existing reads. The |
| 8 | +store enumerates nothing repo- or vendor-wide except through findings, so a |
| 9 | +vendor, call site, or repository with no open finding is visible here only |
| 10 | +through `call_site_counts` once a finding names its repository. That is a |
| 11 | +stated limit of this slice, not an accident. |
| 12 | +
|
| 13 | +The checkpointer side reads langgraph-checkpoint-postgres rows directly. |
| 14 | +`PostgresSaver.put` inlines primitive channel values in the `checkpoint` |
| 15 | +JSONB and splits models out to `checkpoint_blobs`, so everything a page |
| 16 | +renders -- diagnostics, outcome, abandon reason, attempt counts -- is |
| 17 | +readable without the serialiser, and the models stay where they are. |
| 18 | +
|
| 19 | +The thread-id convention is `sync.cli`'s: `{finding_id}:{run_id or |
| 20 | +head_sha[:12]}:{generation}`, one generation per finished run. The queries |
| 21 | +therefore match threads by finding-id prefix and read the newest checkpoint |
| 22 | +across them, which is the run the operator is watching. |
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +from collections import Counter |
| 28 | + |
| 29 | +import psycopg |
| 30 | +from psycopg.rows import dict_row |
| 31 | + |
| 32 | +from sync.graph.store import GraphStore |
| 33 | + |
| 34 | +# The remediation graph's node order, as `sync.remediate.graph` wires it. |
| 35 | +# Mirrored rather than imported: the constraint on this package is that it |
| 36 | +# reads checkpoint rows, never graph code, and a row cannot say the order. |
| 37 | +WORKFLOW_NODES = ( |
| 38 | + "locate", "prepare", "patch", "static_verify", |
| 39 | + "replay", "push_branch", "await_ci", "open_pr", |
| 40 | +) |
| 41 | + |
| 42 | +# `report` and `abandon` end a run rather than advance it; they can be the |
| 43 | +# pending node mid-hop but are rendered through `outcome`, not as steps. |
| 44 | +_TERMINAL_NODES = ("report", "abandon") |
| 45 | + |
| 46 | +# Which RunState keys are one node's evidence. All primitives, so all inline. |
| 47 | +_EVIDENCE_KEYS = { |
| 48 | + "locate": ("tier", "routing_row"), |
| 49 | + "prepare": ("prepare_ok", "verifiable", "verify_gap"), |
| 50 | + "patch": ("static_attempts", "attempt_strategy"), |
| 51 | + "static_verify": ("verify_ok", "diagnostics"), |
| 52 | + "replay": ("replay_outcome", "replay_reason", "replay_evidence"), |
| 53 | + "push_branch": ("branch",), |
| 54 | + "await_ci": ("ci_url", "ci_attempts", "attempt_ci_result"), |
| 55 | + "open_pr": ("pr_url", "pr_number"), |
| 56 | +} |
| 57 | + |
| 58 | +_FINISHED = ("opened", "abandoned", "reported") |
| 59 | + |
| 60 | + |
| 61 | +def _iso(moment) -> str | None: |
| 62 | + return None if moment is None else moment.isoformat() |
| 63 | + |
| 64 | + |
| 65 | +def _shallow_site(site) -> dict: |
| 66 | + return { |
| 67 | + "id": site.id, |
| 68 | + "repo_id": site.repo_id, |
| 69 | + "path": site.path, |
| 70 | + "line": site.line, |
| 71 | + "col": site.col, |
| 72 | + "symbol": site.symbol, |
| 73 | + "operation_id": site.operation_id, |
| 74 | + "sdk_version": site.sdk_version, |
| 75 | + "indexed_at": _iso(site.indexed_at), |
| 76 | + "retracted_at": _iso(site.retracted_at), |
| 77 | + } |
| 78 | + |
| 79 | + |
| 80 | +def _shallow_change(change) -> dict: |
| 81 | + return { |
| 82 | + "id": change.id, |
| 83 | + "vendor_id": change.vendor_id, |
| 84 | + "kind": change.kind, |
| 85 | + "operation_id": change.operation_id, |
| 86 | + "path_ptr": change.path_ptr, |
| 87 | + "severity": change.severity, |
| 88 | + "source": change.source, |
| 89 | + "from_version": change.from_version, |
| 90 | + "to_version": change.to_version, |
| 91 | + "detected_at": _iso(change.detected_at), |
| 92 | + } |
| 93 | + |
| 94 | + |
| 95 | +def _finding_row(finding, site) -> dict: |
| 96 | + return { |
| 97 | + "finding_id": finding.id, |
| 98 | + "detector": finding.detector, |
| 99 | + "claim": finding.claim, |
| 100 | + "severity": finding.severity, |
| 101 | + "status": finding.status, |
| 102 | + "rationale": finding.rationale, |
| 103 | + "binding_rung": finding.binding_rung, |
| 104 | + "file": site.path, |
| 105 | + "line": site.line, |
| 106 | + } |
| 107 | + |
| 108 | + |
| 109 | +def _open_findings_with_sites(store: GraphStore) -> list[tuple]: |
| 110 | + sites: dict[str, object] = {} |
| 111 | + pairs = [] |
| 112 | + for finding in store.open_findings(): |
| 113 | + if finding.call_site_id not in sites: |
| 114 | + sites[finding.call_site_id] = store.get_call_site(finding.call_site_id) |
| 115 | + pairs.append((finding, sites[finding.call_site_id])) |
| 116 | + return pairs |
| 117 | + |
| 118 | + |
| 119 | +def repository_overview(store: GraphStore) -> dict: |
| 120 | + pairs = _open_findings_with_sites(store) |
| 121 | + sites = {site.id: site for _, site in pairs} |
| 122 | + |
| 123 | + call_site_counts: Counter[str] = Counter() |
| 124 | + for repo_id in sorted({site.repo_id for site in sites.values()}): |
| 125 | + call_site_counts.update(store.call_site_counts(repo_id)) |
| 126 | + open_finding_counts = Counter(site.vendor_id for _, site in pairs) |
| 127 | + |
| 128 | + vendors = [ |
| 129 | + { |
| 130 | + "vendor_id": vendor_id, |
| 131 | + "call_site_count": call_site_counts.get(vendor_id, 0), |
| 132 | + "open_finding_count": open_finding_counts.get(vendor_id, 0), |
| 133 | + } |
| 134 | + for vendor_id in sorted(set(call_site_counts) | set(open_finding_counts)) |
| 135 | + ] |
| 136 | + indexed_at = max((site.indexed_at for site in sites.values()), default=None) |
| 137 | + return {"vendors": vendors, "indexed_at": _iso(indexed_at)} |
| 138 | + |
| 139 | + |
| 140 | +def vendor_detail(store: GraphStore, vendor_id: str) -> dict: |
| 141 | + pairs = [ |
| 142 | + (finding, site) |
| 143 | + for finding, site in _open_findings_with_sites(store) |
| 144 | + if site.vendor_id == vendor_id |
| 145 | + ] |
| 146 | + sites = {site.id: site for _, site in pairs} |
| 147 | + return { |
| 148 | + "vendor_id": vendor_id, |
| 149 | + "call_sites": [_shallow_site(site) for site in sites.values()], |
| 150 | + "changes": [_shallow_change(c) for c in store.all_vendor_changes(vendor_id)], |
| 151 | + "findings": [_finding_row(finding, site) for finding, site in pairs], |
| 152 | + } |
| 153 | + |
| 154 | + |
| 155 | +def finding_detail(store: GraphStore, finding_id: str) -> dict | None: |
| 156 | + finding = next( |
| 157 | + (f for f in store.open_findings() if f.id == finding_id), None |
| 158 | + ) |
| 159 | + if finding is None: |
| 160 | + return None |
| 161 | + site = store.get_call_site(finding.call_site_id) |
| 162 | + change = ( |
| 163 | + store.get_vendor_change(finding.vendor_change_id) |
| 164 | + if finding.vendor_change_id |
| 165 | + else None |
| 166 | + ) |
| 167 | + return { |
| 168 | + "finding": _finding_row(finding, site), |
| 169 | + "site": _shallow_site(site), |
| 170 | + "change": None if change is None else _shallow_change(change), |
| 171 | + } |
| 172 | + |
| 173 | + |
| 174 | +def _like_prefix(finding_id: str) -> str: |
| 175 | + escaped = ( |
| 176 | + finding_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") |
| 177 | + ) |
| 178 | + return f"{escaped}:%" |
| 179 | + |
| 180 | + |
| 181 | +def workflow_state(checkpointer_dsn: str, finding_id: str) -> dict | None: |
| 182 | + with psycopg.connect(checkpointer_dsn, row_factory=dict_row) as conn: |
| 183 | + # A database no run has ever checkpointed into has no tables at all; |
| 184 | + # that is the same answer as a finding with no run, not an error. |
| 185 | + if conn.execute("SELECT to_regclass('checkpoints') AS t").fetchone()["t"] is None: |
| 186 | + return None |
| 187 | + # The newest checkpoint across every thread of this finding. |
| 188 | + # `checkpoint_id` is a UUIDv6, so text order is creation order, within |
| 189 | + # a thread and across the generations `sync.cli` steps through. |
| 190 | + row = conn.execute( |
| 191 | + """ |
| 192 | + SELECT checkpoint FROM checkpoints |
| 193 | + WHERE thread_id LIKE %s AND checkpoint_ns = '' |
| 194 | + ORDER BY checkpoint_id DESC LIMIT 1 |
| 195 | + """, |
| 196 | + (_like_prefix(finding_id),), |
| 197 | + ).fetchone() |
| 198 | + if row is None: |
| 199 | + return None |
| 200 | + |
| 201 | + checkpoint = row["checkpoint"] |
| 202 | + values = checkpoint.get("channel_values") or {} |
| 203 | + versions = checkpoint.get("channel_versions") or {} |
| 204 | + seen = checkpoint.get("versions_seen") or {} |
| 205 | + |
| 206 | + outcome = values.get("outcome") |
| 207 | + current = None if outcome in _FINISHED else _pending_node(versions, seen) |
| 208 | + |
| 209 | + nodes = [] |
| 210 | + for name in WORKFLOW_NODES: |
| 211 | + if name == current: |
| 212 | + status = "current" |
| 213 | + elif name in seen: |
| 214 | + status = "done" |
| 215 | + else: |
| 216 | + status = "pending" |
| 217 | + evidence = {key: values[key] for key in _EVIDENCE_KEYS[name] if key in values} |
| 218 | + nodes.append({"name": name, "status": status, "evidence": evidence}) |
| 219 | + |
| 220 | + return { |
| 221 | + "nodes": nodes, |
| 222 | + "outcome": outcome, |
| 223 | + "abandon_reason": values.get("abandon_reason"), |
| 224 | + } |
| 225 | + |
| 226 | + |
| 227 | +def _pending_node(versions: dict, seen: dict) -> str | None: |
| 228 | + """The node the graph owes a visit: its trigger updated past what it saw. |
| 229 | +
|
| 230 | + `branch:to:<node>` is the trigger channel langgraph writes for every edge, |
| 231 | + and `versions_seen[node]` records the trigger version the node consumed |
| 232 | + when it last ran -- a newer version still in `channel_versions` means the |
| 233 | + node is due. That is the checkpointer-row shadow of Pregel's own |
| 234 | + next-task rule, and it is what makes a retry loop render honestly: a |
| 235 | + `patch` that already ran once reads as current again, not done. |
| 236 | + """ |
| 237 | + for name in (*WORKFLOW_NODES, *_TERMINAL_NODES): |
| 238 | + trigger = f"branch:to:{name}" |
| 239 | + if trigger in versions and seen.get(name, {}).get(trigger) != versions[trigger]: |
| 240 | + return name |
| 241 | + return None |
0 commit comments