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