Skip to content

Commit ea8f869

Browse files
committed
feat: add dashboard view models over the graph and the checkpointer
M4-A. queries.py gives the console layer (deferred to later tasks) plain dicts over GraphStore's existing reads and the langgraph-checkpoint-postgres tables, so a page can render a repository, a vendor, a finding, and a live Solution Workflow run without holding a model it could mutate. The plan guessed the checkpoint thread id was the finding id; sync.cli actually uses `{finding_id}:{run_id or head_sha[:12]}:{generation}`, one generation per finished run, so workflow_state matches threads by prefix and reads the newest checkpoint across them. Node status is read off `versions_seen` against each node's `branch:to:<node>` trigger version, which is the checkpoint-row shadow of langgraph's own next-task rule and is what lets a retried node (e.g. a second patch attempt) render as current again rather than stuck done. No new GraphStore methods: overview and vendor detail compose from open_findings/get_call_site/call_site_counts, since the store has no vendor-wide or repository-wide enumeration outside a finding.
1 parent 69a3316 commit ea8f869

3 files changed

Lines changed: 585 additions & 0 deletions

File tree

src/sync/dashboard/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Read-only local dashboard over the graph and the remediation checkpoints.
2+
3+
Task 2 adds `build_app` here. This package imports from `sync.core` and
4+
`sync.graph` only, plus the checkpointer library -- never `sync.remediate`:
5+
it reads checkpoint rows, not graph code.
6+
"""

src/sync/dashboard/queries.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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

Comments
 (0)