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
coord/health/models.py ADDED
@@ -0,0 +1,250 @@
1
+ """Value types for the fleet-health check engine (#1628).
2
+
3
+ The whole point of this module is :class:`CheckResult`. A result carries
4
+ **both** the raw values a probe measured **and** the already-rendered
5
+ headroom string — because the moment a renderer starts re-deriving "is 86%
6
+ used bad?" from ``values["used_pct"]``, the severity logic has forked and
7
+ every new surface (the CLI here, the board projection in H-3, the TUI/web
8
+ renderers in H-4) gets to disagree about what WARN means.
9
+
10
+ So the contract is:
11
+
12
+ * A **probe** owns thresholds. It is the only thing allowed to turn raw
13
+ numbers into a :class:`Severity`.
14
+ * A **renderer** owns layout. It may reorder, truncate, colour, or drop
15
+ results; it may NOT look at ``values`` to decide severity, and it may not
16
+ reformat ``headroom`` from the raw numbers.
17
+ * ``values`` exists for machine consumers (trend series, alert routing,
18
+ whoever comes next) — never as a second source of truth for "how bad".
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ class Severity(str, Enum):
30
+ """How much headroom is left, worst-first when sorted by :attr:`rank`.
31
+
32
+ ``UNKNOWN`` is deliberately not "fine": a probe that could not run tells
33
+ you nothing, and treating that as OK is how a silently-broken check
34
+ becomes indistinguishable from a healthy machine. It ranks *above* OK
35
+ (so it surfaces) and *below* WARN (so it never pages).
36
+ """
37
+
38
+ OK = "ok"
39
+ UNKNOWN = "unknown"
40
+ WARN = "warn"
41
+ CRIT = "crit"
42
+
43
+ @property
44
+ def rank(self) -> int:
45
+ return _SEVERITY_RANK[self]
46
+
47
+ @property
48
+ def label(self) -> str:
49
+ """Fixed-width-ish display token: ``OK`` / ``WARN`` / ``CRIT`` / ``?``."""
50
+ return "?" if self is Severity.UNKNOWN else self.value.upper()
51
+
52
+ def __lt__(self, other: object) -> bool: # type: ignore[override]
53
+ if isinstance(other, Severity):
54
+ return self.rank < other.rank
55
+ return NotImplemented
56
+
57
+
58
+ _SEVERITY_RANK = {
59
+ Severity.OK: 0,
60
+ Severity.UNKNOWN: 1,
61
+ Severity.WARN: 2,
62
+ Severity.CRIT: 3,
63
+ }
64
+
65
+ # The three scopes a check can answer for. ``fleet`` is defined here (so the
66
+ # registry shape is settled — that is this child's whole job) but no seed
67
+ # probe uses it; fleet-scope probes land in H-3.
68
+ SCOPES = ("machine", "checkout", "fleet")
69
+
70
+
71
+ def worst(severities: "list[Severity] | tuple[Severity, ...]") -> Severity:
72
+ """The most severe of *severities* (``OK`` when empty)."""
73
+ out = Severity.OK
74
+ for s in severities:
75
+ if s.rank > out.rank:
76
+ out = s
77
+ return out
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class CheckResult:
82
+ """One answer to "how much headroom is left?".
83
+
84
+ ``headroom`` is the load-bearing field for every renderer: a short,
85
+ already-formatted phrase such as ``"86% used (22G free)"`` or
86
+ ``"128.8h stale, hooks disabled -> will not self-heal"``. It is produced
87
+ by the probe, which is also the thing that chose ``severity`` — the two
88
+ can never disagree because nothing downstream recomputes either.
89
+ """
90
+
91
+ check_id: str
92
+ scope: str
93
+ severity: Severity
94
+ headroom: str
95
+ # Display prefix, e.g. "disk" / "cargo targets" / "graph". Defaults to
96
+ # ``check_id`` when a probe doesn't override it.
97
+ title: str = ""
98
+ # What this result is *about* within the check: "/home", "vimcode", ...
99
+ # ``None`` for whole-machine singletons like the claude-binary check.
100
+ subject: str | None = None
101
+ # Rendered threshold reminder, e.g. "crit at 93%". Optional.
102
+ threshold: str = ""
103
+ # Extra rendered context shown under/after the headroom. Optional.
104
+ detail: str = ""
105
+ # Rendered trend, where a probe can get one cheaply ("+4G since 6h ago").
106
+ trend: str | None = None
107
+ # Raw measurements, for machine consumers only. NOT a severity source.
108
+ values: dict[str, Any] = field(default_factory=dict)
109
+ # Set iff the probe failed soft; ``severity`` is then UNKNOWN.
110
+ error: str | None = None
111
+
112
+ def __post_init__(self) -> None:
113
+ if not self.title:
114
+ object.__setattr__(self, "title", self.check_id)
115
+
116
+ @property
117
+ def key(self) -> str:
118
+ """Stable identity for this row: ``<check_id>`` or ``<check_id>:<subject>``."""
119
+ return f"{self.check_id}:{self.subject}" if self.subject else self.check_id
120
+
121
+ @property
122
+ def label(self) -> str:
123
+ """Human row label, e.g. ``"disk /home"`` or ``"claude binary"``."""
124
+ return f"{self.title} {self.subject}" if self.subject else self.title
125
+
126
+ def to_dict(self) -> dict[str, Any]:
127
+ """The JSON contract H-3/H-4 consume. Keys here are load-bearing."""
128
+ return {
129
+ "key": self.key,
130
+ "check_id": self.check_id,
131
+ "scope": self.scope,
132
+ "subject": self.subject,
133
+ "title": self.title,
134
+ "label": self.label,
135
+ "severity": self.severity.value,
136
+ "headroom": self.headroom,
137
+ "threshold": self.threshold,
138
+ "detail": self.detail,
139
+ "trend": self.trend,
140
+ "values": dict(self.values),
141
+ "error": self.error,
142
+ }
143
+
144
+
145
+ def unknown_result(
146
+ check_id: str,
147
+ *,
148
+ scope: str,
149
+ error: str,
150
+ title: str = "",
151
+ subject: str | None = None,
152
+ ) -> CheckResult:
153
+ """A fail-soft placeholder: the probe blew up, the run carries on.
154
+
155
+ Never raise out of a probe — a health engine that dies on its own
156
+ weakest check reports nothing at all, which is strictly worse than
157
+ reporting eight checks and one ``?``.
158
+ """
159
+ return CheckResult(
160
+ check_id=check_id,
161
+ scope=scope,
162
+ severity=Severity.UNKNOWN,
163
+ headroom=f"probe failed: {error}",
164
+ title=title or check_id,
165
+ subject=subject,
166
+ error=error,
167
+ )
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class Checkout:
172
+ """One local git checkout a ``checkout``-scoped probe can run against."""
173
+
174
+ name: str
175
+ path: Path
176
+ default_branch: str = "main"
177
+ # #934's opt-in develop branch. A repo parked on *this* is not "parked
178
+ # on a non-default branch" — it's the configured integration branch.
179
+ develop_branch: str | None = None
180
+
181
+ @property
182
+ def home_branches(self) -> tuple[str, ...]:
183
+ out = [self.default_branch]
184
+ if self.develop_branch:
185
+ out.append(self.develop_branch)
186
+ return tuple(out)
187
+
188
+
189
+ @dataclass
190
+ class FleetSnapshot:
191
+ """What a ``fleet``-scope probe is allowed to know about the rest of the
192
+ fleet (#1630).
193
+
194
+ Only ever populated by the daemon (``coord.serve_app``'s health-poll
195
+ tick), from data it already collected polling every agent's ``/health``
196
+ plus a handful of facts that are genuinely local to the daemon process
197
+ itself (its own ``coord-serve`` venv). ``coord health`` run by hand on a
198
+ single machine has no fleet view — ``HealthContext.fleet`` is ``None``
199
+ there, and ``fleet``-scope checks are simply not run (see
200
+ ``registry.run_all``'s ``scopes`` filter).
201
+
202
+ #1806: the CLI-venv version and the ``tui/`` binary-vs-source comparison
203
+ are NOT daemon-host facts, even though an earlier version of this
204
+ dataclass's ``daemon_host`` blob carried them — both are facts about
205
+ whichever machine the operator actually put them on, which is often not
206
+ the daemon host. They ride ``machines`` instead (each machine's own
207
+ ``cli_venv``/``tui_binary`` checks, see
208
+ :mod:`coord.health.checks.deploy_lane_facts`) and are aggregated fleet-
209
+ wide from there, not read out of ``daemon_host``.
210
+ """
211
+
212
+ # machine_name -> {"state": "online"/"offline"/..., "reason": str,
213
+ # "latency_ms": float | None, "received_at": float,
214
+ # "checks": <agent's own H-1 report dict, or None if unreachable/old agent>}
215
+ machines: dict[str, dict] = field(default_factory=dict)
216
+ # Free-form daemon-host-local facts a fleet probe needs (its own
217
+ # coord-serve venv version, /board latency + payload size, phantom-
218
+ # running assignment ids, per-repo toolchain kinds + CI's pinned
219
+ # versions, ...). Kept untyped/free-form (like ``values`` on
220
+ # CheckResult) so a new fleet fact doesn't require touching this
221
+ # dataclass. See the class docstring's #1806 note for what does NOT
222
+ # belong here.
223
+ daemon_host: dict[str, Any] = field(default_factory=dict)
224
+
225
+
226
+ @dataclass
227
+ class HealthContext:
228
+ """Everything a probe is allowed to know about the machine it's on.
229
+
230
+ Passing this rather than letting probes reach for ``Path.home()`` /
231
+ ``coord.config.load()`` directly is what makes them unit-testable
232
+ without a real filesystem or a real ``coordinator.yml``.
233
+ """
234
+
235
+ thresholds: Any # coord.config.HealthConfig — untyped to avoid an import cycle
236
+ home: Path
237
+ coord_dir: Path
238
+ now: float
239
+ checkouts: tuple[Checkout, ...] = ()
240
+ # The loaded coordinator.yml, when there is one. Probes must tolerate
241
+ # ``None`` (``coord health`` on a machine with no config still works).
242
+ config: Any = None
243
+ # False on a timer/offline run: probes marked ``cost="network"`` are
244
+ # skipped entirely rather than left to time out.
245
+ allow_network: bool = True
246
+ # Set only by the daemon for a ``scopes=("fleet",)`` run (#1630). ``None``
247
+ # everywhere else, including every ``machine``/``checkout`` probe — a
248
+ # fleet probe that forgets to guard against ``None`` fails soft via the
249
+ # registry's fail-soft ``run_check`` wrapper, not by crashing the run.
250
+ fleet: "FleetSnapshot | None" = None
coord/health/pypi.py ADDED
@@ -0,0 +1,231 @@
1
+ """Poll PyPI's **simple index** for a project's released versions (#1628).
2
+
3
+ Why the simple index and not the JSON API (``/pypi/<name>/json``): they flip
4
+ independently, in both directions. The JSON API's ``info.version`` can lead
5
+ the index (a release is visible there before the files are servable) *and*
6
+ lag it (cached separately). Only the simple index is what ``pip`` actually
7
+ resolves against, so "am I behind?" answered from the JSON API can disagree
8
+ with what an operator sees when they run ``pip install -U`` — which turns
9
+ the check into a source of confusion instead of a signal.
10
+
11
+ The index is HTML (PEP 503) whose anchors are distribution filenames::
12
+
13
+ <a href="...">code_coordinator-0.4.91-py3-none-any.whl</a>
14
+ <a href="...">code_coordinator-0.4.91.tar.gz</a>
15
+
16
+ Parsing is filename-based and defensive: anything unrecognised is skipped
17
+ rather than raising. Yanked releases carry ``data-yanked`` on the anchor and
18
+ are excluded — pip won't resolve to them, so counting one as "you're behind"
19
+ would be a lie.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass, field
26
+
27
+ from coord.dist_name import CANDIDATE_NAMES
28
+
29
+ # PEP 503 normalisation: runs of -_. collapse to a single "-", lowercased.
30
+ _NORMALIZE_RE = re.compile(r"[-_.]+")
31
+
32
+ # One anchor. Non-greedy attribute soup, then the filename as anchor text.
33
+ _ANCHOR_RE = re.compile(r"<a\s+([^>]*)>\s*([^<]+?)\s*</a>", re.IGNORECASE | re.DOTALL)
34
+
35
+ _SDIST_SUFFIXES = (".tar.gz", ".zip", ".tar.bz2")
36
+
37
+
38
+ def split_distribution_filename(filename: str) -> tuple[str, str] | None:
39
+ """``(project_name, version)`` from a distribution filename, or ``None``.
40
+
41
+ Wheels (PEP 427) are ``name-version(-build)?-pytag-abi-platform.whl`` with
42
+ every ``-`` inside the name escaped to ``_``, so splitting on ``-`` is
43
+ exact — no regex needed and no way to mis-split a hyphenated project.
44
+ Sdists are ``name-version.tar.gz`` where the name may legitimately keep
45
+ its hyphens, so those split from the right.
46
+ """
47
+ lower = filename.lower()
48
+ if lower.endswith(".whl"):
49
+ parts = filename[: -len(".whl")].split("-")
50
+ # 5 components without a build tag, 6 with one.
51
+ if len(parts) < 5 or not parts[0] or not parts[1]:
52
+ return None
53
+ return parts[0], parts[1]
54
+ for suffix in _SDIST_SUFFIXES:
55
+ if lower.endswith(suffix):
56
+ stem = filename[: -len(suffix)]
57
+ name, sep, version = stem.rpartition("-")
58
+ if not sep or not name or not version:
59
+ return None
60
+ return name, version
61
+ return None
62
+
63
+
64
+ # Numeric-ish release segments plus an optional pre/post/dev suffix. This is
65
+ # a deliberate PEP 440 *subset*: enough to order 0.4.9 < 0.4.10 < 0.5.0 and to
66
+ # recognise 1.0.0rc1 as a pre-release, without taking a `packaging` dependency
67
+ # the package doesn't currently declare.
68
+ _VERSION_RE = re.compile(
69
+ r"^(?P<release>\d+(?:\.\d+)*)"
70
+ r"(?:(?P<pre_kind>a|b|c|rc|alpha|beta|pre|preview)(?P<pre_num>\d*))?"
71
+ r"(?:\.?(?:post|rev|r)(?P<post>\d*))?"
72
+ r"(?:\.?dev(?P<dev>\d*))?$",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+
77
+ def normalize_name(name: str) -> str:
78
+ """PEP 503 normalised project name (``code_coordinator`` → ``code-coordinator``)."""
79
+ return _NORMALIZE_RE.sub("-", name).lower()
80
+
81
+
82
+ @dataclass(frozen=True, order=True)
83
+ class Version:
84
+ """A comparable subset of PEP 440. ``is_prerelease`` drives filtering.
85
+
86
+ Only ``sort_key`` participates in comparison: ``1.0`` and ``1.0.0`` are
87
+ the same release however they were spelled on the index, and the counting
88
+ of "how many releases am I behind" must not double-count a project that
89
+ changed its spelling.
90
+ """
91
+
92
+ sort_key: tuple
93
+ raw: str = field(default="", compare=False)
94
+ is_prerelease: bool = field(default=False, compare=False)
95
+
96
+ def __str__(self) -> str: # pragma: no cover — trivial
97
+ return self.raw
98
+
99
+
100
+ def parse_version(raw: str) -> Version | None:
101
+ """Parse *raw* into a comparable :class:`Version`, or ``None`` if it isn't
102
+ a shape we can order confidently.
103
+
104
+ Refusing to guess matters: mis-ordering versions would make the check
105
+ report "2 behind" on a machine that is current, and an alert that cries
106
+ wolf gets muted.
107
+ """
108
+ m = _VERSION_RE.match(raw.strip())
109
+ if not m:
110
+ return None
111
+ release = tuple(int(part) for part in m.group("release").split("."))
112
+ # Pad so 1.0 and 1.0.0 compare equal-ish (1.0 sorts just below 1.0.0 only
113
+ # if we don't pad; padding is what a human means by "same release").
114
+ release = release + (0,) * (4 - len(release)) if len(release) < 4 else release
115
+
116
+ pre_kind = m.group("pre_kind")
117
+ is_pre = pre_kind is not None or m.group("dev") is not None
118
+ # Ordering within a release: dev < pre < final < post.
119
+ if m.group("dev") is not None:
120
+ stage, stage_num = 0, int(m.group("dev") or 0)
121
+ elif pre_kind is not None:
122
+ stage, stage_num = 1, int(m.group("pre_num") or 0)
123
+ elif m.group("post") is not None:
124
+ stage, stage_num = 3, int(m.group("post") or 0)
125
+ else:
126
+ stage, stage_num = 2, 0
127
+ return Version(sort_key=(release, stage, stage_num), raw=raw.strip(), is_prerelease=is_pre)
128
+
129
+
130
+ def parse_simple_index(html: str, project: str) -> list[Version]:
131
+ """Every non-yanked, parseable release version in a PEP 503 index page.
132
+
133
+ Sorted ascending. Pre-releases are included here and filtered by the
134
+ caller — ``pip install -U`` won't pick one by default, so the "how far
135
+ behind am I" check excludes them, but a caller that wants them can.
136
+ """
137
+ wanted = normalize_name(project)
138
+ out: list[Version] = []
139
+ seen: set[str] = set()
140
+ for attrs, filename in _ANCHOR_RE.findall(html or ""):
141
+ if "data-yanked" in attrs.lower():
142
+ continue
143
+ parts = split_distribution_filename(filename)
144
+ if parts is None:
145
+ continue
146
+ name, raw_version = parts
147
+ if normalize_name(name) != wanted:
148
+ continue
149
+ version = parse_version(raw_version)
150
+ if version is None or version.raw in seen:
151
+ continue
152
+ seen.add(version.raw)
153
+ out.append(version)
154
+ return sorted(out)
155
+
156
+
157
+ def fetch_simple_index(
158
+ project: str,
159
+ *,
160
+ index_url: str = "https://pypi.org/simple",
161
+ timeout: float = 3.0,
162
+ ) -> str:
163
+ """GET the simple-index page for *project*. Raises on failure — the
164
+ caller (a probe) is the thing that has to fail soft."""
165
+ import httpx # noqa: PLC0415 — keep import cost off the non-network path
166
+
167
+ url = f"{index_url.rstrip('/')}/{normalize_name(project)}/"
168
+ response = httpx.get(
169
+ url,
170
+ timeout=timeout,
171
+ follow_redirects=True,
172
+ headers={"Accept": "text/html"},
173
+ )
174
+ response.raise_for_status()
175
+ return response.text
176
+
177
+
178
+ def latest_release(
179
+ project: str,
180
+ *,
181
+ index_url: str = "https://pypi.org/simple",
182
+ timeout: float = 3.0,
183
+ ) -> tuple[Version | None, list[Version]]:
184
+ """``(latest_final_release, all_final_releases_ascending)``."""
185
+ html = fetch_simple_index(project, index_url=index_url, timeout=timeout)
186
+ finals = [v for v in parse_simple_index(html, project) if not v.is_prerelease]
187
+ return (finals[-1] if finals else None), finals
188
+
189
+
190
+ def latest_release_any(
191
+ names: tuple[str, ...] = CANDIDATE_NAMES,
192
+ *,
193
+ index_url: str = "https://pypi.org/simple",
194
+ timeout: float = 3.0,
195
+ ) -> tuple[str, Version | None, list[Version]]:
196
+ """:func:`latest_release`, tried against each of *names* in turn (#2103).
197
+
198
+ `claude-coordinator` and `code-coordinator` (#2096) are separate PyPI
199
+ projects — a caller resolving "what's the latest fleet-wide release"
200
+ must not hardcode whichever name happened to be current when the code
201
+ was written, or it silently stops seeing new releases the moment #2096
202
+ actually ships and starts publishing under the new name.
203
+
204
+ Returns ``(project_name_used, latest_final_release,
205
+ all_final_releases_ascending)`` for the first name in *names* that has
206
+ any final release on the index — ``code-coordinator`` wins once it has
207
+ releases, mirroring :func:`coord.dist_name.resolve_installed`'s
208
+ preference. A name with no releases yet (including a 404 for "this
209
+ project doesn't exist on the index at all", which is the *expected*
210
+ state for ``code-coordinator`` until #2096 ships) is treated as "try
211
+ the next name", not as an error.
212
+
213
+ Raises only when every name in *names* fails to even resolve (network
214
+ down, index unreachable, ...) — re-raising the last error, since at
215
+ that point there is no meaningful "no releases" to fall back to and the
216
+ caller needs to know the lookup itself failed.
217
+ """
218
+ last_exc: Exception | None = None
219
+ for project in names:
220
+ try:
221
+ latest, finals = latest_release(project, index_url=index_url, timeout=timeout)
222
+ except Exception as exc: # noqa: BLE001 — try the next candidate name
223
+ last_exc = exc
224
+ continue
225
+ if finals:
226
+ return project, latest, finals
227
+ if last_exc is not None:
228
+ raise last_exc
229
+ # Every name resolved (no network/index error) but none has a single
230
+ # final release — an honest "nothing here", not a failure.
231
+ return names[-1], None, []