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/goal.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""#978: GOAL.md → Plans-panel north-star header projection.
|
|
2
|
+
|
|
3
|
+
`GOAL.md` (repo root) is the living cross-repo/cross-machine intent doc —
|
|
4
|
+
`coordinator.yml` is the source of truth for *topology*, GOAL.md is the
|
|
5
|
+
source of truth for *intent* (see the project's own CLAUDE.md). The coord-tui
|
|
6
|
+
Plans panel pins a short read-only excerpt of it above the plan roster.
|
|
7
|
+
|
|
8
|
+
**Fail-open, server-computed (mirrors `coord.plans` / #975-#976).** GOAL.md is
|
|
9
|
+
a repo-root doc, not shipped in the `coord` PyPI package (`pyproject.toml`
|
|
10
|
+
only packages `coord*`) — so it only exists on disk when `coord serve` is
|
|
11
|
+
running from an actual git checkout of this repo (the coordinator's own dev/
|
|
12
|
+
always-on host, typically an editable install). `read_goal_header()` never
|
|
13
|
+
raises: any failure to locate/read/parse the file returns
|
|
14
|
+
`{"available": False}`, and the coord-tui Plans panel simply renders without
|
|
15
|
+
the pinned header in that case — identical to today's behaviour, no
|
|
16
|
+
regression for older daemons or packaged installs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from datetime import date
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Matches the italic "_Last updated: 2026-07-04_" line convention used at the
|
|
26
|
+
# top of GOAL.md.
|
|
27
|
+
_LAST_UPDATED_RE = re.compile(r"_Last updated:\s*(\d{4}-\d{2}-\d{2})_")
|
|
28
|
+
|
|
29
|
+
# The "## \U0001F3AF North star" heading (or a plain "## North star" fallback)
|
|
30
|
+
# — matched case-insensitively so an emoji-less rewrite still resolves.
|
|
31
|
+
# Restricted to heading levels 2-6: GOAL.md's own H1 title
|
|
32
|
+
# ("# Current Goal — North Star") also contains the words "north star", and a
|
|
33
|
+
# level-1-inclusive match would bind here instead of the intended `## North
|
|
34
|
+
# star` section below it (#978 review) — the first bold text after the H1
|
|
35
|
+
# would then be the blockquote's framing sentence, not the real headline.
|
|
36
|
+
_NORTH_STAR_HEADING_RE = re.compile(r"^#{2,6}[^\n]*north star[^\n]*$", re.IGNORECASE | re.MULTILINE)
|
|
37
|
+
|
|
38
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
39
|
+
_H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
|
|
40
|
+
|
|
41
|
+
_HEADLINE_MAX_LEN = 220
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_goal_md_path() -> Path | None:
|
|
45
|
+
"""Best-effort location of this checkout's GOAL.md.
|
|
46
|
+
|
|
47
|
+
Reuses the same editable-checkout detection as `cli.py`'s
|
|
48
|
+
`_warn_if_editable_checkout_moved()`: `coord.__file__`'s grandparent
|
|
49
|
+
directory is the repo root only when running from a checkout (not a
|
|
50
|
+
`site-packages` install, which has no repo root at all). Returns `None`
|
|
51
|
+
when GOAL.md can't be resolved to an existing file.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
import coord as _coord # noqa: PLC0415
|
|
55
|
+
|
|
56
|
+
coord_file = _coord.__file__ or ""
|
|
57
|
+
if "site-packages" in coord_file:
|
|
58
|
+
return None # PyPI install — GOAL.md was never shipped with it.
|
|
59
|
+
repo_root = Path(coord_file).resolve().parents[1]
|
|
60
|
+
candidate = repo_root / "GOAL.md"
|
|
61
|
+
return candidate if candidate.is_file() else None
|
|
62
|
+
except Exception: # noqa: BLE001 — best-effort, never raise
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_goal_header(text: str) -> dict:
|
|
67
|
+
"""Pure parser: GOAL.md text -> the Plans-panel header projection.
|
|
68
|
+
|
|
69
|
+
Split out from `read_goal_header()` so tests can exercise parsing edge
|
|
70
|
+
cases (malformed/missing date, missing north-star section, ...) directly
|
|
71
|
+
against a string fixture, independent of the on-disk file-resolution
|
|
72
|
+
story above.
|
|
73
|
+
"""
|
|
74
|
+
result: dict = {
|
|
75
|
+
"available": True,
|
|
76
|
+
"headline": "",
|
|
77
|
+
"last_updated": None,
|
|
78
|
+
"days_since_update": None,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
m = _LAST_UPDATED_RE.search(text)
|
|
82
|
+
if m:
|
|
83
|
+
date_str = m.group(1)
|
|
84
|
+
result["last_updated"] = date_str
|
|
85
|
+
try:
|
|
86
|
+
parsed = date.fromisoformat(date_str)
|
|
87
|
+
result["days_since_update"] = (date.today() - parsed).days
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass # leave days_since_update as None — an unparseable date still surfaces as text
|
|
90
|
+
|
|
91
|
+
# Headline: first bolded sentence under the "North star" heading — falls
|
|
92
|
+
# back to the document's H1 title so a reformatted GOAL.md still surfaces
|
|
93
|
+
# *something* rather than an empty pinned header.
|
|
94
|
+
headline = ""
|
|
95
|
+
heading = _NORTH_STAR_HEADING_RE.search(text)
|
|
96
|
+
if heading:
|
|
97
|
+
bold = _BOLD_RE.search(text[heading.end():])
|
|
98
|
+
if bold:
|
|
99
|
+
headline = bold.group(1).strip()
|
|
100
|
+
if not headline:
|
|
101
|
+
h1 = _H1_RE.search(text)
|
|
102
|
+
if h1:
|
|
103
|
+
headline = h1.group(1).strip()
|
|
104
|
+
if len(headline) > _HEADLINE_MAX_LEN:
|
|
105
|
+
headline = headline[: _HEADLINE_MAX_LEN - 1].rstrip() + "…"
|
|
106
|
+
result["headline"] = headline
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_goal_header() -> dict:
|
|
111
|
+
"""#978: fail-open GOAL.md -> Plans-panel-header projection.
|
|
112
|
+
|
|
113
|
+
Returns `{"available": False}` when GOAL.md can't be located (packaged
|
|
114
|
+
install, no discoverable repo root, missing file) or read. Never raises —
|
|
115
|
+
called from `coord/serve_app.py`'s `board()` handler, where a failure
|
|
116
|
+
here must never blank the rest of the board payload.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
path = _resolve_goal_md_path()
|
|
120
|
+
if path is None:
|
|
121
|
+
return {"available": False}
|
|
122
|
+
text = path.read_text(encoding="utf-8")
|
|
123
|
+
return parse_goal_header(text)
|
|
124
|
+
except Exception: # noqa: BLE001 — best-effort, never raise
|
|
125
|
+
return {"available": False}
|
coord/graph_health.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Graphify knowledge-graph freshness + worktree-bootstrap health.
|
|
2
|
+
|
|
3
|
+
The repo ships a graphify graph in ``graphify-out/`` that agents are told to
|
|
4
|
+
query first (see CLAUDE.md). Two things silently break it, and neither is
|
|
5
|
+
visible from the code:
|
|
6
|
+
|
|
7
|
+
**1. Linked worktrees are graph-blind.** ``graphify-out/`` is gitignored by
|
|
8
|
+
design (only its ``.gitignore`` is tracked), so ``git worktree add`` produces
|
|
9
|
+
an empty one — and ``graphify query`` resolves ``graphify-out/graph.json``
|
|
10
|
+
strictly relative to cwd, with no upward walk and no ``--graph`` override.
|
|
11
|
+
``.githooks/post-checkout`` fixes this by symlinking each entry of the base
|
|
12
|
+
checkout's graph (``graph.json``, ``manifest.json``, ``cache/``, ...) into
|
|
13
|
+
the worktree's ``graphify-out/`` — the directory itself, and its tracked
|
|
14
|
+
``.gitignore``, are never touched (#1617: replacing the whole directory with
|
|
15
|
+
a symlink deleted the tracked ``.gitignore`` out from under git). This hook
|
|
16
|
+
only runs where ``core.hooksPath`` points at ``.githooks`` — a one-time,
|
|
17
|
+
per-machine ``git config`` that nothing enforces. :func:`hooks_path_status`
|
|
18
|
+
checks it.
|
|
19
|
+
|
|
20
|
+
**2. The graph drifts out of sync with HEAD.** graphify's own hooks are
|
|
21
|
+
best-effort and structurally cannot cover every ref-moving operation:
|
|
22
|
+
|
|
23
|
+
* ``post-commit``/``post-checkout``/``post-merge`` all ``exit 0`` during a
|
|
24
|
+
rebase, merge, or cherry-pick — so the merge agent's proactive rebase
|
|
25
|
+
(#306), the single most common ref move in the fleet, never rebuilds.
|
|
26
|
+
* ``git reset --hard`` fires no rebuild hook at all; git has none.
|
|
27
|
+
* Every hook failure path is ``exit 0``, and the rebuild itself is a detached
|
|
28
|
+
background process with a 600s ``SIGALRM`` timeout that logs to
|
|
29
|
+
``~/.cache/graphify-rebuild.log`` — a timeout, an OOM, or an ENOENT from a
|
|
30
|
+
reaped worktree all fail invisibly.
|
|
31
|
+
* Concurrent triggers coalesce ("Rebuild already in progress — changes
|
|
32
|
+
queued").
|
|
33
|
+
* The hooks' own ``[ ! -f graphify-out/graph.json ] && exit 0`` guard is a
|
|
34
|
+
permanent off-switch: purge the graph once and they no-op forever.
|
|
35
|
+
|
|
36
|
+
So the hooks are an optimization, not the correctness mechanism. Correctness
|
|
37
|
+
comes from a cheap *check*, which is possible because ``GRAPH_REPORT.md``
|
|
38
|
+
records the commit it was built from::
|
|
39
|
+
|
|
40
|
+
- Built from commit: `5be69d08`
|
|
41
|
+
|
|
42
|
+
:func:`graph_status` compares that to HEAD. ``coord diagnose --graph``
|
|
43
|
+
surfaces it, so drift shows up in a routine health check instead of being
|
|
44
|
+
discovered by a confused agent mid-task.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import re
|
|
50
|
+
import subprocess
|
|
51
|
+
import time
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
# ``- Built from commit: `5be69d08` `` in GRAPH_REPORT.md. This is the only
|
|
56
|
+
# machine-readable record of the graph's source commit — manifest.json holds
|
|
57
|
+
# per-file hashes, not a commit.
|
|
58
|
+
_BUILT_FROM_RE = re.compile(r"^-\s*Built from commit:\s*`([0-9a-fA-F]+)`", re.MULTILINE)
|
|
59
|
+
|
|
60
|
+
# The versioned hooks directory this repo expects core.hooksPath to point at.
|
|
61
|
+
HOOKS_PATH = ".githooks"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class GraphStatus:
|
|
66
|
+
"""Freshness of one checkout's graphify graph."""
|
|
67
|
+
|
|
68
|
+
repo_path: Path
|
|
69
|
+
present: bool = False
|
|
70
|
+
# True when graphify-out/graph.json is a symlink — i.e. a worktree
|
|
71
|
+
# borrowing the base checkout's graph (the .githooks/post-checkout
|
|
72
|
+
# bootstrap ran). graphify-out/ itself is always a real directory (its
|
|
73
|
+
# tracked .gitignore has to survive — see #1617); only the entries
|
|
74
|
+
# inside it are symlinked.
|
|
75
|
+
is_symlink: bool = False
|
|
76
|
+
# The base checkout's graphify-out/ directory (graph.json's resolved
|
|
77
|
+
# parent), not graph.json itself — kept as a directory path so existing
|
|
78
|
+
# "owner checkout" math (``link_target.parent``) still lands on the base
|
|
79
|
+
# checkout root.
|
|
80
|
+
link_target: Path | None = None
|
|
81
|
+
built_sha: str | None = None
|
|
82
|
+
head_sha: str | None = None
|
|
83
|
+
in_sync: bool = False
|
|
84
|
+
age_seconds: float | None = None
|
|
85
|
+
# mtime of graphify-out/manifest.json — the last time graphify *checked* the
|
|
86
|
+
# graph against the working tree, whether or not it rewrote anything.
|
|
87
|
+
verified_at: float | None = None
|
|
88
|
+
# Commit timestamp of HEAD, to compare against verified_at.
|
|
89
|
+
head_committed_at: float | None = None
|
|
90
|
+
# Set when we could not determine freshness at all (no report, no git).
|
|
91
|
+
unknown_reason: str | None = None
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def stamp_behind(self) -> bool:
|
|
95
|
+
"""``GRAPH_REPORT.md``'s "Built from commit" differs from HEAD."""
|
|
96
|
+
return bool(self.present and self.built_sha and self.head_sha and not self.in_sync)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def verified_current(self) -> bool:
|
|
100
|
+
"""graphify has checked the graph against the tree since HEAD landed.
|
|
101
|
+
|
|
102
|
+
``graphify update`` re-extracts, compares topology, and when nothing
|
|
103
|
+
changed prints "No code-graph topology changes detected; outputs left
|
|
104
|
+
untouched" — it deliberately does NOT rewrite ``graph.json`` or
|
|
105
|
+
``GRAPH_REPORT.md``, so the "Built from commit" stamp stays at whatever
|
|
106
|
+
HEAD was the last time the content actually changed. It *does* still
|
|
107
|
+
call ``save_manifest``, so ``manifest.json``'s mtime is the honest
|
|
108
|
+
record of "last verified".
|
|
109
|
+
|
|
110
|
+
Without this, a checkout whose graph is genuinely current but whose
|
|
111
|
+
stamp is behind would be reported STALE on every run, forever — a
|
|
112
|
+
health check that cries wolf is worse than none.
|
|
113
|
+
"""
|
|
114
|
+
if self.verified_at is None or self.head_committed_at is None:
|
|
115
|
+
return False
|
|
116
|
+
return self.verified_at >= self.head_committed_at
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def stale(self) -> bool:
|
|
120
|
+
"""The stamp is behind AND graphify has not verified the graph against
|
|
121
|
+
the tree since HEAD landed. Deliberately False when freshness is
|
|
122
|
+
*unknown* — an unknown is reported separately, not counted as drift."""
|
|
123
|
+
return self.stamp_behind and not self.verified_current
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_built_sha(report_path: Path) -> str | None:
|
|
127
|
+
"""The commit ``GRAPH_REPORT.md`` says the graph was built from."""
|
|
128
|
+
try:
|
|
129
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
130
|
+
except OSError:
|
|
131
|
+
return None
|
|
132
|
+
m = _BUILT_FROM_RE.search(text)
|
|
133
|
+
return m.group(1) if m else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _git_out(repo_path: Path, *args: str) -> str | None:
|
|
137
|
+
try:
|
|
138
|
+
r = subprocess.run(
|
|
139
|
+
["git", *args],
|
|
140
|
+
cwd=str(repo_path),
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
timeout=10.0,
|
|
144
|
+
)
|
|
145
|
+
except (subprocess.SubprocessError, OSError):
|
|
146
|
+
return None
|
|
147
|
+
if r.returncode != 0:
|
|
148
|
+
return None
|
|
149
|
+
return r.stdout.strip() or None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _head_sha(repo_path: Path) -> str | None:
|
|
153
|
+
return _git_out(repo_path, "rev-parse", "HEAD")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _head_committed_at(repo_path: Path) -> float | None:
|
|
157
|
+
raw = _git_out(repo_path, "log", "-1", "--format=%ct", "HEAD")
|
|
158
|
+
try:
|
|
159
|
+
return float(raw) if raw else None
|
|
160
|
+
except ValueError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _shas_agree(built: str, head: str) -> bool:
|
|
165
|
+
"""Compare on the shorter of the two — the report abbreviates (8 chars by
|
|
166
|
+
default) while ``git rev-parse HEAD`` is full-length."""
|
|
167
|
+
n = min(len(built), len(head))
|
|
168
|
+
if n < 4: # too short to be a meaningful comparison
|
|
169
|
+
return False
|
|
170
|
+
return built[:n].lower() == head[:n].lower()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def graph_status(repo_path: Path) -> GraphStatus:
|
|
174
|
+
"""Freshness of the graphify graph for the checkout at *repo_path*.
|
|
175
|
+
|
|
176
|
+
Read-only and best-effort: a missing graph, a missing report, or a repo
|
|
177
|
+
git can't read all return a populated :class:`GraphStatus` with
|
|
178
|
+
``unknown_reason`` set rather than raising.
|
|
179
|
+
"""
|
|
180
|
+
st = GraphStatus(repo_path=repo_path)
|
|
181
|
+
out_dir = repo_path / "graphify-out"
|
|
182
|
+
graph_file = out_dir / "graph.json"
|
|
183
|
+
|
|
184
|
+
# A borrowed graph symlinks graph.json (and friends) individually;
|
|
185
|
+
# graphify-out/ itself is always a real directory (#1617).
|
|
186
|
+
st.is_symlink = graph_file.is_symlink()
|
|
187
|
+
if st.is_symlink:
|
|
188
|
+
try:
|
|
189
|
+
st.link_target = graph_file.resolve().parent
|
|
190
|
+
except OSError:
|
|
191
|
+
st.link_target = None
|
|
192
|
+
|
|
193
|
+
if not graph_file.is_file():
|
|
194
|
+
st.unknown_reason = "no graphify-out/graph.json (graph never built here)"
|
|
195
|
+
return st
|
|
196
|
+
st.present = True
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
st.age_seconds = max(0.0, time.time() - graph_file.stat().st_mtime)
|
|
200
|
+
except OSError:
|
|
201
|
+
st.age_seconds = None
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
st.verified_at = (out_dir / "manifest.json").stat().st_mtime
|
|
205
|
+
except OSError:
|
|
206
|
+
st.verified_at = None
|
|
207
|
+
|
|
208
|
+
st.built_sha = read_built_sha(out_dir / "GRAPH_REPORT.md")
|
|
209
|
+
# Freshness is always judged against the checkout that OWNS the graph. For
|
|
210
|
+
# a symlinked worktree that's the base checkout, not the worktree's own
|
|
211
|
+
# HEAD — the worktree is on a feature branch by definition and comparing
|
|
212
|
+
# against it would report permanent, meaningless drift.
|
|
213
|
+
owner = st.link_target.parent if (st.is_symlink and st.link_target) else repo_path
|
|
214
|
+
st.head_sha = _head_sha(owner)
|
|
215
|
+
st.head_committed_at = _head_committed_at(owner)
|
|
216
|
+
|
|
217
|
+
if not st.built_sha:
|
|
218
|
+
st.unknown_reason = "GRAPH_REPORT.md has no 'Built from commit' line"
|
|
219
|
+
elif not st.head_sha:
|
|
220
|
+
st.unknown_reason = f"could not read HEAD of {owner}"
|
|
221
|
+
else:
|
|
222
|
+
st.in_sync = _shas_agree(st.built_sha, st.head_sha)
|
|
223
|
+
return st
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def hooks_path_status(repo_path: Path) -> tuple[bool, str]:
|
|
227
|
+
"""``(ok, detail)`` for this checkout's ``core.hooksPath``.
|
|
228
|
+
|
|
229
|
+
The versioned ``.githooks/post-checkout`` bootstrap only runs when
|
|
230
|
+
``core.hooksPath`` points at it — a one-time per-machine ``git config``.
|
|
231
|
+
Without it, worktrees on this machine stay graph-blind and nothing says so.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
r = subprocess.run(
|
|
235
|
+
["git", "config", "--get", "core.hooksPath"],
|
|
236
|
+
cwd=str(repo_path),
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
timeout=10.0,
|
|
240
|
+
)
|
|
241
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
242
|
+
return False, f"could not read core.hooksPath ({exc})"
|
|
243
|
+
value = r.stdout.strip()
|
|
244
|
+
have_hook = (repo_path / HOOKS_PATH / "post-checkout").is_file()
|
|
245
|
+
if not value:
|
|
246
|
+
if not have_hook:
|
|
247
|
+
# The repo doesn't ship the bootstrap at all — telling the operator
|
|
248
|
+
# to point core.hooksPath at a directory that isn't there would
|
|
249
|
+
# silently disable ALL hooks for that checkout.
|
|
250
|
+
return False, (
|
|
251
|
+
f"no {HOOKS_PATH}/post-checkout in this repo — worktrees here get "
|
|
252
|
+
f"no linked graph (port the hook to this repo to enable it)"
|
|
253
|
+
)
|
|
254
|
+
return False, (
|
|
255
|
+
f"core.hooksPath is unset — worktrees on this machine will NOT get "
|
|
256
|
+
f"a linked graph. Fix: git -C {repo_path} config core.hooksPath {HOOKS_PATH}"
|
|
257
|
+
)
|
|
258
|
+
if Path(value).name != Path(HOOKS_PATH).name:
|
|
259
|
+
return False, (
|
|
260
|
+
f"core.hooksPath is {value!r}, expected {HOOKS_PATH!r} — the worktree "
|
|
261
|
+
f"graph bootstrap will not run"
|
|
262
|
+
)
|
|
263
|
+
if not have_hook:
|
|
264
|
+
return False, (
|
|
265
|
+
f"core.hooksPath={value} but {HOOKS_PATH}/post-checkout is missing "
|
|
266
|
+
f"(stale checkout?)"
|
|
267
|
+
)
|
|
268
|
+
orphaned = orphaned_hooks(repo_path)
|
|
269
|
+
if orphaned:
|
|
270
|
+
return False, (
|
|
271
|
+
f"core.hooksPath={value} but {', '.join(orphaned)} exist(s) only in "
|
|
272
|
+
f".git/hooks — git no longer runs them, so those graphify rebuilds "
|
|
273
|
+
f"are SILENTLY DISABLED. Add a shim in {HOOKS_PATH}/"
|
|
274
|
+
)
|
|
275
|
+
return True, f"core.hooksPath={value}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def orphaned_hooks(repo_path: Path) -> list[str]:
|
|
279
|
+
"""Hooks installed in the machine-local hooks dir with no counterpart in
|
|
280
|
+
:data:`HOOKS_PATH`.
|
|
281
|
+
|
|
282
|
+
Setting ``core.hooksPath`` makes git ignore ``.git/hooks`` **entirely** —
|
|
283
|
+
it does not merge or fall back. So any hook graphify installed there
|
|
284
|
+
(``post-commit``, ``post-checkout``, ``post-merge``) that has no shim in
|
|
285
|
+
the versioned directory stops running, with no error and no log line.
|
|
286
|
+
This shipped exactly once: only ``post-checkout`` had a shim, which
|
|
287
|
+
silently killed graphify's commit- and merge-triggered rebuilds.
|
|
288
|
+
|
|
289
|
+
Returns hook names, sorted. Empty when nothing is orphaned (including
|
|
290
|
+
when ``core.hooksPath`` isn't set — then ``.git/hooks`` is live and
|
|
291
|
+
there is nothing to orphan).
|
|
292
|
+
"""
|
|
293
|
+
local_dir = _git_out(repo_path, "rev-parse", "--git-common-dir")
|
|
294
|
+
if not local_dir:
|
|
295
|
+
return []
|
|
296
|
+
common = Path(local_dir)
|
|
297
|
+
if not common.is_absolute():
|
|
298
|
+
common = repo_path / common
|
|
299
|
+
hooks_dir = common / "hooks"
|
|
300
|
+
versioned = repo_path / HOOKS_PATH
|
|
301
|
+
if not hooks_dir.is_dir() or not versioned.is_dir():
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
out: list[str] = []
|
|
305
|
+
for entry in hooks_dir.iterdir():
|
|
306
|
+
name = entry.name
|
|
307
|
+
# graphify keeps .bak copies alongside; only real, executable hooks
|
|
308
|
+
# matter, and only ones git would actually invoke.
|
|
309
|
+
if name.endswith(".sample") or name.endswith(".bak") or "." in name:
|
|
310
|
+
continue
|
|
311
|
+
if not entry.is_file():
|
|
312
|
+
continue
|
|
313
|
+
if not (versioned / name).is_file():
|
|
314
|
+
out.append(name)
|
|
315
|
+
return sorted(out)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def format_status_lines(st: GraphStatus) -> list[str]:
|
|
319
|
+
"""Human-readable report lines for *st* (used by ``coord diagnose --graph``)."""
|
|
320
|
+
lines: list[str] = []
|
|
321
|
+
where = str(st.repo_path)
|
|
322
|
+
if not st.present:
|
|
323
|
+
lines.append(f"✗ {where}: {st.unknown_reason}")
|
|
324
|
+
return lines
|
|
325
|
+
|
|
326
|
+
if st.is_symlink:
|
|
327
|
+
lines.append(f"↳ {where}/graphify-out → {st.link_target} (linked worktree)")
|
|
328
|
+
|
|
329
|
+
if st.unknown_reason:
|
|
330
|
+
lines.append(f"? {where}: freshness unknown — {st.unknown_reason}")
|
|
331
|
+
elif st.in_sync:
|
|
332
|
+
lines.append(f"✓ {where}: graph in sync (built from {st.built_sha})")
|
|
333
|
+
elif st.verified_current:
|
|
334
|
+
# Stamp behind, but graphify has re-checked the tree since HEAD landed
|
|
335
|
+
# and found no topology change — the graph content is current.
|
|
336
|
+
lines.append(
|
|
337
|
+
f"✓ {where}: graph content current — stamp says {st.built_sha} "
|
|
338
|
+
f"(HEAD {(st.head_sha or '')[:8]}), but verified against the tree "
|
|
339
|
+
f"since; graphify leaves outputs untouched when topology is unchanged"
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
lines.append(
|
|
343
|
+
f"⚠ {where}: graph is STALE — built from {st.built_sha}, "
|
|
344
|
+
f"HEAD is {(st.head_sha or '')[:8]}"
|
|
345
|
+
)
|
|
346
|
+
if st.age_seconds is not None:
|
|
347
|
+
lines.append(f" graph.json age: {st.age_seconds / 3600.0:.1f}h")
|
|
348
|
+
return lines
|
coord/health/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Fleet-health check engine (#1628).
|
|
2
|
+
|
|
3
|
+
Answers one question for a catalogue of fleet-degradation signals: **how much
|
|
4
|
+
headroom is left?** Runs on one machine, against that machine and the
|
|
5
|
+
checkouts ``coordinator.yml`` says live on it. No transport, no board state,
|
|
6
|
+
no renderer beyond ``coord health`` — those are H-3 and H-4.
|
|
7
|
+
|
|
8
|
+
The shape, in the order you'd meet it:
|
|
9
|
+
|
|
10
|
+
* ``models`` — :class:`~coord.health.models.CheckResult`, which carries raw
|
|
11
|
+
values **and** the rendered headroom string. Renderers never re-derive
|
|
12
|
+
severity from raw numbers; that rule is what keeps the logic from forking
|
|
13
|
+
once there is more than one surface.
|
|
14
|
+
* ``registry`` — the ``@check`` decorator and ``run_all``. Adding a check
|
|
15
|
+
means adding one module under ``checks/``; discovery is ``pkgutil`` over
|
|
16
|
+
that package, so nothing central needs editing.
|
|
17
|
+
* ``checks/`` — the seed probes: disk, cargo targets, worktrees, agent
|
|
18
|
+
install/version, claude binary, repo branch/dirt, graph freshness, plan
|
|
19
|
+
usage. Two of them (``graph``, ``plan_usage``) are thin wrappers over
|
|
20
|
+
``coord.graph_health`` and ``coord.usage_limits`` rather than forks.
|
|
21
|
+
* ``render`` / ``cli`` — the one surface this child ships.
|
|
22
|
+
|
|
23
|
+
Every probe is cheap, read-only, and fails soft: a probe that raises becomes
|
|
24
|
+
an ``unknown`` result carrying the error text, never a failed run.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from coord.health.models import (
|
|
28
|
+
SCOPES,
|
|
29
|
+
CheckResult,
|
|
30
|
+
Checkout,
|
|
31
|
+
HealthContext,
|
|
32
|
+
Severity,
|
|
33
|
+
unknown_result,
|
|
34
|
+
worst,
|
|
35
|
+
)
|
|
36
|
+
from coord.health.registry import (
|
|
37
|
+
COST_CHEAP,
|
|
38
|
+
COST_NETWORK,
|
|
39
|
+
Check,
|
|
40
|
+
HealthReport,
|
|
41
|
+
all_checks,
|
|
42
|
+
check,
|
|
43
|
+
discover,
|
|
44
|
+
get,
|
|
45
|
+
register,
|
|
46
|
+
run_all,
|
|
47
|
+
run_check,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"COST_CHEAP",
|
|
52
|
+
"COST_NETWORK",
|
|
53
|
+
"SCOPES",
|
|
54
|
+
"Check",
|
|
55
|
+
"CheckResult",
|
|
56
|
+
"Checkout",
|
|
57
|
+
"HealthContext",
|
|
58
|
+
"HealthReport",
|
|
59
|
+
"Severity",
|
|
60
|
+
"all_checks",
|
|
61
|
+
"check",
|
|
62
|
+
"discover",
|
|
63
|
+
"get",
|
|
64
|
+
"register",
|
|
65
|
+
"run_all",
|
|
66
|
+
"run_check",
|
|
67
|
+
"unknown_result",
|
|
68
|
+
"worst",
|
|
69
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Fleet-wide health aggregation for the always-visible surfaces (#1631, H-4).
|
|
2
|
+
|
|
3
|
+
Consumes the ``fleet_health`` block H-3 computes
|
|
4
|
+
(:mod:`coord.health.fleet_snapshot`) — either straight off ``GET /board``
|
|
5
|
+
(thin client) or reassembled locally from :func:`coord.state.load_machine_health`
|
|
6
|
+
(host mode, no ``board_service`` configured, so there is no daemon process to
|
|
7
|
+
ask for the fleet-scope checks). Every unit counted here already carries its
|
|
8
|
+
own ``severity`` string, chosen by a probe upstream; this module only counts
|
|
9
|
+
and picks the worst — it never looks at a check's raw ``values``.
|
|
10
|
+
|
|
11
|
+
Two renderers consume :class:`FleetHealthSummary`: ``coord status``'s footer
|
|
12
|
+
(:func:`render_fleet_footer`, this module) and coord-tui's status-bar
|
|
13
|
+
indicator + detail overlay (``tui/src/app``, a from-scratch Rust port of the
|
|
14
|
+
same counting rule — see that crate's ``fleet_health`` module for the
|
|
15
|
+
mirrored logic and its own doc comment on why it can't just call this file).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from coord.health.models import Severity
|
|
24
|
+
|
|
25
|
+
# Ascending severity order for the footer's non-OK enumeration — mirrors
|
|
26
|
+
# Severity.rank, so "FLEET: WARN 2, CRIT 1" lists the worst last (#1631's
|
|
27
|
+
# own example). OK is never listed here: zero non-OK units *is* "OK".
|
|
28
|
+
_FOOTER_ORDER = (Severity.UNKNOWN, Severity.WARN, Severity.CRIT)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class FleetHealthSummary:
|
|
33
|
+
"""One aggregate verdict + per-severity counts.
|
|
34
|
+
|
|
35
|
+
One *unit* per machine (its own already-rolled-up ``severity`` — H-3's
|
|
36
|
+
``_effective_severity``: unknown when stale/never-polled/offline, never
|
|
37
|
+
silently carried forward as green) plus one unit per fleet-scope check
|
|
38
|
+
(``fleet_checks``, each already a ``CheckResult.to_dict()``). Counting at
|
|
39
|
+
this granularity — not per individual per-machine check row — is what
|
|
40
|
+
keeps "3 machines, 8 checks each" from reading as "24 problems" when
|
|
41
|
+
really it's "1 machine is unhappy".
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
worst: Severity
|
|
45
|
+
counts: dict[str, int] # "ok"/"unknown"/"warn"/"crit" -> count
|
|
46
|
+
unit_count: int # total machines + fleet checks contributing
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _severity_of(value: "str | None") -> Severity:
|
|
50
|
+
try:
|
|
51
|
+
return Severity(value or "unknown")
|
|
52
|
+
except ValueError:
|
|
53
|
+
return Severity.UNKNOWN
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def summarize_fleet_health(fleet_health: "dict | None") -> FleetHealthSummary:
|
|
57
|
+
"""Aggregate a ``fleet_health`` block into one :class:`FleetHealthSummary`.
|
|
58
|
+
|
|
59
|
+
*fleet_health* is either the dict at ``/board``'s ``fleet_health`` key
|
|
60
|
+
(schema: ``{"machine_health": [...], "fleet_checks": [...]}``, see
|
|
61
|
+
``coord.health.fleet_snapshot.FleetHealthSnapshot.to_dict``) or the
|
|
62
|
+
locally-assembled equivalent from :func:`local_fleet_health_block`.
|
|
63
|
+
``None``/missing keys degrade to "no units" (``worst=OK``, all counts
|
|
64
|
+
zero) rather than raising — a daemon predating #1630 simply never sends
|
|
65
|
+
this key.
|
|
66
|
+
"""
|
|
67
|
+
counts = {"ok": 0, "warn": 0, "crit": 0, "unknown": 0}
|
|
68
|
+
worst = Severity.OK
|
|
69
|
+
block = fleet_health or {}
|
|
70
|
+
machine_health = block.get("machine_health") or []
|
|
71
|
+
fleet_checks = block.get("fleet_checks") or []
|
|
72
|
+
units = [m.get("severity") for m in machine_health] + [
|
|
73
|
+
c.get("severity") for c in fleet_checks
|
|
74
|
+
]
|
|
75
|
+
for raw in units:
|
|
76
|
+
sev = _severity_of(raw)
|
|
77
|
+
counts[sev.value] += 1
|
|
78
|
+
if sev.rank > worst.rank:
|
|
79
|
+
worst = sev
|
|
80
|
+
return FleetHealthSummary(worst=worst, counts=counts, unit_count=len(units))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_fleet_footer(summary: FleetHealthSummary) -> str:
|
|
84
|
+
"""``FLEET: <state> (coord health for detail)`` — the ``coord status``
|
|
85
|
+
footer.
|
|
86
|
+
|
|
87
|
+
``OK`` states its OK-ness rather than printing nothing (#1631's own
|
|
88
|
+
framing: silence is indistinguishable from a broken check). Always
|
|
89
|
+
returns a non-empty string — callers should always print it, never
|
|
90
|
+
conditionally skip it.
|
|
91
|
+
"""
|
|
92
|
+
if summary.worst is Severity.OK:
|
|
93
|
+
state = "OK"
|
|
94
|
+
else:
|
|
95
|
+
parts = [
|
|
96
|
+
f"{sev.label} {summary.counts[sev.value]}"
|
|
97
|
+
for sev in _FOOTER_ORDER
|
|
98
|
+
if summary.counts[sev.value]
|
|
99
|
+
]
|
|
100
|
+
state = ", ".join(parts) if parts else "OK"
|
|
101
|
+
return f"FLEET: {state} (coord health for detail)"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def local_fleet_health_block(machine_names: "list[str]") -> dict:
|
|
105
|
+
"""Best-effort ``fleet_health``-shaped block for host mode (no
|
|
106
|
+
``board_service`` configured — see ``coord.commands.status.status``).
|
|
107
|
+
|
|
108
|
+
Per-machine severities come straight from the local DB's
|
|
109
|
+
``machine_health`` table (written by whichever ``coord serve`` tick loop
|
|
110
|
+
last ran on this host) via the SAME row-assembly H-3's daemon-side
|
|
111
|
+
projection uses (:func:`coord.health.fleet_snapshot._machine_health_rows`)
|
|
112
|
+
— so a machine that never reported reads ``unknown`` here exactly like it
|
|
113
|
+
does on ``/board``, not silently green.
|
|
114
|
+
|
|
115
|
+
``fleet_checks`` is always empty on this path: those probes (board
|
|
116
|
+
latency, phantom-running rows, deploy-lane skew, …) only exist inside a
|
|
117
|
+
live ``coord serve`` process's in-memory ``FleetHealthRefresher`` — a
|
|
118
|
+
separate ``coord status`` invocation has no way to reach into that
|
|
119
|
+
process's memory, and re-running the probes here would mean duplicating
|
|
120
|
+
daemon-only, subprocess-heavy fact-gathering into a read path that must
|
|
121
|
+
stay cheap. A thin client (``board_service`` configured) gets the full
|
|
122
|
+
picture via ``GET /board`` instead.
|
|
123
|
+
"""
|
|
124
|
+
from coord.health.fleet_snapshot import _machine_health_rows
|
|
125
|
+
from coord.state import load_machine_health
|
|
126
|
+
|
|
127
|
+
raw = load_machine_health()
|
|
128
|
+
rows = _machine_health_rows(machine_names, raw, now=time.time())
|
|
129
|
+
return {"machine_health": rows, "fleet_checks": []}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Seed health checks (#1628).
|
|
2
|
+
|
|
3
|
+
**This file imports nothing on purpose.** ``coord.health.registry.discover``
|
|
4
|
+
walks this package with :func:`pkgutil.iter_modules` and imports every module
|
|
5
|
+
it finds, so a new check is installed by dropping ``coord/health/checks/
|
|
6
|
+
<name>.py`` here and decorating its probe with ``@check(...)`` — no edit to
|
|
7
|
+
this ``__init__``, no edit to a renderer, no edit to the CLI.
|
|
8
|
+
|
|
9
|
+
``tests/test_health_registry.py::test_adding_a_check_touches_only_its_own_module``
|
|
10
|
+
enforces that: it writes a throwaway module into this directory, asserts it
|
|
11
|
+
shows up in the report and the JSON, and asserts the renderer/CLI/registry
|
|
12
|
+
files are byte-identical before and after.
|
|
13
|
+
"""
|