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