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,547 @@
1
+ """Toolchain versions: per-machine facts, and fleet-scope skew (#1629, H-2).
2
+
3
+ **The finding.** Measured 2026-07-30 diagnosing vimcode#615: CI
4
+ (``dtolnay/rust-toolchain@stable``) built with rustc 1.97.1 while the three
5
+ fleet machines that can run vimcode's tests were on 1.95.0, 1.95.0, and
6
+ 1.93.1 — five months stale. Six ``insta`` render snapshots passed on every
7
+ fleet machine and failed in CI. The recorded Test verdict was ``passed``;
8
+ the truth was "the code does not build on a current toolchain," and the gate
9
+ had been silently green for months because nothing measured *which*
10
+ toolchain produced a `passed`.
11
+
12
+ **Annotate, don't gate — locked 2026-07-31 (operator).** This module does
13
+ two independent things, and neither one blocks a dispatch, a routing
14
+ decision, or a merge:
15
+
16
+ 1. :func:`probe_toolchain_versions` (machine scope) — what's actually
17
+ installed on THIS box for the toolchains its local checkouts need. Pure
18
+ fact-gathering, like ``agent_venv``: always OK when detected, UNKNOWN
19
+ when it can't be.
20
+ 2. :func:`probe_toolchain_skew` (fleet scope) — reads every machine's #1
21
+ results (fed through the daemon's health-poll tick, same "gather in the
22
+ context, judge in the probe" split as ``fleet_deploy_lanes``) plus the CI
23
+ pin the daemon-host facts read out of the repo's own workflow YAML, and
24
+ turns disagreement into a severity: all equal = OK, any machine differs
25
+ from another = WARN, a machine differs from CI = CRIT.
26
+
27
+ Gating on this is explicitly out of scope (a follow-up issue, once the data
28
+ says something) — see ``tests/test_fleet_health_probes.py``'s
29
+ ``test_toolchain_skew_crit_never_touches_dispatch_routing_or_merge`` and the
30
+ structural guarantee in ``tests/test_fleet_health_snapshot.py``.
31
+
32
+ **Table-driven, not ``rustc --version`` hardcoded (scope item 3).** A new
33
+ interpreter/SDK a repo's tests depend on is a new :data:`TOOLCHAIN_SPECS`
34
+ entry — the detection, the CI-pin parser, and the skew comparison all key
35
+ off the table, not off a toolchain-specific code path.
36
+
37
+ Everything here is fail-soft toward UNKNOWN: a version this module could not
38
+ determine is "we don't know," never fabricated as "matches" or "differs."
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ import subprocess
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+
48
+ import yaml
49
+
50
+ from coord.health.models import CheckResult, HealthContext, Severity
51
+ from coord.health.registry import check
52
+
53
+ VERSIONS_CHECK_ID = "toolchain_versions"
54
+ SKEW_CHECK_ID = "fleet_toolchain_skew"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ToolchainSpec:
59
+ """One row of the table #1629 scope item 3 asks for."""
60
+
61
+ kind: str
62
+ # argv to run locally to get a version string, e.g. ("rustc", "--version").
63
+ version_argv: tuple[str, ...]
64
+ # Regex whose first group is the dotted version number.
65
+ version_regex: str
66
+ # A checkout needs this kind if any of these subdirectories exist...
67
+ dir_markers: tuple[str, ...] = ()
68
+ # ...or the checkout ROOT has any of these marker files (a repo that IS
69
+ # that language, e.g. vimcode/quadraui's root Cargo.toml, vs.
70
+ # claude-coordinator's nested tui/ subdirectory).
71
+ root_markers: tuple[str, ...] = ()
72
+ # GitHub Actions step(s) (the `uses:` name, before the `@`) that install
73
+ # this toolchain in CI.
74
+ ci_actions: tuple[str, ...] = ()
75
+ # The `with:` key on that action carrying a literal version pin, e.g.
76
+ # "python-version" / "node-version" / "toolchain".
77
+ ci_version_key: str = ""
78
+ # `with:`/`@ref` values that mean "CI floats, no literal pin to read" —
79
+ # dtolnay/rust-toolchain@stable being the textbook case from #1629 itself.
80
+ ci_floating_values: tuple[str, ...] = ("stable", "beta", "nightly")
81
+ # True only for actions where the `@ref` itself IS the toolchain version
82
+ # (``dtolnay/rust-toolchain@1.97.1``) — the ref-as-version fallback in
83
+ # ``_parse_ci_pin`` is gated on this. Actions like ``actions/setup-python``
84
+ # / ``actions/setup-node`` version their OWN release tag in `@ref`
85
+ # (``actions/setup-node@v4``), which has nothing to do with the
86
+ # interpreter version installed — falling back to it fabricates a pin
87
+ # that was never configured. Default False; only the rust spec sets it.
88
+ ci_ref_is_version: bool = False
89
+
90
+
91
+ # Scope item 1's starting table: "rustc/cargo for tui/** and the Rust repos,
92
+ # python for the coord suite, node for coord/dashboard/webapp/**".
93
+ TOOLCHAIN_SPECS: tuple[ToolchainSpec, ...] = (
94
+ ToolchainSpec(
95
+ kind="rustc",
96
+ version_argv=("rustc", "--version"),
97
+ version_regex=r"rustc (\d+(?:\.\d+){1,2})",
98
+ dir_markers=("tui",),
99
+ root_markers=("Cargo.toml",),
100
+ ci_actions=("dtolnay/rust-toolchain", "actions-rs/toolchain"),
101
+ ci_version_key="toolchain",
102
+ ci_ref_is_version=True,
103
+ ),
104
+ ToolchainSpec(
105
+ kind="python",
106
+ version_argv=("python3", "--version"),
107
+ version_regex=r"Python (\d+(?:\.\d+){1,2})",
108
+ dir_markers=("coord",),
109
+ root_markers=("pyproject.toml", "setup.py"),
110
+ ci_actions=("actions/setup-python",),
111
+ ci_version_key="python-version",
112
+ ci_floating_values=(),
113
+ ),
114
+ ToolchainSpec(
115
+ kind="node",
116
+ version_argv=("node", "--version"),
117
+ version_regex=r"v?(\d+(?:\.\d+){0,2})",
118
+ dir_markers=("coord/dashboard/webapp",),
119
+ root_markers=("package.json",),
120
+ ci_actions=("actions/setup-node",),
121
+ ci_version_key="node-version",
122
+ ci_floating_values=(),
123
+ ),
124
+ )
125
+
126
+
127
+ # ── applicability: does a checkout's test suite depend on this kind? ────────
128
+
129
+
130
+ def _spec_applies(checkout_path: Path, spec: ToolchainSpec) -> bool:
131
+ for marker in spec.dir_markers:
132
+ if (checkout_path / marker).is_dir():
133
+ return True
134
+ for marker in spec.root_markers:
135
+ if (checkout_path / marker).is_file():
136
+ return True
137
+ return False
138
+
139
+
140
+ def repo_toolchain_kinds(checkout_path: Path) -> list[str]:
141
+ """Which :data:`TOOLCHAIN_SPECS` kinds *checkout_path*'s tests plausibly
142
+ depend on — table-driven, per #1629 scope item 3."""
143
+ return [spec.kind for spec in TOOLCHAIN_SPECS if _spec_applies(checkout_path, spec)]
144
+
145
+
146
+ # ── local version detection (machine-scope fact gathering) ──────────────────
147
+
148
+
149
+ def _parse_version(raw: str, regex: str) -> str | None:
150
+ m = re.search(regex, raw)
151
+ return m.group(1) if m else None
152
+
153
+
154
+ def detect_local_version(spec: ToolchainSpec, *, timeout: float = 5.0) -> str | None:
155
+ """Run ``<spec.version_argv>`` on THIS machine and extract a version.
156
+
157
+ None — never raised, never fabricated — for a missing binary, a
158
+ non-zero exit, unparseable output, or a timeout.
159
+ """
160
+ try:
161
+ result = subprocess.run(
162
+ list(spec.version_argv),
163
+ capture_output=True,
164
+ text=True,
165
+ timeout=timeout,
166
+ )
167
+ except (OSError, subprocess.SubprocessError):
168
+ return None
169
+ if result.returncode != 0:
170
+ return None
171
+ return _parse_version(result.stdout + result.stderr, spec.version_regex)
172
+
173
+
174
+ def local_toolchain_label(checkout_path: Path) -> str | None:
175
+ """``"rustc 1.95.0"`` / ``"python 3.12.4, node 20.11.0"`` for whatever
176
+ *checkout_path*'s tests depend on — used to annotate a Test verdict
177
+ recorded on THIS machine (#1629 scope item 2). ``None`` when no known
178
+ toolchain applies, or none could be detected; a caller records the
179
+ verdict either way, just without this optional annotation.
180
+ """
181
+ parts: list[str] = []
182
+ for spec in TOOLCHAIN_SPECS:
183
+ if not _spec_applies(checkout_path, spec):
184
+ continue
185
+ version = detect_local_version(spec)
186
+ if version:
187
+ parts.append(f"{spec.kind} {version}")
188
+ return ", ".join(parts) or None
189
+
190
+
191
+ def toolchain_label_for_machine(
192
+ health_block: dict | None, kinds: "list[str] | None" = None
193
+ ) -> str | None:
194
+ """Compact label built from one machine's last-known H-1 report block
195
+ (``{"results": [...]}``, the same shape ``fleet_snapshot`` stores per
196
+ machine) — restricted to *kinds* when given. Used by a caller recording
197
+ a verdict for a REMOTE machine's test run, reusing the H-1 tick's
198
+ already-gathered facts instead of shelling out again.
199
+ """
200
+ if not health_block:
201
+ return None
202
+ results = health_block.get("results") or []
203
+ parts: list[str] = []
204
+ for r in results:
205
+ if not isinstance(r, dict) or r.get("check_id") != VERSIONS_CHECK_ID:
206
+ continue
207
+ subject = r.get("subject")
208
+ if kinds is not None and subject not in kinds:
209
+ continue
210
+ version = (r.get("values") or {}).get("version")
211
+ if subject and version:
212
+ parts.append(f"{subject} {version}")
213
+ return ", ".join(parts) or None
214
+
215
+
216
+ @check(
217
+ id=VERSIONS_CHECK_ID,
218
+ scope="machine",
219
+ title="toolchain",
220
+ order=45,
221
+ description=(
222
+ "Installed versions of the toolchains this machine's local "
223
+ "checkouts need (rustc/python/node...). Pure fact-gathering — feeds "
224
+ "the fleet_toolchain_skew judgement, says nothing about skew itself."
225
+ ),
226
+ )
227
+ def probe_toolchain_versions(ctx: HealthContext) -> list[CheckResult] | None:
228
+ needed: dict[str, ToolchainSpec] = {}
229
+ for checkout in ctx.checkouts:
230
+ for spec in TOOLCHAIN_SPECS:
231
+ if spec.kind not in needed and _spec_applies(checkout.path, spec):
232
+ needed[spec.kind] = spec
233
+ if not needed:
234
+ return None # nothing this machine's checkouts need — silence beats a green line
235
+
236
+ results: list[CheckResult] = []
237
+ for kind, spec in sorted(needed.items()):
238
+ version = detect_local_version(spec)
239
+ if version is None:
240
+ results.append(
241
+ CheckResult(
242
+ check_id=VERSIONS_CHECK_ID,
243
+ scope="machine",
244
+ severity=Severity.UNKNOWN,
245
+ subject=kind,
246
+ headroom=f"{' '.join(spec.version_argv)} could not be run",
247
+ detail=(
248
+ "a local checkout needs this toolchain but it could "
249
+ "not be detected on this machine"
250
+ ),
251
+ values={"argv": list(spec.version_argv)},
252
+ )
253
+ )
254
+ else:
255
+ results.append(
256
+ CheckResult(
257
+ check_id=VERSIONS_CHECK_ID,
258
+ scope="machine",
259
+ severity=Severity.OK,
260
+ subject=kind,
261
+ headroom=version,
262
+ values={"version": version},
263
+ )
264
+ )
265
+ return results
266
+
267
+
268
+ # ── CI facts: parse pinned versions out of workflow YAML (daemon-host-local,
269
+ # no network — see coord.health.fleet_snapshot._daemon_host_facts) ───────
270
+
271
+
272
+ def _iter_workflow_steps(checkout_path: Path):
273
+ workflows_dir = checkout_path / ".github" / "workflows"
274
+ if not workflows_dir.is_dir():
275
+ return
276
+ try:
277
+ files = sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml"))
278
+ except OSError:
279
+ return
280
+ for wf_file in files:
281
+ try:
282
+ doc = yaml.safe_load(wf_file.read_text(encoding="utf-8", errors="replace"))
283
+ except (OSError, yaml.YAMLError):
284
+ continue
285
+ if not isinstance(doc, dict):
286
+ continue
287
+ jobs = doc.get("jobs")
288
+ if not isinstance(jobs, dict):
289
+ continue
290
+ for job in jobs.values():
291
+ if not isinstance(job, dict):
292
+ continue
293
+ for step in job.get("steps") or []:
294
+ if isinstance(step, dict):
295
+ yield step
296
+
297
+
298
+ def _version_tuple(s: str) -> tuple[int, ...] | None:
299
+ parts = re.findall(r"\d+", s or "")
300
+ return tuple(int(p) for p in parts) if parts else None
301
+
302
+
303
+ def ci_matches_machine(ci_version: str, machine_version: str) -> bool:
304
+ """True when *machine_version* satisfies a (possibly less precise)
305
+ *ci_version* pin.
306
+
307
+ CI configs commonly name a MINOR version (``python-version: '3.12'``)
308
+ while a local ``--version`` reports the full patch triplet
309
+ (``"3.12.4"``) — that's still "running what CI runs", so the comparison
310
+ is a component-wise PREFIX match at CI's own precision, not string
311
+ equality. A toolchain CI pins to full precision (rustc's 3-part
312
+ ``1.97.1``) still requires every one of those components to match.
313
+ """
314
+ ci_t = _version_tuple(ci_version)
315
+ m_t = _version_tuple(machine_version)
316
+ if not ci_t or not m_t or len(m_t) < len(ci_t):
317
+ return False
318
+ return m_t[: len(ci_t)] == ci_t
319
+
320
+
321
+ def _parse_ci_pin(spec: ToolchainSpec, step: dict) -> str | None:
322
+ uses = str(step.get("uses") or "")
323
+ if "@" not in uses:
324
+ return None
325
+ action_name, action_ref = uses.split("@", 1)
326
+ if action_name not in spec.ci_actions:
327
+ return None
328
+ with_block = step.get("with")
329
+ pinned = with_block.get(spec.ci_version_key) if isinstance(with_block, dict) else None
330
+ if pinned is not None:
331
+ pinned_s = str(pinned).strip().strip("'\"")
332
+ if pinned_s and pinned_s not in spec.ci_floating_values and _version_tuple(pinned_s):
333
+ return pinned_s
334
+ return None # explicit but floating ("stable") or unparseable — honestly unknown
335
+ if not spec.ci_ref_is_version:
336
+ # No `with:` pin, and this action's `@ref` is the ACTION's own release
337
+ # tag (e.g. actions/setup-node@v4), not the toolchain version — that's
338
+ # the common "default runner version" / ".nvmrc-driven" CI pattern,
339
+ # honestly unknown, never fabricated from the action's tag (#1629 fix).
340
+ return None
341
+ action_ref = action_ref.strip()
342
+ if action_ref and action_ref not in spec.ci_floating_values and _version_tuple(action_ref):
343
+ return action_ref
344
+ return None # no `with:` pin and the @ref is a floating channel (or a SHA) — unknown
345
+
346
+
347
+ def ci_toolchain_versions(checkout_path: Path) -> dict[str, str | None]:
348
+ """kind -> the version CI pins, or None when CI floats / doesn't
349
+ configure that toolchain at all.
350
+
351
+ Local-filesystem-only (workflow YAML already on disk) — no ``gh`` call,
352
+ cheap enough for the daemon's health-poll tick. A floating pin
353
+ (``dtolnay/rust-toolchain@stable``, #1629's own motivating example) is
354
+ honestly ``None``: the version it actually resolves to on a given day
355
+ can only be read from a live CI run, which this function does not fetch.
356
+ """
357
+ out: dict[str, str | None] = {spec.kind: None for spec in TOOLCHAIN_SPECS}
358
+ for step in _iter_workflow_steps(checkout_path):
359
+ for spec in TOOLCHAIN_SPECS:
360
+ if out[spec.kind] is not None:
361
+ continue
362
+ pin = _parse_ci_pin(spec, step)
363
+ if pin:
364
+ out[spec.kind] = pin
365
+ return out
366
+
367
+
368
+ # ── fleet-scope skew judgement ───────────────────────────────────────────────
369
+
370
+
371
+ def _machine_toolchain_versions(ctx: HealthContext) -> dict[str, dict[str, str]]:
372
+ """machine_name -> {kind: version}, read from each machine's last-known
373
+ H-1 report (the same ``checks.results`` block ``fleet_deploy_lanes``
374
+ reads for ``agent_venv``)."""
375
+ out: dict[str, dict[str, str]] = {}
376
+ if ctx.fleet is None:
377
+ return out
378
+ for name, entry in ctx.fleet.machines.items():
379
+ checks = (entry or {}).get("checks") or {}
380
+ versions: dict[str, str] = {}
381
+ for r in checks.get("results", []) or []:
382
+ if not isinstance(r, dict) or r.get("check_id") != VERSIONS_CHECK_ID:
383
+ continue
384
+ if r.get("error"):
385
+ continue
386
+ subject = r.get("subject")
387
+ version = (r.get("values") or {}).get("version")
388
+ if subject and version:
389
+ versions[subject] = version
390
+ out[name] = versions
391
+ return out
392
+
393
+
394
+ def _by_version(versions: dict[str, str]) -> dict[str, list[str]]:
395
+ out: dict[str, list[str]] = {}
396
+ for machine, v in versions.items():
397
+ out.setdefault(v, []).append(machine)
398
+ return out
399
+
400
+
401
+ def _format_by_version(by_version: dict[str, list[str]]) -> str:
402
+ return "; ".join(f"{v}: {', '.join(sorted(ms))}" for v, ms in sorted(by_version.items()))
403
+
404
+
405
+ @check(
406
+ id=SKEW_CHECK_ID,
407
+ scope="fleet",
408
+ title="toolchain skew",
409
+ order=15,
410
+ description=(
411
+ "Do all machines that can run a repo's tests agree on its "
412
+ "toolchain, and does that agree with CI? #1629: annotate-only — "
413
+ "this NEVER gates dispatch, routing, or merge."
414
+ ),
415
+ )
416
+ def probe_toolchain_skew(ctx: HealthContext) -> list[CheckResult] | CheckResult | None:
417
+ if ctx.fleet is None:
418
+ return CheckResult(
419
+ check_id=SKEW_CHECK_ID,
420
+ scope="fleet",
421
+ severity=Severity.UNKNOWN,
422
+ headroom="no fleet snapshot (fleet checks only run on the daemon)",
423
+ )
424
+
425
+ dh = ctx.fleet.daemon_host or {}
426
+ repo_kinds: dict[str, list[str]] = dh.get("repo_toolchain_kinds") or {}
427
+ ci_versions: dict[str, dict[str, str | None]] = dh.get("ci_toolchains") or {}
428
+ if not repo_kinds:
429
+ return CheckResult(
430
+ check_id=SKEW_CHECK_ID,
431
+ scope="fleet",
432
+ severity=Severity.UNKNOWN,
433
+ headroom="no repo has a resolvable toolchain from the daemon host's local checkouts",
434
+ )
435
+
436
+ machine_versions = _machine_toolchain_versions(ctx)
437
+ config = ctx.config
438
+ machines_by_repo: dict[str, list[str]] = {}
439
+ for m in getattr(config, "machines", ()) or ():
440
+ for repo_name in getattr(m, "repo_paths", None) or {}:
441
+ machines_by_repo.setdefault(repo_name, []).append(m.name)
442
+
443
+ results: list[CheckResult] = []
444
+ for repo_name, kinds in sorted(repo_kinds.items()):
445
+ candidate_machines = sorted(set(machines_by_repo.get(repo_name, ())))
446
+ for kind in kinds:
447
+ subject = f"{repo_name}:{kind}"
448
+
449
+ if not candidate_machines:
450
+ results.append(
451
+ CheckResult(
452
+ check_id=SKEW_CHECK_ID,
453
+ scope="fleet",
454
+ severity=Severity.UNKNOWN,
455
+ subject=subject,
456
+ headroom="no machine advertises a checkout of this repo",
457
+ )
458
+ )
459
+ continue
460
+
461
+ versions_by_machine = {
462
+ m: machine_versions.get(m, {}).get(kind) for m in candidate_machines
463
+ }
464
+ known = {m: v for m, v in versions_by_machine.items() if v}
465
+ missing = sorted(m for m, v in versions_by_machine.items() if not v)
466
+ ci_version = (ci_versions.get(repo_name) or {}).get(kind)
467
+
468
+ if not known:
469
+ results.append(
470
+ CheckResult(
471
+ check_id=SKEW_CHECK_ID,
472
+ scope="fleet",
473
+ severity=Severity.UNKNOWN,
474
+ subject=subject,
475
+ headroom=f"no {kind} version data yet",
476
+ detail=f"no data for: {', '.join(missing)}" if missing else "",
477
+ )
478
+ )
479
+ continue
480
+
481
+ distinct = sorted(set(known.values()))
482
+ ci_mismatches = (
483
+ sorted(m for m, v in known.items() if not ci_matches_machine(ci_version, v))
484
+ if ci_version
485
+ else []
486
+ )
487
+
488
+ if ci_version and ci_mismatches:
489
+ results.append(
490
+ CheckResult(
491
+ check_id=SKEW_CHECK_ID,
492
+ scope="fleet",
493
+ severity=Severity.CRIT,
494
+ subject=subject,
495
+ headroom=(
496
+ f"CI pins {kind} {ci_version}; "
497
+ f"{', '.join(ci_mismatches)} disagree"
498
+ ),
499
+ detail=_format_by_version(_by_version(known)),
500
+ threshold=f"crit when a machine's {kind} != CI's {ci_version}",
501
+ values={
502
+ "versions": known,
503
+ "ci_version": ci_version,
504
+ "missing": missing,
505
+ },
506
+ )
507
+ )
508
+ elif len(distinct) > 1:
509
+ results.append(
510
+ CheckResult(
511
+ check_id=SKEW_CHECK_ID,
512
+ scope="fleet",
513
+ severity=Severity.WARN,
514
+ subject=subject,
515
+ headroom=f"{len(distinct)} {kind} versions across the fleet",
516
+ detail=_format_by_version(_by_version(known)),
517
+ threshold="warn when machines disagree",
518
+ values={
519
+ "versions": known,
520
+ "ci_version": ci_version,
521
+ "missing": missing,
522
+ },
523
+ )
524
+ )
525
+ else:
526
+ (version,) = distinct
527
+ headroom = f"all machines on {kind} {version}"
528
+ if ci_version:
529
+ headroom += f" (matches CI's {ci_version})"
530
+ if missing:
531
+ headroom += f" ({len(missing)} machine(s) with no data)"
532
+ results.append(
533
+ CheckResult(
534
+ check_id=SKEW_CHECK_ID,
535
+ scope="fleet",
536
+ severity=Severity.OK if not missing else Severity.UNKNOWN,
537
+ subject=subject,
538
+ headroom=headroom,
539
+ detail=f"no data for: {', '.join(missing)}" if missing else "",
540
+ values={
541
+ "versions": known,
542
+ "ci_version": ci_version,
543
+ "missing": missing,
544
+ },
545
+ )
546
+ )
547
+ return results or None