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/prereqs.py ADDED
@@ -0,0 +1,533 @@
1
+ """External-tool prereq manifest and version probing (#1570 parts B/D/E).
2
+
3
+ coord shells out to a handful of external binaries — `git`, `gh`, and
4
+ whatever a machine's declared `capabilities:` promise (`cargo` for `rust`,
5
+ GTK4 dev libs for `gtk`, ...) — but until now it never checked any of them.
6
+ `shutil.which()` presence checks existed for a few binaries; nothing probed
7
+ *capability*, and nothing published what it found. #1564 fixed the sharpest
8
+ edge of this (the CI merge gate's `gh` floor, `GH_PR_CHECKS_JSON_MIN_VERSION`
9
+ in `coord/github_ops.py`) as a one-off inside the seam that needed it most.
10
+ This module generalizes that pattern into a manifest so the same probe/floor
11
+ machinery backs every tool coord depends on, not just `gh`:
12
+
13
+ - :func:`probe_all` — probe every baseline prereq plus whatever prereqs back
14
+ a given set of capabilities. Used by `AgentServer.health()` (#1570 B) to
15
+ publish resolved tool versions fleet-wide, and by `coord doctor` (#1570 E)
16
+ to render a per-machine prereq report without SSHing anywhere.
17
+ - :func:`unmet_capabilities` — cross-reference a machine's *advertised*
18
+ `capabilities:` claims against what its probes actually found, for the
19
+ dispatcher to refuse routing capability-gated work to a machine that can't
20
+ back its claim (#1570 D) instead of finding out 20 minutes into a worker.
21
+
22
+ A prereq's `min_version` is `None` until a floor has actually been confirmed
23
+ (the way #1564 confirmed gh's) — this module never invents one. `None` means
24
+ "probe for presence only," not "no requirement."
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Callable, Iterable
37
+
38
+ from coord.config import provider_capability
39
+ from coord.github_ops import GH_PR_CHECKS_JSON_MIN_VERSION
40
+
41
+ DEFAULT_PROBE_TIMEOUT = 10.0
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Prereq:
46
+ """One external-tool dependency coord relies on.
47
+
48
+ ``capability`` is ``None`` for a baseline prereq — required on every
49
+ machine regardless of its declared `capabilities:` — or the
50
+ `coordinator.yml` capability name (`"rust"`, `"gtk"`, ...) whose promise
51
+ this tool backs.
52
+ """
53
+
54
+ tool: str
55
+ binary: str
56
+ version_args: tuple[str, ...]
57
+ version_re: str
58
+ min_version: str | None
59
+ capability: str | None
60
+ what_breaks: str
61
+ # Escape hatch for a prereq whose real signal is not "is there a binary
62
+ # named X on `PATH`" (#1678). When set, :func:`probe` delegates entirely
63
+ # to this callable and ignores `binary`/`version_args`/`version_re`.
64
+ # Called as `custom_probe(prereq, timeout)`; it must return a
65
+ # :class:`ToolProbe` and, like :func:`probe`, never raise (:func:`probe`
66
+ # wraps it defensively anyway).
67
+ custom_probe: Callable[["Prereq", float], "ToolProbe"] | None = None
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ToolProbe:
72
+ """Result of probing one :class:`Prereq`. Never raises to build one."""
73
+
74
+ tool: str
75
+ capability: str | None
76
+ found: bool
77
+ version: str | None
78
+ min_version: str | None
79
+ meets_floor: bool | None # None: no floor to check, or version unknown
80
+ what_breaks: str
81
+
82
+ @property
83
+ def ok(self) -> bool:
84
+ """False when the tool is missing or fails its documented floor.
85
+
86
+ A tool found but with an unparsable version (`meets_floor is None`
87
+ with `min_version` set) is treated as ok=True — degrade to
88
+ "unknown, assume fine" rather than false-failing on an output-format
89
+ change, matching `_gh_version()`'s existing best-effort contract.
90
+ """
91
+ if not self.found:
92
+ return False
93
+ if self.min_version is not None and self.meets_floor is False:
94
+ return False
95
+ return True
96
+
97
+ def to_dict(self) -> dict:
98
+ return {
99
+ "found": self.found,
100
+ "version": self.version,
101
+ "min_version": self.min_version,
102
+ "meets_floor": self.meets_floor,
103
+ "capability": self.capability,
104
+ "ok": self.ok,
105
+ }
106
+
107
+
108
+ # --- `browser` capability: probe what the suite LAUNCHES (#1678) -----------
109
+ #
110
+ # The original `browser` prereq probed a literal `chromium` binary. That was
111
+ # wrong twice over on the one machine that advertises the capability:
112
+ #
113
+ # 1. the box has `google-chrome`/`firefox` but no binary spelled
114
+ # `chromium`, so the capability read unmet and #1570 D refused to route
115
+ # any `coord/dashboard/webapp/**` Test stage anywhere — forever, with
116
+ # no smoke row and no board-visible reason (the #544 shape); and
117
+ # 2. more importantly, the suite never launches a system browser at all.
118
+ # `coord/dashboard/webapp/playwright.config.ts` declares
119
+ # `use: { ...devices['Desktop Chrome'] }` with no `channel` and no
120
+ # `executablePath`, so `@playwright/test` runs its OWN bundled Chromium
121
+ # out of the Playwright browser cache that `npx playwright install`
122
+ # populates. Renaming the probed binary to `google-chrome` would have
123
+ # flipped the capability green while still checking nothing the suite
124
+ # touches — the same false green `deploy/coord-agent.service` warns
125
+ # about for `cargo`-without-`rustc`.
126
+ #
127
+ # So `browser` is now backed by what `npm run test:e2e` actually needs:
128
+ # `node` + `npm` on the agent's PATH (a worker inherits it, venv-stripped —
129
+ # #402), and a populated Playwright browser cache. The `@playwright/test`
130
+ # package itself is deliberately NOT probed: it is a devDependency installed
131
+ # per-checkout by `npm ci`, not machine state, so its absence is a worker's
132
+ # problem to fix rather than a capability this machine lacks.
133
+
134
+ PLAYWRIGHT_BROWSERS_PATH_ENV = "PLAYWRIGHT_BROWSERS_PATH"
135
+
136
+ # `npx playwright install` writes this marker into a browser directory once
137
+ # the download unpacks cleanly, so it is the cheapest "this build is usable,
138
+ # not a half-extracted stub" signal.
139
+ _PLAYWRIGHT_INSTALL_MARKER = "INSTALLATION_COMPLETE"
140
+
141
+ # Chromium build directories are named `chromium-<build>` (headed) and
142
+ # `chromium_headless_shell-<build>`. Either backs `devices['Desktop Chrome']`.
143
+ _CHROMIUM_DIR_RE = re.compile(r"^chromium(?:_headless_shell)?-(\d+)$")
144
+
145
+ # Where the Chromium executable sits inside such a directory, across the
146
+ # layouts Playwright has shipped. Checked only as a FALLBACK for a cache
147
+ # directory with no marker file, so a future layout rename degrades to the
148
+ # marker rather than false-reporting the capability missing.
149
+ _CHROMIUM_EXECUTABLES: tuple[str, ...] = (
150
+ "chrome-linux64/chrome",
151
+ "chrome-linux/chrome",
152
+ "chrome-headless-shell-linux64/chrome-headless-shell",
153
+ "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
154
+ "chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium",
155
+ "chrome-headless-shell-mac/chrome-headless-shell",
156
+ "chrome-win/chrome.exe",
157
+ "chrome-headless-shell-win/chrome-headless-shell.exe",
158
+ )
159
+
160
+
161
+ def playwright_browsers_root() -> Path | None:
162
+ """Directory `npx playwright install` populates on this machine.
163
+
164
+ Honours `PLAYWRIGHT_BROWSERS_PATH`, including its documented `"0"` mode
165
+ ("install into the package's own directory"), which makes the cache
166
+ per-checkout rather than machine-wide — this machine-level probe cannot
167
+ see that layout, so it returns None and the capability reads unmet
168
+ rather than green-by-guess.
169
+ """
170
+ override = os.environ.get(PLAYWRIGHT_BROWSERS_PATH_ENV)
171
+ if override:
172
+ if override.strip() == "0":
173
+ return None
174
+ return Path(override).expanduser()
175
+ if sys.platform == "darwin":
176
+ return Path.home() / "Library" / "Caches" / "ms-playwright"
177
+ if sys.platform.startswith("win"):
178
+ local_appdata = os.environ.get("LOCALAPPDATA")
179
+ base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
180
+ return base / "ms-playwright"
181
+ xdg_cache = os.environ.get("XDG_CACHE_HOME")
182
+ base = Path(xdg_cache).expanduser() if xdg_cache else Path.home() / ".cache"
183
+ return base / "ms-playwright"
184
+
185
+
186
+ def _chromium_build_is_usable(path: Path) -> bool:
187
+ try:
188
+ if (path / _PLAYWRIGHT_INSTALL_MARKER).exists():
189
+ return True
190
+ return any((path / rel).exists() for rel in _CHROMIUM_EXECUTABLES)
191
+ except OSError:
192
+ return False
193
+
194
+
195
+ def installed_chromium_builds(root: Path | None = None) -> list[int]:
196
+ """Playwright Chromium build numbers usable on this machine, ascending.
197
+
198
+ Empty when the cache is absent, empty, or holds only half-extracted
199
+ stubs. `root` defaults to :func:`playwright_browsers_root`.
200
+ """
201
+ if root is None:
202
+ root = playwright_browsers_root()
203
+ if root is None:
204
+ return []
205
+ try:
206
+ entries = list(root.iterdir())
207
+ except OSError:
208
+ return []
209
+ builds: set[int] = set()
210
+ for entry in entries:
211
+ match = _CHROMIUM_DIR_RE.match(entry.name)
212
+ if match is None:
213
+ continue
214
+ try:
215
+ if not entry.is_dir():
216
+ continue
217
+ except OSError:
218
+ continue
219
+ if _chromium_build_is_usable(entry):
220
+ builds.add(int(match.group(1)))
221
+ return sorted(builds)
222
+
223
+
224
+ def _probe_opencode(prereq: Prereq, timeout: float) -> ToolProbe:
225
+ """``custom_probe`` for the ``opencode`` CLI backing the ``provider:
226
+ opencode`` capability (#1711).
227
+
228
+ Deliberately more lenient than the generic binary-probe path in
229
+ :func:`probe`: :class:`coord.providers.opencode.OpenCodeProvider`'s own
230
+ module docstring flags EVERY CLI-flag assumption as unverified — the
231
+ real binary was never installed on the machine that wrote the
232
+ provider, so a wrong ``--version`` flag is a real possibility. The
233
+ generic :func:`probe` path treats any non-zero exit as "not found"
234
+ (correct for a subcommand-lookup probe like ``pkg-config --modversion
235
+ gtk4``, wrong here) — that would false-negative a machine that
236
+ genuinely has ``opencode`` installed under a CLI surface this module
237
+ hasn't confirmed yet. So: ``shutil.which`` alone decides ``found``;
238
+ ``--version``'s output, only if the call happens to succeed, is a
239
+ best-effort version string and never a reason to report
240
+ ``found=False``.
241
+ """
242
+ binary_path = shutil.which(prereq.binary)
243
+ if binary_path is None:
244
+ return ToolProbe(
245
+ tool=prereq.tool, capability=prereq.capability, found=False,
246
+ version=None, min_version=prereq.min_version, meets_floor=None,
247
+ what_breaks=prereq.what_breaks,
248
+ )
249
+ version: str | None = None
250
+ try:
251
+ result = subprocess.run(
252
+ [prereq.binary, *prereq.version_args],
253
+ capture_output=True, text=True, timeout=timeout,
254
+ )
255
+ if result.returncode == 0:
256
+ version = _parse_version(
257
+ (result.stdout or "") + (result.stderr or ""), prereq.version_re
258
+ )
259
+ except (OSError, subprocess.TimeoutExpired):
260
+ pass
261
+ return ToolProbe(
262
+ tool=prereq.tool, capability=prereq.capability, found=True,
263
+ version=version, min_version=prereq.min_version, meets_floor=None,
264
+ what_breaks=prereq.what_breaks,
265
+ )
266
+
267
+
268
+ def _probe_playwright_browsers(prereq: Prereq, _timeout: float) -> ToolProbe:
269
+ """`custom_probe` for the Playwright browser cache — no binary to run."""
270
+ builds = installed_chromium_builds()
271
+ return ToolProbe(
272
+ tool=prereq.tool, capability=prereq.capability, found=bool(builds),
273
+ version=str(builds[-1]) if builds else None,
274
+ min_version=prereq.min_version, meets_floor=None,
275
+ what_breaks=prereq.what_breaks,
276
+ )
277
+
278
+
279
+ # Required on every machine, no matter its declared capabilities — coord
280
+ # itself doesn't function without these.
281
+ BASELINE_PREREQS: tuple[Prereq, ...] = (
282
+ Prereq(
283
+ tool="git", binary="git", version_args=("--version",),
284
+ version_re=r"git version (\S+)", min_version=None, capability=None,
285
+ what_breaks="coord cannot inspect, commit, or push any repo",
286
+ ),
287
+ Prereq(
288
+ tool="gh", binary="gh", version_args=("--version",),
289
+ version_re=r"gh version (\S+)",
290
+ # Single source of truth stays coord.github_ops — imported, not
291
+ # duplicated, so the two never drift (#1564's own comment on the
292
+ # constant flags exactly this risk).
293
+ min_version=GH_PR_CHECKS_JSON_MIN_VERSION, capability=None,
294
+ what_breaks=(
295
+ "the CI merge gate cannot read check status — see "
296
+ "coord.github_ops.GhTooOldForJsonChecks (#1564)"
297
+ ),
298
+ ),
299
+ )
300
+
301
+ # Gate a `coordinator.yml` `capabilities:` entry. Only probed for a machine
302
+ # that actually claims the matching capability (see `probe_all`) — a plain
303
+ # CLI-only box is never dinged for lacking a browser or GTK4.
304
+ CAPABILITY_PREREQS: tuple[Prereq, ...] = (
305
+ Prereq(
306
+ tool="cargo", binary="cargo", version_args=("--version",),
307
+ version_re=r"cargo (\S+)", min_version=None, capability="rust",
308
+ what_breaks="rust-capability work (cargo build/test) cannot run",
309
+ ),
310
+ Prereq(
311
+ tool="python3", binary="python3", version_args=("--version",),
312
+ version_re=r"Python (\S+)", min_version=None, capability="python",
313
+ what_breaks="python-capability work cannot run",
314
+ ),
315
+ # tui/'s `--features gtk` build links against GTK4 via pkg-config
316
+ # (tui/Cargo.toml: "GTK binary requires the `gtk` feature"); probing
317
+ # pkg-config's module lookup is the cheapest real signal that the dev
318
+ # libs (not just a runtime GTK) are actually present.
319
+ Prereq(
320
+ tool="gtk4", binary="pkg-config", version_args=("--modversion", "gtk4"),
321
+ version_re=r"(\S+)", min_version=None, capability="gtk",
322
+ what_breaks="the tui/ `--features gtk` build cannot link against GTK4",
323
+ ),
324
+ # `browser` gates this repo's own Playwright acceptance suite
325
+ # (`coord/dashboard/webapp`, npm script `test:e2e` -> `playwright test`)
326
+ # and the same shape in consuming projects. See the block above
327
+ # `PLAYWRIGHT_BROWSERS_PATH_ENV` for why these three and not `chromium`.
328
+ #
329
+ # `node` and `npm` are probed SEPARATELY on purpose: nvm ships them
330
+ # together, but a hand-rolled PATH fix that resolves only one of them
331
+ # reports the capability met while `npm run test:e2e` still dies — the
332
+ # cargo-without-rustc false green called out in deploy/coord-agent.service.
333
+ Prereq(
334
+ tool="node", binary="node", version_args=("--version",),
335
+ version_re=r"v?(\d\S*)", min_version=None, capability="browser",
336
+ what_breaks=(
337
+ "the Playwright suite cannot start — a worker inherits this "
338
+ "agent's PATH (#402), so install the ~/.local/bin Node shim "
339
+ "(deploy/node-shim.sh, #1678)"
340
+ ),
341
+ ),
342
+ Prereq(
343
+ tool="npm", binary="npm", version_args=("--version",),
344
+ version_re=r"v?(\d\S*)", min_version=None, capability="browser",
345
+ what_breaks=(
346
+ "`npm ci` / `npm run test:e2e` cannot run, so the suite's own "
347
+ "@playwright/test devDependency can never be installed"
348
+ ),
349
+ ),
350
+ Prereq(
351
+ tool="playwright-browsers", binary="", version_args=(), version_re="",
352
+ min_version=None, capability="browser",
353
+ what_breaks=(
354
+ "@playwright/test has no bundled Chromium to launch — run "
355
+ "`npx playwright install chromium` on this machine"
356
+ ),
357
+ custom_probe=_probe_playwright_browsers,
358
+ ),
359
+ # #1711: backs the `provider:opencode` capability (see
360
+ # `coord.config.provider_capability` / `coord.providers.
361
+ # guard_provider_machine_capability`) — a machine that declares it can
362
+ # run the `opencode` provider gets probed here so `coord doctor`
363
+ # distinguishes DECLARED (capability string present in
364
+ # machines[].capabilities) from PROBED-AND-MET (the binary this probe
365
+ # found on PATH), the same way `rust`/`gtk`/`browser` already do. Tool
366
+ # name matches `coord.providers.opencode.DEFAULT_OPENCODE_BINARY`.
367
+ Prereq(
368
+ tool="opencode", binary="opencode", version_args=("--version",),
369
+ version_re=r"v?(\d\S*)", min_version=None, capability=provider_capability("opencode"),
370
+ what_breaks=(
371
+ "an opencode-provider assignment cannot spawn on this machine "
372
+ "— install the opencode CLI (https://github.com/sst/opencode) "
373
+ "or drop `provider:opencode` from this machine's capabilities"
374
+ ),
375
+ custom_probe=_probe_opencode,
376
+ ),
377
+ )
378
+
379
+ ALL_PREREQS: tuple[Prereq, ...] = BASELINE_PREREQS + CAPABILITY_PREREQS
380
+
381
+
382
+ def _parse_version(text: str, pattern: str) -> str | None:
383
+ match = re.search(pattern, text)
384
+ return match.group(1) if match else None
385
+
386
+
387
+ def _version_tuple(version: str) -> tuple[int, ...]:
388
+ """Best-effort numeric tuple for comparison ("2.86.0" -> (2, 86, 0)).
389
+
390
+ Non-numeric segments (pre-release suffixes etc.) collapse to 0 rather
391
+ than raising — this only needs to be right for well-formed dotted
392
+ versions, and must never blow up a probe over an odd one.
393
+ """
394
+ parts = []
395
+ for segment in re.split(r"[.\-+]", version):
396
+ match = re.match(r"\d+", segment)
397
+ parts.append(int(match.group(0)) if match else 0)
398
+ return tuple(parts)
399
+
400
+
401
+ def meets_floor(version: str, min_version: str) -> bool:
402
+ """Whether `version` is >= `min_version`, comparing dotted numerics.
403
+
404
+ Zero-pads the shorter tuple before comparing — plain tuple comparison
405
+ would otherwise rank "2.86" below "2.86.0" (a shorter-but-equal prefix
406
+ tuple compares as less than a longer one), which is wrong: they're the
407
+ same version.
408
+ """
409
+ v = _version_tuple(version)
410
+ m = _version_tuple(min_version)
411
+ width = max(len(v), len(m))
412
+ v = v + (0,) * (width - len(v))
413
+ m = m + (0,) * (width - len(m))
414
+ return v >= m
415
+
416
+
417
+ def probe(prereq: Prereq, *, timeout: float = DEFAULT_PROBE_TIMEOUT) -> ToolProbe:
418
+ """Run `prereq`'s version probe and classify the result.
419
+
420
+ Never raises — a missing binary, a hang, or unparsable output all
421
+ degrade to a `ToolProbe` describing that, rather than blowing up a
422
+ `/health` response or a `coord doctor` sweep over one flaky tool.
423
+ """
424
+ if prereq.custom_probe is not None:
425
+ try:
426
+ return prereq.custom_probe(prereq, timeout)
427
+ except Exception:
428
+ # Same contract as the binary path: a broken probe degrades to
429
+ # "not found" rather than taking down /health.
430
+ return ToolProbe(
431
+ tool=prereq.tool, capability=prereq.capability, found=False,
432
+ version=None, min_version=prereq.min_version, meets_floor=None,
433
+ what_breaks=prereq.what_breaks,
434
+ )
435
+ if shutil.which(prereq.binary) is None:
436
+ return ToolProbe(
437
+ tool=prereq.tool, capability=prereq.capability, found=False,
438
+ version=None, min_version=prereq.min_version, meets_floor=None,
439
+ what_breaks=prereq.what_breaks,
440
+ )
441
+ try:
442
+ result = subprocess.run(
443
+ [prereq.binary, *prereq.version_args],
444
+ capture_output=True, text=True, timeout=timeout,
445
+ )
446
+ except (OSError, subprocess.TimeoutExpired):
447
+ # Present per `which` but unrunnable/hung — still "found" (the
448
+ # binary exists), version simply couldn't be determined.
449
+ return ToolProbe(
450
+ tool=prereq.tool, capability=prereq.capability, found=True,
451
+ version=None, min_version=prereq.min_version, meets_floor=None,
452
+ what_breaks=prereq.what_breaks,
453
+ )
454
+ if result.returncode != 0:
455
+ # The binary exists (`which` found it) but the version probe itself
456
+ # failed — e.g. `pkg-config --modversion gtk4` exits nonzero with a
457
+ # "not found in the pkg-config search path" message on stdout/stderr
458
+ # when the dev libs aren't installed. That error text is not a
459
+ # version string, and for a subcommand-lookup probe like this one a
460
+ # nonzero exit *is* the "not present" signal for the capability
461
+ # being probed — treat it as not found rather than risk
462
+ # `_parse_version` extracting a bogus token (e.g. "Package") from
463
+ # the error message and reporting a garbage version as ok=True.
464
+ return ToolProbe(
465
+ tool=prereq.tool, capability=prereq.capability, found=False,
466
+ version=None, min_version=prereq.min_version, meets_floor=None,
467
+ what_breaks=prereq.what_breaks,
468
+ )
469
+ version = _parse_version((result.stdout or "") + (result.stderr or ""), prereq.version_re)
470
+ floor_ok = None
471
+ if version is not None and prereq.min_version is not None:
472
+ floor_ok = meets_floor(version, prereq.min_version)
473
+ return ToolProbe(
474
+ tool=prereq.tool, capability=prereq.capability, found=True,
475
+ version=version, min_version=prereq.min_version, meets_floor=floor_ok,
476
+ what_breaks=prereq.what_breaks,
477
+ )
478
+
479
+
480
+ def probe_all(
481
+ capabilities: Iterable[str] = (), *, timeout: float = DEFAULT_PROBE_TIMEOUT
482
+ ) -> dict[str, ToolProbe]:
483
+ """Probe every baseline prereq plus every prereq gating a capability in
484
+ `capabilities` (typically a machine's declared `capabilities:` list).
485
+
486
+ Returns a dict keyed by tool name — JSON-friendly via `ToolProbe.to_dict`
487
+ for embedding in a `/health` response.
488
+ """
489
+ caps = set(capabilities)
490
+ relevant = list(BASELINE_PREREQS) + [
491
+ p for p in CAPABILITY_PREREQS if p.capability in caps
492
+ ]
493
+ return {p.tool: probe(p, timeout=timeout) for p in relevant}
494
+
495
+
496
+ def tool_versions_summary(probes: dict[str, ToolProbe]) -> dict[str, dict]:
497
+ """JSON-friendly form of `probe_all()`'s result."""
498
+ return {tool: p.to_dict() for tool, p in probes.items()}
499
+
500
+
501
+ def unmet_capabilities(
502
+ capabilities: Iterable[str], probes: dict[str, ToolProbe]
503
+ ) -> dict[str, list[str]]:
504
+ """Cross-reference declared `capabilities` against `probes` (typically
505
+ from a `/health` response's `tool_versions`, already restricted to that
506
+ machine's own advertised capabilities).
507
+
508
+ Returns `{capability: [reason, ...]}` for every capability whose backing
509
+ tool(s) failed their probe — empty dict when everything declared checks
510
+ out. A capability with no registered prereq (nothing in
511
+ `CAPABILITY_PREREQS` names it) is silently skipped, not flagged — an
512
+ unprobed claim is not (yet) a *known-broken* one; this only reports
513
+ claims this module can actually verify.
514
+ """
515
+ unmet: dict[str, list[str]] = {}
516
+ for cap in capabilities:
517
+ reasons = []
518
+ for prereq in CAPABILITY_PREREQS:
519
+ if prereq.capability != cap:
520
+ continue
521
+ p = probes.get(prereq.tool)
522
+ if p is None:
523
+ continue # not probed — nothing to report either way
524
+ if not p.found:
525
+ reasons.append(f"{prereq.tool} not found ({prereq.what_breaks})")
526
+ elif p.min_version is not None and p.meets_floor is False:
527
+ reasons.append(
528
+ f"{prereq.tool} {p.version} < required {p.min_version} "
529
+ f"({prereq.what_breaks})"
530
+ )
531
+ if reasons:
532
+ unmet[cap] = reasons
533
+ return unmet