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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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
|