code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,777 @@
1
+ """``coord release verify`` — does every deploy lane on every host actually
2
+ reflect the released version? (#1834)
3
+
4
+ The question this answers did not previously have an answer anywhere. On
5
+ 2026-08-04, hours after v0.4.105 shipped, four independent readouts all said
6
+ the fleet was on 0.4.105 and the board daemon was spawning 0.4.103. Nothing
7
+ was lying: each readout was reading a different lane, correctly, and no
8
+ command compared them.
9
+
10
+ Two design rules, both learned the expensive way, both load-bearing here:
11
+
12
+ **Report skew BETWEEN lanes, not staleness WITHIN one.** Every individual
13
+ lane was green on 2026-08-04. The defect existed only as a relationship —
14
+ daemon 0.4.105 spawning 0.4.103 — so a report that grades each lane against
15
+ an absolute and never compares them would have passed. :func:`verify`
16
+ therefore computes the lane set first and grades the *disagreement*;
17
+ ``--expected`` narrows that to "disagreement with a named version" but is
18
+ never required for the check to bite.
19
+
20
+ **Verify the running process, not the venv.** ``pip install --upgrade``
21
+ silently no-ops often enough to be a documented fleet gotcha, so a venv
22
+ reporting the right version proves nothing about what executes. The
23
+ ``spawns`` lanes (:mod:`coord.health.checks.spawned_coord`) are the ones that
24
+ read a live process; the rest read installs and are kept because a fleet can
25
+ be wrong in both ways at once.
26
+
27
+ **Read-only, always.** ``coord diagnose`` is a documented trap for having
28
+ write side effects; this command must be safe mid-flight. It issues GETs to
29
+ each agent's ``/health`` and (optionally) the daemon's ``/board`` and does
30
+ nothing else. Fixing drift is explicitly *not* its job — ``coord agent
31
+ update`` owns that lane, and an automatic ``systemctl`` write across every
32
+ host is a far bigger blast radius than detection.
33
+
34
+ **Thin-client capable.** Lane facts ride the transport that already exists:
35
+ every machine computes its own machine-scope health checks and serves them
36
+ at ``/health``, and the daemon publishes its own process-local facts in
37
+ ``/board``'s ``fleet_health`` block. Nothing here shells out over ssh, so it
38
+ works from a laptop that holds no credentials and no checkout.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass, field
44
+ from typing import Any, Callable, Iterable
45
+
46
+ # Lane severities, deliberately the same vocabulary as coord.health so a
47
+ # reader moving between `coord health` and `coord release verify` never has
48
+ # to translate. UNKNOWN outranks OK (an unverified lane is not a verified
49
+ # one) and is outranked by WARN (it must never page).
50
+ SEVERITY_RANK = {"ok": 0, "unknown": 1, "warn": 2, "crit": 3}
51
+
52
+ EXIT_OK = 0
53
+ EXIT_WARN = 1
54
+ EXIT_CRIT = 2
55
+
56
+ # How each machine-scope check id projects into a lane row. The value is the
57
+ # lane's display name template; `{machine}` is substituted. Ordering here is
58
+ # the ordering in the report.
59
+ _VERSION_LANES: tuple[tuple[str, str], ...] = (
60
+ ("agent_venv", "~/.coord-venv"),
61
+ ("cli_venv", "~/.coord-cli-venv"),
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Lane:
67
+ """One (host, lane) row: what version that lane is actually on.
68
+
69
+ ``version=None`` means "no data", which is emphatically not "agrees with
70
+ everyone else" — the whole point of #1834 is that a lane nobody can see
71
+ is the one that bites.
72
+ """
73
+
74
+ host: str
75
+ lane: str
76
+ version: str | None = None
77
+ editable: bool | None = None
78
+ # Free-text context for the report (a resolved path, a unit name).
79
+ detail: str = ""
80
+ # True for lanes that read a LIVE process rather than an install.
81
+ process: bool = False
82
+
83
+ @property
84
+ def label(self) -> str:
85
+ return f"{self.lane} ({self.host})"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Finding:
90
+ """Something wrong, named down to the host and the lane."""
91
+
92
+ severity: str
93
+ host: str
94
+ lane: str
95
+ summary: str
96
+ detail: str = ""
97
+
98
+ @property
99
+ def rank(self) -> int:
100
+ return SEVERITY_RANK.get(self.severity, 1)
101
+
102
+
103
+ @dataclass
104
+ class VerifyReport:
105
+ expected: str | None = None
106
+ lanes: list[Lane] = field(default_factory=list)
107
+ findings: list[Finding] = field(default_factory=list)
108
+ # Hosts that could not be reached at all, with the reason.
109
+ unreachable: dict[str, str] = field(default_factory=dict)
110
+
111
+ @property
112
+ def severity(self) -> str:
113
+ worst = "ok"
114
+ for f in self.findings:
115
+ if f.rank > SEVERITY_RANK[worst]:
116
+ worst = f.severity
117
+ return worst
118
+
119
+ @property
120
+ def ok(self) -> bool:
121
+ return self.severity == "ok"
122
+
123
+ @property
124
+ def exit_code(self) -> int:
125
+ sev = self.severity
126
+ if sev == "crit":
127
+ return EXIT_CRIT
128
+ if sev in ("warn", "unknown"):
129
+ return EXIT_WARN
130
+ return EXIT_OK
131
+
132
+ @property
133
+ def versions(self) -> dict[str, list[str]]:
134
+ """version -> the lane labels on it, for every lane with data."""
135
+ out: dict[str, list[str]] = {}
136
+ for lane in self.lanes:
137
+ if lane.version:
138
+ out.setdefault(lane.version, []).append(lane.label)
139
+ for labels in out.values():
140
+ labels.sort()
141
+ return out
142
+
143
+ def to_dict(self) -> dict:
144
+ return {
145
+ "schema": 1,
146
+ "expected": self.expected,
147
+ "severity": self.severity,
148
+ "exit_code": self.exit_code,
149
+ "unreachable": dict(self.unreachable),
150
+ "versions": self.versions,
151
+ "lanes": [
152
+ {
153
+ "host": lane.host,
154
+ "lane": lane.lane,
155
+ "version": lane.version,
156
+ "editable": lane.editable,
157
+ "detail": lane.detail,
158
+ "process": lane.process,
159
+ }
160
+ for lane in self.lanes
161
+ ],
162
+ "findings": [
163
+ {
164
+ "severity": f.severity,
165
+ "host": f.host,
166
+ "lane": f.lane,
167
+ "summary": f.summary,
168
+ "detail": f.detail,
169
+ }
170
+ for f in self.findings
171
+ ],
172
+ }
173
+
174
+
175
+ # ──────────────────────────────────────────────────────────────────────────
176
+ # Projection: /health payload -> lanes + findings
177
+ # ──────────────────────────────────────────────────────────────────────────
178
+
179
+
180
+ def _results(health: dict | None) -> list[dict]:
181
+ """The machine-scope check rows inside an agent's ``/health`` body.
182
+
183
+ The agent nests its health report under a ``health`` key alongside
184
+ ``version``/``tool_versions``/... (``coord/agent.py``); a payload without
185
+ one is an older agent, which is "no data", not "no findings".
186
+ """
187
+ if not health:
188
+ return []
189
+ block = health.get("health") or {}
190
+ return [r for r in (block.get("results") or []) if isinstance(r, dict)]
191
+
192
+
193
+ def _rows(health: dict | None, check_id: str) -> list[dict]:
194
+ return [r for r in _results(health) if r.get("check_id") == check_id]
195
+
196
+
197
+ def lanes_for_host(host: str, health: dict | None) -> list[Lane]:
198
+ """Every deploy lane *host* can speak for, projected from its ``/health``.
199
+
200
+ Pure and side-effect free so the whole projection is unit-testable from a
201
+ dict — the transport is the part that needs a live fleet, the judgement
202
+ is not.
203
+ """
204
+ lanes: list[Lane] = []
205
+
206
+ for check_id, name in _VERSION_LANES:
207
+ for row in _rows(health, check_id):
208
+ values = row.get("values") or {}
209
+ # cli_venv reports present=False on the many machines that never
210
+ # had one; that is a genuine absence, not a missing lane.
211
+ if check_id == "cli_venv" and not values.get("present"):
212
+ continue
213
+ if row.get("error"):
214
+ lanes.append(Lane(host=host, lane=name, version=None,
215
+ detail=str(row.get("error"))[:200]))
216
+ continue
217
+ lanes.append(
218
+ Lane(
219
+ host=host,
220
+ lane=name,
221
+ version=values.get("version") or None,
222
+ editable=values.get("editable"),
223
+ )
224
+ )
225
+
226
+ for row in _rows(health, "spawned_coord"):
227
+ values = row.get("values") or {}
228
+ unit = row.get("subject") or values.get("unit")
229
+ if not unit:
230
+ # The "no coord service running here" singleton row.
231
+ continue
232
+ if values.get("fallback"):
233
+ # No `coord` on the service PATH: coord_argv() falls back to the
234
+ # parent's own interpreter, which cannot be skewed against the
235
+ # parent. Not a lane, and not a gap.
236
+ continue
237
+ lanes.append(
238
+ Lane(
239
+ host=host,
240
+ lane=f"{unit} spawns",
241
+ version=values.get("version") or None,
242
+ editable=values.get("editable"),
243
+ detail=str(values.get("resolved") or ""),
244
+ process=True,
245
+ )
246
+ )
247
+
248
+ return lanes
249
+
250
+
251
+ def findings_for_host(host: str, health: dict | None) -> list[Finding]:
252
+ """Per-lane findings that are true regardless of what any other lane says.
253
+
254
+ Version skew is NOT computed here — it is a relationship between lanes
255
+ and belongs to :func:`verify`. What lives here is the set of defects that
256
+ are wrong on their own terms: an editable install on a service PATH, a
257
+ unit file that drifted from ``deploy/``, a stale ``coord-tui`` binary, an
258
+ unreadable lane.
259
+ """
260
+ out: list[Finding] = []
261
+
262
+ # ── editable installs ────────────────────────────────────────────────
263
+ # #1834: "any editable install on a service PATH is a finding on its own,
264
+ # independent of its current version — it is a drift amplifier that
265
+ # silently tracks a checkout nothing keeps current."
266
+ for row in _rows(health, "agent_venv"):
267
+ if (row.get("values") or {}).get("editable"):
268
+ out.append(
269
+ Finding(
270
+ severity="crit",
271
+ host=host,
272
+ lane="~/.coord-venv",
273
+ summary="agent venv is an EDITABLE install",
274
+ detail=(
275
+ "this machine runs whatever branch its checkout is "
276
+ "parked on; no release can account for its behaviour"
277
+ ),
278
+ )
279
+ )
280
+
281
+ for row in _rows(health, "spawned_coord"):
282
+ values = row.get("values") or {}
283
+ unit = row.get("subject") or values.get("unit") or "unit"
284
+ if values.get("editable"):
285
+ out.append(
286
+ Finding(
287
+ severity="crit",
288
+ host=host,
289
+ lane=f"{unit} spawns",
290
+ summary=f"{unit} would spawn an EDITABLE checkout",
291
+ detail=(
292
+ f"{values.get('resolved') or 'coord'} resolves to "
293
+ f"{values.get('module_file') or 'a checkout'} on this "
294
+ "unit's live PATH — a drift amplifier regardless of "
295
+ "the version it happens to report today"
296
+ ),
297
+ )
298
+ )
299
+ elif row.get("severity") == "unknown" and not values.get("fallback"):
300
+ out.append(
301
+ Finding(
302
+ severity="unknown",
303
+ host=host,
304
+ lane=f"{unit} spawns",
305
+ summary=f"could not read what {unit} would spawn",
306
+ detail=str(row.get("headroom") or row.get("error") or ""),
307
+ )
308
+ )
309
+
310
+ # ── unit files vs the packaged release units (#1831, #1927, folded in
311
+ # per #1834's lane 3) ────────────────────────────────────────────────
312
+ # UNKNOWN rows are findings too (#1927): the machine-scope check grades a
313
+ # match against an *unverified* reference — a working copy nothing keeps
314
+ # current — as UNKNOWN rather than OK, because a stale checkout and a
315
+ # stale installed unit go stale together and agree. Dropping those rows
316
+ # here would restore precisely the false green this fold-in exists to
317
+ # surface. UNKNOWN outranks OK and is outranked by WARN, so it annotates
318
+ # the report without paging.
319
+ for row in _rows(health, "unit_drift"):
320
+ sev = row.get("severity")
321
+ if sev in ("crit", "warn", "unknown"):
322
+ out.append(
323
+ Finding(
324
+ severity=str(sev),
325
+ host=host,
326
+ lane=f"unit {row.get('subject') or '?'}",
327
+ summary=str(row.get("headroom") or "unit drift"),
328
+ detail=str(row.get("detail") or ""),
329
+ )
330
+ )
331
+
332
+ # ── coord-tui, until PKG-3/PKG-4 give it a real channel ──────────────
333
+ # #2102's grading rule: staleness (this binary's mtime vs. its `tui/`
334
+ # source tree — see coord.health.checks.deploy_lane_facts), never a
335
+ # version compared against `expected`/`--pypi`. A `tui/`-only range now
336
+ # publishes no PyPI wheel, so PyPI's "latest" stays behind a fresh
337
+ # `coord tui update` on purpose — grading coord-tui's version against it
338
+ # would read every such release as "ahead of expected" forever. This is
339
+ # also why `lanes_for_host` never emits a `coord-tui` Lane at all: a
340
+ # WARN here is a real, actionable finding, but it must never enter
341
+ # `report.versions`' skew map. If coord-tui ever gets a real
342
+ # installed-version lane, grade it against the latest GitHub Release
343
+ # tag — PyPI cannot see a tui-only release.
344
+ for row in _rows(health, "tui_binary"):
345
+ if row.get("severity") == "warn":
346
+ out.append(
347
+ Finding(
348
+ severity="warn",
349
+ host=host,
350
+ lane="coord-tui",
351
+ summary=str(row.get("headroom") or "tui binary is stale"),
352
+ detail=str(row.get("detail") or ""),
353
+ )
354
+ )
355
+
356
+ # ── webapp bundle vs coord/dashboard/webapp/ source (#1834 lane 5) ───
357
+ # Deliberately not a Lane/version comparison — see release_verify.py's
358
+ # module docstring and coord.health.checks.fleet_deploy_lanes: the
359
+ # bundle is versioned by origin/main's SHA on a continuous publish
360
+ # timer (#1543), never by the pip release every other lane compares
361
+ # against, so folding it into `versions` would manufacture permanent,
362
+ # meaningless skew rather than report a real one.
363
+ for row in _rows(health, "webapp_bundle"):
364
+ if row.get("severity") == "warn":
365
+ out.append(
366
+ Finding(
367
+ severity="warn",
368
+ host=host,
369
+ lane="webapp bundle",
370
+ summary=str(row.get("headroom") or "webapp bundle is stale"),
371
+ detail=str(row.get("detail") or ""),
372
+ )
373
+ )
374
+
375
+ return out
376
+
377
+
378
+ #: The systemd user unit that IS the daemon. A machine whose ``spawned_coord``
379
+ #: check reports a row for this unit is, by definition, the machine running
380
+ #: ``coord-serve`` — that check only emits a row per unit it found *running*
381
+ #: (see :func:`coord.health.checks.spawned_coord.running_unit_pids`).
382
+ DAEMON_UNIT = "coord-serve"
383
+
384
+
385
+ def daemon_host_from_health(machine_health: dict[str, dict | None]) -> str | None:
386
+ """Which machine actually runs ``coord-serve``, derived — never guessed.
387
+
388
+ #2052 fault 2: ``coord release propagate`` could not identify the daemon
389
+ host and fell back to ``coordinator.yml`` order, which during a partial
390
+ revert put the daemon *behind* its callers — precisely the 405 hazard the
391
+ lane order exists to prevent. The daemon host is not a mystery: it is the
392
+ machine with a running ``coord-serve``, and every agent already publishes
393
+ exactly that fact in its own ``/health`` (``spawned_coord`` emits one row
394
+ per *running* coord unit).
395
+
396
+ Returns ``None`` when nobody reports one **or when more than one machine
397
+ does** — two live daemons is a fault in its own right, and a caller that
398
+ must order a roll around "the" daemon should refuse rather than pick one.
399
+ """
400
+ found = [
401
+ host
402
+ for host in sorted(machine_health)
403
+ if any(
404
+ (row.get("subject") or (row.get("values") or {}).get("unit")) == DAEMON_UNIT
405
+ for row in _rows(machine_health[host], "spawned_coord")
406
+ )
407
+ ]
408
+ return found[0] if len(found) == 1 else None
409
+
410
+
411
+ def daemon_lanes(daemon_host: dict | None, *, host: str = "daemon") -> list[Lane]:
412
+ """The lanes only the ``coord-serve`` process itself can report.
413
+
414
+ ``coord_serve_version`` is genuinely process-local: it is the daemon
415
+ introspecting its own interpreter, which no other machine can do for it
416
+ (that conflation is what #1806 was about). It arrives via ``/board``'s
417
+ ``fleet_health.daemon_host`` block.
418
+ """
419
+ if not daemon_host:
420
+ return []
421
+ if not {"coord_serve_version", "coord_serve_editable"} & set(daemon_host):
422
+ return []
423
+ version = daemon_host.get("coord_serve_version")
424
+ editable = daemon_host.get("coord_serve_editable")
425
+ # A published-but-null version is a lane with no data, NOT an absent lane:
426
+ # it still emits an UNKNOWN row below rather than vanishing from the table.
427
+ return [
428
+ Lane(
429
+ host=host,
430
+ lane="coord-serve process",
431
+ version=version or None,
432
+ editable=editable,
433
+ process=True,
434
+ )
435
+ ]
436
+
437
+
438
+ # ──────────────────────────────────────────────────────────────────────────
439
+ # The verdict
440
+ # ──────────────────────────────────────────────────────────────────────────
441
+
442
+
443
+ def verify(
444
+ *,
445
+ machine_health: dict[str, dict | None],
446
+ daemon_host: dict | None = None,
447
+ unreachable: dict[str, str] | None = None,
448
+ expected: str | None = None,
449
+ daemon_host_name: str = "daemon",
450
+ ) -> VerifyReport:
451
+ """Grade a whole fleet's deploy lanes. Pure: no I/O, no config, no clock.
452
+
453
+ *machine_health* maps machine name -> that machine's ``/health`` body (or
454
+ ``None`` when it answered with nothing usable). *unreachable* maps
455
+ machine name -> why, for hosts that did not answer at all.
456
+
457
+ An unreachable host is UNKNOWN, never OK: #1834's whole thesis is that a
458
+ lane nobody looked at is the one that drifts, so "we could not ask" must
459
+ not render as "verified".
460
+ """
461
+ report = VerifyReport(expected=expected, unreachable=dict(unreachable or {}))
462
+
463
+ for host in sorted(machine_health):
464
+ health = machine_health[host]
465
+ report.lanes.extend(lanes_for_host(host, health))
466
+ report.findings.extend(findings_for_host(host, health))
467
+
468
+ report.lanes.extend(daemon_lanes(daemon_host, host=daemon_host_name))
469
+
470
+ for host, reason in sorted(report.unreachable.items()):
471
+ report.findings.append(
472
+ Finding(
473
+ severity="unknown",
474
+ host=host,
475
+ lane="(all lanes)",
476
+ summary="host unreachable — its lanes are unverified",
477
+ detail=reason,
478
+ )
479
+ )
480
+
481
+ # ── lanes with no data ───────────────────────────────────────────────
482
+ for lane in report.lanes:
483
+ if lane.version is None:
484
+ report.findings.append(
485
+ Finding(
486
+ severity="unknown",
487
+ host=lane.host,
488
+ lane=lane.lane,
489
+ summary="no version reported for this lane",
490
+ detail=lane.detail,
491
+ )
492
+ )
493
+
494
+ if not report.lanes and not report.unreachable:
495
+ report.findings.append(
496
+ Finding(
497
+ severity="unknown",
498
+ host="(fleet)",
499
+ lane="(all lanes)",
500
+ summary="no host reported a single deploy lane",
501
+ detail=(
502
+ "every machine answered, none had health results — check "
503
+ "that the agents are running a release with the health "
504
+ "engine (#1628) enabled"
505
+ ),
506
+ )
507
+ )
508
+
509
+ # ── the relationship, which is the actual check ──────────────────────
510
+ versions = report.versions
511
+ if expected:
512
+ for version, labels in sorted(versions.items()):
513
+ if version == expected:
514
+ continue
515
+ report.findings.append(
516
+ Finding(
517
+ severity="crit",
518
+ host=_hosts_of(report, labels),
519
+ lane=", ".join(labels),
520
+ summary=f"on {version}, expected {expected}",
521
+ detail=(
522
+ "the released version is not what this lane is "
523
+ "actually running"
524
+ ),
525
+ )
526
+ )
527
+ else:
528
+ # No --expected: skew alone is the finding. This is the 2026-08-04
529
+ # shape — nobody knew what to expect, but two lanes disagreeing was
530
+ # already conclusive.
531
+ if len(versions) > 1:
532
+ spread = "; ".join(
533
+ f"{v}: {', '.join(labels)}" for v, labels in sorted(versions.items())
534
+ )
535
+ report.findings.append(
536
+ Finding(
537
+ severity="crit",
538
+ host="(fleet)",
539
+ lane="(version skew)",
540
+ summary=f"{len(versions)} versions live across the fleet",
541
+ detail=spread,
542
+ )
543
+ )
544
+ elif versions:
545
+ # #2035 item 4, demonstrated by #2052: after a botched
546
+ # propagation reverted the whole fleet to 0.4.104 while `main`
547
+ # was four releases ahead, this command reported `crit=0` — it
548
+ # compares the fleet against *itself*, so **uniform staleness
549
+ # reads as health**. A skew-only run can never see that, and
550
+ # must therefore never render as a clean bill of health.
551
+ report.findings.append(
552
+ Finding(
553
+ severity="unknown",
554
+ host="(fleet)",
555
+ lane="(expected version)",
556
+ summary=(
557
+ "no expected version to grade against — a fleet that "
558
+ "is uniformly BEHIND reads as clean here"
559
+ ),
560
+ detail=(
561
+ f"every lane agrees on {next(iter(sorted(versions)))}, "
562
+ "but nothing here knows whether that is the released "
563
+ "version. Pass --expected vX.Y.Z, or --pypi to resolve "
564
+ "it from the simple index."
565
+ ),
566
+ )
567
+ )
568
+
569
+ report.findings.sort(key=lambda f: (-f.rank, f.host, f.lane))
570
+ return report
571
+
572
+
573
+ def _hosts_of(report: VerifyReport, labels: Iterable[str]) -> str:
574
+ wanted = set(labels)
575
+ hosts = sorted({lane.host for lane in report.lanes if lane.label in wanted})
576
+ return ", ".join(hosts) if hosts else "(fleet)"
577
+
578
+
579
+ # ──────────────────────────────────────────────────────────────────────────
580
+ # Transport (thin-client capable; every call is a GET)
581
+ # ──────────────────────────────────────────────────────────────────────────
582
+
583
+
584
+ def gather(
585
+ config: Any,
586
+ *,
587
+ timeout: float = 5.0,
588
+ machine_filter: str | None = None,
589
+ check_machine: Callable[..., Any] | None = None,
590
+ board_payload: Callable[[], dict] | None = None,
591
+ ) -> tuple[dict[str, dict | None], dict[str, str], dict | None, str]:
592
+ """Poll the fleet. Returns ``(machine_health, unreachable, daemon_host,
593
+ daemon_host_name)``.
594
+
595
+ Injectable seams (*check_machine*, *board_payload*) exist so the whole
596
+ command can be driven in tests without a live fleet — and so this module
597
+ never has to care whether the board came from a local DB or from a
598
+ daemon over Tailscale.
599
+ """
600
+ from coord import network # noqa: PLC0415 — import cycle at module scope
601
+
602
+ probe = check_machine or network.check_machine
603
+
604
+ machines = list(getattr(config, "machines", ()) or ())
605
+ if machine_filter:
606
+ machines = [m for m in machines if m.name == machine_filter]
607
+
608
+ health: dict[str, dict | None] = {}
609
+ unreachable: dict[str, str] = {}
610
+ for machine in machines:
611
+ try:
612
+ status = probe(machine, timeout=timeout)
613
+ except Exception as exc: # noqa: BLE001 — a probe must never abort the sweep
614
+ unreachable[machine.name] = f"{type(exc).__name__}: {exc}"
615
+ continue
616
+ if not getattr(status, "is_online", False):
617
+ unreachable[machine.name] = (
618
+ getattr(status, "reason", None) or getattr(status, "state", "offline")
619
+ )
620
+ continue
621
+ health[machine.name] = getattr(status, "health", None)
622
+
623
+ daemon_host, daemon_name = _daemon_facts(board_payload)
624
+ # #2052 fault 2: label the daemon's lane with the machine that actually
625
+ # runs it whenever the fleet's own health says which one that is. The
626
+ # literal "daemon" placeholder is a last resort, not a name — a lane
627
+ # labelled after nothing cannot be matched to a host by anything
628
+ # downstream (which is how propagation ended up guessing at config order).
629
+ derived = daemon_host_from_health(health)
630
+ if derived:
631
+ daemon_name = derived
632
+ return health, unreachable, daemon_host, daemon_name
633
+
634
+
635
+ #: The lane name `coord.health.checks.fleet_deploy_lanes` publishes for the
636
+ #: daemon's own install. Matched by string because that is the only contract
637
+ #: `/board` offers; `tests/test_release_verify.py` pins the two together so a
638
+ #: rename over there fails here loudly instead of silently dropping the lane.
639
+ DAEMON_SERVE_LANE = "coord-serve (daemon host)"
640
+
641
+ #: Deliberately NOT the per-host `--timeout`. `/board` is a multi-megabyte
642
+ #: read on a real fleet and routinely takes several seconds; a recorded
643
+ #: operational gotcha is that a 5s budget makes a healthy daemon look
644
+ #: unreachable. Timing out here would silently drop the `coord-serve process`
645
+ #: lane — precisely the lane #1834 exists to stop losing — so it gets its own,
646
+ #: generous budget.
647
+ _BOARD_TIMEOUT = 30.0
648
+
649
+
650
+ def _default_board_fetch() -> dict:
651
+ """``GET /board``, from a thin client *or* from the daemon host itself.
652
+
653
+ A thin client has ``board_service`` configured and reads it over
654
+ Tailscale. On the daemon host ``resolve_board_service()`` returns None
655
+ (host mode reads the DB directly) — and without the loopback fallback
656
+ below, running ``coord release verify`` *on the daemon host* would
657
+ silently drop the ``coord-serve process`` lane, which is exactly the lane
658
+ #1834 exists to stop losing. The daemon's own version cannot be read any
659
+ other way from a sibling process: it is introspection of a running
660
+ interpreter (#1806), so ``/board`` is the only publisher.
661
+ """
662
+ from coord.client import ( # noqa: PLC0415
663
+ ServiceConfig,
664
+ fetch_board_payload,
665
+ resolve_board_service,
666
+ )
667
+
668
+ svc = resolve_board_service()
669
+ if svc is None:
670
+ from coord.commands._common import SERVE_PORT # noqa: PLC0415
671
+ from coord.serve_app import resolve_serve_token # noqa: PLC0415
672
+
673
+ svc = ServiceConfig(
674
+ url=f"http://127.0.0.1:{SERVE_PORT}", token=resolve_serve_token()
675
+ )
676
+ return fetch_board_payload(svc, timeout=_BOARD_TIMEOUT)
677
+
678
+
679
+ def _daemon_facts(board_payload: Callable[[], dict] | None) -> tuple[dict | None, str]:
680
+ """The ``coord-serve`` process's own version, read out of ``/board``.
681
+
682
+ ``coord-serve``'s version can only be introspected from the process
683
+ actually running it (#1806) — no other machine's ``/health`` can speak
684
+ for it. The daemon gathers it into its fleet snapshot and publishes it
685
+ inside ``fleet_health.fleet_checks``' ``fleet_deploy_lanes`` row, which
686
+ is the only place a thin client can read it from. (The richer internal
687
+ ``daemon_host`` fact block, which also carries ``coord_serve_editable``,
688
+ is deliberately not on the wire; editability of the daemon's own install
689
+ therefore reads as unknown from a thin client rather than as ``False``.)
690
+
691
+ Absent on a fleet whose daemon predates the health engine, and absent in
692
+ a host-mode run with no daemon configured. Both are "no data" for the
693
+ ``coord-serve process`` lane, which :func:`verify` reports as UNKNOWN
694
+ rather than skipping — a daemon nobody could ask is exactly the lane
695
+ 2026-08-04 hid in.
696
+ """
697
+ fetch = board_payload or _default_board_fetch
698
+
699
+ try:
700
+ payload = fetch() or {}
701
+ except Exception: # noqa: BLE001 — read-only, best effort, never fatal
702
+ return None, "daemon"
703
+
704
+ fleet_health = payload.get("fleet_health") or {}
705
+ for row in fleet_health.get("fleet_checks") or []:
706
+ if not isinstance(row, dict) or row.get("check_id") != "fleet_deploy_lanes":
707
+ continue
708
+ lanes = (row.get("values") or {}).get("lanes") or {}
709
+ if DAEMON_SERVE_LANE in lanes:
710
+ return {"coord_serve_version": lanes[DAEMON_SERVE_LANE]}, "daemon"
711
+ return None, "daemon"
712
+
713
+
714
+ # ──────────────────────────────────────────────────────────────────────────
715
+ # Rendering
716
+ # ──────────────────────────────────────────────────────────────────────────
717
+
718
+ _SEVERITY_MARK = {"ok": "OK ", "unknown": "? ", "warn": "WARN", "crit": "CRIT"}
719
+
720
+
721
+ def render(report: VerifyReport, *, verbose: bool = False) -> str:
722
+ """A per-lane report. Lanes first, then findings, worst first.
723
+
724
+ The lane table is printed even on success, deliberately: the failure mode
725
+ this command exists for is a readout that says "fine" while hiding the
726
+ lane it never looked at, so the set of lanes actually inspected is part
727
+ of the answer, not debug output.
728
+ """
729
+ lines: list[str] = []
730
+
731
+ if report.expected:
732
+ lines.append(f"expected version: {report.expected}")
733
+
734
+ lines.append("lanes:")
735
+ if not report.lanes:
736
+ lines.append(" (none)")
737
+ for lane in sorted(report.lanes, key=lambda l: (l.host, l.lane)):
738
+ version = lane.version or "?"
739
+ marks = []
740
+ if lane.process:
741
+ marks.append("live process")
742
+ if lane.editable:
743
+ marks.append("EDITABLE")
744
+ if lane.detail and verbose:
745
+ marks.append(lane.detail)
746
+ suffix = f" [{', '.join(marks)}]" if marks else ""
747
+ lines.append(f" {lane.host:<14} {lane.lane:<26} {version}{suffix}")
748
+
749
+ versions = report.versions
750
+ if len(versions) > 1:
751
+ lines.append("")
752
+ lines.append("SKEW:")
753
+ for version, labels in sorted(versions.items()):
754
+ lines.append(f" {version}: {', '.join(labels)}")
755
+
756
+ if report.findings:
757
+ lines.append("")
758
+ lines.append("findings:")
759
+ for f in report.findings:
760
+ lines.append(
761
+ f" {_SEVERITY_MARK.get(f.severity, '? ')} "
762
+ f"{f.host}/{f.lane}: {f.summary}"
763
+ )
764
+ if f.detail:
765
+ lines.append(f" {f.detail}")
766
+
767
+ lines.append("")
768
+ counts = {sev: 0 for sev in SEVERITY_RANK}
769
+ for f in report.findings:
770
+ counts[f.severity] = counts.get(f.severity, 0) + 1
771
+ lines.append(
772
+ f"RELEASE VERIFY: {report.severity.upper()} "
773
+ f"crit={counts.get('crit', 0)} warn={counts.get('warn', 0)} "
774
+ f"unknown={counts.get('unknown', 0)} "
775
+ f"lanes={len(report.lanes)} hosts={len({l.host for l in report.lanes})}"
776
+ )
777
+ return "\n".join(lines)